package store import ( "database/sql" "fmt" "os" "path/filepath" "time" _ "modernc.org/sqlite" "ncore-hnr/internal/model" ) type Store struct { db *sql.DB } type State struct { FirstSeenAt time.Time LastSeenAt time.Time } func Open(path string) (*Store, error) { if dir := filepath.Dir(path); dir != "." && dir != "" { if err := os.MkdirAll(dir, 0o755); err != nil { return nil, err } } db, err := sql.Open("sqlite", path) if err != nil { return nil, err } store := &Store{db: db} if err := store.migrate(); err != nil { _ = db.Close() return nil, err } return store, nil } func (s *Store) Close() error { return s.db.Close() } func (s *Store) UpsertSeen(torrent model.Torrent, now time.Time) (State, error) { existing, err := s.state(torrent.ID) if err != nil { return State{}, err } firstSeen := now if !existing.FirstSeenAt.IsZero() { firstSeen = existing.FirstSeenAt } _, err = s.db.Exec(` INSERT INTO torrents ( ncore_id, name, first_seen_at, last_seen_at, status, hnr_marked, uploaded_text, downloaded_text, remaining_text, ratio_text ) VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?) ON CONFLICT(ncore_id) DO UPDATE SET name = excluded.name, last_seen_at = excluded.last_seen_at, status = 'active', hnr_marked = excluded.hnr_marked, uploaded_text = excluded.uploaded_text, downloaded_text = excluded.downloaded_text, remaining_text = excluded.remaining_text, ratio_text = excluded.ratio_text `, torrent.ID, torrent.Name, firstSeen.Format(time.RFC3339), now.Format(time.RFC3339), boolInt(torrent.HnRMarked), torrent.Uploaded, torrent.Downloaded, torrent.Remaining, torrent.Ratio) if err != nil { return State{}, err } return State{FirstSeenAt: firstSeen, LastSeenAt: now}, nil } func (s *Store) MarkResolved(activeIDs map[int64]bool, now time.Time) error { rows, err := s.db.Query(`SELECT ncore_id FROM torrents WHERE status = 'active'`) if err != nil { return err } defer rows.Close() var resolved []int64 for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return err } if !activeIDs[id] { resolved = append(resolved, id) } } if err := rows.Err(); err != nil { return err } for _, id := range resolved { if _, err := s.db.Exec(`UPDATE torrents SET status = 'resolved', last_resolved_at = ? WHERE ncore_id = ?`, now.Format(time.RFC3339), id); err != nil { return err } } return nil } func (s *Store) RecordQBit(torrent model.Torrent, qbit model.QBitTorrent, now time.Time, forceStarted bool, reannounced bool) error { _, err := s.db.Exec(` UPDATE torrents SET qbit_hash = ?, qbit_name = ?, qbit_state = ?, qbit_progress = ?, qbit_ratio = ?, qbit_uploaded = ?, qbit_downloaded = ?, qbit_last_activity = ?, last_action_at = CASE WHEN ? OR ? THEN ? ELSE last_action_at END WHERE ncore_id = ? `, qbit.Hash, qbit.Name, qbit.State, qbit.Progress, qbit.Ratio, qbit.Uploaded, qbit.Downloaded, qbit.LastActivity, forceStarted, reannounced, now.Format(time.RFC3339), torrent.ID) return err } func (s *Store) MarkManualNeeded(id int64, now time.Time) error { _, err := s.db.Exec(` UPDATE torrents SET status = 'manual_needed', manual_needed_at = COALESCE(manual_needed_at, ?) WHERE ncore_id = ? `, now.Format(time.RFC3339), id) return err } func (s *Store) InsertRun(summary model.RunSummary) error { _, err := s.db.Exec(` INSERT INTO runs (started_at, dry_run, total_risk, matched, unmatched, force_started, reannounced, manual_needed) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `, summary.StartedAt.Format(time.RFC3339), boolInt(summary.DryRun), summary.TotalRisk, summary.Matched, summary.Unmatched, summary.ForceStarted, summary.Reannounced, summary.ManualNeeded) return err } func (s *Store) Status() (model.StatusSnapshot, error) { torrents, err := s.torrentStatuses("") if err != nil { return model.StatusSnapshot{}, err } return model.StatusSnapshot{Torrents: torrents}, nil } func (s *Store) Stats() (model.StatsSnapshot, error) { counts, err := s.statusCounts() if err != nil { return model.StatsSnapshot{}, err } lastRun, err := s.lastRun() if err != nil { return model.StatsSnapshot{}, err } manualNeeded, err := s.torrentStatuses("manual_needed") if err != nil { return model.StatsSnapshot{}, err } return model.StatsSnapshot{ Counts: counts, LastRun: lastRun, ManualNeeded: manualNeeded, }, nil } func (s *Store) state(id int64) (State, error) { var firstText string var lastText string err := s.db.QueryRow(`SELECT first_seen_at, last_seen_at FROM torrents WHERE ncore_id = ?`, id).Scan(&firstText, &lastText) if err == sql.ErrNoRows { return State{}, nil } if err != nil { return State{}, err } firstSeen, err := time.Parse(time.RFC3339, firstText) if err != nil { return State{}, fmt.Errorf("parse first_seen_at for %d: %w", id, err) } lastSeen, err := time.Parse(time.RFC3339, lastText) if err != nil { return State{}, fmt.Errorf("parse last_seen_at for %d: %w", id, err) } return State{FirstSeenAt: firstSeen, LastSeenAt: lastSeen}, nil } func (s *Store) statusCounts() ([]model.StatusCount, error) { rows, err := s.db.Query(`SELECT status, COUNT(*) FROM torrents GROUP BY status ORDER BY status`) if err != nil { return nil, err } defer rows.Close() var counts []model.StatusCount for rows.Next() { var count model.StatusCount if err := rows.Scan(&count.Status, &count.Count); err != nil { return nil, err } counts = append(counts, count) } return counts, rows.Err() } func (s *Store) lastRun() (*model.RunRecord, error) { var run model.RunRecord var dryRun int err := s.db.QueryRow(` SELECT started_at, dry_run, total_risk, matched, unmatched, force_started, reannounced, manual_needed FROM runs ORDER BY id DESC LIMIT 1 `).Scan(&run.StartedAt, &dryRun, &run.TotalRisk, &run.Matched, &run.Unmatched, &run.ForceStarted, &run.Reannounced, &run.ManualNeeded) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } run.DryRun = dryRun != 0 return &run, nil } func (s *Store) torrentStatuses(status string) ([]model.TorrentStatus, error) { query := ` SELECT ncore_id, name, status, first_seen_at, last_seen_at, COALESCE(last_resolved_at, ''), hnr_marked, COALESCE(qbit_name, ''), COALESCE(qbit_state, ''), COALESCE(qbit_progress, 0), COALESCE(qbit_ratio, 0), COALESCE(last_action_at, ''), COALESCE(manual_needed_at, '') FROM torrents ` var args []any if status != "" { query += ` WHERE status = ?` args = append(args, status) } query += ` ORDER BY CASE status WHEN 'manual_needed' THEN 0 WHEN 'active' THEN 1 WHEN 'resolved' THEN 2 ELSE 3 END, last_seen_at DESC, name ASC ` rows, err := s.db.Query(query, args...) if err != nil { return nil, err } defer rows.Close() var torrents []model.TorrentStatus for rows.Next() { var torrent model.TorrentStatus var hnrMarked int if err := rows.Scan( &torrent.ID, &torrent.Name, &torrent.Status, &torrent.FirstSeenAt, &torrent.LastSeenAt, &torrent.LastResolvedAt, &hnrMarked, &torrent.QBitName, &torrent.QBitState, &torrent.QBitProgress, &torrent.QBitRatio, &torrent.LastActionAt, &torrent.ManualNeededAt, ); err != nil { return nil, err } torrent.HnRMarked = hnrMarked != 0 torrents = append(torrents, torrent) } return torrents, rows.Err() } func (s *Store) migrate() error { _, err := s.db.Exec(` CREATE TABLE IF NOT EXISTS torrents ( ncore_id INTEGER PRIMARY KEY, name TEXT NOT NULL, first_seen_at TEXT NOT NULL, last_seen_at TEXT NOT NULL, last_resolved_at TEXT, status TEXT NOT NULL, hnr_marked INTEGER NOT NULL DEFAULT 0, uploaded_text TEXT, downloaded_text TEXT, remaining_text TEXT, ratio_text TEXT, qbit_hash TEXT, qbit_name TEXT, qbit_state TEXT, qbit_progress REAL, qbit_ratio REAL, qbit_uploaded INTEGER, qbit_downloaded INTEGER, qbit_last_activity INTEGER, last_action_at TEXT, manual_needed_at TEXT ); CREATE TABLE IF NOT EXISTS runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, started_at TEXT NOT NULL, dry_run INTEGER NOT NULL, total_risk INTEGER NOT NULL, matched INTEGER NOT NULL, unmatched INTEGER NOT NULL, force_started INTEGER NOT NULL, reannounced INTEGER NOT NULL, manual_needed INTEGER NOT NULL ); `) return err } func boolInt(value bool) int { if value { return 1 } return 0 }