From 37791086ae7802ec358e0d4cb8e1d3e0483bc129 Mon Sep 17 00:00:00 2001 From: gramps Date: Tue, 10 Nov 2026 17:45:39 -0800 Subject: [PATCH] feat(37): keep a default layout snapshot beside the current layout The matching-build persistence slice is in place: existing layouts are captured once as defaults, first-run factory scenes are saved and captured before the creator can edit, and same-build startup continues to load current rows. Default asset references survive current-layout saves. One integration test covers the current-layout roundtrip and immutable default. 609 tests green, clean build with 0 warnings. Reference scan: OBS stores scene collections separately from output profiles; this split keeps layout state independent in the same SQLite store: https://obsproject.com/kb/scene-collections --- Services/LayoutStore.Migrations.cs | 1 + Services/LayoutStore.Save.cs | 6 + Services/LayoutStore.Snapshots.cs | 168 +++++++++++++++++++++++ ViewModels/MainViewModel.cs | 9 ++ ytLive.Tests/LayoutStoreSnapshotTests.cs | 64 +++++++++ 5 files changed, 248 insertions(+) create mode 100644 Services/LayoutStore.Snapshots.cs create mode 100644 ytLive.Tests/LayoutStoreSnapshotTests.cs diff --git a/Services/LayoutStore.Migrations.cs b/Services/LayoutStore.Migrations.cs index ab598aa..0bfe92b 100644 --- a/Services/LayoutStore.Migrations.cs +++ b/Services/LayoutStore.Migrations.cs @@ -147,6 +147,7 @@ public partial class LayoutStore : IDisposable MigrateSocialEntryTable(); if (GetUserVersion() < 3) MigrateToV3(); + EnsureLayoutSnapshotSchema(); using (var cmd = _connection.CreateCommand()) { cmd.CommandText = "PRAGMA user_version = 10;"; diff --git a/Services/LayoutStore.Save.cs b/Services/LayoutStore.Save.cs index 8d60d8e..e114d5f 100644 --- a/Services/LayoutStore.Save.cs +++ b/Services/LayoutStore.Save.cs @@ -434,10 +434,16 @@ public partial class LayoutStore : IDisposable DELETE FROM Asset WHERE Id NOT IN ( SELECT AssetId FROM Source WHERE AssetId IS NOT NULL UNION + SELECT AssetId FROM DefaultSource WHERE AssetId IS NOT NULL + UNION SELECT ThumbnailAssetId FROM Scene WHERE ThumbnailAssetId IS NOT NULL UNION + SELECT ThumbnailAssetId FROM DefaultScene WHERE ThumbnailAssetId IS NOT NULL + UNION SELECT AlertVideoAssetId FROM Source WHERE AlertVideoAssetId IS NOT NULL UNION + SELECT AlertVideoAssetId FROM DefaultSource WHERE AlertVideoAssetId IS NOT NULL + UNION SELECT Value FROM Settings WHERE Key = 'AlertDefaultVideoAssetId' UNION SELECT Value FROM Settings WHERE Key = 'OnAirImageAssetId' diff --git a/Services/LayoutStore.Snapshots.cs b/Services/LayoutStore.Snapshots.cs new file mode 100644 index 0000000..3846174 --- /dev/null +++ b/Services/LayoutStore.Snapshots.cs @@ -0,0 +1,168 @@ +using Microsoft.Data.Sqlite; + +namespace ytLive.Services; + +public partial class LayoutStore : IDisposable +{ + private static readonly string[] SnapshotTables = + { + "Scene", "Source", "Webcam", "WebcamSceneConfig", "Socials", "SocialEntry", "Music", + }; + + private const string DefaultsInitializedKey = "DefaultsInitialized"; + private const string CurrentBuildIdKey = "CurrentBuildId"; + + private void EnsureLayoutSnapshotSchema() + { + using (var cmd = _connection.CreateCommand()) + { + cmd.CommandText = """ + CREATE TABLE IF NOT EXISTS LayoutSnapshotState ( + Key TEXT PRIMARY KEY, + Value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS DefaultLayoutSetting ( + Key TEXT PRIMARY KEY, + Value TEXT NOT NULL + ); + """; + cmd.ExecuteNonQuery(); + } + + foreach (var table in SnapshotTables) + { + var snapshotTable = "Default" + table; + using (var create = _connection.CreateCommand()) + { + create.CommandText = $"CREATE TABLE IF NOT EXISTS {snapshotTable} AS SELECT * FROM {table} WHERE 0;"; + create.ExecuteNonQuery(); + } + + var snapshotColumns = ReadColumnNames(snapshotTable); + foreach (var (name, type, defaultValue) in ReadColumnDefinitions(table)) + { + if (snapshotColumns.Contains(name, StringComparer.OrdinalIgnoreCase)) continue; + var definition = string.IsNullOrWhiteSpace(type) ? "BLOB" : type; + if (defaultValue != null) definition += " DEFAULT " + defaultValue; + using var add = _connection.CreateCommand(); + add.CommandText = $"ALTER TABLE {snapshotTable} ADD COLUMN {QuoteIdentifier(name)} {definition};"; + add.ExecuteNonQuery(); + } + } + } + + /// Captures the installed layout once as the immutable branded default. Existing databases + /// are snapshotted before MainViewModel loads them; a fresh database calls + /// after its factory scenes have been seeded. + public void PrepareLayoutSnapshots(string buildId) + { + if (GetSnapshotState(DefaultsInitializedKey) != "1" && HasStoredScenes()) + CaptureDefaults(); + + if (string.IsNullOrWhiteSpace(GetSnapshotState(CurrentBuildIdKey))) + SetSnapshotState(CurrentBuildIdKey, buildId); + } + + /// Seeds the default snapshot after the first-run factory layout has been persisted. + public void CaptureDefaultsIfMissing() + { + if (GetSnapshotState(DefaultsInitializedKey) != "1") + CaptureDefaults(); + } + + private void CaptureDefaults() + { + var columnsByTable = SnapshotTables.ToDictionary(table => table, ReadColumnNames); + using var tx = _connection.BeginTransaction(); + foreach (var table in SnapshotTables.Reverse()) + { + using var delete = _connection.CreateCommand(); + delete.Transaction = tx; + delete.CommandText = $"DELETE FROM Default{table};"; + delete.ExecuteNonQuery(); + } + + foreach (var table in SnapshotTables) + { + var columns = string.Join(", ", columnsByTable[table].Select(QuoteIdentifier)); + using var copy = _connection.CreateCommand(); + copy.Transaction = tx; + copy.CommandText = $"INSERT INTO Default{table} ({columns}) SELECT {columns} FROM {table};"; + copy.ExecuteNonQuery(); + } + + using (var clearSettings = _connection.CreateCommand()) + { + clearSettings.Transaction = tx; + clearSettings.CommandText = "DELETE FROM DefaultLayoutSetting;"; + clearSettings.ExecuteNonQuery(); + } + using (var copySettings = _connection.CreateCommand()) + { + copySettings.Transaction = tx; + copySettings.CommandText = """ + INSERT INTO DefaultLayoutSetting (Key, Value) + SELECT Key, Value FROM Settings + WHERE Key LIKE 'BackgroundPath_%' OR Key LIKE 'BackgroundUseDefault_%'; + """; + copySettings.ExecuteNonQuery(); + } + + using (var state = _connection.CreateCommand()) + { + state.Transaction = tx; + state.CommandText = """ + INSERT INTO LayoutSnapshotState (Key, Value) VALUES ($key, '1') + ON CONFLICT(Key) DO UPDATE SET Value = excluded.Value; + """; + state.Parameters.AddWithValue("$key", DefaultsInitializedKey); + state.ExecuteNonQuery(); + } + tx.Commit(); + } + + private bool HasStoredScenes() + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "SELECT EXISTS(SELECT 1 FROM Scene LIMIT 1);"; + return Convert.ToInt32(cmd.ExecuteScalar()) != 0; + } + + private string? GetSnapshotState(string key) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "SELECT Value FROM LayoutSnapshotState WHERE Key = $key;"; + cmd.Parameters.AddWithValue("$key", key); + return cmd.ExecuteScalar() as string; + } + + private void SetSnapshotState(string key, string value) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = """ + INSERT INTO LayoutSnapshotState (Key, Value) VALUES ($key, $value) + ON CONFLICT(Key) DO UPDATE SET Value = excluded.Value; + """; + cmd.Parameters.AddWithValue("$key", key); + cmd.Parameters.AddWithValue("$value", value); + cmd.ExecuteNonQuery(); + } + + private List ReadColumnNames(string table) + => ReadColumnDefinitions(table).Select(column => column.Name).ToList(); + + private List<(string Name, string Type, string? DefaultValue)> ReadColumnDefinitions(string table) + { + var columns = new List<(string, string, string?)>(); + using var cmd = _connection.CreateCommand(); + cmd.CommandText = $"PRAGMA table_info({table});"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + columns.Add((reader.GetString(1), reader.IsDBNull(2) ? string.Empty : reader.GetString(2), + reader.IsDBNull(4) ? null : reader.GetString(4))); + return columns; + } + + private static string QuoteIdentifier(string identifier) + => "\"" + identifier.Replace("\"", "\"\"") + "\""; +} diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index ec1cbe6..b5c31f6 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -514,9 +514,11 @@ public partial class MainViewModel : ViewModelBase private void LoadLayout() { _isLoading = true; + var seededFactoryLayout = false; try { AppLog.Write("LoadLayout begin"); + _layoutStore.PrepareLayoutSnapshots(Helpers.BuildStamp.Id); var scenes = _layoutStore.Load(); Scenes.Clear(); foreach (var scene in scenes) @@ -526,6 +528,7 @@ public partial class MainViewModel : ViewModelBase { foreach (var name in SceneCatalog.All) AddScene(name); + seededFactoryLayout = true; } ReorderScenesToCanonical(); HealBackgrounds(); @@ -582,6 +585,12 @@ public partial class MainViewModel : ViewModelBase LoadClipSettings(); BroadcastForm.Load(); + if (seededFactoryLayout) + { + SaveLayoutNow(); + _layoutStore.CaptureDefaultsIfMissing(); + } + ScheduleSave(); AppLog.Write("LoadLayout end"); } diff --git a/ytLive.Tests/LayoutStoreSnapshotTests.cs b/ytLive.Tests/LayoutStoreSnapshotTests.cs new file mode 100644 index 0000000..dc17c4a --- /dev/null +++ b/ytLive.Tests/LayoutStoreSnapshotTests.cs @@ -0,0 +1,64 @@ +using System; +using System.IO; +using System.Linq; +using Microsoft.Data.Sqlite; +using Xunit; +using ytLive.Models; +using ytLive.Services; + +namespace ytLive.Tests; + +public sealed class LayoutStoreSnapshotTests +{ + [Fact] + public void SameBuild_OpensCurrentLayout_WithoutChangingDefaultSnapshot() + { + var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-snapshot-{Guid.NewGuid():N}.db"); + const string buildId = "build-a"; + try + { + using (var store = new LayoutStore(path)) + { + var factory = MakeScene("Factory title"); + store.Save(new[] { factory }, null, null); + store.PrepareLayoutSnapshots(buildId); + + store.Save(new[] { MakeScene("Creator edit") }, null, null); + } + + using (var reopened = new LayoutStore(path)) + { + reopened.PrepareLayoutSnapshots(buildId); + var current = Assert.Single(reopened.Load()); + Assert.Equal("Creator edit", Assert.Single(current.Elements.OfType()).Name); + + using var connection = GetConnection(path); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT Value FROM LayoutSnapshotState WHERE Key = 'CurrentBuildId';"; + Assert.Equal(buildId, command.ExecuteScalar()); + + command.CommandText = "SELECT Name FROM DefaultSource;"; + Assert.Equal("Factory title", command.ExecuteScalar()); + } + } + finally + { + SqliteConnection.ClearAllPools(); + try { File.Delete(path); } catch { /* best-effort cleanup */ } + } + } + + private static Scene MakeScene(string sourceName) + => new() + { + Name = SceneCatalog.Starting, + Elements = { new Source { Name = sourceName, Type = SourceType.TextOverlay } }, + }; + + private static SqliteConnection GetConnection(string path) + { + var connection = new SqliteConnection($"Data Source={path}"); + connection.Open(); + return connection; + } +}