Files

272 lines
9.1 KiB
Go
Raw Permalink Normal View History

package userdb
import (
"database/sql"
"fmt"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingsmigrate"
)
const (
sqliteAudioPreferencesTable = "audio_preferences"
sqliteSubtitlePreferencesTable = "subtitle_preferences"
sqliteLibraryPreferencesTable = "library_playback_preferences"
)
// migrateSettingsToCanonical is the one-time backfill from legacy settings
// storage into user_setting_values.
//
// The conversion rules live in internal/settingsmigrate so this backend and
// Postgres cannot disagree about them; everything here is reading rows, handing
// them over, and writing what comes back. It runs inside the caller's
// transaction, so a failure anywhere leaves the database exactly as it was —
// the design's "completes and verifies atomically or leaves the database
// unchanged".
//
// The legacy tables are left in place. Dropping them belongs to the follow-up
// migration named in the design's post-cutover cleanup, once migrated counts
// have been verified against a backup.
func migrateSettingsToCanonical(tx *sql.Tx) error {
contract, err := settingscontract.Load()
if err != nil {
return fmt.Errorf("loading settings contract: %w", err)
}
input, err := readLegacySettings(tx)
if err != nil {
return err
}
planner := settingsmigrate.New(contract, settingscontract.ObjectSchemas())
result := planner.Plan(input)
now := time.Now().UTC().Format(time.RFC3339Nano)
for _, row := range result.Rows {
if _, err := tx.Exec(`
INSERT INTO user_setting_values
(key, scope, profile_id, device_id, library_id, series_id, value, revision, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`,
row.Key, string(row.Scope),
nullableText(row.ProfileID), nullableText(row.DeviceID),
nullableInt(row.LibraryID), nullableText(row.SeriesID),
string(row.Value), now, now,
); err != nil {
return fmt.Errorf("writing migrated setting %s at %s: %w", row.Key, row.Scope, err)
}
}
for _, reject := range result.Rejects {
if _, err := tx.Exec(`
INSERT INTO user_setting_migration_rejects
(source_table, source_key, identity, value, reason, recorded_at)
VALUES (?, ?, ?, ?, ?, ?)`,
reject.SourceTable, reject.SourceKey, string(reject.Identity),
reject.Value, reject.Reason, now,
); err != nil {
return fmt.Errorf("recording migration reject for %s: %w", reject.SourceKey, err)
}
}
return nil
}
// readLegacySettings gathers every source the migration reads.
//
// Each query tolerates a missing table: this runs against databases created at
// any schema version, and a table an older install never had is simply empty
// rather than fatal.
func readLegacySettings(tx *sql.Tx) (settingsmigrate.Input, error) {
var input settingsmigrate.Input
profiles, err := readLegacyProfiles(tx)
if err != nil {
return input, err
}
input.Profiles = profiles
if err := eachRow(tx, `SELECT key, value FROM user_settings`,
func(scan func(...any) error) error {
var row settingsmigrate.LegacySetting
if err := scan(&row.Key, &row.Value); err != nil {
return err
}
input.Settings = append(input.Settings, row)
return nil
}); err != nil {
return input, fmt.Errorf("reading user_settings: %w", err)
}
if err := eachRow(tx, `SELECT profile_id, device_id, key, value FROM user_device_settings`,
func(scan func(...any) error) error {
var row settingsmigrate.LegacyDeviceSetting
if err := scan(&row.ProfileID, &row.DeviceID, &row.Key, &row.Value); err != nil {
return err
}
input.DeviceSettings = append(input.DeviceSettings, row)
return nil
}); err != nil {
return input, fmt.Errorf("reading user_device_settings: %w", err)
}
// Subtitle and audio preferences are two tables keyed the same way, so they
// merge into one per-series record rather than producing two rows that
// would each overwrite the other's scope.
bySeries := map[[2]string]*settingsmigrate.LegacySeriesPreference{}
seriesRecord := func(profileID, seriesID string) *settingsmigrate.LegacySeriesPreference {
key := [2]string{profileID, seriesID}
if existing, ok := bySeries[key]; ok {
return existing
}
record := &settingsmigrate.LegacySeriesPreference{ProfileID: profileID, SeriesID: seriesID}
bySeries[key] = record
return record
}
if err := eachRow(tx, `
SELECT profile_id, series_id, subtitle_language, subtitle_mode, show_forced_subtitles
FROM `+sqliteSubtitlePreferencesTable,
func(scan func(...any) error) error {
var profileID, seriesID string
var language, mode sql.NullString
var forced sql.NullBool
if err := scan(&profileID, &seriesID, &language, &mode, &forced); err != nil {
return err
}
record := seriesRecord(profileID, seriesID)
record.SubtitleSourceTable = sqliteSubtitlePreferencesTable
record.SubtitleLanguage = nullString(language)
record.SubtitleMode = nullString(mode)
record.ShowForcedSubtitles = nullBool(forced)
return nil
}); err != nil {
return input, fmt.Errorf("reading %s: %w", sqliteSubtitlePreferencesTable, err)
}
if err := eachRow(tx, `SELECT profile_id, series_id, audio_language FROM `+sqliteAudioPreferencesTable,
func(scan func(...any) error) error {
var profileID, seriesID string
var language sql.NullString
if err := scan(&profileID, &seriesID, &language); err != nil {
return err
}
record := seriesRecord(profileID, seriesID)
record.AudioSourceTable = sqliteAudioPreferencesTable
record.AudioLanguage = nullString(language)
return nil
}); err != nil {
return input, fmt.Errorf("reading %s: %w", sqliteAudioPreferencesTable, err)
}
for _, record := range bySeries {
input.SeriesPrefs = append(input.SeriesPrefs, *record)
}
if err := eachRow(tx, `
SELECT profile_id, library_id, audio_language, subtitle_language, subtitle_mode, show_forced_subtitles
FROM `+sqliteLibraryPreferencesTable,
func(scan func(...any) error) error {
var row settingsmigrate.LegacyLibraryPreference
var audio, subtitle, mode sql.NullString
var forced sql.NullBool
if err := scan(&row.ProfileID, &row.LibraryID,
&audio, &subtitle, &mode, &forced); err != nil {
return err
}
row.SourceTable = sqliteLibraryPreferencesTable
row.AudioLanguage = nullString(audio)
row.SubtitleLanguage = nullString(subtitle)
row.SubtitleMode = nullString(mode)
row.ShowForcedSubtitles = nullBool(forced)
input.LibraryPrefs = append(input.LibraryPrefs, row)
return nil
}); err != nil {
return input, fmt.Errorf("reading %s: %w", sqliteLibraryPreferencesTable, err)
}
return input, nil
}
// readLegacyProfiles reads the preference columns off the profiles table.
//
// preferred_metadata_language is deliberately absent: the column exists only in
// the Postgres schema, so catalog.metadata_language has no SQLite source and
// the field stays nil here.
func readLegacyProfiles(tx *sql.Tx) ([]settingsmigrate.LegacyProfile, error) {
// Preserve loaded-empty versus not-loaded. The migration planner rejects
// profile-scoped rows against an empty loaded list, while nil means profile
// ownership was unavailable to check.
profiles := make([]settingsmigrate.LegacyProfile, 0)
err := eachRow(tx, `
SELECT id, quality_preference, language, subtitle_language, subtitle_mode, show_forced_subtitles,
auto_skip_intro, auto_skip_credits, auto_skip_recap, auto_play_next_preview
FROM profiles`,
func(scan func(...any) error) error {
var profile settingsmigrate.LegacyProfile
var quality, language, subtitle, mode sql.NullString
var forced, skipIntro, skipCredits, skipRecap, nextPreview sql.NullBool
if err := scan(&profile.ID, &quality, &language, &subtitle, &mode, &forced,
&skipIntro, &skipCredits, &skipRecap, &nextPreview); err != nil {
return err
}
profile.QualityPreference = nullString(quality)
profile.Language = nullString(language)
profile.SubtitleLanguage = nullString(subtitle)
profile.SubtitleMode = nullString(mode)
profile.ShowForcedSubtitles = nullBool(forced)
profile.AutoSkipIntro = nullBool(skipIntro)
profile.AutoSkipCredits = nullBool(skipCredits)
profile.AutoSkipRecap = nullBool(skipRecap)
profile.AutoPlayNextPreview = nullBool(nextPreview)
profiles = append(profiles, profile)
return nil
})
if err != nil {
return nil, fmt.Errorf("reading profiles: %w", err)
}
return profiles, nil
}
// eachRow runs a query and calls fn per row, treating a missing table as no
// rows. Every legacy table this migration reads was added at some schema
// version, so a database older than that simply has nothing to migrate from it.
func eachRow(tx *sql.Tx, query string, fn func(scan func(...any) error) error) error {
rows, err := tx.Query(query)
if err != nil {
if isMissingTable(err) {
return nil
}
return err
}
defer rows.Close() //nolint:errcheck // read-only iteration
for rows.Next() {
if err := fn(rows.Scan); err != nil {
return err
}
}
return rows.Err()
}
func isMissingTable(err error) bool {
return err != nil && strings.Contains(err.Error(), "no such table")
}
func nullString(value sql.NullString) *string {
if !value.Valid {
return nil
}
text := value.String
return &text
}
func nullBool(value sql.NullBool) *bool {
if !value.Valid {
return nil
}
flag := value.Bool
return &flag
}