From 70e2c481360aaf651a4ca0a6e73cabd63d3a354b Mon Sep 17 00:00:00 2001 From: gramps Date: Fri, 6 Nov 2026 12:21:24 -0800 Subject: [PATCH] fix(art): re-seed a bundled background when the stored bytes differ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The creator's profile still showed the previous Starting art after the replacement, with `Art.DefaultVersion = 4` recorded — so the version gate had already declared itself done. It compared a constant against the embedded resource, and those two were compiled at different moments (version 4 landed with the OLD image embedded; the corrected image reached the assembly one build later). A version bump can always be raced that way. The seed now compares BYTES: `LayoutStore.AssetMatches` hashes the bundled resource and checks the row the background already points at. Same bytes → leave it untouched (so this is idempotent and never churns the database). Different bytes → re-stamp. The version constant and its setting are gone, because a heal that only runs once can silently skip the one launch it was needed for. `SceneThumbnailTests` planted its own art on a background still flagged as default art — in the real app that state only ever means stale bundled art, because Browse… sets the path and clears the flag. The fixture now models a creator's own picture. Tests: 572 green, 0 warnings. Verified the profile's Starting asset hash (167CDD3B…, the two-category render) differs from the shipped `starting-background.jpg` (5FC6DD22…), so the next launch re-seeds it. --- Services/LayoutStore.Assets.cs | 14 +++++++++ Services/LayoutStore.Settings.cs | 6 ---- ViewModels/MainViewModel.Background.cs | 37 +++++------------------ ytLive.Tests/BackgroundDefaultArtTests.cs | 17 ++++++----- ytLive.Tests/SceneThumbnailTests.cs | 5 +++ 5 files changed, 36 insertions(+), 43 deletions(-) diff --git a/Services/LayoutStore.Assets.cs b/Services/LayoutStore.Assets.cs index 11e66ec..7710aec 100644 --- a/Services/LayoutStore.Assets.cs +++ b/Services/LayoutStore.Assets.cs @@ -35,6 +35,20 @@ public partial class LayoutStore : IDisposable return (string)cmd.ExecuteScalar()!; } + /// True when the asset already holds exactly these bytes. Used to decide whether a + /// bundled image needs re-seeding: comparing bytes cannot go stale the way a version constant + /// can (the constant and the embedded resource were built at different moments twice). + public bool AssetMatches(string assetId, byte[] data) + { + if (string.IsNullOrWhiteSpace(assetId)) return false; + var hash = Convert.ToHexString(SHA256.HashData(data)); + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "SELECT 1 FROM Asset WHERE Id = $id AND Hash = $hash;"; + cmd.Parameters.AddWithValue("$id", assetId); + cmd.Parameters.AddWithValue("$hash", hash); + return cmd.ExecuteScalar() != null; + } + // ─── License (TASK 10 — Polar billing) ─── /// The saved license key, or null if never entered. diff --git a/Services/LayoutStore.Settings.cs b/Services/LayoutStore.Settings.cs index 2ff1d56..fcdaf09 100644 --- a/Services/LayoutStore.Settings.cs +++ b/Services/LayoutStore.Settings.cs @@ -63,12 +63,6 @@ public partial class LayoutStore : IDisposable public void SaveShowMonetaryMetrics(bool show) => UpsertSetting("Metrics.ShowMonetary", show ? "1" : "0"); - /// Which vintage of the shipped default scene art this layout was stamped with. Bumped when - /// a bundled background is replaced, so existing layouts re-seed instead of keeping the old art. - public int LoadDefaultArtVersion() => int.TryParse(GetSetting("Art.DefaultVersion"), out var v) ? v : 1; - - public void SaveDefaultArtVersion(int version) => UpsertSetting("Art.DefaultVersion", version.ToString()); - public bool LoadAutoChromaScreen() => GetSetting("Video.AutoChromaScreen") == "1"; public void SaveAutoChromaScreen(bool enabled) => UpsertSetting("Video.AutoChromaScreen", enabled ? "1" : "0"); diff --git a/ViewModels/MainViewModel.Background.cs b/ViewModels/MainViewModel.Background.cs index 97b12f6..5d50c70 100644 --- a/ViewModels/MainViewModel.Background.cs +++ b/ViewModels/MainViewModel.Background.cs @@ -427,37 +427,9 @@ public partial class MainViewModel /// Heals every scene to the locked Background model after a layout /// load: keeps the correct flavor per scene at index 0 named "Background", /// drops duplicates/stale rows, re-seeds missing ones. - /// Bump when a bundled background image is replaced. Layouts stamped with an older version - /// re-seed their DEFAULT art on next load; a creator's own custom background is never touched. - private const int CurrentDefaultArtVersion = 4; - - /// Re-stamp the shipped art for scenes still using the default. Without this, a replaced - /// starting-background.jpg reaches new installs only: the asset is hash-keyed into the layout - /// database on first seed, so an existing profile keeps showing the old image forever. - private void HealDefaultArtVersion() - { - if (_layoutStore.LoadDefaultArtVersion() >= CurrentDefaultArtVersion) return; - - // Every scene's background, not AllBackgrounds(): that helper only returns the LIVE scene's - // capture-flavoured rows, which is why version 2 healed nothing (2026-11-06). - var cleared = 0; - foreach (var scene in Scenes) - foreach (var background in scene.Elements.OfType().Where(element => element.IsBackground)) - { - if (!background.UseDefaultBackground && !string.IsNullOrWhiteSpace(background.CustomBackgroundPath)) - continue; // the creator chose their own art - background.AssetId = null; // the seed below re-stamps the bundled art - cleared++; - } - - AppLog.Write($"Default art version {CurrentDefaultArtVersion}: re-seeding {cleared} background(s)"); - _layoutStore.SaveDefaultArtVersion(CurrentDefaultArtVersion); - } - private void HealBackgrounds() { NormalizeBackgrounds(Scenes); - HealDefaultArtVersion(); foreach (var scene in Scenes) { _sceneGraph.InvalidateBake(scene); @@ -516,7 +488,6 @@ public partial class MainViewModel /// carries none. A custom image (Browse…) always wins and is never overwritten. private void StampDefaultBackgroundAsset(Source background, string sceneName) { - if (!string.IsNullOrWhiteSpace(background.AssetId)) return; if (!background.UseDefaultBackground && !string.IsNullOrWhiteSpace(background.CustomBackgroundPath)) return; var asset = DefaultBackgroundAsset(sceneName); if (asset == null) return; @@ -531,7 +502,13 @@ public partial class MainViewModel if (stream == null) return; using var ms = new MemoryStream(); stream.CopyTo(ms); - background.AssetId = AddAsset(ms.ToArray()); + var bytes = ms.ToArray(); + + // Already the shipped art → leave it. Compared by BYTES, not by a version stamp: assets are + // hash-keyed on first seed, so a replaced bundled image never reaches an existing profile on + // its own, and the version constant proved just as stale (2026-11-06). + if (_layoutStore.AssetMatches(background.AssetId!, bytes)) return; + background.AssetId = AddAsset(bytes); } catch { /* non-critical — the app works without default art */ } } diff --git a/ytLive.Tests/BackgroundDefaultArtTests.cs b/ytLive.Tests/BackgroundDefaultArtTests.cs index e567b4d..f3c17da 100644 --- a/ytLive.Tests/BackgroundDefaultArtTests.cs +++ b/ytLive.Tests/BackgroundDefaultArtTests.cs @@ -10,9 +10,10 @@ namespace ytLive.Tests; /// /// 2026-11-06: replacing a bundled background reached new installs only. Default art is hash-keyed into the -/// layout database on first seed, so an existing profile kept the old image for ever — and the first -/// attempt at a version heal silently did nothing because it iterated `AllBackgrounds()`, which returns -/// only the LIVE scene's rows. +/// layout database on first seed, so an existing profile kept the old image for ever. A version-stamp heal +/// failed twice (first it iterated `AllBackgrounds()`, which returns only the LIVE scene's rows; then the +/// constant and the embedded resource were built at different moments). The seed now compares BYTES, which +/// is what this test pins: a profile already pointing at other art is re-stamped without any version bump. /// [Collection("RealApp")] public sealed class BackgroundDefaultArtTests @@ -35,8 +36,8 @@ public sealed class BackgroundDefaultArtTests Assert.False(string.IsNullOrWhiteSpace(firstId), "the bundled Starting art never seeded"); var firstBytes = AssetLength(tempDb, firstId!); - // Simulate a profile stamped with the OLD art: point the Starting background at a planted - // asset and mark the layout as an older art vintage. The heal must re-stamp the shipped art. + // Simulate a profile stamped with the OLD art. No version flag is touched — the seed has to + // notice the bytes are not the shipped ones by itself. using (var connection = new SqliteConnection($"Data Source={tempDb}")) { connection.Open(); @@ -49,8 +50,6 @@ public sealed class BackgroundDefaultArtTests plant.ExecuteNonQuery(); } SqliteConnection.ClearAllPools(); - using (var store = new LayoutStore(tempDb)) - store.SaveDefaultArtVersion(1); var second = new MainViewModel(); var secondId = StartingBackgroundId(second); @@ -64,6 +63,10 @@ public sealed class BackgroundDefaultArtTests AppContext.BaseDirectory, "..", "..", "..", "..", "Assets", "starting-background.jpg")).Length, AssetLength(tempDb, secondId!)); Assert.NotEqual(firstBytes, 0); + + // Idempotent: a third launch finds the shipped bytes already in place and must not re-seed. + var third = new MainViewModel(); + Assert.Equal(secondId, StartingBackgroundId(third)); } finally { diff --git a/ytLive.Tests/SceneThumbnailTests.cs b/ytLive.Tests/SceneThumbnailTests.cs index c8699e2..ed15ac0 100644 --- a/ytLive.Tests/SceneThumbnailTests.cs +++ b/ytLive.Tests/SceneThumbnailTests.cs @@ -263,6 +263,11 @@ public sealed class SceneThumbnailTests var id = LayoutStore.Instance!.UpsertAsset(bytes, 320, 180); Assert.NotNull(id); bg!.AssetId = id; + // The creator's own picture, not the shipped art: a default-art background pointing at + // other bytes is re-seeded by StampDefaultBackgroundAsset (2026-11-06), and this fixture + // needs its planted art to stay put. Browse… always sets the path alongside the asset. + bg.UseDefaultBackground = false; + bg.CustomBackgroundPath = Path.Combine(Path.GetTempPath(), $"ytLive-missing-{i}.jpg"); backgroundIds[scene.Name] = id; } builder.SaveLayoutNow();