stremio/node/media-node/internal/database/store.go
Jos Vooges | STH a5c6fd5fe4 Initial commit: Distributed Stremio Media Cluster V1
Master API, Admin UI, Go media-node, Docker/Dokploy deploy configs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 00:17:04 +02:00

256 lines
6.7 KiB
Go

package database
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"time"
_ "modernc.org/sqlite"
)
type Store struct {
db *sql.DB
}
type LocalFile struct {
LocalFileID string
Path string
SizeBytes int64
ModifiedAt time.Time
MediaType string
ReleaseName string
Revision int64
}
type PlaybackSession struct {
SessionID string
TokenHash string
LocalFileID string
IdleTimeoutSeconds int
AbsoluteExpiresAt time.Time
LastActivity time.Time
}
func Open(dataDir string) (*Store, error) {
if err := os.MkdirAll(dataDir, 0o750); err != nil {
return nil, err
}
dbPath := filepath.Join(dataDir, "node.db")
db, err := sql.Open("sqlite", dbPath+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
if err != nil {
return nil, err
}
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
return nil, err
}
return s, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) migrate() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS local_files (
local_file_id TEXT PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
size_bytes INTEGER NOT NULL,
modified_at TEXT NOT NULL,
media_type TEXT NOT NULL,
release_name TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS playback_sessions (
session_id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
local_file_id TEXT NOT NULL,
idle_timeout_seconds INTEGER NOT NULL,
absolute_expires_at TEXT NOT NULL,
last_activity TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS node_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON playback_sessions(token_hash);
`)
return err
}
func (s *Store) UpsertFile(f LocalFile) error {
_, err := s.db.Exec(`
INSERT INTO local_files (local_file_id, path, size_bytes, modified_at, media_type, release_name, revision)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(local_file_id) DO UPDATE SET
path=excluded.path,
size_bytes=excluded.size_bytes,
modified_at=excluded.modified_at,
media_type=excluded.media_type,
release_name=excluded.release_name,
revision=excluded.revision
`, f.LocalFileID, f.Path, f.SizeBytes, f.ModifiedAt.UTC().Format(time.RFC3339),
f.MediaType, f.ReleaseName, f.Revision)
return err
}
func (s *Store) DeleteFile(localFileID string) error {
_, err := s.db.Exec(`DELETE FROM local_files WHERE local_file_id = ?`, localFileID)
return err
}
func (s *Store) GetFile(localFileID string) (*LocalFile, error) {
row := s.db.QueryRow(`
SELECT local_file_id, path, size_bytes, modified_at, media_type, release_name, revision
FROM local_files WHERE local_file_id = ?
`, localFileID)
return scanFile(row)
}
func (s *Store) GetFileByPath(path string) (*LocalFile, error) {
row := s.db.QueryRow(`
SELECT local_file_id, path, size_bytes, modified_at, media_type, release_name, revision
FROM local_files WHERE path = ?
`, path)
return scanFile(row)
}
func (s *Store) AllFiles() ([]LocalFile, error) {
rows, err := s.db.Query(`
SELECT local_file_id, path, size_bytes, modified_at, media_type, release_name, revision
FROM local_files ORDER BY release_name
`)
if err != nil {
return nil, err
}
defer rows.Close()
var files []LocalFile
for rows.Next() {
f, err := scanFile(rows)
if err != nil {
return nil, err
}
files = append(files, *f)
}
return files, rows.Err()
}
func (s *Store) FileCount() (int, error) {
var count int
err := s.db.QueryRow(`SELECT COUNT(*) FROM local_files`).Scan(&count)
return count, err
}
func (s *Store) NextRevision() (int64, error) {
var rev int64
err := s.db.QueryRow(`SELECT COALESCE(MAX(revision), 0) + 1 FROM local_files`).Scan(&rev)
return rev, err
}
func (s *Store) MaxRevision() (int64, error) {
var rev int64
err := s.db.QueryRow(`SELECT COALESCE(MAX(revision), 0) FROM local_files`).Scan(&rev)
return rev, err
}
func (s *Store) SaveSession(sess PlaybackSession) error {
_, err := s.db.Exec(`
INSERT OR REPLACE INTO playback_sessions
(session_id, token_hash, local_file_id, idle_timeout_seconds, absolute_expires_at, last_activity)
VALUES (?, ?, ?, ?, ?, ?)
`, sess.SessionID, sess.TokenHash, sess.LocalFileID, sess.IdleTimeoutSeconds,
sess.AbsoluteExpiresAt.UTC().Format(time.RFC3339), sess.LastActivity.UTC().Format(time.RFC3339))
return err
}
func (s *Store) GetSessionByTokenHash(tokenHash string) (*PlaybackSession, error) {
row := s.db.QueryRow(`
SELECT session_id, token_hash, local_file_id, idle_timeout_seconds, absolute_expires_at, last_activity
FROM playback_sessions WHERE token_hash = ?
`, tokenHash)
return scanSession(row)
}
func (s *Store) DeleteSession(sessionID string) error {
_, err := s.db.Exec(`DELETE FROM playback_sessions WHERE session_id = ?`, sessionID)
return err
}
func (s *Store) TouchSession(sessionID string) error {
_, err := s.db.Exec(`UPDATE playback_sessions SET last_activity = ? WHERE session_id = ?`,
time.Now().UTC().Format(time.RFC3339), sessionID)
return err
}
func (s *Store) ActiveSessions() ([]PlaybackSession, error) {
rows, err := s.db.Query(`
SELECT session_id, token_hash, local_file_id, idle_timeout_seconds, absolute_expires_at, last_activity
FROM playback_sessions
`)
if err != nil {
return nil, err
}
defer rows.Close()
var sessions []PlaybackSession
for rows.Next() {
sess, err := scanSession(rows)
if err != nil {
return nil, err
}
sessions = append(sessions, *sess)
}
return sessions, rows.Err()
}
func (s *Store) SetState(key, value string) error {
_, err := s.db.Exec(`
INSERT INTO node_state (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value
`, key, value)
return err
}
func (s *Store) GetState(key string) (string, error) {
var value string
err := s.db.QueryRow(`SELECT value FROM node_state WHERE key = ?`, key).Scan(&value)
if err == sql.ErrNoRows {
return "", nil
}
return value, err
}
func FileID(path string) string {
sum := sha256.Sum256([]byte(path))
return fmt.Sprintf("f-%s", hex.EncodeToString(sum[:])[:12])
}
type scannable interface {
Scan(dest ...any) error
}
func scanFile(row scannable) (*LocalFile, error) {
var f LocalFile
var modStr string
if err := row.Scan(&f.LocalFileID, &f.Path, &f.SizeBytes, &modStr, &f.MediaType, &f.ReleaseName, &f.Revision); err != nil {
return nil, err
}
f.ModifiedAt, _ = time.Parse(time.RFC3339, modStr)
return &f, nil
}
func scanSession(row scannable) (*PlaybackSession, error) {
var s PlaybackSession
var absStr, lastStr string
if err := row.Scan(&s.SessionID, &s.TokenHash, &s.LocalFileID, &s.IdleTimeoutSeconds, &absStr, &lastStr); err != nil {
return nil, err
}
s.AbsoluteExpiresAt, _ = time.Parse(time.RFC3339, absStr)
s.LastActivity, _ = time.Parse(time.RFC3339, lastStr)
return &s, nil
}