added cargo files

This commit is contained in:
2026-03-03 10:57:43 -05:00
parent 478a90e01b
commit 169df46bc2
813 changed files with 227273 additions and 9 deletions

View File

@@ -0,0 +1,290 @@
package db
import (
"context"
"database/sql"
"fmt"
"log"
"net/url"
"os"
"pinepods/gpodder-api/config"
"regexp"
"strings"
"time"
_ "github.com/go-sql-driver/mysql" // MySQL driver
_ "github.com/lib/pq" // PostgreSQL driver
)
// Database represents a database connection that can be either PostgreSQL or MySQL
type Database struct {
*sql.DB
Type string // "postgresql" or "mysql"
}
// NewDatabase creates a new database connection based on the DB_TYPE environment variable
func NewDatabase(cfg config.DatabaseConfig) (*Database, error) {
// Print connection details for debugging (hide password for security)
fmt.Printf("Connecting to %s database: host=%s port=%d user=%s dbname=%s\n",
cfg.Type, cfg.Host, cfg.Port, cfg.User, cfg.DBName)
var db *sql.DB
var err error
switch cfg.Type {
case "postgresql":
db, err = connectPostgreSQL(cfg)
case "mysql", "mariadb":
db, err = connectMySQL(cfg)
default:
return nil, fmt.Errorf("unsupported database type: %s", cfg.Type)
}
if err != nil {
return nil, err
}
// Test the connection
if err := db.Ping(); err != nil {
db.Close()
if strings.Contains(err.Error(), "password authentication failed") {
// Print environment variables (hide password)
fmt.Println("Password authentication failed. Environment variables:")
fmt.Printf("DB_HOST=%s\n", os.Getenv("DB_HOST"))
fmt.Printf("DB_PORT=%s\n", os.Getenv("DB_PORT"))
fmt.Printf("DB_USER=%s\n", os.Getenv("DB_USER"))
fmt.Printf("DB_NAME=%s\n", os.Getenv("DB_NAME"))
fmt.Printf("DB_PASSWORD=*** (length: %d)\n", len(os.Getenv("DB_PASSWORD")))
}
return nil, fmt.Errorf("failed to ping database: %w", err)
}
fmt.Println("Successfully connected to the database")
// Migrations are now handled by the Python migration system
// Skip Go migrations to avoid conflicts
log.Println("Skipping Go migrations - now handled by Python migration system")
return &Database{DB: db, Type: cfg.Type}, nil
}
// runMigrationsWithRetry - DISABLED: migrations now handled by Python system
// func runMigrationsWithRetry(db *sql.DB, dbType string) error {
// All migration logic has been moved to the Python migration system
// to ensure consistency and centralized management
// This function is kept for reference but is no longer used
// }
// connectPostgreSQL connects to a PostgreSQL database
func connectPostgreSQL(cfg config.DatabaseConfig) (*sql.DB, error) {
// Escape special characters in password
escapedPassword := url.QueryEscape(cfg.Password)
// Use a connection string without password for logging
logConnStr := fmt.Sprintf(
"host=%s port=%d user=%s dbname=%s sslmode=%s",
cfg.Host, cfg.Port, cfg.User, cfg.DBName, cfg.SSLMode,
)
fmt.Printf("PostgreSQL connection string (without password): %s\n", logConnStr)
// Build the actual connection string with password
connStr := fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode,
)
// Try standard connection string first
db, err := sql.Open("postgres", connStr)
if err != nil {
// Try URL format connection string
urlConnStr := fmt.Sprintf(
"postgres://%s:%s@%s:%d/%s?sslmode=%s",
cfg.User, escapedPassword, cfg.Host, cfg.Port, cfg.DBName, cfg.SSLMode,
)
fmt.Println("First connection attempt failed, trying URL format...")
db, err = sql.Open("postgres", urlConnStr)
}
return db, err
}
// Replace the existing connectMySQL function with this version
func connectMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
// Add needed parameters for MySQL authentication
connStr := fmt.Sprintf(
"%s:%s@tcp(%s:%d)/%s?parseTime=true&allowNativePasswords=true&multiStatements=true",
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName,
)
fmt.Printf("Attempting MySQL connection to %s:%d as user '%s'\n",
cfg.Host, cfg.Port, cfg.User)
// Open the connection
db, err := sql.Open("mysql", connStr)
if err != nil {
return nil, fmt.Errorf("failed to open MySQL connection: %w", err)
}
// Configure connection pool
db.SetConnMaxLifetime(time.Minute * 3)
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
// Explicitly test the connection
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
fmt.Println("Testing MySQL connection with ping...")
if err := db.PingContext(ctx); err != nil {
db.Close()
fmt.Printf("MySQL connection failed: %v\n", err)
return nil, fmt.Errorf("failed to ping MySQL database: %w", err)
}
fmt.Println("MySQL connection successful!")
return db, nil
}
// Close closes the database connection
func (db *Database) Close() error {
return db.DB.Close()
}
// IsMySQLDB returns true if the database is MySQL/MariaDB
func (db *Database) IsMySQLDB() bool {
return db.Type == "mysql"
}
// IsPostgreSQLDB returns true if the database is PostgreSQL
func (db *Database) IsPostgreSQLDB() bool {
return db.Type == "postgresql"
}
// FormatQuery formats a query for the specific database type
func (db *Database) FormatQuery(query string) string {
if db.Type == "postgresql" {
return query // PostgreSQL queries already have correct format
}
// For MySQL:
result := query
// First, replace quoted table names
knownTables := []string{
"Users", "GpodderDevices", "GpodderSyncSettings",
"GpodderSyncSubscriptions", "GpodderSyncEpisodeActions",
"GpodderSyncPodcastLists", "GpodderSyncState", "GpodderSessions",
"GpodderSyncMigrations", "Podcasts", "Episodes", "SavedEpisodes",
"UserEpisodeHistory", "UserSettings", "APIKeys",
}
for _, table := range knownTables {
quoted := fmt.Sprintf("\"%s\"", table)
result = strings.ReplaceAll(result, quoted, table)
}
// Replace column quotes (double quotes to backticks)
re := regexp.MustCompile(`"([^"]+)"`)
result = re.ReplaceAllString(result, "`$1`")
// Then replace placeholders
for i := 10; i > 0; i-- {
old := fmt.Sprintf("$%d", i)
result = strings.ReplaceAll(result, old, "?")
}
return result
}
// Exec executes a query with the correct formatting for the database type
func (db *Database) Exec(query string, args ...interface{}) (sql.Result, error) {
formattedQuery := db.FormatQuery(query)
return db.DB.Exec(formattedQuery, args...)
}
// Query executes a query with the correct formatting for the database type
func (db *Database) Query(query string, args ...interface{}) (*sql.Rows, error) {
formattedQuery := db.FormatQuery(query)
return db.DB.Query(formattedQuery, args...)
}
// QueryRow executes a query with the correct formatting for the database type
func (db *Database) QueryRow(query string, args ...interface{}) *sql.Row {
formattedQuery := db.FormatQuery(query)
return db.DB.QueryRow(formattedQuery, args...)
}
// Begin starts a transaction with the correct formatting for the database type
func (db *Database) Begin() (*Transaction, error) {
tx, err := db.DB.Begin()
if err != nil {
return nil, err
}
return &Transaction{tx: tx, dbType: db.Type}, nil
}
// Transaction is a wrapper around sql.Tx that formats queries correctly
type Transaction struct {
tx *sql.Tx
dbType string
}
// Commit commits the transaction
func (tx *Transaction) Commit() error {
return tx.tx.Commit()
}
// Rollback rolls back the transaction
func (tx *Transaction) Rollback() error {
return tx.tx.Rollback()
}
// Exec executes a query in the transaction with correct formatting
func (tx *Transaction) Exec(query string, args ...interface{}) (sql.Result, error) {
formattedQuery := formatQuery(query, tx.dbType)
return tx.tx.Exec(formattedQuery, args...)
}
// Query executes a query in the transaction with correct formatting
func (tx *Transaction) Query(query string, args ...interface{}) (*sql.Rows, error) {
formattedQuery := formatQuery(query, tx.dbType)
return tx.tx.Query(formattedQuery, args...)
}
// QueryRow executes a query in the transaction with correct formatting
func (tx *Transaction) QueryRow(query string, args ...interface{}) *sql.Row {
formattedQuery := formatQuery(query, tx.dbType)
return tx.tx.QueryRow(formattedQuery, args...)
}
// Helper function to format queries
func formatQuery(query string, dbType string) string {
if dbType == "postgresql" {
return query
}
// For MySQL:
// Same logic as FormatQuery method
result := query
knownTables := []string{
"Users", "GpodderDevices", "GpodderSyncSettings",
"GpodderSyncSubscriptions", "GpodderSyncEpisodeActions",
"GpodderSyncPodcastLists", "GpodderSyncState", "GpodderSessions",
"GpodderSyncMigrations", "Podcasts", "Episodes", "SavedEpisodes",
"UserEpisodeHistory", "UserSettings", "APIKeys",
}
for _, table := range knownTables {
quoted := fmt.Sprintf("\"%s\"", table)
result = strings.ReplaceAll(result, quoted, table)
}
for i := 10; i > 0; i-- {
old := fmt.Sprintf("$%d", i)
result = strings.ReplaceAll(result, old, "?")
}
return result
}

View File

@@ -0,0 +1,176 @@
package db
import (
"fmt"
"strings"
)
// GetTableName returns the properly formatted table name based on DB type
func GetTableName(tableName string, dbType string) string {
if dbType == "postgresql" {
return fmt.Sprintf("\"%s\"", tableName)
}
return tableName
}
// GetPlaceholder returns the correct parameter placeholder based on DB type and index
func GetPlaceholder(index int, dbType string) string {
if dbType == "postgresql" {
return fmt.Sprintf("$%d", index)
}
return "?"
}
// GetPlaceholders returns a comma-separated list of placeholders
func GetPlaceholders(count int, dbType string) string {
placeholders := make([]string, count)
for i := 0; i < count; i++ {
if dbType == "postgresql" {
placeholders[i] = fmt.Sprintf("$%d", i+1)
} else {
placeholders[i] = "?"
}
}
return strings.Join(placeholders, ", ")
}
// GetColumnDefinition returns the appropriate column definition
func GetColumnDefinition(columnName, dataType string, dbType string) string {
// Handle special cases for different database types
switch dataType {
case "serial":
if dbType == "postgresql" {
return fmt.Sprintf("%s SERIAL", columnName)
}
return fmt.Sprintf("%s INT AUTO_INCREMENT", columnName)
case "boolean":
if dbType == "postgresql" {
return fmt.Sprintf("%s BOOLEAN", columnName)
}
return fmt.Sprintf("%s TINYINT(1)", columnName)
case "timestamp":
if dbType == "postgresql" {
return fmt.Sprintf("%s TIMESTAMP", columnName)
}
return fmt.Sprintf("%s TIMESTAMP", columnName)
default:
return fmt.Sprintf("%s %s", columnName, dataType)
}
}
// GetSerialPrimaryKey returns a serial primary key definition
func GetSerialPrimaryKey(columnName string, dbType string) string {
if dbType == "postgresql" {
return fmt.Sprintf("%s SERIAL PRIMARY KEY", columnName)
}
return fmt.Sprintf("%s INT AUTO_INCREMENT PRIMARY KEY", columnName)
}
// GetTimestampDefault returns a timestamp with default value
func GetTimestampDefault(columnName string, dbType string) string {
if dbType == "postgresql" {
return fmt.Sprintf("%s TIMESTAMP DEFAULT CURRENT_TIMESTAMP", columnName)
}
return fmt.Sprintf("%s TIMESTAMP DEFAULT CURRENT_TIMESTAMP", columnName)
}
// GetAutoUpdateTimestamp returns a timestamp that updates automatically
func GetAutoUpdateTimestamp(columnName string, dbType string) string {
if dbType == "postgresql" {
// PostgreSQL doesn't have a direct equivalent to MySQL's ON UPDATE
// In PostgreSQL this would typically be handled with a trigger
return fmt.Sprintf("%s TIMESTAMP DEFAULT CURRENT_TIMESTAMP", columnName)
}
return fmt.Sprintf("%s TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP", columnName)
}
// BuildInsertQuery builds an INSERT query with the correct placeholder syntax
func BuildInsertQuery(tableName string, columns []string, dbType string) string {
columnsStr := strings.Join(columns, ", ")
placeholders := GetPlaceholders(len(columns), dbType)
if dbType == "postgresql" {
return fmt.Sprintf("INSERT INTO \"%s\" (%s) VALUES (%s)", tableName, columnsStr, placeholders)
}
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", tableName, columnsStr, placeholders)
}
// BuildSelectQuery builds a SELECT query with the correct table name syntax
func BuildSelectQuery(tableName string, columns []string, whereClause string, dbType string) string {
columnsStr := strings.Join(columns, ", ")
if dbType == "postgresql" {
if whereClause != "" {
return fmt.Sprintf("SELECT %s FROM \"%s\" WHERE %s", columnsStr, tableName, whereClause)
}
return fmt.Sprintf("SELECT %s FROM \"%s\"", columnsStr, tableName)
}
if whereClause != "" {
return fmt.Sprintf("SELECT %s FROM %s WHERE %s", columnsStr, tableName, whereClause)
}
return fmt.Sprintf("SELECT %s FROM %s", columnsStr, tableName)
}
// BuildUpdateQuery builds an UPDATE query with the correct syntax
func BuildUpdateQuery(tableName string, setColumns []string, whereClause string, dbType string) string {
setClauses := make([]string, len(setColumns))
for i, col := range setColumns {
if dbType == "postgresql" {
setClauses[i] = fmt.Sprintf("%s = $%d", col, i+1)
} else {
setClauses[i] = fmt.Sprintf("%s = ?", col)
}
}
setClauseStr := strings.Join(setClauses, ", ")
if dbType == "postgresql" {
return fmt.Sprintf("UPDATE \"%s\" SET %s WHERE %s", tableName, setClauseStr, whereClause)
}
return fmt.Sprintf("UPDATE %s SET %s WHERE %s", tableName, setClauseStr, whereClause)
}
// RewriteQuery rewrites a PostgreSQL query to MySQL syntax
func RewriteQuery(query, dbType string) string {
if dbType == "postgresql" {
return query
}
// Replace placeholders
rewritten := query
// Replace placeholders first, starting from highest number to avoid conflicts
for i := 20; i > 0; i-- {
placeholder := fmt.Sprintf("$%d", i)
rewritten = strings.ReplaceAll(rewritten, placeholder, "?")
}
// Replace quoted table names
knownTables := []string{
"Users", "GpodderDevices", "GpodderSyncSettings",
"GpodderSyncSubscriptions", "GpodderSyncEpisodeActions",
"GpodderSyncPodcastLists", "GpodderSyncState", "GpodderSessions",
"GpodderSyncMigrations", "Podcasts", "Episodes", "SavedEpisodes",
"UserEpisodeHistory", "UserSettings", "APIKeys", "UserVideoHistory",
"SavedVideos", "DownloadedEpisodes", "DownloadedVideos", "EpisodeQueue",
}
for _, table := range knownTables {
quotedTable := fmt.Sprintf("\"%s\"", table)
rewritten = strings.ReplaceAll(rewritten, quotedTable, table)
}
// Handle RETURNING clause (MySQL doesn't support it)
returningIdx := strings.Index(strings.ToUpper(rewritten), "RETURNING")
if returningIdx > 0 {
rewritten = rewritten[:returningIdx]
}
return rewritten
}

View File

@@ -0,0 +1,538 @@
package db
import (
"database/sql"
"fmt"
"log"
"time"
)
// Migration represents a database migration
type Migration struct {
Version int
Description string
PostgreSQLSQL string
MySQLSQL string
}
// MigrationRecord represents a record of an applied migration
type MigrationRecord struct {
Version int
Description string
AppliedAt time.Time
}
// EnsureMigrationsTable creates the migrations table if it doesn't exist
func EnsureMigrationsTable(db *sql.DB, dbType string) error {
log.Println("Creating GpodderSyncMigrations table if it doesn't exist...")
var query string
if dbType == "postgresql" {
query = `
CREATE TABLE IF NOT EXISTS "GpodderSyncMigrations" (
Version INT PRIMARY KEY,
Description TEXT NOT NULL,
AppliedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`
} else {
query = `
CREATE TABLE IF NOT EXISTS GpodderSyncMigrations (
Version INT PRIMARY KEY,
Description TEXT NOT NULL,
AppliedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`
}
_, err := db.Exec(query)
if err != nil {
log.Printf("Error creating migrations table: %v", err)
return err
}
log.Println("GpodderSyncMigrations table is ready")
return nil
}
// GetAppliedMigrations returns a list of already applied migrations
func GetAppliedMigrations(db *sql.DB, dbType string) ([]MigrationRecord, error) {
log.Println("Checking previously applied migrations...")
var query string
if dbType == "postgresql" {
query = `
SELECT Version, Description, AppliedAt
FROM "GpodderSyncMigrations"
ORDER BY Version ASC
`
} else {
query = `
SELECT Version, Description, AppliedAt
FROM GpodderSyncMigrations
ORDER BY Version ASC
`
}
rows, err := db.Query(query)
if err != nil {
log.Printf("Error checking applied migrations: %v", err)
return nil, err
}
defer rows.Close()
var migrations []MigrationRecord
for rows.Next() {
var m MigrationRecord
if err := rows.Scan(&m.Version, &m.Description, &m.AppliedAt); err != nil {
log.Printf("Error scanning migration record: %v", err)
return nil, err
}
migrations = append(migrations, m)
}
if len(migrations) > 0 {
log.Printf("Found %d previously applied migrations", len(migrations))
} else {
log.Println("No previously applied migrations found")
}
return migrations, nil
}
// ApplyMigration applies a single migration
func ApplyMigration(db *sql.DB, migration Migration, dbType string) error {
log.Printf("Applying migration %d: %s", migration.Version, migration.Description)
// Select the appropriate SQL based on database type
var sql string
if dbType == "postgresql" {
sql = migration.PostgreSQLSQL
} else {
sql = migration.MySQLSQL
}
// Begin transaction
tx, err := db.Begin()
if err != nil {
log.Printf("Error beginning transaction for migration %d: %v", migration.Version, err)
return err
}
defer func() {
if err != nil {
log.Printf("Rolling back migration %d due to error", migration.Version)
tx.Rollback()
return
}
}()
// Execute the migration SQL
_, err = tx.Exec(sql)
if err != nil {
log.Printf("Failed to apply migration %d: %v", migration.Version, err)
return fmt.Errorf("failed to apply migration %d: %w", migration.Version, err)
}
// Record the migration
var insertQuery string
if dbType == "postgresql" {
insertQuery = `
INSERT INTO "GpodderSyncMigrations" (Version, Description)
VALUES ($1, $2)
`
} else {
insertQuery = `
INSERT INTO GpodderSyncMigrations (Version, Description)
VALUES (?, ?)
`
}
_, err = tx.Exec(insertQuery, migration.Version, migration.Description)
if err != nil {
log.Printf("Failed to record migration %d: %v", migration.Version, err)
return fmt.Errorf("failed to record migration %d: %w", migration.Version, err)
}
// Commit the transaction
err = tx.Commit()
if err != nil {
log.Printf("Failed to commit migration %d: %v", migration.Version, err)
return err
}
log.Printf("Successfully applied migration %d", migration.Version)
return nil
}
// checkRequiredTables verifies that required PinePods tables exist before running migrations
func checkRequiredTables(db *sql.DB, dbType string) error {
log.Println("Checking for required PinePods tables...")
requiredTables := []string{"Users", "GpodderDevices"}
for _, table := range requiredTables {
var query string
if dbType == "postgresql" {
query = `SELECT 1 FROM "` + table + `" LIMIT 1`
} else {
query = `SELECT 1 FROM ` + table + ` LIMIT 1`
}
_, err := db.Exec(query)
if err != nil {
log.Printf("Required table %s does not exist or is not accessible: %v", table, err)
return fmt.Errorf("required table %s does not exist - please ensure PinePods main migrations have run first", table)
}
log.Printf("Required table %s exists", table)
}
log.Println("All required tables found")
return nil
}
// RunMigrations runs all pending migrations
func RunMigrations(db *sql.DB, dbType string) error {
log.Println("Starting gpodder API migrations...")
// Check that required PinePods tables exist first
if err := checkRequiredTables(db, dbType); err != nil {
return fmt.Errorf("prerequisite check failed: %w", err)
}
// Ensure migrations table exists
if err := EnsureMigrationsTable(db, dbType); err != nil {
return fmt.Errorf("failed to create migrations table: %w", err)
}
// Get applied migrations
appliedMigrations, err := GetAppliedMigrations(db, dbType)
if err != nil {
return fmt.Errorf("failed to get applied migrations: %w", err)
}
// Build a map of applied migration versions for quick lookup
appliedVersions := make(map[int]bool)
for _, m := range appliedMigrations {
appliedVersions[m.Version] = true
}
// Get all migrations
migrations := GetMigrations()
log.Printf("Found %d total migrations to check", len(migrations))
// Apply pending migrations
appliedCount := 0
for _, migration := range migrations {
if appliedVersions[migration.Version] {
// Migration already applied, skip
log.Printf("Migration %d already applied, skipping", migration.Version)
continue
}
log.Printf("Applying migration %d: %s", migration.Version, migration.Description)
if err := ApplyMigration(db, migration, dbType); err != nil {
return err
}
appliedCount++
}
if appliedCount > 0 {
log.Printf("Successfully applied %d new migrations", appliedCount)
} else {
log.Println("No new migrations to apply")
}
return nil
}
// GetMigrations returns all migrations with SQL variants for both database types
func GetMigrations() []Migration {
return []Migration{
{
Version: 1,
Description: "Initial schema creation",
PostgreSQLSQL: `
-- Device sync state for the API
CREATE TABLE IF NOT EXISTS "GpodderSyncDeviceState" (
DeviceStateID SERIAL PRIMARY KEY,
UserID INT NOT NULL,
DeviceID INT NOT NULL,
SubscriptionCount INT DEFAULT 0,
LastUpdated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE,
UNIQUE(UserID, DeviceID)
);
-- Subscription changes
CREATE TABLE IF NOT EXISTS "GpodderSyncSubscriptions" (
SubscriptionID SERIAL PRIMARY KEY,
UserID INT NOT NULL,
DeviceID INT NOT NULL,
PodcastURL TEXT NOT NULL,
Action VARCHAR(10) NOT NULL,
Timestamp BIGINT NOT NULL,
FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE
);
-- Episode actions
CREATE TABLE IF NOT EXISTS "GpodderSyncEpisodeActions" (
ActionID SERIAL PRIMARY KEY,
UserID INT NOT NULL,
DeviceID INT,
PodcastURL TEXT NOT NULL,
EpisodeURL TEXT NOT NULL,
Action VARCHAR(20) NOT NULL,
Timestamp BIGINT NOT NULL,
Started INT,
Position INT,
Total INT,
FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE
);
-- Podcast lists
CREATE TABLE IF NOT EXISTS "GpodderSyncPodcastLists" (
ListID SERIAL PRIMARY KEY,
UserID INT NOT NULL,
Name VARCHAR(255) NOT NULL,
Title VARCHAR(255) NOT NULL,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE,
UNIQUE(UserID, Name)
);
-- Podcast list entries
CREATE TABLE IF NOT EXISTS "GpodderSyncPodcastListEntries" (
EntryID SERIAL PRIMARY KEY,
ListID INT NOT NULL,
PodcastURL TEXT NOT NULL,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (ListID) REFERENCES "GpodderSyncPodcastLists"(ListID) ON DELETE CASCADE
);
-- Synchronization relationships between devices
CREATE TABLE IF NOT EXISTS "GpodderSyncDevicePairs" (
PairID SERIAL PRIMARY KEY,
UserID INT NOT NULL,
DeviceID1 INT NOT NULL,
DeviceID2 INT NOT NULL,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID1) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID2) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE,
UNIQUE(UserID, DeviceID1, DeviceID2)
);
-- Settings storage
CREATE TABLE IF NOT EXISTS "GpodderSyncSettings" (
SettingID SERIAL PRIMARY KEY,
UserID INT NOT NULL,
Scope VARCHAR(20) NOT NULL,
DeviceID INT,
PodcastURL TEXT,
EpisodeURL TEXT,
SettingKey VARCHAR(255) NOT NULL,
SettingValue TEXT,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
LastUpdated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE
);
-- Create indexes for faster queries
CREATE INDEX IF NOT EXISTS idx_gpodder_sync_subscriptions_userid ON "GpodderSyncSubscriptions"(UserID);
CREATE INDEX IF NOT EXISTS idx_gpodder_sync_subscriptions_deviceid ON "GpodderSyncSubscriptions"(DeviceID);
CREATE INDEX IF NOT EXISTS idx_gpodder_sync_episode_actions_userid ON "GpodderSyncEpisodeActions"(UserID);
CREATE INDEX IF NOT EXISTS idx_gpodder_sync_podcast_lists_userid ON "GpodderSyncPodcastLists"(UserID);
`,
MySQLSQL: `
-- Device sync state for the API
CREATE TABLE IF NOT EXISTS GpodderSyncDeviceState (
DeviceStateID INT AUTO_INCREMENT PRIMARY KEY,
UserID INT NOT NULL,
DeviceID INT NOT NULL,
SubscriptionCount INT DEFAULT 0,
LastUpdated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE,
UNIQUE(UserID, DeviceID)
);
-- Subscription changes
CREATE TABLE IF NOT EXISTS GpodderSyncSubscriptions (
SubscriptionID INT AUTO_INCREMENT PRIMARY KEY,
UserID INT NOT NULL,
DeviceID INT NOT NULL,
PodcastURL TEXT NOT NULL,
Action VARCHAR(10) NOT NULL,
Timestamp BIGINT NOT NULL,
FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE
);
-- Episode actions
CREATE TABLE IF NOT EXISTS GpodderSyncEpisodeActions (
ActionID INT AUTO_INCREMENT PRIMARY KEY,
UserID INT NOT NULL,
DeviceID INT,
PodcastURL TEXT NOT NULL,
EpisodeURL TEXT NOT NULL,
Action VARCHAR(20) NOT NULL,
Timestamp BIGINT NOT NULL,
Started INT,
Position INT,
Total INT,
FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE
);
-- Podcast lists
CREATE TABLE IF NOT EXISTS GpodderSyncPodcastLists (
ListID INT AUTO_INCREMENT PRIMARY KEY,
UserID INT NOT NULL,
Name VARCHAR(255) NOT NULL,
Title VARCHAR(255) NOT NULL,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE,
UNIQUE(UserID, Name)
);
-- Podcast list entries
CREATE TABLE IF NOT EXISTS GpodderSyncPodcastListEntries (
EntryID INT AUTO_INCREMENT PRIMARY KEY,
ListID INT NOT NULL,
PodcastURL TEXT NOT NULL,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (ListID) REFERENCES GpodderSyncPodcastLists(ListID) ON DELETE CASCADE
);
-- Synchronization relationships between devices
CREATE TABLE IF NOT EXISTS GpodderSyncDevicePairs (
PairID INT AUTO_INCREMENT PRIMARY KEY,
UserID INT NOT NULL,
DeviceID1 INT NOT NULL,
DeviceID2 INT NOT NULL,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID1) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID2) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE,
UNIQUE(UserID, DeviceID1, DeviceID2)
);
-- Settings storage
CREATE TABLE IF NOT EXISTS GpodderSyncSettings (
SettingID INT AUTO_INCREMENT PRIMARY KEY,
UserID INT NOT NULL,
Scope VARCHAR(20) NOT NULL,
DeviceID INT,
PodcastURL TEXT,
EpisodeURL TEXT,
SettingKey VARCHAR(255) NOT NULL,
SettingValue TEXT,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
LastUpdated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE
);
-- Create indexes for faster queries
CREATE INDEX idx_gpodder_sync_subscriptions_userid ON GpodderSyncSubscriptions(UserID);
CREATE INDEX idx_gpodder_sync_subscriptions_deviceid ON GpodderSyncSubscriptions(DeviceID);
CREATE INDEX idx_gpodder_sync_episode_actions_userid ON GpodderSyncEpisodeActions(UserID);
CREATE INDEX idx_gpodder_sync_podcast_lists_userid ON GpodderSyncPodcastLists(UserID);
`,
},
{
Version: 2,
Description: "Add API version column to GpodderSyncSettings",
PostgreSQLSQL: `
ALTER TABLE "GpodderSyncSettings"
ADD COLUMN IF NOT EXISTS APIVersion VARCHAR(10) DEFAULT '2.0';
`,
MySQLSQL: `
-- Check if column exists first
SET @s = (SELECT IF(
COUNT(*) = 0,
'ALTER TABLE GpodderSyncSettings ADD COLUMN APIVersion VARCHAR(10) DEFAULT "2.0"',
'SELECT 1'
) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'GpodderSyncSettings'
AND COLUMN_NAME = 'APIVersion');
PREPARE stmt FROM @s;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
`,
},
{
Version: 3,
Description: "Create GpodderSessions table for API sessions",
PostgreSQLSQL: `
CREATE TABLE IF NOT EXISTS "GpodderSessions" (
SessionID SERIAL PRIMARY KEY,
UserID INT NOT NULL,
SessionToken TEXT NOT NULL,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ExpiresAt TIMESTAMP NOT NULL,
LastActive TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UserAgent TEXT,
ClientIP TEXT,
FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE,
UNIQUE(SessionToken)
);
CREATE INDEX IF NOT EXISTS idx_gpodder_sessions_token ON "GpodderSessions"(SessionToken);
CREATE INDEX IF NOT EXISTS idx_gpodder_sessions_userid ON "GpodderSessions"(UserID);
CREATE INDEX IF NOT EXISTS idx_gpodder_sessions_expires ON "GpodderSessions"(ExpiresAt);
`,
MySQLSQL: `
CREATE TABLE IF NOT EXISTS GpodderSessions (
SessionID INT AUTO_INCREMENT PRIMARY KEY,
UserID INT NOT NULL,
SessionToken TEXT NOT NULL,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ExpiresAt TIMESTAMP NOT NULL,
LastActive TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UserAgent TEXT,
ClientIP TEXT,
FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE
);
CREATE INDEX idx_gpodder_sessions_userid ON GpodderSessions(UserID);
CREATE INDEX idx_gpodder_sessions_expires ON GpodderSessions(ExpiresAt);
`,
},
{
Version: 4,
Description: "Add sync state table for tracking device sync status",
PostgreSQLSQL: `
CREATE TABLE IF NOT EXISTS "GpodderSyncState" (
SyncStateID SERIAL PRIMARY KEY,
UserID INT NOT NULL,
DeviceID INT NOT NULL,
LastTimestamp BIGINT DEFAULT 0,
LastSync TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE,
UNIQUE(UserID, DeviceID)
);
CREATE INDEX IF NOT EXISTS idx_gpodder_syncstate_userid_deviceid ON "GpodderSyncState"(UserID, DeviceID);
`,
MySQLSQL: `
CREATE TABLE IF NOT EXISTS GpodderSyncState (
SyncStateID INT AUTO_INCREMENT PRIMARY KEY,
UserID INT NOT NULL,
DeviceID INT NOT NULL,
LastTimestamp BIGINT DEFAULT 0,
LastSync TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE,
UNIQUE(UserID, DeviceID)
);
CREATE INDEX idx_gpodder_syncstate_userid_deviceid ON GpodderSyncState(UserID, DeviceID);
`,
},
}
}

View File

@@ -0,0 +1,91 @@
package db
import (
"database/sql"
"fmt"
"net/url"
"os"
"pinepods/gpodder-api/config"
"strings"
_ "github.com/lib/pq"
)
// PostgresDB represents a connection to the PostgreSQL database
type PostgresDB struct {
*sql.DB
}
// NewPostgresDB creates a new PostgreSQL database connection
func NewPostgresDB(cfg config.DatabaseConfig) (*PostgresDB, error) {
// Print connection details for debugging (hide password for security)
fmt.Printf("Connecting to database: host=%s port=%d user=%s dbname=%s sslmode=%s\n",
cfg.Host, cfg.Port, cfg.User, cfg.DBName, cfg.SSLMode)
// Get password directly from environment to handle special characters
password := os.Getenv("DB_PASSWORD")
if password == "" {
// Fall back to config if env var is empty
password = cfg.Password
}
// Escape special characters in password
escapedPassword := url.QueryEscape(password)
// Use a connection string without password for logging
logConnStr := fmt.Sprintf(
"host=%s port=%d user=%s dbname=%s sslmode=%s",
cfg.Host, cfg.Port, cfg.User, cfg.DBName, cfg.SSLMode,
)
fmt.Printf("Connection string (without password): %s\n", logConnStr)
// Build the actual connection string with password
connStr := fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
cfg.Host, cfg.Port, cfg.User, password, cfg.DBName, cfg.SSLMode,
)
// Try alternate connection string format if the first fails
db, err := sql.Open("postgres", connStr)
if err != nil {
// Try URL format connection string
urlConnStr := fmt.Sprintf(
"postgres://%s:%s@%s:%d/%s?sslmode=%s",
cfg.User, escapedPassword, cfg.Host, cfg.Port, cfg.DBName, cfg.SSLMode,
)
fmt.Println("First connection attempt failed, trying URL format...")
db, err = sql.Open("postgres", urlConnStr)
if err != nil {
return nil, fmt.Errorf("failed to open database connection: %w", err)
}
}
// Test the connection
if err := db.Ping(); err != nil {
db.Close()
// Check if error contains password authentication failure
if strings.Contains(err.Error(), "password authentication failed") {
// Print environment variables (hide password)
fmt.Println("Password authentication failed. Environment variables:")
fmt.Printf("DB_HOST=%s\n", os.Getenv("DB_HOST"))
fmt.Printf("DB_PORT=%s\n", os.Getenv("DB_PORT"))
fmt.Printf("DB_USER=%s\n", os.Getenv("DB_USER"))
fmt.Printf("DB_NAME=%s\n", os.Getenv("DB_NAME"))
fmt.Printf("DB_PASSWORD=*** (length: %d)\n", len(os.Getenv("DB_PASSWORD")))
}
return nil, fmt.Errorf("failed to ping database: %w", err)
}
fmt.Println("Successfully connected to the database")
// Migrations are now handled by the Python migration system
// Skip Go migrations to avoid conflicts
fmt.Println("Skipping Go migrations - now handled by Python migration system")
return &PostgresDB{DB: db}, nil
}
// Close closes the database connection
func (db *PostgresDB) Close() error {
return db.DB.Close()
}