Scene/source builder with image overlays, reuse dialog, and SQLite layout persistence
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
public class LayoutStore : IDisposable
|
||||
{
|
||||
public static LayoutStore? Instance { get; private set; }
|
||||
|
||||
private readonly SqliteConnection _connection;
|
||||
public string ActivePath { get; }
|
||||
|
||||
public LayoutStore(string path)
|
||||
{
|
||||
ActivePath = path;
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
_connection = new SqliteConnection($"Data Source={path}");
|
||||
_connection.Open();
|
||||
using (var pragma = _connection.CreateCommand())
|
||||
{
|
||||
pragma.CommandText = "PRAGMA foreign_keys = ON;";
|
||||
pragma.ExecuteNonQuery();
|
||||
}
|
||||
EnsureSchema();
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
private void EnsureSchema()
|
||||
{
|
||||
string[] statements =
|
||||
{
|
||||
"PRAGMA user_version = 1;",
|
||||
"""
|
||||
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,
|
||||
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,
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""",
|
||||
};
|
||||
foreach (var sql in statements)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
public List<Scene> Load()
|
||||
{
|
||||
var scenes = new List<Scene>();
|
||||
var sourcesByScene = new Dictionary<string, List<Source>>();
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene FROM Scene ORDER BY SortOrder";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
scenes.Add(new Scene
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
Name = reader.GetString(1),
|
||||
IsHidden = reader.GetInt32(2) != 0,
|
||||
IsChatScene = reader.GetInt32(3) != 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
SELECT Id, SceneId, AssetId, Type, Name, IsEnabled,
|
||||
X, Y, Width, Height, Opacity, MonitorIndex, DeviceId
|
||||
FROM Source ORDER BY SortOrder
|
||||
""";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
var sceneId = reader.GetString(1);
|
||||
var source = new Source
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
AssetId = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
Type = Enum.Parse<SourceType>(reader.GetString(3)),
|
||||
Name = reader.GetString(4),
|
||||
IsEnabled = reader.GetInt32(5) != 0,
|
||||
X = reader.GetDouble(6),
|
||||
Y = reader.GetDouble(7),
|
||||
Width = reader.GetDouble(8),
|
||||
Height = reader.GetDouble(9),
|
||||
Opacity = reader.GetDouble(10),
|
||||
MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11),
|
||||
DeviceId = reader.IsDBNull(12) ? null : reader.GetString(12),
|
||||
};
|
||||
if (!sourcesByScene.TryGetValue(sceneId, out var list))
|
||||
sourcesByScene[sceneId] = list = new List<Source>();
|
||||
list.Add(source);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var scene in scenes)
|
||||
{
|
||||
if (sourcesByScene.TryGetValue(scene.Id, out var list))
|
||||
foreach (var source in list)
|
||||
scene.Sources.Add(source);
|
||||
}
|
||||
|
||||
return scenes;
|
||||
}
|
||||
|
||||
public void Save(IEnumerable<Scene> scenes)
|
||||
{
|
||||
using var tx = _connection.BeginTransaction();
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM Source;";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM Scene;";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, SortOrder)
|
||||
VALUES ($id, $name, $isHidden, $isChat, $sort)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
var nameP = cmd.Parameters.Add("$name", SqliteType.Text);
|
||||
var hiddenP = cmd.Parameters.Add("$isHidden", SqliteType.Integer);
|
||||
var chatP = cmd.Parameters.Add("$isChat", SqliteType.Integer);
|
||||
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||
|
||||
var sort = 0;
|
||||
foreach (var scene in scenes)
|
||||
{
|
||||
idP.Value = scene.Id;
|
||||
nameP.Value = scene.Name;
|
||||
hiddenP.Value = scene.IsHidden ? 1 : 0;
|
||||
chatP.Value = scene.IsChatScene ? 1 : 0;
|
||||
sortP.Value = sort++;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
INSERT INTO Source (Id, SceneId, AssetId, Type, Name, IsEnabled,
|
||||
X, Y, Width, Height, Opacity, MonitorIndex, DeviceId, SortOrder)
|
||||
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
|
||||
$x, $y, $w, $h, $opacity, $monitor, $device, $sort)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
var sceneIdP = cmd.Parameters.Add("$sceneId", SqliteType.Text);
|
||||
var assetIdP = cmd.Parameters.Add("$assetId", SqliteType.Text);
|
||||
var typeP = cmd.Parameters.Add("$type", SqliteType.Text);
|
||||
var nameP = cmd.Parameters.Add("$name", SqliteType.Text);
|
||||
var enabledP = cmd.Parameters.Add("$isEnabled", SqliteType.Integer);
|
||||
var xP = cmd.Parameters.Add("$x", SqliteType.Real);
|
||||
var yP = cmd.Parameters.Add("$y", SqliteType.Real);
|
||||
var wP = cmd.Parameters.Add("$w", SqliteType.Real);
|
||||
var hP = cmd.Parameters.Add("$h", SqliteType.Real);
|
||||
var opacityP = cmd.Parameters.Add("$opacity", SqliteType.Real);
|
||||
var monitorP = cmd.Parameters.Add("$monitor", SqliteType.Integer);
|
||||
var deviceP = cmd.Parameters.Add("$device", SqliteType.Text);
|
||||
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||
|
||||
foreach (var scene in scenes)
|
||||
{
|
||||
var sort = 0;
|
||||
foreach (var source in scene.Sources)
|
||||
{
|
||||
idP.Value = source.Id;
|
||||
sceneIdP.Value = scene.Id;
|
||||
assetIdP.Value = (object?)source.AssetId ?? DBNull.Value;
|
||||
typeP.Value = source.Type.ToString();
|
||||
nameP.Value = source.Name;
|
||||
enabledP.Value = source.IsEnabled ? 1 : 0;
|
||||
xP.Value = source.X;
|
||||
yP.Value = source.Y;
|
||||
wP.Value = source.Width;
|
||||
hP.Value = source.Height;
|
||||
opacityP.Value = source.Opacity;
|
||||
monitorP.Value = (object?)source.MonitorIndex ?? DBNull.Value;
|
||||
deviceP.Value = (object?)source.DeviceId ?? DBNull.Value;
|
||||
sortP.Value = sort++;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM Asset WHERE Id NOT IN (SELECT AssetId FROM Source WHERE AssetId IS NOT NULL);";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
tx.Commit();
|
||||
}
|
||||
|
||||
public byte[]? GetAssetBytes(string assetId)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Data FROM Asset WHERE Id = $id";
|
||||
cmd.Parameters.AddWithValue("$id", assetId);
|
||||
return cmd.ExecuteScalar() as byte[];
|
||||
}
|
||||
|
||||
public string UpsertAsset(byte[] data, int pixelWidth, int pixelHeight)
|
||||
{
|
||||
var hash = Convert.ToHexString(SHA256.HashData(data));
|
||||
var id = Guid.NewGuid().ToString();
|
||||
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO Asset (Id, Hash, Data, PixelWidth, PixelHeight)
|
||||
VALUES ($id, $hash, $data, $w, $h)
|
||||
ON CONFLICT(Hash) DO NOTHING;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$id", id);
|
||||
cmd.Parameters.AddWithValue("$hash", hash);
|
||||
cmd.Parameters.AddWithValue("$data", data);
|
||||
cmd.Parameters.AddWithValue("$w", pixelWidth);
|
||||
cmd.Parameters.AddWithValue("$h", pixelHeight);
|
||||
cmd.ExecuteNonQuery();
|
||||
|
||||
cmd.CommandText = "SELECT Id FROM Asset WHERE Hash = $hash;";
|
||||
return (string)cmd.ExecuteScalar()!;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
@@ -46,6 +47,49 @@ public class YouTubeAuthService
|
||||
return $"{AuthorizationEndpoint}?{encoded}";
|
||||
}
|
||||
|
||||
public async Task<YouTubeChannel?> AuthenticateAsync()
|
||||
{
|
||||
const int port = 8765;
|
||||
var redirectUri = $"http://localhost:{port}/oauth2/callback";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_clientId) || string.IsNullOrWhiteSpace(_clientSecret))
|
||||
return null;
|
||||
|
||||
using var listener = new HttpListener();
|
||||
listener.Prefixes.Add($"{redirectUri}/");
|
||||
listener.Start();
|
||||
|
||||
Process.Start(new ProcessStartInfo(GetAuthorizationUrl(redirectUri)) { UseShellExecute = true });
|
||||
|
||||
HttpListenerContext context;
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
|
||||
context = await listener.GetContextAsync().WaitAsync(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var code = context.Request.QueryString["code"];
|
||||
var error = context.Request.QueryString["error"];
|
||||
|
||||
var html = error != null
|
||||
? "<html><body style=\"font-family:sans-serif\"><h2>Sign-in failed</h2><p>You can close this window and return to ytLlive.</p></body></html>"
|
||||
: "<html><body style=\"font-family:sans-serif\"><h2>Sign-in successful!</h2><p>You can close this window and return to ytLlive.</p></body></html>";
|
||||
var buffer = Encoding.UTF8.GetBytes(html);
|
||||
context.Response.ContentType = "text/html; charset=utf-8";
|
||||
context.Response.ContentLength64 = buffer.Length;
|
||||
await context.Response.OutputStream.WriteAsync(buffer);
|
||||
context.Response.Close();
|
||||
|
||||
if (error != null || string.IsNullOrEmpty(code))
|
||||
return null;
|
||||
|
||||
return await ExchangeCodeForToken(code, redirectUri);
|
||||
}
|
||||
|
||||
public async Task<YouTubeChannel?> ExchangeCodeForToken(string code, string redirectUri)
|
||||
{
|
||||
var body = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
|
||||
Reference in New Issue
Block a user