Social bar v2: fediverse icons resolved via nodeinfo (with redirect-following), persisted software column, icon colors — 112 tests passing, 0 warnings
This commit is contained in:
+53
-8
@@ -135,11 +135,13 @@ public class LayoutStore : IDisposable
|
||||
MigrateWebcamConfigTable();
|
||||
MigrateSceneTable();
|
||||
MigrateSceneSocialBarColumn();
|
||||
MigrateSocialsTable();
|
||||
MigrateSocialEntryTable();
|
||||
if (GetUserVersion() < 3)
|
||||
MigrateToV3();
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA user_version = 7;";
|
||||
cmd.CommandText = "PRAGMA user_version = 8;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
@@ -360,6 +362,46 @@ public class LayoutStore : IDisposable
|
||||
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();
|
||||
}
|
||||
|
||||
public List<Scene> Load()
|
||||
{
|
||||
Webcam = null;
|
||||
@@ -403,19 +445,19 @@ public class LayoutStore : IDisposable
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT Id, BarPosition, BarJustify FROM Socials LIMIT 1;";
|
||||
cmd.CommandText = "SELECT Id, BarPosition, BarEnabled FROM Socials LIMIT 1;";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
if (reader.Read())
|
||||
{
|
||||
Socials = new SocialsConfig
|
||||
{
|
||||
BarPosition = Enum.TryParse<SocialBarPosition>(reader.GetString(1), out var pos) ? pos : SocialBarPosition.Bottom,
|
||||
BarJustify = Enum.TryParse<SocialBarJustify>(reader.GetString(2), out var just) ? just : SocialBarJustify.Center,
|
||||
BarEnabled = reader.FieldCount > 2 && !reader.IsDBNull(2) && reader.GetInt32(2) != 0,
|
||||
};
|
||||
var socialsId = reader.GetString(0);
|
||||
|
||||
using var entryCmd = _connection.CreateCommand();
|
||||
entryCmd.CommandText = "SELECT Service, Handle, ProfileUrl FROM SocialEntry WHERE SocialsId = $id ORDER BY SortOrder;";
|
||||
entryCmd.CommandText = "SELECT Service, Handle, ProfileUrl, Software FROM SocialEntry WHERE SocialsId = $id ORDER BY SortOrder;";
|
||||
entryCmd.Parameters.AddWithValue("$id", socialsId);
|
||||
using var entryReader = entryCmd.ExecuteReader();
|
||||
while (entryReader.Read())
|
||||
@@ -425,6 +467,7 @@ public class LayoutStore : IDisposable
|
||||
Service = Enum.TryParse<SocialService>(entryReader.GetString(0), out var svc) ? svc : SocialService.Link,
|
||||
Handle = entryReader.GetString(1),
|
||||
ProfileUrl = entryReader.GetString(2),
|
||||
FediverseSoftware = entryReader.FieldCount > 3 && !entryReader.IsDBNull(3) ? entryReader.GetString(3) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -717,18 +760,18 @@ public class LayoutStore : IDisposable
|
||||
var socialsId = Guid.NewGuid().ToString();
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "INSERT INTO Socials (Id, BarPosition, BarJustify) VALUES ($id, $pos, $just);";
|
||||
cmd.CommandText = "INSERT INTO Socials (Id, BarPosition, BarEnabled) VALUES ($id, $pos, $enabled);";
|
||||
cmd.Transaction = tx;
|
||||
cmd.Parameters.AddWithValue("$id", socialsId);
|
||||
cmd.Parameters.AddWithValue("$pos", socials.BarPosition.ToString());
|
||||
cmd.Parameters.AddWithValue("$just", socials.BarJustify.ToString());
|
||||
cmd.Parameters.AddWithValue("$enabled", socials.BarEnabled ? 1 : 0);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using var entryCmd = _connection.CreateCommand();
|
||||
entryCmd.CommandText = """
|
||||
INSERT INTO SocialEntry (Id, SocialsId, Service, Handle, ProfileUrl, SortOrder)
|
||||
VALUES ($id, $socialsId, $service, $handle, $url, $sort)
|
||||
INSERT INTO SocialEntry (Id, SocialsId, Service, Handle, ProfileUrl, Software, SortOrder)
|
||||
VALUES ($id, $socialsId, $service, $handle, $url, $software, $sort)
|
||||
""";
|
||||
entryCmd.Transaction = tx;
|
||||
var eId = entryCmd.Parameters.Add("$id", SqliteType.Text);
|
||||
@@ -736,6 +779,7 @@ public class LayoutStore : IDisposable
|
||||
var eService = entryCmd.Parameters.Add("$service", SqliteType.Text);
|
||||
var eHandle = entryCmd.Parameters.Add("$handle", SqliteType.Text);
|
||||
var eUrl = entryCmd.Parameters.Add("$url", SqliteType.Text);
|
||||
var eSoftware = entryCmd.Parameters.Add("$software", SqliteType.Text);
|
||||
var eSort = entryCmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||
|
||||
var entrySort = 0;
|
||||
@@ -746,6 +790,7 @@ public class LayoutStore : IDisposable
|
||||
eService.Value = entry.Service.ToString();
|
||||
eHandle.Value = entry.Handle;
|
||||
eUrl.Value = entry.ProfileUrl;
|
||||
eSoftware.Value = (object?)entry.FediverseSoftware ?? DBNull.Value;
|
||||
eSort.Value = entrySort++;
|
||||
entryCmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
+121
-5
@@ -1,4 +1,5 @@
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ytLive.Models;
|
||||
@@ -11,11 +12,17 @@ public sealed class SocialLookupResult
|
||||
public string ProfileUrl { get; init; } = string.Empty;
|
||||
public string Handle { get; init; } = string.Empty;
|
||||
public string Error { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Fediverse instance software (nodeinfo `software.name`), when the handle is federated.</summary>
|
||||
public string? FediverseSoftware { get; init; }
|
||||
|
||||
/// <summary>True when the caller canceled the lookup before it finished.</summary>
|
||||
public bool Canceled { get; init; }
|
||||
}
|
||||
|
||||
public interface ISocialValidator
|
||||
{
|
||||
Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl);
|
||||
Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -36,12 +43,39 @@ public sealed class HttpSocialValidator : ISocialValidator
|
||||
"Mozilla/5.0 (compatible; ytLlive social validator)");
|
||||
}
|
||||
|
||||
public async Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl)
|
||||
public async Task<SocialLookupResult> LookupAsync(SocialService service, string handleOrUrl, CancellationToken ct)
|
||||
{
|
||||
var input = handleOrUrl.Trim();
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return new SocialLookupResult { Error = "Enter a handle or URL." };
|
||||
|
||||
// Fediverse handle (@user@domain): the domain is part of the identity,
|
||||
// so the canonical URL can't be rebuilt from a bare handle. After the
|
||||
// profile validates, ask the instance which software it runs (nodeinfo)
|
||||
// so the caller can show the right service logo.
|
||||
if (input.StartsWith('@') && input.IndexOf('@', 1) > 0)
|
||||
{
|
||||
var at = input.IndexOf('@', 1);
|
||||
var user = input[1..at];
|
||||
var domain = input[(at + 1)..];
|
||||
if (user.Length > 0 && domain.Length > 0)
|
||||
{
|
||||
var url = $"https://{domain}/@{user}";
|
||||
var result = await CheckAsync(service, input, url, ct);
|
||||
if (!result.Success) return result;
|
||||
var software = await TryFetchFediverseSoftwareAsync(domain, ct);
|
||||
if (ct.IsCancellationRequested)
|
||||
return new SocialLookupResult { Canceled = true };
|
||||
return new SocialLookupResult
|
||||
{
|
||||
Success = true,
|
||||
ProfileUrl = result.ProfileUrl,
|
||||
Handle = result.Handle,
|
||||
FediverseSoftware = software,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var handle = input;
|
||||
if (input.StartsWith("http://", System.StringComparison.OrdinalIgnoreCase) ||
|
||||
input.StartsWith("https://", System.StringComparison.OrdinalIgnoreCase))
|
||||
@@ -50,23 +84,31 @@ public sealed class HttpSocialValidator : ISocialValidator
|
||||
handle = uri.AbsolutePath.Trim('/');
|
||||
}
|
||||
|
||||
var url = SocialServiceIcons.CanonicalUrlFor(service, handle);
|
||||
var canonical = SocialServiceIcons.CanonicalUrlFor(service, handle);
|
||||
return await CheckAsync(service, handle, canonical, ct);
|
||||
}
|
||||
|
||||
private async Task<SocialLookupResult> CheckAsync(SocialService service, string handle, string url, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var resp = await _client.GetAsync(url, CancellationToken.None);
|
||||
using var resp = await _client.GetAsync(url, ct);
|
||||
if (resp.IsSuccessStatusCode || (int)resp.StatusCode is 301 or 302 or 303 or 307 or 308)
|
||||
return new SocialLookupResult
|
||||
{
|
||||
Success = true,
|
||||
ProfileUrl = url,
|
||||
Handle = handle.TrimStart('@'),
|
||||
Handle = handle,
|
||||
};
|
||||
return new SocialLookupResult
|
||||
{
|
||||
Error = $"{service} returned {resp.StatusCode} for '{handle}'. The handle may be wrong or the page doesn't exist.",
|
||||
};
|
||||
}
|
||||
catch (System.OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return new SocialLookupResult { Canceled = true };
|
||||
}
|
||||
catch (System.Net.Http.HttpRequestException)
|
||||
{
|
||||
return new SocialLookupResult
|
||||
@@ -79,4 +121,78 @@ public sealed class HttpSocialValidator : ISocialValidator
|
||||
return new SocialLookupResult { Error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort nodeinfo lookup: GET /.well-known/nodeinfo, follow the first
|
||||
/// nodeinfo link, read `software.name`. If the identity domain is itself a
|
||||
/// redirect (e.g. a YunoHost domain whose default app lives on a subdomain),
|
||||
/// the bare root 302s to the real instance — follow it and ask that host.
|
||||
/// Failures return null — validation still succeeds, the icon just falls
|
||||
/// back to the generic fediverse glyph.
|
||||
/// </summary>
|
||||
private async Task<string?> TryFetchFediverseSoftwareAsync(string domain, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var software = await FetchSoftwareNameAsync(domain, ct);
|
||||
if (software != null) return software;
|
||||
|
||||
var resolved = await ResolveInstanceHostAsync(domain, ct);
|
||||
if (resolved == null
|
||||
|| string.Equals(resolved, domain, System.StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
return await FetchSoftwareNameAsync(resolved, ct);
|
||||
}
|
||||
catch (System.OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return null; // caller checks ct.IsCancellationRequested and reports Canceled
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> FetchSoftwareNameAsync(string domain, CancellationToken ct)
|
||||
{
|
||||
var nodeInfoUrl = await FetchNodeInfoUrlAsync(domain, ct);
|
||||
if (nodeInfoUrl == null) return null;
|
||||
using var resp = await _client.GetAsync(nodeInfoUrl, ct);
|
||||
if (!resp.IsSuccessStatusCode) return null;
|
||||
var json = await resp.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.TryGetProperty("software", out var software)
|
||||
&& software.TryGetProperty("name", out var name))
|
||||
return name.GetString();
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Follows the bare-domain root redirect (HttpClient follows 3xx automatically)
|
||||
/// and returns the final host — the "default app" behind an identity domain.</summary>
|
||||
private async Task<string?> ResolveInstanceHostAsync(string domain, CancellationToken ct)
|
||||
{
|
||||
using var resp = await _client.GetAsync($"https://{domain}/", ct);
|
||||
return resp.RequestMessage?.RequestUri?.Host;
|
||||
}
|
||||
|
||||
private async Task<string?> FetchNodeInfoUrlAsync(string domain, CancellationToken ct)
|
||||
{
|
||||
using var resp = await _client.GetAsync($"https://{domain}/.well-known/nodeinfo", ct);
|
||||
if (!resp.IsSuccessStatusCode) return null;
|
||||
var json = await resp.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (!doc.RootElement.TryGetProperty("links", out var links)) return null;
|
||||
foreach (var link in links.EnumerateArray())
|
||||
{
|
||||
if (!link.TryGetProperty("rel", out var rel)) continue;
|
||||
var relStr = rel.GetString() ?? string.Empty;
|
||||
if (!relStr.Contains("nodeinfo", System.StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (!link.TryGetProperty("href", out var href) || href.GetString() is not { } hrefStr) continue;
|
||||
if (System.Uri.TryCreate(hrefStr, System.UriKind.RelativeOrAbsolute, out var parsed))
|
||||
return parsed.IsAbsoluteUri
|
||||
? parsed.AbsoluteUri
|
||||
: new System.Uri(new System.Uri($"https://{domain}"), parsed).AbsoluteUri;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ External-facing logic: YouTube API, persistence. See
|
||||
| `Compositor/StretchMath.cs` | Pure pixel math: the WPF `UniformToFill` cover-crop, clamped bilinear sample/scale (unit-tested half of the compositor) |
|
||||
| `Compositor/StaticPixelCache.cs` | Asset bytes → cached BGRA8 `VideoFrame`, decoded once per content hash — the output path's raw-pixel source (the preview uses ImageCache's `BitmapImage`) |
|
||||
| `Encoder/IFfmpegLocator.cs` | **TASK 4 ship step 2 seam**: resolves an absolute path to `ffmpeg.exe` (PATH first → cached `%APPDATA%\ytLlive\tools\ffmpeg.exe` → pinned BtbN LGPL-shared zip download). The encoder step and tests fake it |
|
||||
| `SocialValidator.cs` | `ISocialValidator` seam + `HttpSocialValidator` default: validates a social handle/URL by constructing the canonical profile URL and issuing an HTTP GET — the "act of validation" before adding to the bar. 200/redirect = exists; 404/connection failure = rejected. Constructor takes optional `HttpClient` for tests |
|
||||
| `SocialValidator.cs` | `ISocialValidator` seam + `HttpSocialValidator` default: validates a social handle/URL by constructing the canonical profile URL and issuing an HTTP GET — the "act of validation" before adding to the bar. 200/redirect = exists; 404/connection failure = rejected. Fediverse `@user@domain` additionally does a best-effort nodeinfo lookup (`/.well-known/nodeinfo` → `software.name`) so the entry can show the instance's real logo; nodeinfo failure still validates (generic fediverse glyph). If the identity domain's nodeinfo is blocked (SSO) but the bare root 302s to the real instance (YunoHost default-app subdomains), the lookup follows the redirect and asks that host instead. Constructor takes optional `HttpClient` for tests |
|
||||
| `Encoder/FfmpegLocator.cs` | The default locator: PATH probe wins, then the cache, then pull+extract (`ffmpeg.exe` **+ the `libav*.dll` family** via a staging dir, failures via `AppLog`). Pinned to BtbN `autobuild-2026-08-09-13-03` **lgpl-shared** (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see `ai.md` → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Not yet constructed by the app (the encoder step wires it) |
|
||||
|
||||
Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs)
|
||||
|
||||
Reference in New Issue
Block a user