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
This commit is contained in:
@@ -147,6 +147,7 @@ public partial class LayoutStore : IDisposable
|
|||||||
MigrateSocialEntryTable();
|
MigrateSocialEntryTable();
|
||||||
if (GetUserVersion() < 3)
|
if (GetUserVersion() < 3)
|
||||||
MigrateToV3();
|
MigrateToV3();
|
||||||
|
EnsureLayoutSnapshotSchema();
|
||||||
using (var cmd = _connection.CreateCommand())
|
using (var cmd = _connection.CreateCommand())
|
||||||
{
|
{
|
||||||
cmd.CommandText = "PRAGMA user_version = 10;";
|
cmd.CommandText = "PRAGMA user_version = 10;";
|
||||||
|
|||||||
@@ -434,10 +434,16 @@ public partial class LayoutStore : IDisposable
|
|||||||
DELETE FROM Asset WHERE Id NOT IN (
|
DELETE FROM Asset WHERE Id NOT IN (
|
||||||
SELECT AssetId FROM Source WHERE AssetId IS NOT NULL
|
SELECT AssetId FROM Source WHERE AssetId IS NOT NULL
|
||||||
UNION
|
UNION
|
||||||
|
SELECT AssetId FROM DefaultSource WHERE AssetId IS NOT NULL
|
||||||
|
UNION
|
||||||
SELECT ThumbnailAssetId FROM Scene WHERE ThumbnailAssetId IS NOT NULL
|
SELECT ThumbnailAssetId FROM Scene WHERE ThumbnailAssetId IS NOT NULL
|
||||||
UNION
|
UNION
|
||||||
|
SELECT ThumbnailAssetId FROM DefaultScene WHERE ThumbnailAssetId IS NOT NULL
|
||||||
|
UNION
|
||||||
SELECT AlertVideoAssetId FROM Source WHERE AlertVideoAssetId IS NOT NULL
|
SELECT AlertVideoAssetId FROM Source WHERE AlertVideoAssetId IS NOT NULL
|
||||||
UNION
|
UNION
|
||||||
|
SELECT AlertVideoAssetId FROM DefaultSource WHERE AlertVideoAssetId IS NOT NULL
|
||||||
|
UNION
|
||||||
SELECT Value FROM Settings WHERE Key = 'AlertDefaultVideoAssetId'
|
SELECT Value FROM Settings WHERE Key = 'AlertDefaultVideoAssetId'
|
||||||
UNION
|
UNION
|
||||||
SELECT Value FROM Settings WHERE Key = 'OnAirImageAssetId'
|
SELECT Value FROM Settings WHERE Key = 'OnAirImageAssetId'
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Captures the installed layout once as the immutable branded default. Existing databases
|
||||||
|
/// are snapshotted before MainViewModel loads them; a fresh database calls
|
||||||
|
/// <see cref="CaptureDefaultsIfMissing"/> after its factory scenes have been seeded.</summary>
|
||||||
|
public void PrepareLayoutSnapshots(string buildId)
|
||||||
|
{
|
||||||
|
if (GetSnapshotState(DefaultsInitializedKey) != "1" && HasStoredScenes())
|
||||||
|
CaptureDefaults();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(GetSnapshotState(CurrentBuildIdKey)))
|
||||||
|
SetSnapshotState(CurrentBuildIdKey, buildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Seeds the default snapshot after the first-run factory layout has been persisted.</summary>
|
||||||
|
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<string> 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("\"", "\"\"") + "\"";
|
||||||
|
}
|
||||||
@@ -514,9 +514,11 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
private void LoadLayout()
|
private void LoadLayout()
|
||||||
{
|
{
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
|
var seededFactoryLayout = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
AppLog.Write("LoadLayout begin");
|
AppLog.Write("LoadLayout begin");
|
||||||
|
_layoutStore.PrepareLayoutSnapshots(Helpers.BuildStamp.Id);
|
||||||
var scenes = _layoutStore.Load();
|
var scenes = _layoutStore.Load();
|
||||||
Scenes.Clear();
|
Scenes.Clear();
|
||||||
foreach (var scene in scenes)
|
foreach (var scene in scenes)
|
||||||
@@ -526,6 +528,7 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
foreach (var name in SceneCatalog.All)
|
foreach (var name in SceneCatalog.All)
|
||||||
AddScene(name);
|
AddScene(name);
|
||||||
|
seededFactoryLayout = true;
|
||||||
}
|
}
|
||||||
ReorderScenesToCanonical();
|
ReorderScenesToCanonical();
|
||||||
HealBackgrounds();
|
HealBackgrounds();
|
||||||
@@ -582,6 +585,12 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
LoadClipSettings();
|
LoadClipSettings();
|
||||||
BroadcastForm.Load();
|
BroadcastForm.Load();
|
||||||
|
|
||||||
|
if (seededFactoryLayout)
|
||||||
|
{
|
||||||
|
SaveLayoutNow();
|
||||||
|
_layoutStore.CaptureDefaultsIfMissing();
|
||||||
|
}
|
||||||
|
|
||||||
ScheduleSave();
|
ScheduleSave();
|
||||||
AppLog.Write("LoadLayout end");
|
AppLog.Write("LoadLayout end");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Source>()).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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user