Files
LlamaCasty/Services/LayoutStore.Migrations.cs
T
gramps a9d5f29084 feat(ypp): YPP journey tracker slice 1 — the YPP pull-out below Stream Settings (TASK 39, Good Dog ONE test)
Current-OAuth-scope data, no re-consent: subscriber bars for both tiers (500 fan-funding /
1,000 ad-revenue; the 2027-02-01 doubling is versioned date-aware data in YppThresholds),
3-uploads/90d, live standing from channels.list auditDetails, self-reported 2FA/AdSense
checkboxes + deep links (Google 2-Step / youtube.com/earn), and the "what counts" education.
Every refresh appends a YppSnapshot (SQLite v10) so the analytics slice has history from day one.

- ChannelStatsService (channels.list statistics,auditDetails,contentDetails + playlistItems.list,
  virtual CaptureCurrentAsync = test seam); YppTrackerViewModel mirrors LiveBroadcastFormViewModel.
- MainViewModel.Ypp.cs: Ypp property, ChannelStatsFactoryOverride seam, one-open-at-a-time drawer
  exclusivity (both directions); account sign-in/out/restore hooks in Account.cs.
- PreviewPane.xaml: TextPullOutHost is now a drawer bank [broadcast][ypp][stacked white tabs];
  outside-click collapse covers both drawers. MainWindow.xaml.cs updated.
- Wait - watchers: the whole feature already reviewed against YouTube's own docs (YPP Earn tab +
  API availability) before building — see TASKS/task-39-ypp-journey-tracker.md.
- ONE integration test: YppPullOutTests (snapshot capture/persist + checklist round-trip +
  drawer exclusivity). 313/313 pass, 0 warnings.
- Memory in same commit: TASKS.md row, ai.md Journey-tracker bullet (roadmap→reality), HANDOFF.md.

Slice 2 (Analytics yt-analytics.readonly re-consent → watch-hours/Shorts + velocity/ETA +
sparkline) is queued behind this, not merged.
2026-09-22 08:07:54 -07:00

512 lines
20 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();
}
// 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();
}
}