Files
LlamaCasty/Services/LayoutStore.Migrations.cs
T
gramps a11b15e444 feat(alerts): TASK 47 — alert box plays a video (built-in/custom clip) + read-time fade + message ticker
TASK 43's alert box grows a real video celebration. Per-alert IAlertClipDecoder
(ffmpeg bgra + f32le pipes, real-time paced, disposed at drain) plays the shipped
Assets/alert-default.mp4 (stamped into the Asset table at startup) unless the
creator picks their own file — path reference only, never stored in the DB; the
six AlertRenderer animations stay the fallback. ~0.3s fade rides the alpha
envelope on straight-source copies (EOF freeze-frames then fades out); audio
forwards to a new AudioMixer alert ring (8s, 48k stereo) drained at unity — no
duck, creator ruling — scaled by volume × fade. An auto-composed marquee ticker
('Funder — Super Chat · $10.00', 140px/s) scrolls top-of-frame via a
FramePump._alertTicker seam through Render/CompositeLayers, mixed into the cache
signature (dynamic overlay, never baked). New Stream Alerts section in LeftPanel.

Derivative-work references (how OBS/Streamlabs alert boxes do per-alert video):
- https://support.streamlabs.com/hc/en-us/articles/217741147-Setting-Up-Your-Streamlabs-Alerts (custom image/video per alert type + variations)
- https://obsproject.com/kb/stream-tutorial-2-alerts (alert overlay as an on-screen zone)
- https://streamlabs.com/content-hub/widgets/alert-box (per-event alert playback)

Good Dog: AlertLayerVideoTests drives a fake IAlertClipDecoder through the whole
lifecycle in one pass (custom path wins, decoder spawns/disposes, fade envelope
0→127→255, audio volume×fade, ticker scrolls, EOF fade-drain to idle). It caught
the clip branch of Advance not clearing _current before AdvanceToNext — the layer
stayed IsPlaying after drain (MyMistakes post-mortem).

Full vstest 319/319; clean build 0 warnings; scope check green.
2026-09-26 11:15:17 -07:00

546 lines
22 KiB
C#

using Microsoft.Data.Sqlite;
using ytLive.Models;
namespace ytLive.Services;
public partial class LayoutStore : IDisposable
{
private void EnsureSchema()
{
string[] statements =
{
"""
CREATE TABLE IF NOT EXISTS Scene (
Id TEXT PRIMARY KEY,
Name TEXT NOT NULL,
IsHidden INTEGER NOT NULL DEFAULT 0,
IsChatScene INTEGER NOT NULL DEFAULT 0,
HasBackground INTEGER NOT NULL DEFAULT 1,
SortOrder INTEGER NOT NULL DEFAULT 0
);
""",
"""
CREATE TABLE IF NOT EXISTS Asset (
Id TEXT PRIMARY KEY,
Hash TEXT NOT NULL UNIQUE,
Data BLOB NOT NULL,
PixelWidth INTEGER NOT NULL DEFAULT 0,
PixelHeight INTEGER NOT NULL DEFAULT 0
);
""",
"""
CREATE TABLE IF NOT EXISTS Source (
Id TEXT PRIMARY KEY,
SceneId TEXT NOT NULL REFERENCES Scene(Id) ON DELETE CASCADE,
AssetId TEXT REFERENCES Asset(Id),
Type TEXT NOT NULL,
Name TEXT NOT NULL,
IsEnabled INTEGER NOT NULL DEFAULT 1,
X REAL NOT NULL DEFAULT 0,
Y REAL NOT NULL DEFAULT 0,
Width REAL NOT NULL DEFAULT 0,
Height REAL NOT NULL DEFAULT 0,
Opacity REAL NOT NULL DEFAULT 1,
MonitorIndex INTEGER,
DeviceId TEXT,
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
IsMirrored INTEGER NOT NULL DEFAULT 0,
IsBackground INTEGER NOT NULL DEFAULT 0,
CaptureKey TEXT,
SortOrder INTEGER NOT NULL DEFAULT 0
);
""",
"""
CREATE TABLE IF NOT EXISTS Webcam (
Id TEXT PRIMARY KEY,
DeviceId TEXT NOT NULL UNIQUE,
Name TEXT NOT NULL
);
""",
"""
CREATE TABLE IF NOT EXISTS WebcamSceneConfig (
SceneId TEXT NOT NULL REFERENCES Scene(Id) ON DELETE CASCADE,
WebcamId TEXT NOT NULL REFERENCES Webcam(Id) ON DELETE CASCADE,
IsVisible INTEGER NOT NULL DEFAULT 1,
X REAL NOT NULL DEFAULT 0,
Y REAL NOT NULL DEFAULT 0,
Width REAL NOT NULL DEFAULT 0,
Height REAL NOT NULL DEFAULT 0,
Opacity REAL NOT NULL DEFAULT 1,
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
IsMirrored INTEGER NOT NULL DEFAULT 0,
RectWidth REAL,
RectHeight REAL,
BorderColor TEXT,
BorderOpacity REAL NOT NULL DEFAULT 1,
BorderWidth INTEGER NOT NULL DEFAULT 0,
BorderAnimation TEXT NOT NULL DEFAULT 'None',
SortOrder INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (SceneId, WebcamId)
);
""",
"""
CREATE TABLE IF NOT EXISTS Socials (
Id TEXT PRIMARY KEY,
BarPosition TEXT NOT NULL DEFAULT 'Bottom',
BarJustify TEXT NOT NULL DEFAULT 'Center'
);
""",
"""
CREATE TABLE IF NOT EXISTS SocialEntry (
Id TEXT PRIMARY KEY,
SocialsId TEXT NOT NULL REFERENCES Socials(Id) ON DELETE CASCADE,
Service TEXT NOT NULL,
Handle TEXT NOT NULL,
ProfileUrl TEXT NOT NULL,
SortOrder INTEGER NOT NULL DEFAULT 0
);
""",
"""
CREATE TABLE IF NOT EXISTS Music (
Id TEXT PRIMARY KEY,
TrackPath TEXT NOT NULL,
IsEnabled INTEGER NOT NULL DEFAULT 0
);
""",
"""
CREATE TABLE IF NOT EXISTS Settings (
Key TEXT PRIMARY KEY,
Value TEXT NOT NULL
);
""",
// v9 → v10 (TASK 39): YPP journey-tracker snapshots, appended on every
// stats refresh. The history is the raw data a later analytics slice
// reads to compute velocity/(de)acceleration/ETA.
"""
CREATE TABLE IF NOT EXISTS YppSnapshot (
CapturedAtUtc TEXT NOT NULL PRIMARY KEY,
SubscriberCount INTEGER NOT NULL,
VideoCount INTEGER NOT NULL,
TotalViews INTEGER NOT NULL,
UploadsLast90Days INTEGER NOT NULL,
CommunityGuidelinesGoodStanding INTEGER NOT NULL,
CopyrightStrikesGoodStanding INTEGER NOT NULL,
ContentIdClaimsGoodStanding INTEGER NOT NULL,
OverallGoodStanding INTEGER NOT NULL
);
""",
};
foreach (var sql in statements)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
MigrateSourceTable();
MigrateWebcamConfigTable();
MigrateSceneTable();
MigrateSceneSocialBarColumn();
MigrateSocialsTable();
MigrateSocialEntryTable();
if (GetUserVersion() < 3)
MigrateToV3();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA user_version = 10;";
cmd.ExecuteNonQuery();
}
}
// v1 → v2: Source gains ClipShape + IsMirrored (webcam clip/mirror prefs).
// CREATE TABLE IF NOT EXISTS doesn't touch existing tables, so pre-v2 DBs
// get the columns here instead.
private void MigrateSourceTable()
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA table_info(Source);";
using var reader = cmd.ExecuteReader();
while (reader.Read())
columns.Add(reader.GetString(1));
}
if (!columns.Contains("ClipShape"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ClipShape TEXT NOT NULL DEFAULT 'Traditional';";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("IsMirrored"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsMirrored INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
if (columns.Contains("IsBackdrop"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source RENAME COLUMN IsBackdrop TO IsBackground;";
cmd.ExecuteNonQuery();
}
else if (!columns.Contains("IsBackground"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsBackground INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("CaptureKey"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN CaptureKey TEXT;";
cmd.ExecuteNonQuery();
}
// TASK 3.18: ChatBox overlay source
if (!columns.Contains("ChatFontSize"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatFontSize REAL NOT NULL DEFAULT 16;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatFontColor"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatFontColor TEXT NOT NULL DEFAULT '#FFFFFF';";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatBackgroundColor"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatBackgroundColor TEXT NOT NULL DEFAULT '#80000000';";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatMaxMessages"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatMaxMessages INTEGER NOT NULL DEFAULT 5;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatShowTimestamps"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatShowTimestamps INTEGER NOT NULL DEFAULT 1;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatShowBadges"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatShowBadges INTEGER NOT NULL DEFAULT 1;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatMemberColor"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatMemberColor TEXT NOT NULL DEFAULT '#e94560';";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatSuperChatColor"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatSuperChatColor TEXT NOT NULL DEFAULT '#eab308';";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ShowDesktop"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ShowDesktop INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("WebUri"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN WebUri TEXT;";
cmd.ExecuteNonQuery();
}
// TASK 21: Media source (video/audio file playback)
if (!columns.Contains("MediaPath"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaPath TEXT;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("MediaIsLooping"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaIsLooping INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("MediaVolume"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaVolume REAL NOT NULL DEFAULT 1;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("MediaPlaybackState"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaPlaybackState TEXT NOT NULL DEFAULT 'Stopped';";
cmd.ExecuteNonQuery();
}
// TASK 47: alert video config. The default clip asset id + the creator's
// replacement path (a FILE reference, never a stored BLOB) + the pill,
// ticker toggle and volume live on the row.
if (!columns.Contains("AlertVideoAssetId"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertVideoAssetId TEXT;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("AlertVideoPath"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertVideoPath TEXT;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("AlertUseDefaultVideo"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertUseDefaultVideo INTEGER NOT NULL DEFAULT 1;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("AlertShowTicker"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertShowTicker INTEGER NOT NULL DEFAULT 1;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("AlertVideoVolume"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertVideoVolume REAL NOT NULL DEFAULT 1;";
cmd.ExecuteNonQuery();
}
// Rename legacy "Backdrop" display name to "Background" in existing rows
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "UPDATE Source SET Name = 'Background' WHERE Name = 'Backdrop';";
cmd.ExecuteNonQuery();
}
}
// v3 → v4: WebcamSceneConfig gains RectWidth/RectHeight (the pre-Round rect,
// so a reloaded Round webcam restores its aspect on toggle-back).
private void MigrateWebcamConfigTable()
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA table_info(WebcamSceneConfig);";
using var reader = cmd.ExecuteReader();
while (reader.Read())
columns.Add(reader.GetString(1));
}
if (!columns.Contains("RectWidth"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN RectWidth REAL;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("RectHeight"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN RectHeight REAL;";
cmd.ExecuteNonQuery();
}
}
// v2 → v3: the webcam leaves the Source table. Any webcam Source rows become
// one Webcam identity (first row — one camera app-wide) + a WebcamSceneConfig
// per scene that had one, then the webcam rows are deleted. No backfill:
// scenes without the webcam stay webcam-free.
private void MigrateToV3()
{
var rows = new List<(string Id, string SceneId, string Name, double X, double Y, double W, double H, double Opacity, string DeviceId, string ClipShape, bool IsMirrored)>();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
SELECT Id, SceneId, Name, X, Y, Width, Height, Opacity, DeviceId, ClipShape, IsMirrored
FROM Source WHERE Type = 'Webcam' ORDER BY SortOrder;
""";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rows.Add((
reader.GetString(0),
reader.GetString(1),
reader.GetString(2),
reader.GetDouble(3),
reader.GetDouble(4),
reader.GetDouble(5),
reader.GetDouble(6),
reader.GetDouble(7),
reader.IsDBNull(8) ? string.Empty : reader.GetString(8),
reader.GetString(9),
reader.GetInt32(10) != 0));
}
}
if (rows.Count == 0) return;
using var tx = _connection.BeginTransaction();
var webcamId = Guid.NewGuid().ToString();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ($id, $device, $name);";
cmd.Transaction = tx;
cmd.Parameters.AddWithValue("$id", webcamId);
cmd.Parameters.AddWithValue("$device", rows[0].DeviceId);
cmd.Parameters.AddWithValue("$name", rows[0].Name);
cmd.ExecuteNonQuery();
}
foreach (var row in rows)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
INSERT INTO WebcamSceneConfig
(SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation, SortOrder)
VALUES ($sceneId, $webcamId, 1, $x, $y, $w, $h, $opacity,
$clip, $mirrored, NULL, 1, 0, 'None', 0);
""";
cmd.Transaction = tx;
cmd.Parameters.AddWithValue("$sceneId", row.SceneId);
cmd.Parameters.AddWithValue("$webcamId", webcamId);
cmd.Parameters.AddWithValue("$x", row.X);
cmd.Parameters.AddWithValue("$y", row.Y);
cmd.Parameters.AddWithValue("$w", row.W);
cmd.Parameters.AddWithValue("$h", row.H);
cmd.Parameters.AddWithValue("$opacity", row.Opacity);
cmd.Parameters.AddWithValue("$clip", row.ClipShape);
cmd.Parameters.AddWithValue("$mirrored", row.IsMirrored ? 1 : 0);
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM Source WHERE Type = 'Webcam';";
cmd.Transaction = tx;
cmd.ExecuteNonQuery();
}
tx.Commit();
}
// v5 → v6: Scene gains HasBackground. Added columns default to true; the
// ONE-TIME backfill (runs only when the column is first added) turns every
// non-Live canonical scene off and drops their background sources — the fix for
// DBs saved before the Live-only policy. MainViewModel.NormalizeBackgrounds
// re-runs the same normalization on every load for DBs that miss the backfill.
private void MigrateSceneTable()
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA table_info(Scene);";
using var reader = cmd.ExecuteReader();
while (reader.Read())
columns.Add(reader.GetString(1));
}
if (columns.Contains("HasBackdrop"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Scene RENAME COLUMN HasBackdrop TO HasBackground;";
cmd.ExecuteNonQuery();
}
else if (columns.Contains("HasBackground"))
{
return;
}
if (!columns.Contains("HasBackground"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Scene ADD COLUMN HasBackground INTEGER NOT NULL DEFAULT 1;";
cmd.ExecuteNonQuery();
}
const string noBackground = "('starting', 'brb', 'chat', 'ending')";
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = $"UPDATE Scene SET HasBackground = 0 WHERE LOWER(TRIM(Name)) IN {noBackground};";
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = $"""
DELETE FROM Source
WHERE IsBackground = 1 AND SceneId IN
(SELECT Id FROM Scene WHERE LOWER(TRIM(Name)) IN {noBackground});
""";
cmd.ExecuteNonQuery();
}
}
// v6 → v7: Scene gains HasSocialBar (the per-scene social-bar toggle).
// Defaults to 0 (off) — the creator adds it explicitly via the footer [+].
private void MigrateSceneSocialBarColumn()
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA table_info(Scene);";
using var reader = cmd.ExecuteReader();
while (reader.Read())
columns.Add(reader.GetString(1));
}
if (columns.Contains("HasSocialBar")) return;
using var alter = _connection.CreateCommand();
alter.CommandText = "ALTER TABLE Scene ADD COLUMN HasSocialBar INTEGER NOT NULL DEFAULT 0;";
alter.ExecuteNonQuery();
}
// v7 → v8: Socials gains BarEnabled (the dialog's show/hide switch). The old
// BarJustify column stays for back-compat but is no longer read or written.
private void MigrateSocialsTable()
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA table_info(Socials);";
using var reader = cmd.ExecuteReader();
while (reader.Read())
columns.Add(reader.GetString(1));
}
if (columns.Contains("BarEnabled")) return;
using var alter = _connection.CreateCommand();
alter.CommandText = "ALTER TABLE Socials ADD COLUMN BarEnabled INTEGER NOT NULL DEFAULT 1;";
alter.ExecuteNonQuery();
}
// v8 → v9: SocialEntry gains Software (the fediverse instance's nodeinfo
// software name) so a reloaded entry still shows the right service logo.
private void MigrateSocialEntryTable()
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA table_info(SocialEntry);";
using var reader = cmd.ExecuteReader();
while (reader.Read())
columns.Add(reader.GetString(1));
}
if (columns.Contains("Software")) return;
using var alter = _connection.CreateCommand();
alter.CommandText = "ALTER TABLE SocialEntry ADD COLUMN Software TEXT;";
alter.ExecuteNonQuery();
}
}