2e491e20c1
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.
131 lines
3.0 KiB
Go
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:")
|
|
}
|
|
}
|