chore: remove Go archive

This commit is contained in:
2026-05-17 16:08:09 -07:00
parent 4d23f89494
commit 93a75a9464
23 changed files with 0 additions and 3003 deletions
-36
View File
@@ -1,36 +0,0 @@
.PHONY: build test clean install run lint fmt vet check
BINARY := kindexr
CLI := kindexr-cli
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS := -ldflags "-X main.version=$(VERSION) -s -w"
build:
go build $(LDFLAGS) -o ./bin/$(BINARY) ./cmd/kindexr
go build $(LDFLAGS) -o ./bin/$(CLI) ./cmd/kindexr-cli
test:
go test -race ./...
lint:
golangci-lint run
fmt:
gofmt -w .
vet:
go vet ./...
check: fmt vet test
install: build
install -D -m 0755 ./bin/$(BINARY) /usr/local/bin/$(BINARY)
install -D -m 0755 ./bin/$(CLI) /usr/local/bin/$(CLI)
install -D -m 0644 ./deploy/kindexr.service /etc/systemd/system/kindexr.service
install -D -m 0640 ./deploy/kindexr.example.yaml /etc/kindexr/config.yaml
run:
go run ./cmd/kindexr --config ./deploy/kindexr.example.yaml
clean:
rm -rf ./bin
-95
View File
@@ -1,95 +0,0 @@
// Command kindexr-cli is the admin CLI for kindexr.
//
// TODO (Phase 3): implement the following sub-commands:
// - apikey list
// - apikey revoke <key>
// - relay add <url>
// - relay remove <url>
// - relay list
// - publisher trust <pubkey>
// - publisher block <pubkey>
// - db stats
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() {
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>")
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)")
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); err != nil {
fmt.Fprintf(os.Stderr, "error creating api key: %v\n", err)
os.Exit(1)
}
fmt.Println(key)
}
-117
View File
@@ -1,117 +0,0 @@
// Command kindexr is the main daemon for the kindexr Nostr-native Torznab indexer.
package main
import (
"context"
"flag"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"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"
)
// version is overridden at build time via -ldflags "-X main.version=<tag>".
var version = "dev"
func main() {
configPath := flag.String("config", "/etc/kindexr/config.yaml", "path to config file")
flag.Parse()
// Load configuration.
cfg, err := config.Load(*configPath)
if err != nil {
slog.Error("failed to load config", "error", err)
os.Exit(1)
}
// Set up structured logging.
var logLevel slog.Level
switch cfg.Logging.Level {
case "debug":
logLevel = slog.LevelDebug
case "warn", "warning":
logLevel = slog.LevelWarn
case "error":
logLevel = slog.LevelError
default:
logLevel = slog.LevelInfo
}
handlerOpts := &slog.HandlerOptions{Level: logLevel}
var handler slog.Handler
if cfg.Logging.Format == "json" {
handler = slog.NewJSONHandler(os.Stderr, handlerOpts)
} else {
handler = slog.NewTextHandler(os.Stderr, handlerOpts)
}
logger := slog.New(handler)
slog.SetDefault(logger)
// Open database.
database, err := db.Open(cfg.Database.Path)
if err != nil {
slog.Error("failed to open database", "error", err, "path", cfg.Database.Path)
os.Exit(1)
}
defer database.Close()
migrationVer, err := database.MigrationVersion(context.Background())
if err != nil {
slog.Error("failed to query migration version", "error", err)
os.Exit(1)
}
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(),
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
slog.Info("starting kindexr", "version", version, "listen", cfg.Server.Listen)
go func() {
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("http server error", "error", err)
cancel()
}
}()
// Wait for shutdown signal.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
select {
case sig := <-sigCh:
slog.Info("received signal, shutting down", "signal", sig)
case <-ctx.Done():
}
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
slog.Error("graceful shutdown failed", "error", err)
}
slog.Info("kindexr stopped")
}
-51
View File
@@ -1,51 +0,0 @@
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
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.50.1 // indirect
)
-203
View File
@@ -1,203 +0,0 @@
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=
github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg=
github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc=
github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY=
github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM=
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=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
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=
-280
View File
@@ -1,280 +0,0 @@
// Package config provides koanf-based configuration loading for kindexr.
// Load order: defaults → YAML file → env vars (KINDEXR_ prefix).
package config
import (
"os"
"strings"
"github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/providers/env"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2"
)
// ServerConfig holds HTTP server settings.
type ServerConfig struct {
Listen string `koanf:"listen"`
BaseURL string `koanf:"base_url"`
}
// DatabaseConfig holds SQLite settings.
type DatabaseConfig struct {
Path string `koanf:"path"`
}
// LoggingConfig holds structured logging settings.
type LoggingConfig struct {
Level string `koanf:"level"`
Format string `koanf:"format"`
}
// CurationConfig controls ingestion filtering.
type CurationConfig struct {
WoTOnly bool `koanf:"wot_only"`
FollowDepth int `koanf:"follow_depth"`
Allowlist []string `koanf:"allowlist"`
Blocklist []string `koanf:"blocklist"`
CurationSets []string `koanf:"curation_sets"`
OperatorPubkey string `koanf:"operator_pubkey"`
}
// TMDBConfig controls TMDB enrichment.
type TMDBConfig struct {
Enabled bool `koanf:"enabled"`
APIKey string `koanf:"api_key"`
CacheTTL string `koanf:"cache_ttl"`
}
// HealthConfig controls torrent health scraping.
type HealthConfig struct {
Enabled bool `koanf:"enabled"`
Method string `koanf:"method"`
RefreshInterval string `koanf:"refresh_interval"`
}
// SignerConfig controls the Nostr signing backend.
type SignerConfig struct {
Mode string `koanf:"mode"`
BunkerURI string `koanf:"bunker_uri"`
Ncryptsec string `koanf:"ncryptsec"`
Nsec string `koanf:"nsec"`
}
// QBittorrentConfig holds qBittorrent Web API settings.
type QBittorrentConfig struct {
URL string `koanf:"url"`
Username string `koanf:"username"`
Password string `koanf:"password"`
Category string `koanf:"category"`
}
// WatchDirConfig holds watch-directory settings.
type WatchDirConfig struct {
Path string `koanf:"path"`
}
// ClientConfig holds the download client settings.
type ClientConfig struct {
Type string `koanf:"type"`
QBittorrent QBittorrentConfig `koanf:"qbittorrent"`
WatchDir WatchDirConfig `koanf:"watch_dir"`
}
// PublisherConfig controls the writer side (publishing your own events).
type PublisherConfig struct {
Enabled bool `koanf:"enabled"`
Signer SignerConfig `koanf:"signer"`
OutboxRelays []string `koanf:"outbox_relays"`
Client ClientConfig `koanf:"client"`
EnrichBeforePublish bool `koanf:"enrich_before_publish"`
}
// Config is the top-level configuration structure.
type Config struct {
Server ServerConfig `koanf:"server"`
Database DatabaseConfig `koanf:"database"`
Logging LoggingConfig `koanf:"logging"`
Relays []string `koanf:"relays"`
NegentropyBootstrap bool `koanf:"negentropy_bootstrap"`
BackfillDays int `koanf:"backfill_days"`
Curation CurationConfig `koanf:"curation"`
TMDB TMDBConfig `koanf:"tmdb"`
Health HealthConfig `koanf:"health"`
Publisher PublisherConfig `koanf:"publisher"`
}
// defaultRelays is the starter set of public Nostr relays.
var defaultRelays = []string{
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.primal.net",
"wss://nostr.mom",
"wss://relay.snort.social",
"wss://sovbit.host",
}
// Defaults returns a Config populated with sane default values.
func Defaults() *Config {
return &Config{
Server: ServerConfig{
Listen: "127.0.0.1:9117",
BaseURL: "https://kindexr.example.com",
},
Database: DatabaseConfig{
Path: "/var/lib/kindexr/kindexr.db",
},
Logging: LoggingConfig{
Level: "info",
Format: "json",
},
Relays: defaultRelays,
NegentropyBootstrap: true,
BackfillDays: 365,
Curation: CurationConfig{
WoTOnly: false,
FollowDepth: 2,
},
TMDB: TMDBConfig{
Enabled: true,
CacheTTL: "168h",
},
Health: HealthConfig{
Enabled: false,
Method: "dht",
RefreshInterval: "30m",
},
Publisher: PublisherConfig{
Enabled: false,
Signer: SignerConfig{
Mode: "bunker",
},
OutboxRelays: []string{
"wss://sovbit.host",
"wss://relay.damus.io",
"wss://nos.lol",
},
Client: ClientConfig{
Type: "qbittorrent",
QBittorrent: QBittorrentConfig{
URL: "http://127.0.0.1:8080",
Username: "admin",
Category: "publish-nostr",
},
WatchDir: WatchDirConfig{
Path: "/var/lib/kindexr/watch",
},
},
EnrichBeforePublish: true,
},
}
}
// Load reads config from defaults, then optionally from a YAML file, then from
// environment variables with the KINDEXR_ prefix. If path is empty or the file
// does not exist, the file step is silently skipped.
func Load(path string) (*Config, error) {
k := koanf.New(".")
defaults := Defaults()
// Apply defaults via Set calls.
if err := k.Set("server.listen", defaults.Server.Listen); err != nil {
return nil, err
}
if err := k.Set("server.base_url", defaults.Server.BaseURL); err != nil {
return nil, err
}
if err := k.Set("database.path", defaults.Database.Path); err != nil {
return nil, err
}
if err := k.Set("logging.level", defaults.Logging.Level); err != nil {
return nil, err
}
if err := k.Set("logging.format", defaults.Logging.Format); err != nil {
return nil, err
}
if err := k.Set("relays", defaults.Relays); err != nil {
return nil, err
}
if err := k.Set("negentropy_bootstrap", defaults.NegentropyBootstrap); err != nil {
return nil, err
}
if err := k.Set("backfill_days", defaults.BackfillDays); err != nil {
return nil, err
}
if err := k.Set("curation.wot_only", defaults.Curation.WoTOnly); err != nil {
return nil, err
}
if err := k.Set("curation.follow_depth", defaults.Curation.FollowDepth); err != nil {
return nil, err
}
if err := k.Set("tmdb.enabled", defaults.TMDB.Enabled); err != nil {
return nil, err
}
if err := k.Set("tmdb.cache_ttl", defaults.TMDB.CacheTTL); err != nil {
return nil, err
}
if err := k.Set("health.enabled", defaults.Health.Enabled); err != nil {
return nil, err
}
if err := k.Set("health.method", defaults.Health.Method); err != nil {
return nil, err
}
if err := k.Set("health.refresh_interval", defaults.Health.RefreshInterval); err != nil {
return nil, err
}
if err := k.Set("publisher.enabled", defaults.Publisher.Enabled); err != nil {
return nil, err
}
if err := k.Set("publisher.signer.mode", defaults.Publisher.Signer.Mode); err != nil {
return nil, err
}
if err := k.Set("publisher.outbox_relays", defaults.Publisher.OutboxRelays); err != nil {
return nil, err
}
if err := k.Set("publisher.client.type", defaults.Publisher.Client.Type); err != nil {
return nil, err
}
if err := k.Set("publisher.client.qbittorrent.url", defaults.Publisher.Client.QBittorrent.URL); err != nil {
return nil, err
}
if err := k.Set("publisher.client.qbittorrent.username", defaults.Publisher.Client.QBittorrent.Username); err != nil {
return nil, err
}
if err := k.Set("publisher.client.qbittorrent.category", defaults.Publisher.Client.QBittorrent.Category); err != nil {
return nil, err
}
if err := k.Set("publisher.client.watch_dir.path", defaults.Publisher.Client.WatchDir.Path); err != nil {
return nil, err
}
if err := k.Set("publisher.enrich_before_publish", defaults.Publisher.EnrichBeforePublish); err != nil {
return nil, err
}
// Load YAML file if path is set and file exists.
if path != "" {
if _, err := os.Stat(path); err == nil {
if err := k.Load(file.Provider(path), yaml.Parser()); err != nil {
return nil, err
}
}
// If file does not exist, skip gracefully (no error).
}
// Load env vars with KINDEXR_ prefix; map KINDEXR_LOGGING_LEVEL -> logging.level.
// Ignore errors from env loading (no KINDEXR_ vars present is fine).
_ = k.Load(env.Provider("KINDEXR_", ".", func(s string) string {
// Strip prefix, lowercase, replace _ with .
s = strings.TrimPrefix(s, "KINDEXR_")
s = strings.ToLower(s)
s = strings.ReplaceAll(s, "_", ".")
return s
}), nil)
cfg := &Config{}
if err := k.Unmarshal("", cfg); err != nil {
return nil, err
}
return cfg, nil
}
-83
View File
@@ -1,83 +0,0 @@
package config_test
import (
"os"
"path/filepath"
"testing"
"git.utn.lol/enki/kindexr/internal/config"
)
func TestDefaults(t *testing.T) {
cfg, err := config.Load("")
if err != nil {
t.Fatalf("Load with empty path failed: %v", err)
}
if cfg.Server.Listen != "127.0.0.1:9117" {
t.Errorf("expected default listen 127.0.0.1:9117, got %q", cfg.Server.Listen)
}
if cfg.Logging.Format != "json" {
t.Errorf("expected default log format json, got %q", cfg.Logging.Format)
}
if cfg.Logging.Level != "info" {
t.Errorf("expected default log level info, got %q", cfg.Logging.Level)
}
if cfg.Database.Path != "/var/lib/kindexr/kindexr.db" {
t.Errorf("expected default db path, got %q", cfg.Database.Path)
}
}
func TestLoadFromFile(t *testing.T) {
dir := t.TempDir()
cfgFile := filepath.Join(dir, "config.yaml")
content := `
server:
listen: "0.0.0.0:8888"
logging:
level: "debug"
`
if err := os.WriteFile(cfgFile, []byte(content), 0644); err != nil {
t.Fatalf("failed to write temp config: %v", err)
}
cfg, err := config.Load(cfgFile)
if err != nil {
t.Fatalf("Load from file failed: %v", err)
}
if cfg.Server.Listen != "0.0.0.0:8888" {
t.Errorf("expected listen 0.0.0.0:8888, got %q", cfg.Server.Listen)
}
if cfg.Logging.Level != "debug" {
t.Errorf("expected log level debug, got %q", cfg.Logging.Level)
}
// Non-overridden defaults should still be present.
if cfg.Logging.Format != "json" {
t.Errorf("expected default log format json, got %q", cfg.Logging.Format)
}
}
func TestEnvOverride(t *testing.T) {
t.Setenv("KINDEXR_LOGGING_LEVEL", "warn")
cfg, err := config.Load("")
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.Logging.Level != "warn" {
t.Errorf("expected logging level warn from env, got %q", cfg.Logging.Level)
}
}
func TestLoadMissingFile(t *testing.T) {
dir := t.TempDir()
nonExistent := filepath.Join(dir, "does-not-exist.yaml")
cfg, err := config.Load(nonExistent)
if err != nil {
t.Fatalf("Load with missing file should succeed, got error: %v", err)
}
// Should still have defaults.
if cfg.Server.Listen != "127.0.0.1:9117" {
t.Errorf("expected default listen, got %q", cfg.Server.Listen)
}
}
-275
View File
@@ -1,275 +0,0 @@
// Package db provides SQLite database access and migration management for kindexr.
package db
import (
"context"
"database/sql"
"embed"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
_ "modernc.org/sqlite" // pure-Go SQLite driver, no CGO
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// DB wraps *sql.DB with kindexr-specific helpers.
type DB struct {
*sql.DB
}
// Open opens (or creates) the SQLite database at path, applies pending
// migrations, and returns a ready-to-use *DB. The parent directory is created
// if it does not exist.
func Open(path string) (*DB, error) {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return nil, fmt.Errorf("db: create parent directory: %w", err)
}
dsn := "file:" + path + "?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
sqlDB, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("db: open: %w", err)
}
// SQLite WAL allows concurrent reads but serialises writes; one writer.
sqlDB.SetMaxOpenConns(1)
if err := sqlDB.Ping(); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("db: ping: %w", err)
}
d := &DB{sqlDB}
if err := d.migrate(); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("db: migrate: %w", err)
}
return d, nil
}
// migrate creates the schema_migrations table and applies any unapplied
// migration files found in the embedded migrations/ directory.
func (d *DB) migrate() error {
// Ensure the migrations tracking table exists.
_, err := d.DB.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
)`)
if err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
// Find the current applied version.
var currentVersion int
row := d.DB.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_migrations`)
if err := row.Scan(&currentVersion); err != nil {
return fmt.Errorf("query current version: %w", err)
}
// Read embedded migration files.
entries, err := migrationsFS.ReadDir("migrations")
if err != nil {
return fmt.Errorf("read embedded migrations: %w", err)
}
// Sort by filename so they apply in order.
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".sql") {
continue
}
// Parse version from prefix: "001_initial.sql" → 1
parts := strings.SplitN(name, "_", 2)
if len(parts) < 1 {
continue
}
version, err := strconv.Atoi(parts[0])
if err != nil {
return fmt.Errorf("parse version from %q: %w", name, err)
}
if version <= currentVersion {
continue // already applied
}
sqlBytes, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("read migration %q: %w", name, err)
}
statements := splitStatements(string(sqlBytes))
tx, err := d.DB.Begin()
if err != nil {
return fmt.Errorf("begin transaction for migration %d: %w", version, err)
}
for _, stmt := range statements {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
if _, err := tx.Exec(stmt); err != nil {
tx.Rollback()
return fmt.Errorf("migration %d, statement %q: %w", version, stmt[:min(len(stmt), 60)], err)
}
}
_, err = tx.Exec(
`INSERT INTO schema_migrations(version, applied_at) VALUES (?, datetime('now'))`,
version,
)
if err != nil {
tx.Rollback()
return fmt.Errorf("record migration %d: %w", version, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %d: %w", version, err)
}
}
return nil
}
// splitStatements splits a SQL script into individual statements, handling
// single-quoted strings, -- line comments, and BEGIN...END trigger bodies so
// that semicolons inside them are not treated as statement boundaries.
func splitStatements(sql string) []string {
var statements []string
var current strings.Builder
var word strings.Builder
inString := false
depth := 0 // tracks BEGIN...END nesting for trigger bodies
i := 0
flushWord := func() {
switch strings.ToUpper(word.String()) {
case "BEGIN":
depth++
case "END":
depth--
}
word.Reset()
}
for i < len(sql) {
ch := sql[i]
if inString {
current.WriteByte(ch)
if ch == '\'' {
if i+1 < len(sql) && sql[i+1] == '\'' {
current.WriteByte(sql[i+1])
i += 2
continue
}
inString = false
}
i++
continue
}
// Outside a string.
if ch == '\'' {
flushWord()
inString = true
current.WriteByte(ch)
i++
continue
}
// Line comment: skip to end of line.
if ch == '-' && i+1 < len(sql) && sql[i+1] == '-' {
flushWord()
for i < len(sql) && sql[i] != '\n' {
i++
}
continue
}
// Accumulate identifier/keyword characters.
if isWordChar(ch) {
word.WriteByte(ch)
current.WriteByte(ch)
i++
continue
}
// Non-word character: finalize any pending keyword.
flushWord()
if ch == ';' {
if depth == 0 {
stmt := strings.TrimSpace(current.String())
if stmt != "" {
statements = append(statements, stmt)
}
current.Reset()
} else {
current.WriteByte(ch)
}
i++
continue
}
current.WriteByte(ch)
i++
}
// Catch any trailing statement without a trailing semicolon.
flushWord()
if stmt := strings.TrimSpace(current.String()); stmt != "" {
statements = append(statements, stmt)
}
return statements
}
func isWordChar(ch byte) bool {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_'
}
// Stats holds aggregate statistics about the indexed content.
type Stats struct {
EventsTotal int64
LastEventAt *int64
}
// GetStats returns aggregate statistics from the torrents table.
func (d *DB) GetStats(ctx context.Context) (Stats, error) {
row := d.DB.QueryRowContext(ctx, `SELECT COUNT(*), MAX(created_at) FROM torrents`)
var s Stats
var lastEventAt sql.NullInt64
if err := row.Scan(&s.EventsTotal, &lastEventAt); err != nil {
return Stats{}, fmt.Errorf("get stats: %w", err)
}
if lastEventAt.Valid {
s.LastEventAt = &lastEventAt.Int64
}
return s, nil
}
// MigrationVersion returns the highest applied migration version.
func (d *DB) MigrationVersion(ctx context.Context) (int, error) {
var version int
row := d.DB.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM schema_migrations`)
if err := row.Scan(&version); err != nil {
return 0, fmt.Errorf("migration version: %w", err)
}
return version, nil
}
-122
View File
@@ -1,122 +0,0 @@
package db_test
import (
"context"
"database/sql"
"path/filepath"
"testing"
"git.utn.lol/enki/kindexr/internal/db"
)
func tempDB(t *testing.T) *db.DB {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "test.db")
d, err := db.Open(path)
if err != nil {
t.Fatalf("Open failed: %v", err)
}
t.Cleanup(func() { d.Close() })
return d
}
func TestOpen(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.db")
d, err := db.Open(path)
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
d.Close()
}
func TestMigrationsApplied(t *testing.T) {
d := tempDB(t)
ctx := context.Background()
version, err := d.MigrationVersion(ctx)
if err != nil {
t.Fatalf("MigrationVersion error: %v", err)
}
if version < 1 {
t.Errorf("expected at least 1 migration applied, got version %d", version)
}
}
func TestMigrationsIdempotent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.db")
d1, err := db.Open(path)
if err != nil {
t.Fatalf("first Open failed: %v", err)
}
d1.Close()
d2, err := db.Open(path)
if err != nil {
t.Fatalf("second Open failed (idempotency): %v", err)
}
d2.Close()
}
func TestSchema(t *testing.T) {
d := tempDB(t)
expectedTables := []string{
"torrents", "files", "trackers", "tags",
"publishers", "relays", "api_keys", "health", "comments",
}
for _, table := range expectedTables {
var name string
err := d.QueryRow(
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table,
).Scan(&name)
if err == sql.ErrNoRows {
t.Errorf("expected table %q to exist, but it does not", table)
} else if err != nil {
t.Errorf("querying for table %q: %v", table, err)
}
}
}
func TestFTS5(t *testing.T) {
d := tempDB(t)
// Insert a torrent row.
_, err := d.Exec(`
INSERT INTO torrents
(event_id, info_hash, pubkey, created_at, ingested_at, title, description, raw_event)
VALUES
('evt1', 'aabbcc', 'pubkey1', 1715000000, 1715000001, 'test torrent title', 'test description', '{}')
`)
if err != nil {
t.Fatalf("insert torrent: %v", err)
}
// FTS5 search.
var rowid int64
err = d.QueryRow(`SELECT rowid FROM torrents_fts WHERE torrents_fts MATCH 'test'`).Scan(&rowid)
if err == sql.ErrNoRows {
t.Error("FTS5 search for 'test' returned no rows; trigger may not be working")
} else if err != nil {
t.Fatalf("FTS5 search error: %v", err)
}
}
func TestGetStats(t *testing.T) {
d := tempDB(t)
ctx := context.Background()
stats, err := d.GetStats(ctx)
if err != nil {
t.Fatalf("GetStats error: %v", err)
}
if stats.EventsTotal != 0 {
t.Errorf("expected EventsTotal=0, got %d", stats.EventsTotal)
}
if stats.LastEventAt != nil {
t.Errorf("expected LastEventAt=nil, got %v", stats.LastEventAt)
}
}
@@ -1,120 +0,0 @@
CREATE TABLE torrents (
event_id TEXT PRIMARY KEY,
info_hash TEXT NOT NULL,
pubkey TEXT NOT NULL,
created_at INTEGER NOT NULL,
ingested_at INTEGER NOT NULL,
title TEXT NOT NULL,
description TEXT,
size_bytes INTEGER,
category TEXT,
newznab_cat INTEGER,
imdb_id TEXT,
tmdb_id TEXT,
tvdb_id TEXT,
season INTEGER,
episode INTEGER,
quality TEXT,
source TEXT,
raw_event TEXT NOT NULL
);
CREATE UNIQUE INDEX idx_torrents_event_id ON torrents(event_id);
CREATE INDEX idx_torrents_info_hash ON torrents(info_hash);
CREATE INDEX idx_torrents_pubkey ON torrents(pubkey);
CREATE INDEX idx_torrents_imdb ON torrents(imdb_id) WHERE imdb_id IS NOT NULL;
CREATE INDEX idx_torrents_tmdb ON torrents(tmdb_id) WHERE tmdb_id IS NOT NULL;
CREATE INDEX idx_torrents_tvdb ON torrents(tvdb_id) WHERE tvdb_id IS NOT NULL;
CREATE INDEX idx_torrents_created ON torrents(created_at DESC);
CREATE INDEX idx_torrents_cat ON torrents(newznab_cat);
CREATE VIRTUAL TABLE torrents_fts USING fts5(
title, description,
content='torrents',
content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
);
CREATE TRIGGER torrents_ai AFTER INSERT ON torrents BEGIN
INSERT INTO torrents_fts(rowid, title, description) VALUES (new.rowid, new.title, new.description);
END;
CREATE TRIGGER torrents_ad AFTER DELETE ON torrents BEGIN
INSERT INTO torrents_fts(torrents_fts, rowid, title, description) VALUES ('delete', old.rowid, old.title, old.description);
END;
CREATE TRIGGER torrents_au AFTER UPDATE ON torrents BEGIN
INSERT INTO torrents_fts(torrents_fts, rowid, title, description) VALUES ('delete', old.rowid, old.title, old.description);
INSERT INTO torrents_fts(rowid, title, description) VALUES (new.rowid, new.title, new.description);
END;
CREATE TABLE files (
event_id TEXT NOT NULL,
idx INTEGER NOT NULL,
path TEXT NOT NULL,
size_bytes INTEGER,
PRIMARY KEY (event_id, idx),
FOREIGN KEY (event_id) REFERENCES torrents(event_id) ON DELETE CASCADE
);
CREATE TABLE trackers (
event_id TEXT NOT NULL,
url TEXT NOT NULL,
PRIMARY KEY (event_id, url),
FOREIGN KEY (event_id) REFERENCES torrents(event_id) ON DELETE CASCADE
);
CREATE TABLE tags (
event_id TEXT NOT NULL,
tag TEXT NOT NULL,
PRIMARY KEY (event_id, tag),
FOREIGN KEY (event_id) REFERENCES torrents(event_id) ON DELETE CASCADE
);
CREATE INDEX idx_tags_tag ON tags(tag);
CREATE TABLE publishers (
pubkey TEXT PRIMARY KEY,
npub TEXT,
name TEXT,
trust REAL DEFAULT 0,
notes TEXT,
blocked INTEGER DEFAULT 0,
torrents_n INTEGER DEFAULT 0,
first_seen INTEGER,
last_seen INTEGER
);
CREATE TABLE relays (
url TEXT PRIMARY KEY,
enabled INTEGER DEFAULT 1,
last_event INTEGER,
last_sync INTEGER,
notes TEXT
);
CREATE TABLE api_keys (
key TEXT PRIMARY KEY,
label TEXT NOT NULL,
created_at INTEGER NOT NULL,
visibility TEXT NOT NULL DEFAULT 'all',
curation_set TEXT,
last_used INTEGER
);
CREATE TABLE health (
info_hash TEXT PRIMARY KEY,
seeders INTEGER,
leechers INTEGER,
checked_at INTEGER NOT NULL
);
CREATE TABLE comments (
event_id TEXT PRIMARY KEY,
torrent_event_id TEXT NOT NULL,
pubkey TEXT NOT NULL,
created_at INTEGER NOT NULL,
content TEXT NOT NULL,
raw_event TEXT NOT NULL,
FOREIGN KEY (torrent_event_id) REFERENCES torrents(event_id) ON DELETE CASCADE
);
@@ -1,14 +0,0 @@
-- SQLite does not support DROP COLUMN before 3.35.0; use table rebuild.
CREATE TABLE api_keys_new (
key TEXT PRIMARY KEY,
label TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used INTEGER
);
INSERT INTO api_keys_new (key, label, created_at, last_used)
SELECT key, label, created_at, last_used FROM api_keys;
DROP TABLE api_keys;
ALTER TABLE api_keys_new RENAME TO api_keys;
-343
View File
@@ -1,343 +0,0 @@
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
}
// 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 FROM api_keys WHERE key = ?`, key,
).Scan(&ak.Key, &ak.Label, &ak.CreatedAt)
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 string) error {
_, err := d.DB.ExecContext(ctx,
`INSERT INTO api_keys (key, label, created_at) VALUES (?, ?, ?)`,
key, label, time.Now().Unix(),
)
if err != nil {
return fmt.Errorf("create api key: %w", err)
}
return nil
}
// GetRelayLastEvent returns the created_at of the most recent event seen from
// the given relay, or 0 if none has been recorded.
func (d *DB) GetRelayLastEvent(ctx context.Context, url string) (int64, error) {
var ts sql.NullInt64
err := d.DB.QueryRowContext(ctx,
`SELECT last_event FROM relays WHERE url = ?`, url,
).Scan(&ts)
if err == sql.ErrNoRows {
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("get relay last event: %w", err)
}
return ts.Int64, 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
@@ -1,130 +0,0 @@
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
@@ -1,157 +0,0 @@
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)
}
}
-140
View File
@@ -1,140 +0,0 @@
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.
// Backoff resets to the base delay if the connection stayed up long enough to
// be considered successful (30 seconds), so a briefly-dropped live relay
// reconnects quickly rather than waiting minutes.
func (rd *Reader) connectLoop(ctx context.Context, relayURL string) {
const baseBackoff = 5 * time.Second
const maxBackoff = 5 * time.Minute
const successThreshold = 30 * time.Second
backoff := baseBackoff
for {
if ctx.Err() != nil {
return
}
start := time.Now()
if err := rd.runRelay(ctx, relayURL); err != nil {
slog.Warn("relay disconnected", "url", relayURL, "err", err, "retry_in", backoff)
}
if time.Since(start) >= successThreshold {
backoff = baseBackoff
}
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)
backfillSince := time.Now().Add(-time.Duration(rd.cfg.BackfillDays) * 24 * time.Hour).Unix()
lastEvent, _ := rd.db.GetRelayLastEvent(ctx, relayURL)
if lastEvent > backfillSince {
backfillSince = lastEvent
}
since := nostrlib.Timestamp(backfillSince)
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)
}
-93
View File
@@ -1,93 +0,0 @@
// Package server provides the HTTP server for kindexr, including the /health endpoint.
package server
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"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
connectedRelays func() int // returns live relay count; nil = always 0
}
// New creates a new Server with the given config, database, and version string.
func New(cfg *config.Config, database *db.DB, version string) *Server {
s := &Server{
cfg: cfg,
db: database,
version: version,
}
s.router = s.buildRouter()
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
}
// buildRouter wires up all routes and middleware.
func (s *Server) buildRouter() chi.Router {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Get("/health", s.healthHandler)
tz := torznab.New(s.cfg, s.db, s.version)
tz.Mount(r)
return r
}
// healthResponse is the JSON body returned by GET /health.
type healthResponse struct {
Status string `json:"status"`
Version string `json:"version"`
RelaysConnected int `json:"relays_connected"`
EventsTotal int64 `json:"events_total"`
LastEventAt *int64 `json:"last_event_at"`
}
// healthHandler handles GET /health.
func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
stats, err := s.db.GetStats(r.Context())
if err != nil {
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
return
}
relaysConnected := 0
if s.connectedRelays != nil {
relaysConnected = s.connectedRelays()
}
resp := healthResponse{
Status: "ok",
Version: s.version,
RelaysConnected: relaysConnected,
EventsTotal: stats.EventsTotal,
LastEventAt: stats.LastEventAt,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(resp)
}
-114
View File
@@ -1,114 +0,0 @@
package server_test
import (
"encoding/json"
"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/server"
)
func newTestServer(t *testing.T) *httptest.Server {
t.Helper()
dir := t.TempDir()
database, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open test db: %v", err)
}
t.Cleanup(func() { database.Close() })
cfg, err := config.Load("")
if err != nil {
t.Fatalf("load config: %v", err)
}
srv := server.New(cfg, database, "0.1.0-test")
return httptest.NewServer(srv.Handler())
}
func TestHealthEndpoint(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
resp, err := http.Get(ts.URL + "/health")
if err != nil {
t.Fatalf("GET /health: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected status 200, got %d", resp.StatusCode)
}
var body map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode JSON: %v", err)
}
if body["status"] != "ok" {
t.Errorf("expected status=ok, got %v", body["status"])
}
if _, ok := body["version"]; !ok {
t.Error("expected 'version' field in response")
}
if rc, ok := body["relays_connected"]; !ok {
t.Error("expected 'relays_connected' field in response")
} else if rc.(float64) != 0 {
t.Errorf("expected relays_connected=0, got %v", rc)
}
if et, ok := body["events_total"]; !ok {
t.Error("expected 'events_total' field in response")
} else if et.(float64) != 0 {
t.Errorf("expected events_total=0, got %v", et)
}
// last_event_at should be present (null is fine for empty DB).
if _, ok := body["last_event_at"]; !ok {
t.Error("expected 'last_event_at' field in response")
}
}
func TestHealthContentType(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
resp, err := http.Get(ts.URL + "/health")
if err != nil {
t.Fatalf("GET /health: %v", err)
}
defer resp.Body.Close()
ct := resp.Header.Get("Content-Type")
if !strings.HasPrefix(ct, "application/json") {
t.Errorf("expected Content-Type application/json, got %q", ct)
}
}
func TestHealthMethod(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
// GET should return 200.
resp, err := http.Get(ts.URL + "/health")
if err != nil {
t.Fatalf("GET /health: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("GET /health: expected 200, got %d", resp.StatusCode)
}
// POST should return 405.
resp, err = http.Post(ts.URL+"/health", "application/json", nil)
if err != nil {
t.Fatalf("POST /health: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("POST /health: expected 405, got %d", resp.StatusCode)
}
}
-69
View File
@@ -1,69 +0,0 @@
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
@@ -1,46 +0,0 @@
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
}
}
-157
View File
@@ -1,157 +0,0 @@
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)
attrs := []TorznabAttr{
{Name: "infohash", Value: row.InfoHash},
{Name: "magneturl", Value: magnet},
{Name: "downloadvolumefactor", Value: "0"},
{Name: "uploadvolumefactor", Value: "1"},
}
var sizeBytes int64
if row.SizeBytes != nil {
sizeBytes = *row.SizeBytes
attrs = append(attrs, TorznabAttr{Name: "size", Value: fmt.Sprintf("%d", sizeBytes)})
}
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: sizeBytes,
Enclosure: RSSEnclosure{
URL: magnet,
Length: sizeBytes,
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
@@ -1,69 +0,0 @@
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)
}
-175
View File
@@ -1,175 +0,0 @@
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 newTestServerWithDB(t *testing.T) (*httptest.Server, string, *db.DB) {
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)
}
keyBytes := make([]byte, 32)
rand.Read(keyBytes)
apiKey := hex.EncodeToString(keyBytes)
if err := database.CreateAPIKey(context.Background(), apiKey, "test"); 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, database
}
func newTestServer(t *testing.T) (*httptest.Server, string) {
ts, apiKey, _ := newTestServerWithDB(t)
return ts, 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, database := newTestServerWithDB(t)
defer ts.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)
}
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)
}
var rss torznab.RSS
if err := xml.NewDecoder(resp.Body).Decode(&rss); err != nil {
t.Fatalf("decode RSS: %v", err)
}
if len(rss.Channel.Items) == 0 {
t.Error("expected at least one search result")
}
if rss.Channel.Items[0].Title != "Breaking.Bad.S01E01.1080p.WEB-DL" {
t.Errorf("unexpected first result title: %q", rss.Channel.Items[0].Title)
}
}
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
@@ -1,114 +0,0 @@
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"`
}