333 lines
11 KiB
Go
333 lines
11 KiB
Go
package database
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"database/sql"
|
||
|
|
"fmt"
|
||
|
|
|
||
|
|
"github.com/pressly/goose/v3"
|
||
|
|
|
||
|
|
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
||
|
|
"github.com/Silo-Server/silo-server/internal/settingsmigrate"
|
||
|
|
)
|
||
|
|
|
||
|
|
// settingsBackfillVersion is the timestamp version this backfill occupies. It
|
||
|
|
// sorts immediately after 20260727010621_user_setting_values.sql, which creates
|
||
|
|
// the tables this fills.
|
||
|
|
const settingsBackfillVersion int64 = 20260727010622
|
||
|
|
|
||
|
|
// settingsBackfillMigration is the one-time conversion of legacy settings
|
||
|
|
// storage into user_setting_values.
|
||
|
|
//
|
||
|
|
// A Go migration rather than SQL because the conversion is not expressible in
|
||
|
|
// SQL without duplicating the contract: every value has to be validated against
|
||
|
|
// its own definition and re-encoded as typed JSON, and a legacy quality string
|
||
|
|
// decomposes into two rows. Those rules live in internal/settingsmigrate so
|
||
|
|
// this and the SQLite backend cannot disagree; this file reads rows, hands them
|
||
|
|
// over, and writes what comes back.
|
||
|
|
//
|
||
|
|
// RunTx, so the whole backfill lands in goose's transaction — a partial
|
||
|
|
// migration is the one state neither an operator's backup nor a rollback
|
||
|
|
// covers.
|
||
|
|
func settingsBackfillMigration() *goose.Migration {
|
||
|
|
return goose.NewGoMigration(
|
||
|
|
settingsBackfillVersion,
|
||
|
|
&goose.GoFunc{RunTx: backfillSettingValues},
|
||
|
|
&goose.GoFunc{RunTx: rollbackSettingValues},
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// backfillSettingValues converts every user's legacy settings.
|
||
|
|
func backfillSettingValues(ctx context.Context, tx *sql.Tx) error {
|
||
|
|
contract, err := settingscontract.Load()
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("loading settings contract: %w", err)
|
||
|
|
}
|
||
|
|
planner := settingsmigrate.New(contract, settingscontract.ObjectSchemas())
|
||
|
|
|
||
|
|
userIDs, err := settingsBackfillUserIDs(ctx, tx)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, userID := range userIDs {
|
||
|
|
input, err := readLegacySettingsForUser(ctx, tx, userID)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("reading legacy settings for user %d: %w", userID, err)
|
||
|
|
}
|
||
|
|
result := planner.Plan(input)
|
||
|
|
|
||
|
|
for _, row := range result.Rows {
|
||
|
|
if _, err := tx.ExecContext(ctx, `
|
||
|
|
INSERT INTO user_setting_values
|
||
|
|
(user_id, key, scope, profile_id, device_id, library_id, series_id, value)
|
||
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)`,
|
||
|
|
userID, row.Key, string(row.Scope),
|
||
|
|
nullText(row.ProfileID), nullText(row.DeviceID),
|
||
|
|
nullInt(row.LibraryID), nullText(row.SeriesID),
|
||
|
|
string(row.Value),
|
||
|
|
); err != nil {
|
||
|
|
return fmt.Errorf("writing %s at %s for user %d: %w",
|
||
|
|
row.Key, row.Scope, userID, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, reject := range result.Rejects {
|
||
|
|
if _, err := tx.ExecContext(ctx, `
|
||
|
|
INSERT INTO user_setting_migration_rejects
|
||
|
|
(user_id, source_table, source_key, identity, value, reason)
|
||
|
|
VALUES ($1, $2, $3, $4::jsonb, $5, $6)`,
|
||
|
|
userID, reject.SourceTable, reject.SourceKey,
|
||
|
|
string(reject.Identity), reject.Value, reject.Reason,
|
||
|
|
); err != nil {
|
||
|
|
return fmt.Errorf("recording reject %s for user %d: %w",
|
||
|
|
reject.SourceKey, userID, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// rollbackSettingValues empties the canonical tables.
|
||
|
|
//
|
||
|
|
// The legacy tables are never modified by the up migration, so undoing it is
|
||
|
|
// simply discarding what was derived from them. This is what makes the cutover
|
||
|
|
// reversible before the follow-up migration drops the legacy columns.
|
||
|
|
func rollbackSettingValues(ctx context.Context, tx *sql.Tx) error {
|
||
|
|
for _, table := range []string{
|
||
|
|
"user_setting_values",
|
||
|
|
"user_setting_migration_rejects",
|
||
|
|
} {
|
||
|
|
if _, err := tx.ExecContext(ctx, "DELETE FROM "+table); err != nil {
|
||
|
|
return fmt.Errorf("clearing %s: %w", table, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// settingsBackfillUserIDs returns every user with something to migrate.
|
||
|
|
//
|
||
|
|
// A user with no settings and no profiles produces nothing, so they are skipped
|
||
|
|
// rather than queried five times each.
|
||
|
|
func settingsBackfillUserIDs(ctx context.Context, tx *sql.Tx) ([]int, error) {
|
||
|
|
rows, err := tx.QueryContext(ctx, `
|
||
|
|
SELECT id FROM users
|
||
|
|
WHERE EXISTS (SELECT 1 FROM user_profiles p WHERE p.user_id = users.id)
|
||
|
|
OR EXISTS (SELECT 1 FROM user_settings s WHERE s.user_id = users.id)
|
||
|
|
ORDER BY id`)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("listing users: %w", err)
|
||
|
|
}
|
||
|
|
defer rows.Close() //nolint:errcheck // read-only iteration
|
||
|
|
|
||
|
|
var ids []int
|
||
|
|
for rows.Next() {
|
||
|
|
var id int
|
||
|
|
if err := rows.Scan(&id); err != nil {
|
||
|
|
return nil, fmt.Errorf("scanning user id: %w", err)
|
||
|
|
}
|
||
|
|
ids = append(ids, id)
|
||
|
|
}
|
||
|
|
return ids, rows.Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
// readLegacySettingsForUser gathers one user's legacy rows.
|
||
|
|
func readLegacySettingsForUser(
|
||
|
|
ctx context.Context, tx *sql.Tx, userID int,
|
||
|
|
) (settingsmigrate.Input, error) {
|
||
|
|
var input settingsmigrate.Input
|
||
|
|
// Non-nil records that the profile list was loaded even when the account
|
||
|
|
// has none. The planner uses nil only for callers that could not load
|
||
|
|
// profiles; an empty loaded list must reject every profile-scoped orphan.
|
||
|
|
input.Profiles = make([]settingsmigrate.LegacyProfile, 0)
|
||
|
|
|
||
|
|
// Profiles. preferred_metadata_language exists only in this schema — the
|
||
|
|
// SQLite profiles table never had the column — so this is the sole source
|
||
|
|
// for catalog.metadata_language.
|
||
|
|
if err := eachRow(ctx, tx, `
|
||
|
|
SELECT id, quality_preference, language, subtitle_language, subtitle_mode,
|
||
|
|
show_forced_subtitles, preferred_metadata_language,
|
||
|
|
auto_skip_intro, auto_skip_credits, auto_skip_recap, auto_play_next_preview
|
||
|
|
FROM user_profiles WHERE user_id = $1`,
|
||
|
|
func(scan func(...any) error) error {
|
||
|
|
var profile settingsmigrate.LegacyProfile
|
||
|
|
var quality, language, subtitle, mode, metadata sql.NullString
|
||
|
|
var forced, skipIntro, skipCredits, skipRecap, nextPreview sql.NullBool
|
||
|
|
if err := scan(&profile.ID, &quality, &language, &subtitle,
|
||
|
|
&mode, &forced, &metadata,
|
||
|
|
&skipIntro, &skipCredits, &skipRecap, &nextPreview); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
profile.QualityPreference = nullableString(quality)
|
||
|
|
profile.Language = nullableString(language)
|
||
|
|
profile.SubtitleLanguage = nullableString(subtitle)
|
||
|
|
profile.SubtitleMode = nullableString(mode)
|
||
|
|
profile.ShowForcedSubtitles = nullableBool(forced)
|
||
|
|
profile.PreferredMetadataLanguage = nullableString(metadata)
|
||
|
|
profile.AutoSkipIntro = nullableBool(skipIntro)
|
||
|
|
profile.AutoSkipCredits = nullableBool(skipCredits)
|
||
|
|
profile.AutoSkipRecap = nullableBool(skipRecap)
|
||
|
|
profile.AutoPlayNextPreview = nullableBool(nextPreview)
|
||
|
|
input.Profiles = append(input.Profiles, profile)
|
||
|
|
return nil
|
||
|
|
}, userID); err != nil {
|
||
|
|
return input, fmt.Errorf("reading user_profiles: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := eachRow(ctx, tx,
|
||
|
|
`SELECT key, value FROM user_settings WHERE user_id = $1`,
|
||
|
|
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
|
||
|
|
}, userID); err != nil {
|
||
|
|
return input, fmt.Errorf("reading user_settings: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := eachRow(ctx, tx, `
|
||
|
|
SELECT profile_id, device_id, key, value
|
||
|
|
FROM user_device_settings WHERE user_id = $1`,
|
||
|
|
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
|
||
|
|
}, userID); err != nil {
|
||
|
|
return input, fmt.Errorf("reading user_device_settings: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Subtitle and audio preferences are keyed alike, so they merge into one
|
||
|
|
// per-series record; converting them independently would produce two rows
|
||
|
|
// racing for the same identity.
|
||
|
|
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(ctx, tx, `
|
||
|
|
SELECT profile_id, series_id, subtitle_language, subtitle_mode, show_forced_subtitles
|
||
|
|
FROM user_subtitle_preferences WHERE user_id = $1`,
|
||
|
|
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 = "user_subtitle_preferences"
|
||
|
|
record.SubtitleLanguage = nullableString(language)
|
||
|
|
record.SubtitleMode = nullableString(mode)
|
||
|
|
record.ShowForcedSubtitles = nullableBool(forced)
|
||
|
|
return nil
|
||
|
|
}, userID); err != nil {
|
||
|
|
return input, fmt.Errorf("reading user_subtitle_preferences: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := eachRow(ctx, tx, `
|
||
|
|
SELECT profile_id, series_id, audio_language
|
||
|
|
FROM user_audio_preferences WHERE user_id = $1`,
|
||
|
|
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 = "user_audio_preferences"
|
||
|
|
record.AudioLanguage = nullableString(language)
|
||
|
|
return nil
|
||
|
|
}, userID); err != nil {
|
||
|
|
return input, fmt.Errorf("reading user_audio_preferences: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, record := range bySeries {
|
||
|
|
input.SeriesPrefs = append(input.SeriesPrefs, *record)
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := eachRow(ctx, tx, `
|
||
|
|
SELECT profile_id, library_id, audio_language, subtitle_language, subtitle_mode,
|
||
|
|
show_forced_subtitles
|
||
|
|
FROM user_library_playback_preferences WHERE user_id = $1`,
|
||
|
|
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 = "user_library_playback_preferences"
|
||
|
|
row.AudioLanguage = nullableString(audio)
|
||
|
|
row.SubtitleLanguage = nullableString(subtitle)
|
||
|
|
row.SubtitleMode = nullableString(mode)
|
||
|
|
row.ShowForcedSubtitles = nullableBool(forced)
|
||
|
|
input.LibraryPrefs = append(input.LibraryPrefs, row)
|
||
|
|
return nil
|
||
|
|
}, userID); err != nil {
|
||
|
|
return input, fmt.Errorf("reading user_library_playback_preferences: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return input, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func eachRow(
|
||
|
|
ctx context.Context, tx *sql.Tx, query string,
|
||
|
|
fn func(scan func(...any) error) error, args ...any,
|
||
|
|
) error {
|
||
|
|
rows, err := tx.QueryContext(ctx, query, args...)
|
||
|
|
if err != 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 nullableString(value sql.NullString) *string {
|
||
|
|
if !value.Valid {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
text := value.String
|
||
|
|
return &text
|
||
|
|
}
|
||
|
|
|
||
|
|
func nullableBool(value sql.NullBool) *bool {
|
||
|
|
if !value.Valid {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
flag := value.Bool
|
||
|
|
return &flag
|
||
|
|
}
|
||
|
|
|
||
|
|
func nullText(value string) any {
|
||
|
|
if value == "" {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return value
|
||
|
|
}
|
||
|
|
|
||
|
|
func nullInt(value int) any {
|
||
|
|
if value == 0 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return value
|
||
|
|
}
|