diff --git a/cmd/kindexr-cli/main.go b/cmd/kindexr-cli/main.go index 43a7212..f62a78a 100644 --- a/cmd/kindexr-cli/main.go +++ b/cmd/kindexr-cli/main.go @@ -26,7 +26,7 @@ import ( func main() { if len(os.Args) < 3 { fmt.Fprintln(os.Stderr, "usage: kindexr-cli [flags]") - fmt.Fprintln(os.Stderr, " kindexr-cli apikey create --label [--visibility all|wot|curated]") + fmt.Fprintln(os.Stderr, " kindexr-cli apikey create --label ") os.Exit(1) } @@ -57,7 +57,6 @@ 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) } @@ -87,7 +86,7 @@ func runAPIKeyCreate(args []string) { } key := hex.EncodeToString(keyBytes) - if err := database.CreateAPIKey(context.Background(), key, *label, *visibility); err != nil { + if err := database.CreateAPIKey(context.Background(), key, *label); err != nil { fmt.Fprintf(os.Stderr, "error creating api key: %v\n", err) os.Exit(1) } diff --git a/internal/config/config.go b/internal/config/config.go index 1c532d2..8fbb824 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -171,7 +171,7 @@ func Defaults() *Config { } // Load reads config from defaults, then optionally from a YAML file, then from -// environment variables with the NZBSTR_ prefix. If path is empty or the file +// 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(".") diff --git a/internal/db/db.go b/internal/db/db.go index 6c51b74..3bdb553 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -18,7 +18,7 @@ import ( //go:embed migrations/*.sql var migrationsFS embed.FS -// DB wraps *sql.DB with nzbstr-specific helpers. +// DB wraps *sql.DB with kindexr-specific helpers. type DB struct { *sql.DB } diff --git a/internal/db/migrations/002_drop_apikey_visibility.sql b/internal/db/migrations/002_drop_apikey_visibility.sql new file mode 100644 index 0000000..0ea6334 --- /dev/null +++ b/internal/db/migrations/002_drop_apikey_visibility.sql @@ -0,0 +1,14 @@ +-- 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; diff --git a/internal/db/queries.go b/internal/db/queries.go index c9ebd8e..22fea2b 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -230,18 +230,17 @@ func (d *DB) GetTrackers(ctx context.Context, eventID string) ([]string, error) // APIKey holds a validated API key record. type APIKey struct { - Key string - Label string - CreatedAt int64 - Visibility string + 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, visibility FROM api_keys WHERE key = ?`, key, - ).Scan(&ak.Key, &ak.Label, &ak.CreatedAt, &ak.Visibility) + `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 } @@ -254,10 +253,10 @@ func (d *DB) GetAPIKey(ctx context.Context, key string) (*APIKey, error) { } // CreateAPIKey inserts a new API key record. -func (d *DB) CreateAPIKey(ctx context.Context, key, label, visibility string) error { +func (d *DB) CreateAPIKey(ctx context.Context, key, label string) error { _, err := d.DB.ExecContext(ctx, - `INSERT INTO api_keys (key, label, created_at, visibility) VALUES (?, ?, ?, ?)`, - key, label, time.Now().Unix(), visibility, + `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) @@ -265,6 +264,22 @@ func (d *DB) CreateAPIKey(ctx context.Context, key, label, visibility string) er 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, diff --git a/internal/nostr/reader.go b/internal/nostr/reader.go index 92ad73c..75fbeed 100644 --- a/internal/nostr/reader.go +++ b/internal/nostr/reader.go @@ -40,19 +40,30 @@ func (rd *Reader) Start(ctx context.Context) { } // 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) { - backoff := 5 * time.Second + 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 @@ -79,9 +90,12 @@ func (rd *Reader) runRelay(ctx context.Context, relayURL string) error { _ = 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()) + 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}, diff --git a/internal/torznab/search.go b/internal/torznab/search.go index 6d32a96..a9dd784 100644 --- a/internal/torznab/search.go +++ b/internal/torznab/search.go @@ -67,19 +67,16 @@ func buildItem(baseURL string, row db.TorrentRow, trackers []string) RSSItem { 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"}, + } + 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 @@ -109,10 +106,10 @@ func buildItem(baseURL string, row db.TorrentRow, trackers []string) RSSItem { GUID: RSSGUID{IsPermaLink: "false", Value: guid}, Link: magnet, PubDate: pubDate, - Size: sizeVal, + Size: sizeBytes, Enclosure: RSSEnclosure{ URL: magnet, - Length: sizeVal, + Length: sizeBytes, Type: "application/x-bittorrent", }, Attrs: attrs, diff --git a/internal/torznab/server_test.go b/internal/torznab/server_test.go index d1eabe5..431e082 100644 --- a/internal/torznab/server_test.go +++ b/internal/torznab/server_test.go @@ -17,7 +17,7 @@ import ( "github.com/go-chi/chi/v5" ) -func newTestServer(t *testing.T) (*httptest.Server, string) { +func newTestServerWithDB(t *testing.T) (*httptest.Server, string, *db.DB) { t.Helper() dir := t.TempDir() @@ -32,11 +32,10 @@ func newTestServer(t *testing.T) (*httptest.Server, string) { 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 { + if err := database.CreateAPIKey(context.Background(), apiKey, "test"); err != nil { t.Fatalf("create api key: %v", err) } @@ -44,7 +43,12 @@ func newTestServer(t *testing.T) (*httptest.Server, string) { srv := torznab.New(cfg, database, "0.1.0-test") srv.Mount(r) - return httptest.NewServer(r), apiKey + 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) { @@ -115,17 +119,9 @@ func TestSearchEmpty(t *testing.T) { } func TestSearchWithResults(t *testing.T) { - ts, apiKey := newTestServer(t) + ts, apiKey, database := newTestServerWithDB(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", @@ -142,8 +138,6 @@ func TestSearchWithResults(t *testing.T) { 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) @@ -153,6 +147,17 @@ func TestSearchWithResults(t *testing.T) { 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) {