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); `, }, } }