Files
kindexr/internal/nostr/parser.go
T
enki 2e491e20c1 feat: Phase 1 — Nostr reader + Torznab API
Reader (internal/nostr):
- Connects to all configured relays via WebSocket
- Subscribes to kind 2003 (NIP-35) events since (now - backfill_days)
- Parses x/title/file/tracker/i/t tags into DB rows
- Reconnects with exponential backoff (5s → 5min) on disconnect

Torznab API (internal/torznab):
- GET /api?t=caps  — XML capabilities doc (exact shape Sonarr expects)
- GET /api?t=search — FTS5 full-text search over title+description
- All other t= types (tvsearch, movie, etc.) route to the same search handler
- API key auth via ?apikey= query param; 401 XML error on missing/bad key
- Magnet links built from info_hash + event tracker tags

DB (internal/db/queries.go):
- InsertTorrent with upsert + related rows (trackers, tags, files)
- Search with FTS5 + optional category filter (parent cat expands to range)
- GetAPIKey, CreateAPIKey, UpsertRelay, UpdateRelaySync/LastEvent

CLI (cmd/kindexr-cli):
- apikey create --label <name> [--visibility all|wot|curated]

Health endpoint now reports live relays_connected count.
2026-05-16 19:19:49 -07:00

131 lines
3.0 KiB
Go

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:")
}
}