1933306392
- 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
344 lines
9.2 KiB
Go
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
|
|
}
|