Files
kindexr/internal/db/queries.go
T
enki 1933306392 cleanup: pre-phase-2 fixes
- fix two stale nzbstr comment refs
- migration 002: drop api_keys.visibility and curation_set
- remove Visibility from APIKey struct, GetAPIKey, CreateAPIKey, CLI
- omit torznab size/seeders/peers attrs when data is absent
- reset relay backoff on successful connection (>= 30s uptime)
- use last_event from relays table as since on reconnect
- fix TestSearchWithResults to actually query the test server DB
2026-05-16 23:21:22 -07:00

344 lines
9.2 KiB
Go

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
}