Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65d88b5167 | ||
|
|
4f68805d9a | ||
|
|
55fc210385 | ||
|
|
d1c04030af | ||
|
|
945d108334 | ||
|
|
1cf4a7a90c | ||
|
|
21ede49253 | ||
|
|
d04769ccdb | ||
|
|
439272b405 | ||
|
|
b564545ff7 |
@@ -274,8 +274,8 @@ jobs:
|
||||
done
|
||||
echo " FAIL $path (no 2xx containing '$needle' within timeout)"; return 1
|
||||
}
|
||||
if check "/iptv/channels.m3u" "#EXTM3U" && check "/iptv/xmltv.xml" "<tv"; then
|
||||
echo "Smoke + IPTV E2E passed: channels.m3u + xmltv.xml serve a valid playlist + guide"
|
||||
if check "/iptv/channels.m3u" "#EXTM3U" && check "/iptv/xmltv.xml" "<tv" && check "/app/" "ChicoryTV"; then
|
||||
echo "Smoke + IPTV E2E passed: channels.m3u + xmltv.xml serve a valid playlist + guide; /app/ serves the ChicoryTV SPA"
|
||||
else
|
||||
echo "===== container logs (tail) ====="; docker logs "$NAME" 2>&1 | tail -n 40 || true
|
||||
exit 1
|
||||
|
||||
@@ -4,7 +4,8 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Language**: C# / .NET 10, Blazor Server UI (MudBlazor)
|
||||
- **Language**: C# / .NET 10
|
||||
- **UI**: ChicoryTV React SPA (`web/`, Vite, served at `/app`) over the REST API — the default UI; root `/` and migrated legacy routes 302 there (`ErsatzTV/LegacyUiRedirects.cs`). The legacy Blazor Server UI (MudBlazor) still serves un-migrated admin screens (collections, media browse, trakt, filler, watermarks, ffmpeg profiles, blocks/decos/templates, playout editors, logs, troubleshooting; Blazor home = `/system/health`); its removal is #91 phase (b), gated on parity issues #140–#147
|
||||
- **Pattern**: CQRS via MediatR — queries/commands in `ErsatzTV.Application/`
|
||||
- **Database**: EF Core (SQLite default, MySQL optional) — context in `ErsatzTV.Infrastructure/Data/TvContext.cs`
|
||||
- **Media**: FFmpeg via CliWrap, SkiaSharp for logo generation
|
||||
@@ -14,7 +15,8 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
|
||||
|
||||
| Project | Role |
|
||||
|---------|------|
|
||||
| `ErsatzTV/` | ASP.NET Core host, Blazor pages, API controllers, DI setup |
|
||||
| `ErsatzTV/` | ASP.NET Core host, API controllers, SPA static hosting, legacy Blazor pages, DI setup |
|
||||
| `web/` | ChicoryTV React SPA (Vite + TypeScript; builds into `ErsatzTV/wwwroot/app`) |
|
||||
| `ErsatzTV.Application/` | MediatR handlers (business logic) |
|
||||
| `ErsatzTV.Core/` | Domain entities, interfaces, no infrastructure deps |
|
||||
| `ErsatzTV.Infrastructure/` | EF Core repos, data access |
|
||||
@@ -36,7 +38,7 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
|
||||
- **Docker host**: jazz (192.168.1.99), container `ersatztv`, port 8409
|
||||
- **Config volume**: `~/downloadswarm/ersatztv/` on jazz → `/config` in container
|
||||
- **SQLite DB**: `/config/ersatztv.sqlite3` (WAL mode, root-owned)
|
||||
- **Images** (our fork, built by `.gitea/workflows/docker-build.yml` → `192.168.1.95:3000/timothy/ersatztv`): push to `main` → `:latest` + `:<sha>` (test image); push `v*` tag → `:prod` + `:<version>` + `:<sha>`. Cutover done (2026-06-27, server-management#481/#482): prod container runs fork `:prod` (currently v26.3.1), test container tracks `:latest`; prod advances only when a new `v*` tag is pushed (next: `v26.4.0`, first app-change release). Pipeline details: `docs/ci-cd.md`.
|
||||
- **Images** (our fork, built by `.gitea/workflows/docker-build.yml` → `192.168.1.95:3000/timothy/ersatztv`): push to `main` → `:latest` + `:<sha>` (test image); push `v*` tag → `:prod` + `:<version>` + `:<sha>`. Prod deploys via **Komodo GitOps**: the `media-servers` compose in `timothy/server-management` (`docker/bumblebee/stacks/media-servers/compose.yaml`) pins the version tag (currently `26.5.0`, deployed 2026-07-07); releasing = tag here, wait for the image build, bump that pin and push (the Komodo pre-deploy hook backs up before recreating). Test container tracks `:latest`. Pipeline details: `docs/ci-cd.md`.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -56,7 +58,7 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
|
||||
- **Read [`docs/contributing.md`](docs/contributing.md)** before non-trivial changes — it documents the established patterns (layering, CQRS handlers, LanguageExt, Blazor/MudBlazor, EF Core + dual-provider migrations, the FFmpeg pipeline, analyzers, testing) and the **deviation policy**: match the established style; diverge only with a concrete, stated reason.
|
||||
- Follow existing MediatR CQRS pattern for new features
|
||||
- Domain logic in `ErsatzTV.Core`, infrastructure in `ErsatzTV.Infrastructure`
|
||||
- Keep Blazor pages thin — delegate to MediatR handlers
|
||||
- Keep UI thin: the SPA talks to `/api/*` only; legacy Blazor pages delegate to MediatR handlers. New screens go in the SPA (`web/`), never in Blazor
|
||||
- Test with **NUnit** + Shouldly + NSubstitute (the existing `*.Tests` projects); xUnit is **not** used here
|
||||
- **Dependencies use Central Package Management**: versions live in the repo-root `Directory.Packages.props`; csproj reference packages by name only. Add/upgrade by editing the central `<PackageVersion>` — never put `Version=` back on a `<PackageReference>` (trips `NU1008`). See `docs/ci-cd.md` → Dependency management.
|
||||
- **DB migrations target BOTH providers**: a `TvContext` model change needs a migration in `ErsatzTV.Infrastructure.Sqlite` **and** `ErsatzTV.Infrastructure.MySql` — run `scripts/add-migration.sh <Name>` (does both). CI's `migrations` job enforces model-drift + apply-to-fresh-DB per provider. See `docs/ci-cd.md` → Migration integrity.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Text;
|
||||
using ErsatzTV.Mcp;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Mcp.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class BoundedLineReaderTests
|
||||
{
|
||||
[Test]
|
||||
public async Task ReadLineAsync_Should_Return_Line_Without_Trailing_Newline()
|
||||
{
|
||||
using var reader = new StringReader("hello world\n");
|
||||
|
||||
BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(reader, 1024);
|
||||
|
||||
line.EndOfStream.ShouldBeFalse();
|
||||
line.Overflowed.ShouldBeFalse();
|
||||
line.Text.ShouldBe("hello world");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReadLineAsync_Should_Return_Line_Of_Exactly_Cap_Length_Intact()
|
||||
{
|
||||
// The cap is the inclusive max: a line of exactly `cap` chars is returned, not overflowed.
|
||||
using var reader = new StringReader("abcdefgh\n");
|
||||
|
||||
BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(reader, 8);
|
||||
|
||||
line.Overflowed.ShouldBeFalse();
|
||||
line.Text.ShouldBe("abcdefgh");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReadLineAsync_Should_Strip_Carriage_Return_In_Crlf()
|
||||
{
|
||||
using var reader = new StringReader("hello\r\n");
|
||||
|
||||
BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(reader, 1024);
|
||||
|
||||
line.Text.ShouldBe("hello");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReadLineAsync_Should_Signal_End_Of_Stream()
|
||||
{
|
||||
using var reader = new StringReader(string.Empty);
|
||||
|
||||
BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(reader, 1024);
|
||||
|
||||
line.EndOfStream.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReadLineAsync_Should_Overflow_And_Not_Buffer_Oversized_Line()
|
||||
{
|
||||
// A line far longer than the cap must be reported overflowed with no buffered text —
|
||||
// the memory-exhaustion guard.
|
||||
string oversized = new string('x', 10_000) + "\n";
|
||||
using var reader = new StringReader(oversized);
|
||||
|
||||
BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(reader, 16);
|
||||
|
||||
line.EndOfStream.ShouldBeFalse();
|
||||
line.Overflowed.ShouldBeTrue();
|
||||
line.Text.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReadLineAsync_Should_Keep_Subsequent_Lines_Aligned_After_Overflow()
|
||||
{
|
||||
// After draining an oversized line, the next line must still be read intact.
|
||||
var content = new StringBuilder()
|
||||
.Append(new string('x', 100)).Append('\n')
|
||||
.Append("good\n")
|
||||
.ToString();
|
||||
using var reader = new StringReader(content);
|
||||
|
||||
BoundedLineReader.Line first = await BoundedLineReader.ReadLineAsync(reader, 16);
|
||||
BoundedLineReader.Line second = await BoundedLineReader.ReadLineAsync(reader, 16);
|
||||
|
||||
first.Overflowed.ShouldBeTrue();
|
||||
second.Overflowed.ShouldBeFalse();
|
||||
second.Text.ShouldBe("good");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NUnit" />
|
||||
<PackageReference Include="NUnit.Analyzers">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="NUnit3TestAdapter" />
|
||||
<PackageReference Include="Shouldly" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ErsatzTV.Mcp\ErsatzTV.Mcp.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,264 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using ErsatzTV.Mcp;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Mcp.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class ErsatzTvApiClientTests
|
||||
{
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Substitute_Path_Parameters_And_Send_Api_Key()
|
||||
{
|
||||
CapturingHandler handler = new("""{"id":12,"name":"Kids"}""");
|
||||
var client = new ErsatzTvApiClient(
|
||||
new HttpClient(handler),
|
||||
new ErsatzTvApiClientOptions(new Uri("http://localhost:8409/"), "secret"));
|
||||
|
||||
ToolCallResult result = await client.CallToolAsync(
|
||||
new ToolDefinition(
|
||||
"ersatztv_get_channel",
|
||||
"Get channel",
|
||||
HttpMethod.Get,
|
||||
"/api/channels/{id}",
|
||||
ToolInputSchemas.Object(("id", "integer", "Channel id", true))),
|
||||
JsonDocument.Parse("""{"id":12}""").RootElement,
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsError.ShouldBeFalse();
|
||||
result.Text.ShouldBe("""{"id":12,"name":"Kids"}""");
|
||||
handler.RequestUri.ShouldBe(new Uri("http://localhost:8409/api/channels/12"));
|
||||
handler.ApiKey.ShouldBe("secret");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Url_Encode_Path_Parameters()
|
||||
{
|
||||
CapturingHandler handler = new("""{"id":1}""");
|
||||
var client = new ErsatzTvApiClient(new HttpClient(handler), new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));
|
||||
|
||||
await client.CallToolAsync(
|
||||
new ToolDefinition(
|
||||
"ersatztv_get_resolution_by_name",
|
||||
"Get resolution",
|
||||
HttpMethod.Get,
|
||||
"/api/ffmpeg/resolution/by-name/{name}",
|
||||
ToolInputSchemas.Object(("name", "string", "Resolution name", true))),
|
||||
JsonDocument.Parse("""{"name":"1920 x 1080"}""").RootElement,
|
||||
CancellationToken.None);
|
||||
|
||||
handler.RequestUri.ShouldBe(new Uri("http://localhost/api/ffmpeg/resolution/by-name/1920%20x%201080"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Return_Error_Result_For_Non_Success_Status()
|
||||
{
|
||||
CapturingHandler handler = new("""{"status":404,"title":"Resource not found"}""", HttpStatusCode.NotFound);
|
||||
var client = new ErsatzTvApiClient(new HttpClient(handler), new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));
|
||||
|
||||
ToolCallResult result = await client.CallToolAsync(
|
||||
new ToolDefinition(
|
||||
"ersatztv_get_channel",
|
||||
"Get channel",
|
||||
HttpMethod.Get,
|
||||
"/api/channels/{id}",
|
||||
ToolInputSchemas.Object(("id", "integer", "Channel id", true))),
|
||||
JsonDocument.Parse("""{"id":404}""").RootElement,
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsError.ShouldBeTrue();
|
||||
result.Text.ShouldContain("404");
|
||||
result.Text.ShouldContain("Resource not found");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Refuse_Non_Get_Tool_When_Read_Only()
|
||||
{
|
||||
CapturingHandler handler = new("{}");
|
||||
var client = new ErsatzTvApiClient(
|
||||
new HttpClient(handler),
|
||||
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));
|
||||
|
||||
ToolCallResult result = await client.CallToolAsync(
|
||||
new ToolDefinition(
|
||||
"ersatztv_delete_channel",
|
||||
"Delete channel",
|
||||
HttpMethod.Delete,
|
||||
"/api/channels/{id}",
|
||||
ToolInputSchemas.Object(("id", "integer", "Channel id", true))),
|
||||
JsonDocument.Parse("""{"id":12}""").RootElement,
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsError.ShouldBeTrue();
|
||||
result.Text.ShouldContain("read-only");
|
||||
// The request must never reach the API.
|
||||
handler.RequestUri.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Allow_Non_Get_Tool_When_Writes_Enabled()
|
||||
{
|
||||
CapturingHandler handler = new("""{"ok":true}""");
|
||||
var client = new ErsatzTvApiClient(
|
||||
new HttpClient(handler),
|
||||
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, AllowWrites: true));
|
||||
|
||||
ToolCallResult result = await client.CallToolAsync(
|
||||
new ToolDefinition(
|
||||
"ersatztv_delete_channel",
|
||||
"Delete channel",
|
||||
HttpMethod.Delete,
|
||||
"/api/channels/{id}",
|
||||
ToolInputSchemas.Object(("id", "integer", "Channel id", true))),
|
||||
JsonDocument.Parse("""{"id":12}""").RootElement,
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsError.ShouldBeFalse();
|
||||
handler.RequestUri.ShouldBe(new Uri("http://localhost/api/channels/12"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Truncate_Oversized_Response()
|
||||
{
|
||||
CapturingHandler handler = new(new string('x', 500));
|
||||
var client = new ErsatzTvApiClient(
|
||||
new HttpClient(handler),
|
||||
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, MaxResponseBytes: 16));
|
||||
|
||||
ToolCallResult result = await client.CallToolAsync(
|
||||
new ToolDefinition("ersatztv_list_channels", "List", HttpMethod.Get, "/api/channels", ToolInputSchemas.Empty),
|
||||
JsonDocument.Parse("{}").RootElement,
|
||||
CancellationToken.None);
|
||||
|
||||
result.Text.ShouldStartWith(new string('x', 16));
|
||||
result.Text.ShouldContain("truncated");
|
||||
result.Text.Length.ShouldBeLessThan(500);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Preserve_Reverse_Proxy_Path_Prefix()
|
||||
{
|
||||
CapturingHandler handler = new("""{"id":12}""");
|
||||
var client = new ErsatzTvApiClient(
|
||||
new HttpClient(handler),
|
||||
new ErsatzTvApiClientOptions(new Uri("http://host/etv/"), null));
|
||||
|
||||
await client.CallToolAsync(
|
||||
new ToolDefinition(
|
||||
"ersatztv_get_channel",
|
||||
"Get channel",
|
||||
HttpMethod.Get,
|
||||
"/api/channels/{id}",
|
||||
ToolInputSchemas.Object(("id", "integer", "Channel id", true))),
|
||||
JsonDocument.Parse("""{"id":12}""").RootElement,
|
||||
CancellationToken.None);
|
||||
|
||||
handler.RequestUri.ShouldBe(new Uri("http://host/etv/api/channels/12"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Reject_Unknown_Argument()
|
||||
{
|
||||
CapturingHandler handler = new("{}");
|
||||
var client = new ErsatzTvApiClient(
|
||||
new HttpClient(handler),
|
||||
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));
|
||||
|
||||
await Should.ThrowAsync<ArgumentException>(async () => await client.CallToolAsync(
|
||||
new ToolDefinition(
|
||||
"ersatztv_get_channel",
|
||||
"Get channel",
|
||||
HttpMethod.Get,
|
||||
"/api/channels/{id}",
|
||||
ToolInputSchemas.Object(("id", "integer", "Channel id", true))),
|
||||
JsonDocument.Parse("""{"id":12,"evil":"drop"}""").RootElement,
|
||||
CancellationToken.None));
|
||||
|
||||
handler.RequestUri.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Reject_Dot_Segment_Path_Parameter()
|
||||
{
|
||||
CapturingHandler handler = new("{}");
|
||||
var client = new ErsatzTvApiClient(
|
||||
new HttpClient(handler),
|
||||
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));
|
||||
|
||||
// ".." would canonicalize the URL onto a different route — must be rejected pre-flight.
|
||||
await Should.ThrowAsync<ArgumentException>(async () => await client.CallToolAsync(
|
||||
new ToolDefinition(
|
||||
"ersatztv_get_resolution_by_name",
|
||||
"Get resolution",
|
||||
HttpMethod.Get,
|
||||
"/api/ffmpeg/resolution/by-name/{name}",
|
||||
ToolInputSchemas.Object(("name", "string", "Resolution name", true))),
|
||||
JsonDocument.Parse("""{"name":".."}""").RootElement,
|
||||
CancellationToken.None));
|
||||
|
||||
handler.RequestUri.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Fall_Back_To_Default_Cap_On_Overflowing_Configured_Cap()
|
||||
{
|
||||
CapturingHandler handler = new("""{"ok":true}""");
|
||||
// int.MaxValue would overflow `cap + 1` to a negative array length; the client must
|
||||
// clamp to the default instead of crashing.
|
||||
var client = new ErsatzTvApiClient(
|
||||
new HttpClient(handler),
|
||||
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, MaxResponseBytes: int.MaxValue));
|
||||
|
||||
ToolCallResult result = await client.CallToolAsync(
|
||||
new ToolDefinition("ersatztv_list_channels", "List", HttpMethod.Get, "/api/channels", ToolInputSchemas.Empty),
|
||||
JsonDocument.Parse("{}").RootElement,
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsError.ShouldBeFalse();
|
||||
result.Text.ShouldBe("""{"ok":true}""");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Not_Emit_Replacement_Char_When_Truncating_Mid_Codepoint()
|
||||
{
|
||||
// "ab😀" — the emoji is a 4-byte sequence starting at byte index 2; a 4-byte cap cuts it
|
||||
// mid-sequence. The truncated text must end cleanly, not with a U+FFFD replacement char.
|
||||
CapturingHandler handler = new("ab\U0001F600");
|
||||
var client = new ErsatzTvApiClient(
|
||||
new HttpClient(handler),
|
||||
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, MaxResponseBytes: 4));
|
||||
|
||||
ToolCallResult result = await client.CallToolAsync(
|
||||
new ToolDefinition("ersatztv_list_channels", "List", HttpMethod.Get, "/api/channels", ToolInputSchemas.Empty),
|
||||
JsonDocument.Parse("{}").RootElement,
|
||||
CancellationToken.None);
|
||||
|
||||
result.Text.ShouldStartWith("ab");
|
||||
result.Text.ShouldNotContain("�");
|
||||
result.Text.ShouldContain("truncated");
|
||||
}
|
||||
|
||||
private sealed class CapturingHandler(string response, HttpStatusCode statusCode = HttpStatusCode.OK)
|
||||
: HttpMessageHandler
|
||||
{
|
||||
public Uri? RequestUri { get; private set; }
|
||||
public string? ApiKey { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
RequestUri = request.RequestUri;
|
||||
ApiKey = request.Headers.TryGetValues("X-Api-Key", out IEnumerable<string>? values)
|
||||
? values.Single()
|
||||
: null;
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(statusCode)
|
||||
{
|
||||
Content = new StringContent(response)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.Text.Json;
|
||||
using ErsatzTV.Mcp;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Mcp.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class McpServerTests
|
||||
{
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_Return_Server_Capabilities_For_Initialize()
|
||||
{
|
||||
using JsonDocument response = await HandleAsync("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}""");
|
||||
|
||||
JsonElement result = response.RootElement.GetProperty("result");
|
||||
result.GetProperty("protocolVersion").GetString().ShouldBe("2024-11-05");
|
||||
result.GetProperty("serverInfo").GetProperty("name").GetString().ShouldBe("ersatztv-mcp");
|
||||
result.GetProperty("capabilities").TryGetProperty("tools", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_List_Tools()
|
||||
{
|
||||
using JsonDocument response = await HandleAsync("""{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}""");
|
||||
|
||||
string[] toolNames = response.RootElement
|
||||
.GetProperty("result")
|
||||
.GetProperty("tools")
|
||||
.EnumerateArray()
|
||||
.Select(t => t.GetProperty("name").GetString())
|
||||
.OfType<string>()
|
||||
.ToArray();
|
||||
|
||||
toolNames.ShouldContain("ersatztv_list_channels");
|
||||
toolNames.ShouldContain("ersatztv_get_version");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_Call_Tool_And_Return_Text_Content()
|
||||
{
|
||||
using JsonDocument response = await HandleAsync(
|
||||
"""{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ersatztv_get_version","arguments":{}}}""",
|
||||
new StubToolExecutor("""{"apiVersion":3,"appVersion":"develop"}"""));
|
||||
|
||||
JsonElement content = response.RootElement.GetProperty("result").GetProperty("content").EnumerateArray().Single();
|
||||
content.GetProperty("type").GetString().ShouldBe("text");
|
||||
content.GetProperty("text").GetString().ShouldBe("""{"apiVersion":3,"appVersion":"develop"}""");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_Return_Method_Not_Found_For_Unknown_Tool()
|
||||
{
|
||||
using JsonDocument response = await HandleAsync(
|
||||
"""{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"missing","arguments":{}}}""");
|
||||
|
||||
response.RootElement.GetProperty("error").GetProperty("code").GetInt32().ShouldBe(-32602);
|
||||
string message = response.RootElement.GetProperty("error").GetProperty("message").GetString().ShouldNotBeNull();
|
||||
message.ShouldContain("Unknown tool");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_Return_Parse_Error_For_Malformed_Json()
|
||||
{
|
||||
McpServer server = new(new StubToolExecutor("{}"));
|
||||
|
||||
// A malformed line must be answered with a JSON-RPC parse error, never crash the loop.
|
||||
string? response = await server.HandleAsync("{ this is not json", CancellationToken.None);
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(response.ShouldNotBeNull());
|
||||
document.RootElement.GetProperty("error").GetProperty("code").GetInt32().ShouldBe(-32700);
|
||||
document.RootElement.GetProperty("id").ValueKind.ShouldBe(JsonValueKind.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_Return_Invalid_Request_For_Missing_Method()
|
||||
{
|
||||
McpServer server = new(new StubToolExecutor("{}"));
|
||||
|
||||
string? response = await server.HandleAsync("""{"jsonrpc":"2.0","id":7}""", CancellationToken.None);
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(response.ShouldNotBeNull());
|
||||
document.RootElement.GetProperty("error").GetProperty("code").GetInt32().ShouldBe(-32600);
|
||||
document.RootElement.GetProperty("id").GetInt32().ShouldBe(7);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_Return_Invalid_Request_For_Non_Object_Request()
|
||||
{
|
||||
McpServer server = new(new StubToolExecutor("{}"));
|
||||
|
||||
string? response = await server.HandleAsync("5", CancellationToken.None);
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(response.ShouldNotBeNull());
|
||||
document.RootElement.GetProperty("error").GetProperty("code").GetInt32().ShouldBe(-32600);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_Ignore_Notifications()
|
||||
{
|
||||
McpServer server = new(new StubToolExecutor("{}"));
|
||||
|
||||
string? response = await server.HandleAsync("""{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}""", CancellationToken.None);
|
||||
|
||||
response.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_Return_Internal_Error_When_Executor_Throws_Transport_Error()
|
||||
{
|
||||
// A network/transport failure must still yield a JSON-RPC error for the id, not escape
|
||||
// HandleAsync (which would leave a compliant client hanging).
|
||||
McpServer server = new(new ThrowingToolExecutor(new HttpRequestException("connection refused")));
|
||||
|
||||
string? response = await server.HandleAsync(
|
||||
"""{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"ersatztv_get_version","arguments":{}}}""",
|
||||
CancellationToken.None);
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(response.ShouldNotBeNull());
|
||||
document.RootElement.GetProperty("error").GetProperty("code").GetInt32().ShouldBe(-32603);
|
||||
document.RootElement.GetProperty("id").GetInt32().ShouldBe(9);
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> HandleAsync(string request, IToolExecutor? executor = null)
|
||||
{
|
||||
McpServer server = new(executor ?? new StubToolExecutor("{}"));
|
||||
string? response = await server.HandleAsync(request, CancellationToken.None);
|
||||
response.ShouldNotBeNull();
|
||||
return JsonDocument.Parse(response);
|
||||
}
|
||||
|
||||
private sealed class StubToolExecutor(string response) : IToolExecutor
|
||||
{
|
||||
public Task<ToolCallResult> CallToolAsync(
|
||||
ToolDefinition tool,
|
||||
JsonElement arguments,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new ToolCallResult(false, response));
|
||||
}
|
||||
|
||||
private sealed class ThrowingToolExecutor(Exception exception) : IToolExecutor
|
||||
{
|
||||
public Task<ToolCallResult> CallToolAsync(
|
||||
ToolDefinition tool,
|
||||
JsonElement arguments,
|
||||
CancellationToken cancellationToken) =>
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Text.Json;
|
||||
using ErsatzTV.Mcp;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Mcp.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class ToolArgumentValidatorTests
|
||||
{
|
||||
private static ToolDefinition IdTool() => new(
|
||||
"ersatztv_get_channel",
|
||||
"Get channel",
|
||||
HttpMethod.Get,
|
||||
"/api/channels/{id}",
|
||||
ToolInputSchemas.Object(("id", "integer", "Channel id", true)));
|
||||
|
||||
[Test]
|
||||
public void Validate_Should_Accept_Well_Formed_Arguments()
|
||||
{
|
||||
Should.NotThrow(() =>
|
||||
ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("""{"id":12}""").RootElement));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validate_Should_Accept_Empty_Arguments_For_No_Param_Tool()
|
||||
{
|
||||
ToolDefinition tool = new("ersatztv_get_version", "Version", HttpMethod.Get, "/api/version", ToolInputSchemas.Empty);
|
||||
|
||||
Should.NotThrow(() =>
|
||||
ToolArgumentValidator.Validate(tool, JsonDocument.Parse("{}").RootElement));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validate_Should_Reject_Unknown_Argument()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() =>
|
||||
ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("""{"id":12,"extra":1}""").RootElement));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validate_Should_Reject_Missing_Required_Argument()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() =>
|
||||
ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("{}").RootElement));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validate_Should_Reject_Wrong_Type()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() =>
|
||||
ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("""{"id":"twelve"}""").RootElement));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validate_Should_Reject_Non_Object_Arguments()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() =>
|
||||
ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("[]").RootElement));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using ErsatzTV.Mcp;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Mcp.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class ToolCatalogTests
|
||||
{
|
||||
[Test]
|
||||
public void All_Should_Expose_Read_First_Current_Api_Tools()
|
||||
{
|
||||
string[] names = ToolCatalog.All.Select(t => t.Name).ToArray();
|
||||
|
||||
names.ShouldContain("ersatztv_list_channels");
|
||||
names.ShouldContain("ersatztv_get_channel");
|
||||
names.ShouldContain("ersatztv_list_collections");
|
||||
names.ShouldContain("ersatztv_get_collection");
|
||||
names.ShouldContain("ersatztv_list_smart_collections");
|
||||
names.ShouldContain("ersatztv_get_smart_collection");
|
||||
names.ShouldContain("ersatztv_list_schedules");
|
||||
names.ShouldContain("ersatztv_get_schedule");
|
||||
names.ShouldContain("ersatztv_list_schedule_items");
|
||||
names.ShouldContain("ersatztv_get_playout");
|
||||
names.ShouldContain("ersatztv_list_ffmpeg_profiles");
|
||||
names.ShouldContain("ersatztv_get_ffmpeg_profile");
|
||||
names.ShouldContain("ersatztv_get_resolution_by_name");
|
||||
names.ShouldContain("ersatztv_list_sessions");
|
||||
names.ShouldContain("ersatztv_get_version");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void All_Should_Not_Expose_Deferred_Redesign_Workflow_Tools()
|
||||
{
|
||||
string[] names = ToolCatalog.All.Select(t => t.Name).ToArray();
|
||||
|
||||
names.ShouldNotContain("ersatztv_create_channel_from_lineup");
|
||||
names.ShouldNotContain("ersatztv_list_channel_templates");
|
||||
names.ShouldNotContain("ersatztv_browse_library");
|
||||
names.ShouldNotContain("ersatztv_upload_channel_logo");
|
||||
names.ShouldNotContain("ersatztv_resume_playback");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void All_Should_Only_Expose_Get_Tools_In_V0()
|
||||
{
|
||||
ToolCatalog.All
|
||||
.Where(t => t.HttpMethod != HttpMethod.Get)
|
||||
.Select(t => t.Name)
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Get_Channel_Tool_Should_Have_OpenApi_Aligned_Path_And_Id_Input()
|
||||
{
|
||||
ToolDefinition tool = ToolCatalog.Find("ersatztv_get_channel").ShouldNotBeNull();
|
||||
|
||||
tool.HttpMethod.ShouldBe(HttpMethod.Get);
|
||||
tool.PathTemplate.ShouldBe("/api/channels/{id}");
|
||||
tool.InputSchema.RootElement.GetProperty("required").EnumerateArray().Single().GetString().ShouldBe("id");
|
||||
tool.InputSchema.RootElement.GetProperty("properties").TryGetProperty("id", out _).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace ErsatzTV.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Reads newline-delimited lines from a <see cref="TextReader"/> with a hard character cap, so a
|
||||
/// hostile client cannot exhaust memory by sending an enormous line with no newline. A line longer
|
||||
/// than the cap is drained (not buffered) and reported as overflowed rather than returned.
|
||||
/// </summary>
|
||||
public static class BoundedLineReader
|
||||
{
|
||||
public readonly record struct Line(bool EndOfStream, bool Overflowed, string Text);
|
||||
|
||||
public const int DefaultMaxChars = 1024 * 1024;
|
||||
|
||||
public static async Task<Line> ReadLineAsync(TextReader reader, int maxChars = DefaultMaxChars)
|
||||
{
|
||||
int cap = maxChars > 0 ? maxChars : DefaultMaxChars;
|
||||
var builder = new System.Text.StringBuilder();
|
||||
var buffer = new char[1];
|
||||
bool sawAny = false;
|
||||
bool overflowed = false;
|
||||
|
||||
while (await reader.ReadAsync(buffer, 0, 1) == 1)
|
||||
{
|
||||
sawAny = true;
|
||||
char c = buffer[0];
|
||||
if (c == '\n')
|
||||
{
|
||||
return new Line(false, overflowed, overflowed ? string.Empty : builder.ToString());
|
||||
}
|
||||
|
||||
if (c == '\r')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (overflowed || builder.Length >= cap)
|
||||
{
|
||||
// Past the cap: stop buffering and free what we have, but keep draining to the
|
||||
// newline so the next line stays aligned.
|
||||
overflowed = true;
|
||||
builder.Clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append(c);
|
||||
}
|
||||
|
||||
if (!sawAny)
|
||||
{
|
||||
return new Line(true, false, string.Empty);
|
||||
}
|
||||
|
||||
// Final line with no trailing newline.
|
||||
return new Line(false, overflowed, overflowed ? string.Empty : builder.ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ErsatzTV.Mcp;
|
||||
|
||||
public sealed partial class ErsatzTvApiClient(HttpClient httpClient, ErsatzTvApiClientOptions options) : IToolExecutor
|
||||
{
|
||||
public async Task<ToolCallResult> CallToolAsync(
|
||||
ToolDefinition tool,
|
||||
JsonElement arguments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Runtime backstop for the read-only posture: even if a catalog entry is wrong,
|
||||
// a non-GET tool cannot execute unless writes are explicitly enabled. This is the
|
||||
// forward-compatible seam for future write/operational tools (#58) — they run only
|
||||
// when the operator opts in via ERSATZTV_ALLOW_WRITES.
|
||||
if (!options.AllowWrites && tool.HttpMethod != HttpMethod.Get)
|
||||
{
|
||||
return new ToolCallResult(
|
||||
true,
|
||||
$"Refused: tool '{tool.Name}' uses HTTP {tool.HttpMethod.Method}, but this MCP server is "
|
||||
+ "read-only. Set ERSATZTV_ALLOW_WRITES=true to enable write/operational tools.");
|
||||
}
|
||||
|
||||
ToolArgumentValidator.Validate(tool, arguments);
|
||||
|
||||
string path = BuildPath(tool.PathTemplate, arguments);
|
||||
using var request = new HttpRequestMessage(tool.HttpMethod, CombineUri(options.BaseUrl, path));
|
||||
if (!string.IsNullOrWhiteSpace(options.ApiKey))
|
||||
{
|
||||
request.Headers.Add("X-Api-Key", options.ApiKey);
|
||||
}
|
||||
|
||||
// ResponseHeadersRead streams the body so we can cap it without buffering the whole
|
||||
// thing — but that moves the body read outside HttpClient.Timeout, so a per-request
|
||||
// timeout token must cover the entire operation (headers + body) or a slow-drip
|
||||
// upstream would hang the single-threaded session.
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(options.EffectiveRequestTimeout);
|
||||
CancellationToken token = timeoutCts.Token;
|
||||
|
||||
using HttpResponseMessage response = await httpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
token);
|
||||
string body = await ReadCappedBodyAsync(response.Content, token);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return new ToolCallResult(false, body);
|
||||
}
|
||||
|
||||
string message = $"{(int)response.StatusCode} {response.ReasonPhrase}: {body}";
|
||||
return new ToolCallResult(true, message);
|
||||
}
|
||||
|
||||
// Read at most MaxResponseBytes from the response, truncating gracefully with a marker
|
||||
// rather than buffering an unbounded body into memory / the model's context.
|
||||
private async Task<string> ReadCappedBodyAsync(HttpContent content, CancellationToken cancellationToken)
|
||||
{
|
||||
int cap = options.MaxResponseBytes is > 0 and <= ErsatzTvApiClientOptions.MaxAllowedResponseBytes
|
||||
? options.MaxResponseBytes
|
||||
: ErsatzTvApiClientOptions.DefaultMaxResponseBytes;
|
||||
await using Stream stream = await content.ReadAsStreamAsync(cancellationToken);
|
||||
|
||||
// One extra byte lets us detect (but not keep) overflow past the cap.
|
||||
byte[] buffer = new byte[cap + 1];
|
||||
int total = 0;
|
||||
int read;
|
||||
while (total < buffer.Length
|
||||
&& (read = await stream.ReadAsync(buffer.AsMemory(total, buffer.Length - total), cancellationToken)) > 0)
|
||||
{
|
||||
total += read;
|
||||
}
|
||||
|
||||
bool truncated = total > cap;
|
||||
int length = truncated ? TrimToUtf8Boundary(buffer, cap) : total;
|
||||
string text = Encoding.UTF8.GetString(buffer, 0, length);
|
||||
return truncated
|
||||
? text + $"\n…[truncated: response exceeded {cap} bytes]"
|
||||
: text;
|
||||
}
|
||||
|
||||
// When cutting at a fixed byte cap, back off any incomplete trailing UTF-8 sequence so the
|
||||
// decoded text ends on a complete code point instead of a U+FFFD replacement char.
|
||||
private static int TrimToUtf8Boundary(byte[] buffer, int length)
|
||||
{
|
||||
int i = length;
|
||||
while (i > 0 && (buffer[i - 1] & 0b1100_0000) == 0b1000_0000)
|
||||
{
|
||||
i--; // step back over UTF-8 continuation bytes (10xxxxxx)
|
||||
}
|
||||
|
||||
if (i == 0)
|
||||
{
|
||||
return length; // all continuation bytes (not valid UTF-8) — leave the cut as-is
|
||||
}
|
||||
|
||||
byte lead = buffer[i - 1];
|
||||
int expected = lead switch
|
||||
{
|
||||
< 0x80 => 1,
|
||||
>= 0xF0 => 4,
|
||||
>= 0xE0 => 3,
|
||||
>= 0xC0 => 2,
|
||||
_ => 1 // stray continuation byte as "lead"; leave the cut as-is
|
||||
};
|
||||
|
||||
// Keep the sequence if it is complete within the cap; otherwise drop the incomplete lead.
|
||||
return length - (i - 1) >= expected ? length : i - 1;
|
||||
}
|
||||
|
||||
private static Uri CombineUri(Uri baseUrl, string absolutePath)
|
||||
{
|
||||
// absolutePath is a root-relative "/api/..." path. new Uri(baseUrl, "/api/...") would
|
||||
// discard any path prefix on baseUrl (e.g. a reverse-proxy mount like http://host/etv/),
|
||||
// so combine on the base's full path instead to preserve the prefix.
|
||||
string prefix = baseUrl.GetLeftPart(UriPartial.Path).TrimEnd('/');
|
||||
return new Uri(prefix + absolutePath);
|
||||
}
|
||||
|
||||
private static string BuildPath(string pathTemplate, JsonElement arguments)
|
||||
{
|
||||
string path = pathTemplate;
|
||||
foreach (JsonProperty property in arguments.EnumerateObject())
|
||||
{
|
||||
string value = property.Value.ValueKind == JsonValueKind.String
|
||||
? property.Value.GetString() ?? string.Empty
|
||||
: property.Value.GetRawText();
|
||||
|
||||
// EscapeDataString escapes '/', but bare "." / ".." survive and would collapse the
|
||||
// URL onto a different route during Uri canonicalization — reject them. This assumes
|
||||
// each {param} is its own path segment (true for every current template); if a template
|
||||
// ever concatenates two adjacent params, revalidate the substituted path as a whole.
|
||||
if (value is "." or "..")
|
||||
{
|
||||
throw new ArgumentException($"Invalid value for argument '{property.Name}': '{value}'.");
|
||||
}
|
||||
|
||||
path = path.Replace("{" + property.Name + "}", Uri.EscapeDataString(value), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
Match unresolved = PathParameterRegex().Match(path);
|
||||
if (unresolved.Success)
|
||||
{
|
||||
throw new ArgumentException($"Missing required argument '{unresolved.Groups[1].Value}'");
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\{([^}]+)\}")]
|
||||
private static partial Regex PathParameterRegex();
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ErsatzTV.Mcp;
|
||||
|
||||
public sealed class McpServer(IToolExecutor toolExecutor)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
// Process-lifetime document so NullId stays valid; used as the JSON-RPC id for
|
||||
// parse errors / requests with no usable id.
|
||||
private static readonly JsonDocument NullIdDocument = JsonDocument.Parse("null");
|
||||
private static readonly JsonElement NullId = NullIdDocument.RootElement;
|
||||
|
||||
public async Task<string?> HandleAsync(string requestJson, CancellationToken cancellationToken)
|
||||
{
|
||||
JsonDocument request;
|
||||
try
|
||||
{
|
||||
request = JsonDocument.Parse(requestJson);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// A malformed line must never crash the session loop (JSON-RPC parse error, id null).
|
||||
return SerializeError(NullId, -32700, "Parse error: invalid JSON.");
|
||||
}
|
||||
|
||||
using (request)
|
||||
{
|
||||
JsonElement root = request.RootElement;
|
||||
JsonElement id = NullId;
|
||||
bool hasId = false;
|
||||
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("id", out JsonElement idValue))
|
||||
{
|
||||
id = idValue;
|
||||
hasId = true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new JsonRpcException(-32600, "Invalid Request: expected a JSON-RPC object.");
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("method", out JsonElement methodElement)
|
||||
|| methodElement.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
throw new JsonRpcException(-32600, "Invalid Request: missing or non-string 'method'.");
|
||||
}
|
||||
|
||||
// No id ⇒ notification ⇒ no response.
|
||||
if (!hasId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? method = methodElement.GetString();
|
||||
object result = method switch
|
||||
{
|
||||
"initialize" => InitializeResult(),
|
||||
"tools/list" => ToolsListResult(),
|
||||
"tools/call" => await CallToolAsync(RequireParams(root), cancellationToken),
|
||||
_ => throw new JsonRpcException(-32601, $"Method not found: {method}")
|
||||
};
|
||||
|
||||
return SerializeResponse(id, result);
|
||||
}
|
||||
catch (JsonRpcException ex)
|
||||
{
|
||||
return SerializeError(id, ex.Code, ex.Message);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or KeyNotFoundException or InvalidOperationException)
|
||||
{
|
||||
return hasId ? SerializeError(id, -32602, ex.Message) : null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Transport/timeout/unexpected failures (HttpRequestException, a fired request
|
||||
// timeout, etc.) must still return a JSON-RPC error for the id — otherwise a
|
||||
// compliant client blocks forever awaiting a response that never comes.
|
||||
return hasId ? SerializeError(id, -32603, $"Internal error: {ex.Message}") : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonElement RequireParams(JsonElement root) =>
|
||||
root.TryGetProperty("params", out JsonElement parameters)
|
||||
? parameters
|
||||
: throw new JsonRpcException(-32602, "Invalid params: missing 'params'.");
|
||||
|
||||
private static object InitializeResult() => new
|
||||
{
|
||||
protocolVersion = "2024-11-05",
|
||||
capabilities = new
|
||||
{
|
||||
tools = new { }
|
||||
},
|
||||
serverInfo = new
|
||||
{
|
||||
name = "ersatztv-mcp",
|
||||
version = "0.1.0"
|
||||
}
|
||||
};
|
||||
|
||||
private static object ToolsListResult() => new
|
||||
{
|
||||
tools = ToolCatalog.All.Select(t => new
|
||||
{
|
||||
name = t.Name,
|
||||
description = t.Description,
|
||||
inputSchema = t.InputSchema.RootElement
|
||||
})
|
||||
};
|
||||
|
||||
private async Task<object> CallToolAsync(JsonElement parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
string name = parameters.GetProperty("name").GetString() ?? throw new ArgumentException("Tool name is required");
|
||||
ToolDefinition tool = ToolCatalog.Find(name) ?? throw new ArgumentException($"Unknown tool: {name}");
|
||||
JsonElement arguments = parameters.TryGetProperty("arguments", out JsonElement args)
|
||||
? args
|
||||
: JsonDocument.Parse("{}").RootElement;
|
||||
|
||||
ToolCallResult result = await toolExecutor.CallToolAsync(tool, arguments, cancellationToken);
|
||||
return new
|
||||
{
|
||||
content = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "text",
|
||||
text = result.Text
|
||||
}
|
||||
},
|
||||
isError = result.IsError
|
||||
};
|
||||
}
|
||||
|
||||
private static string SerializeResponse(JsonElement id, object result) =>
|
||||
JsonSerializer.Serialize(
|
||||
new
|
||||
{
|
||||
jsonrpc = "2.0",
|
||||
id = id.Clone(),
|
||||
result
|
||||
},
|
||||
JsonOptions);
|
||||
|
||||
private static string SerializeError(JsonElement id, int code, string message) =>
|
||||
JsonSerializer.Serialize(
|
||||
new
|
||||
{
|
||||
jsonrpc = "2.0",
|
||||
id = id.Clone(),
|
||||
error = new
|
||||
{
|
||||
code,
|
||||
message
|
||||
}
|
||||
},
|
||||
JsonOptions);
|
||||
|
||||
private sealed class JsonRpcException(int code, string message) : Exception(message)
|
||||
{
|
||||
public int Code { get; } = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
namespace ErsatzTV.Mcp;
|
||||
|
||||
public static class Program
|
||||
{
|
||||
public static async Task Main()
|
||||
{
|
||||
string baseUrl = Environment.GetEnvironmentVariable("ERSATZTV_URL") ?? "http://localhost:8409";
|
||||
string? apiKey = Environment.GetEnvironmentVariable("ERSATZTV_API_KEY");
|
||||
bool allowWrites = ParseBool(Environment.GetEnvironmentVariable("ERSATZTV_ALLOW_WRITES"));
|
||||
int maxResponseBytes = ParseInt(
|
||||
Environment.GetEnvironmentVariable("ERSATZTV_MAX_RESPONSE_BYTES"),
|
||||
fallback: ErsatzTvApiClientOptions.DefaultMaxResponseBytes,
|
||||
min: 1024,
|
||||
max: ErsatzTvApiClientOptions.MaxAllowedResponseBytes);
|
||||
int timeoutSeconds = ParseInt(
|
||||
Environment.GetEnvironmentVariable("ERSATZTV_REQUEST_TIMEOUT_SECONDS"),
|
||||
fallback: 30,
|
||||
min: 1,
|
||||
max: 3600);
|
||||
var requestTimeout = TimeSpan.FromSeconds(timeoutSeconds);
|
||||
|
||||
// The per-request timeout is enforced via a CancellationToken inside the client (it must
|
||||
// cover the streamed body read too), so leave HttpClient's own timeout off to avoid a
|
||||
// second, header-only timer racing it.
|
||||
using var httpClient = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };
|
||||
var apiClient = new ErsatzTvApiClient(
|
||||
httpClient,
|
||||
new ErsatzTvApiClientOptions(new Uri(baseUrl), apiKey, allowWrites, maxResponseBytes, requestTimeout));
|
||||
var server = new McpServer(apiClient);
|
||||
|
||||
while (true)
|
||||
{
|
||||
BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(Console.In);
|
||||
if (line.EndOfStream)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (line.Overflowed)
|
||||
{
|
||||
await Console.Error.WriteLineAsync("[ersatztv-mcp] dropped oversized request line.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(line.Text))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string? response = await server.HandleAsync(line.Text, CancellationToken.None);
|
||||
if (response is not null)
|
||||
{
|
||||
await Console.Out.WriteLineAsync(response);
|
||||
await Console.Out.FlushAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Last-resort guard: a single failed request must never terminate the session.
|
||||
await Console.Error.WriteLineAsync($"[ersatztv-mcp] error handling request: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ParseBool(string? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (bool.TryParse(value, out bool parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
string trimmed = value.Trim();
|
||||
return trimmed is "1"
|
||||
|| string.Equals(trimmed, "yes", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(trimmed, "on", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static int ParseInt(string? value, int fallback, int min, int max) =>
|
||||
int.TryParse(value, out int parsed) ? Math.Clamp(parsed, min, max) : fallback;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ErsatzTV.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight validation of caller-supplied tool arguments against a tool's declared
|
||||
/// <c>InputSchema</c>. Not a full JSON-Schema implementation — it enforces the shapes the
|
||||
/// catalog actually emits (typed properties, a required list, <c>additionalProperties:false</c>)
|
||||
/// so unknown/malformed arguments are rejected before an API request is built.
|
||||
/// Throws <see cref="ArgumentException"/> (mapped to JSON-RPC -32602 by the server).
|
||||
/// </summary>
|
||||
public static class ToolArgumentValidator
|
||||
{
|
||||
public static void Validate(ToolDefinition tool, JsonElement arguments)
|
||||
{
|
||||
if (arguments.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new ArgumentException($"Arguments for tool '{tool.Name}' must be a JSON object.");
|
||||
}
|
||||
|
||||
JsonElement schema = tool.InputSchema.RootElement;
|
||||
JsonElement properties = schema.TryGetProperty("properties", out JsonElement props)
|
||||
? props
|
||||
: default;
|
||||
bool additionalAllowed = !schema.TryGetProperty("additionalProperties", out JsonElement additional)
|
||||
|| additional.ValueKind != JsonValueKind.False;
|
||||
|
||||
foreach (JsonProperty arg in arguments.EnumerateObject())
|
||||
{
|
||||
if (properties.ValueKind != JsonValueKind.Object
|
||||
|| !properties.TryGetProperty(arg.Name, out JsonElement propertySchema))
|
||||
{
|
||||
if (!additionalAllowed)
|
||||
{
|
||||
throw new ArgumentException($"Unknown argument '{arg.Name}' for tool '{tool.Name}'.");
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
string? type = propertySchema.TryGetProperty("type", out JsonElement typeElement)
|
||||
? typeElement.GetString()
|
||||
: null;
|
||||
if (!MatchesType(type, arg.Value))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Argument '{arg.Name}' for tool '{tool.Name}' must be of type '{type}'.");
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.TryGetProperty("required", out JsonElement required)
|
||||
&& required.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement name in required.EnumerateArray())
|
||||
{
|
||||
string? propertyName = name.GetString();
|
||||
if (propertyName is not null && !arguments.TryGetProperty(propertyName, out _))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Missing required argument '{propertyName}' for tool '{tool.Name}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool MatchesType(string? type, JsonElement value) => type switch
|
||||
{
|
||||
"integer" => value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out _),
|
||||
"number" => value.ValueKind == JsonValueKind.Number,
|
||||
"string" => value.ValueKind == JsonValueKind.String,
|
||||
"boolean" => value.ValueKind is JsonValueKind.True or JsonValueKind.False,
|
||||
"array" => value.ValueKind == JsonValueKind.Array,
|
||||
"object" => value.ValueKind == JsonValueKind.Object,
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace ErsatzTV.Mcp;
|
||||
|
||||
public static class ToolCatalog
|
||||
{
|
||||
public static IReadOnlyList<ToolDefinition> All { get; } =
|
||||
[
|
||||
Get("ersatztv_list_channels", "List channels.", "/api/channels"),
|
||||
Get("ersatztv_get_channel", "Get a channel by id.", "/api/channels/{id}", Id("id", "Channel id.")),
|
||||
Get("ersatztv_list_collections", "List collections.", "/api/collections"),
|
||||
Get("ersatztv_get_collection", "Get a collection by id.", "/api/collections/{id}", Id("id", "Collection id.")),
|
||||
Get("ersatztv_list_smart_collections", "List smart collections.", "/api/smart-collections"),
|
||||
Get("ersatztv_get_smart_collection", "Get a smart collection by id.", "/api/smart-collections/{id}", Id("id", "Smart collection id.")),
|
||||
Get("ersatztv_list_schedules", "List schedules.", "/api/schedules"),
|
||||
Get("ersatztv_get_schedule", "Get a schedule by id.", "/api/schedules/{id}", Id("id", "Schedule id.")),
|
||||
Get("ersatztv_list_schedule_items", "List schedule items.", "/api/schedules/{id}/items", Id("id", "Schedule id.")),
|
||||
Get("ersatztv_get_playout", "Get a playout by id.", "/api/playouts/{id}", Id("id", "Playout id.")),
|
||||
Get("ersatztv_list_ffmpeg_profiles", "List FFmpeg profiles.", "/api/ffmpeg/profiles"),
|
||||
Get("ersatztv_get_ffmpeg_profile", "Get an FFmpeg profile by id.", "/api/ffmpeg/profiles/{id}", Id("id", "FFmpeg profile id.")),
|
||||
Get(
|
||||
"ersatztv_get_resolution_by_name",
|
||||
"Get an FFmpeg resolution by name.",
|
||||
"/api/ffmpeg/resolution/by-name/{name}",
|
||||
("name", "string", "Resolution name.", true)),
|
||||
Get("ersatztv_list_sessions", "List active HLS sessions.", "/api/sessions"),
|
||||
Get("ersatztv_get_version", "Get API and app version.", "/api/version")
|
||||
];
|
||||
|
||||
public static ToolDefinition? Find(string name) =>
|
||||
All.FirstOrDefault(t => string.Equals(t.Name, name, StringComparison.Ordinal));
|
||||
|
||||
private static ToolDefinition Get(
|
||||
string name,
|
||||
string description,
|
||||
string path,
|
||||
params (string Name, string Type, string Description, bool Required)[] properties) =>
|
||||
new(
|
||||
name,
|
||||
description,
|
||||
HttpMethod.Get,
|
||||
path,
|
||||
properties.Length == 0 ? ToolInputSchemas.Empty : ToolInputSchemas.Object(properties));
|
||||
|
||||
private static (string Name, string Type, string Description, bool Required) Id(string name, string description) =>
|
||||
(name, "integer", description, true);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ErsatzTV.Mcp;
|
||||
|
||||
public sealed record ToolDefinition(
|
||||
string Name,
|
||||
string Description,
|
||||
HttpMethod HttpMethod,
|
||||
string PathTemplate,
|
||||
JsonDocument InputSchema);
|
||||
|
||||
public sealed record ToolCallResult(bool IsError, string Text);
|
||||
|
||||
public sealed record ErsatzTvApiClientOptions(
|
||||
Uri BaseUrl,
|
||||
string? ApiKey,
|
||||
bool AllowWrites = false,
|
||||
int MaxResponseBytes = ErsatzTvApiClientOptions.DefaultMaxResponseBytes,
|
||||
TimeSpan RequestTimeout = default)
|
||||
{
|
||||
// Cap the response body buffered back to the model so a large/hostile API
|
||||
// response cannot exhaust memory or flood the context window.
|
||||
public const int DefaultMaxResponseBytes = 1024 * 1024;
|
||||
|
||||
// Hard ceiling so a hostile/typo'd cap can't request a huge (or overflowing) allocation.
|
||||
public const int MaxAllowedResponseBytes = 64 * 1024 * 1024;
|
||||
|
||||
public static readonly TimeSpan DefaultRequestTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
// The per-request timeout, covering headers *and* body (see ErsatzTvApiClient).
|
||||
public TimeSpan EffectiveRequestTimeout => RequestTimeout > TimeSpan.Zero ? RequestTimeout : DefaultRequestTimeout;
|
||||
}
|
||||
|
||||
public interface IToolExecutor
|
||||
{
|
||||
Task<ToolCallResult> CallToolAsync(
|
||||
ToolDefinition tool,
|
||||
JsonElement arguments,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ErsatzTV.Mcp;
|
||||
|
||||
public static class ToolInputSchemas
|
||||
{
|
||||
public static JsonDocument Empty { get; } = JsonDocument.Parse(
|
||||
"""
|
||||
{"type":"object","properties":{},"additionalProperties":false}
|
||||
""");
|
||||
|
||||
public static JsonDocument Object(params (string Name, string Type, string Description, bool Required)[] properties)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (var writer = new Utf8JsonWriter(stream))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "object");
|
||||
writer.WriteStartObject("properties");
|
||||
foreach ((string name, string type, string description, bool _) in properties)
|
||||
{
|
||||
writer.WriteStartObject(name);
|
||||
writer.WriteString("type", type);
|
||||
writer.WriteString("description", description);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
writer.WriteStartArray("required");
|
||||
foreach ((string name, string _, string _, bool required) in properties.Where(p => p.Required))
|
||||
{
|
||||
writer.WriteStringValue(name);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
writer.WriteBoolean("additionalProperties", false);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
return JsonDocument.Parse(stream.ToArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class LegacyUiRedirectsTests
|
||||
{
|
||||
private static readonly string StartupSource = File.ReadAllText(FindStartupPath());
|
||||
|
||||
[Test]
|
||||
public void Every_Mapping_Should_Resolve()
|
||||
{
|
||||
foreach ((string from, string to) in LegacyUiRedirects.Map)
|
||||
{
|
||||
LegacyUiRedirects.TryGetRedirect(new PathString(from), out string target).ShouldBeTrue();
|
||||
target.ShouldBe(to);
|
||||
}
|
||||
}
|
||||
|
||||
[TestCase("/", "/app")]
|
||||
[TestCase("/channels", "/app/channels")]
|
||||
[TestCase("/channels/add", "/app/new-channel")]
|
||||
[TestCase("/schedules", "/app/schedules")]
|
||||
[TestCase("/playouts", "/app/playouts")]
|
||||
[TestCase("/media/libraries", "/app/libraries")]
|
||||
[TestCase("/settings/ffmpeg", "/app/settings/streaming")]
|
||||
[TestCase("/settings/hdhr", "/app/settings/system")]
|
||||
[TestCase("/settings/logging", "/app/settings/logging")]
|
||||
[TestCase("/settings/playout", "/app/settings/playout")]
|
||||
[TestCase("/settings/scanner", "/app/settings/scanner")]
|
||||
[TestCase("/settings/ui", "/app/settings/general")]
|
||||
[TestCase("/settings/xmltv", "/app/settings/xmltv")]
|
||||
public void Known_Route_Should_Redirect(string path, string expected)
|
||||
{
|
||||
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue();
|
||||
target.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[TestCase("/channels/", "/app/channels")]
|
||||
[TestCase("/schedules/", "/app/schedules")]
|
||||
[TestCase("/settings/ffmpeg/", "/app/settings/streaming")]
|
||||
public void Trailing_Slash_Should_Match(string path, string expected)
|
||||
{
|
||||
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue();
|
||||
target.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Lookup_Should_Be_Case_Insensitive()
|
||||
{
|
||||
LegacyUiRedirects.TryGetRedirect(new PathString("/Channels"), out string target).ShouldBeTrue();
|
||||
target.ShouldBe("/app/channels");
|
||||
}
|
||||
|
||||
[TestCase("/channels/5")] // channel edit (Blazor-only)
|
||||
[TestCase("/channels/numbers")] // Blazor-only
|
||||
[TestCase("/system/health")] // Blazor home escape hatch
|
||||
[TestCase("/media/collections")] // Blazor-only media page
|
||||
[TestCase("/ffmpeg")] // Blazor-only
|
||||
[TestCase("/watermarks")] // Blazor-only
|
||||
[TestCase("/app")] // already the SPA
|
||||
[TestCase("/app/channels")] // already the SPA
|
||||
[TestCase("/iptv/channels.m3u")] // IPTV surface
|
||||
[TestCase("/api/health")] // API surface
|
||||
[TestCase("")] // empty
|
||||
[TestCase("//")] // all-slash path must not collapse to root "/"
|
||||
[TestCase("/channels//")] // double trailing slash is not normalized to a match
|
||||
public void Non_Migrated_Route_Should_Not_Redirect(string path)
|
||||
{
|
||||
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeFalse();
|
||||
target.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Startup_Should_Redirect_Legacy_Routes_In_Blazor_Branch_Before_Routing()
|
||||
{
|
||||
// The redirect middleware must be wired inside the blazor branch and run
|
||||
// before UseRouting so migrated routes never reach the Blazor fallback.
|
||||
int redirectIndex = StartupSource.IndexOf("LegacyUiRedirects.TryGetRedirect", StringComparison.Ordinal);
|
||||
redirectIndex.ShouldBeGreaterThan(-1);
|
||||
|
||||
// The Blazor branch's UseRouting call that follows the redirect middleware.
|
||||
int routingIndex = StartupSource.IndexOf("blazor.UseRouting()", StringComparison.Ordinal);
|
||||
routingIndex.ShouldBeGreaterThan(-1);
|
||||
|
||||
redirectIndex.ShouldBeLessThan(routingIndex);
|
||||
|
||||
// 302 (temporary), not a permanent redirect.
|
||||
StartupSource.ShouldContain("context.Request.PathBase + target");
|
||||
StartupSource.ShouldNotContain("RedirectPermanent(target");
|
||||
}
|
||||
|
||||
private static string FindStartupPath()
|
||||
{
|
||||
DirectoryInfo? directory = new(TestContext.CurrentContext.TestDirectory);
|
||||
|
||||
while (directory is not null)
|
||||
{
|
||||
string candidate = Path.Combine(directory.FullName, "ErsatzTV", "Startup.cs");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException("Could not find ErsatzTV/Startup.cs");
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Tests", "ErsatzTV.
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Architecture.Tests", "ErsatzTV.Architecture.Tests\ErsatzTV.Architecture.Tests.csproj", "{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Mcp", "ErsatzTV.Mcp\ErsatzTV.Mcp.csproj", "{A5BB7668-FE00-49F8-888C-866F75A74BD9}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Mcp.Tests", "ErsatzTV.Mcp.Tests\ErsatzTV.Mcp.Tests.csproj", "{C72D3941-6207-4638-AA2A-B5488EDFED28}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -315,6 +319,42 @@ Global
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x64.Build.0 = Debug|Any CPU
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x86.ActiveCfg = Debug|Any CPU
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x86.Build.0 = Debug|Any CPU
|
||||
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug No Sync|x64.ActiveCfg = Debug|Any CPU
|
||||
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug No Sync|x64.Build.0 = Debug|Any CPU
|
||||
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug No Sync|x86.ActiveCfg = Debug|Any CPU
|
||||
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug No Sync|x86.Build.0 = Debug|Any CPU
|
||||
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug No Sync|x64.ActiveCfg = Debug|Any CPU
|
||||
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug No Sync|x64.Build.0 = Debug|Any CPU
|
||||
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug No Sync|x86.ActiveCfg = Debug|Any CPU
|
||||
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug No Sync|x86.Build.0 = Debug|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace ErsatzTV;
|
||||
|
||||
// Phase (a) of the Blazor -> ChicoryTV SPA cutover (ersatztv#91).
|
||||
//
|
||||
// The React SPA (served under /app) is now the default UI: the legacy Blazor
|
||||
// routes below 302-redirect to their SPA equivalents. Only routes that already
|
||||
// have SPA parity are listed here. Blazor pages WITHOUT a SPA equivalent are
|
||||
// deliberately left reachable (no redirect) so their functionality stays
|
||||
// available while the SPA catches up:
|
||||
// /system/health (Blazor home escape hatch; Index.razor also lives here),
|
||||
// /channels/{id} edit, /channels/numbers, /media/* (collections etc.),
|
||||
// /ffmpeg, /watermarks, /blocks, /decos, /templates, /deco-templates,
|
||||
// schedule/playout detail editors, /system/logs, /system/troubleshooting.
|
||||
//
|
||||
// Phase (b) removes the redirected Blazor pages entirely, but that is GATED on
|
||||
// full SPA parity for every route in this map. Until then this map is the
|
||||
// single source of truth for "what has migrated" and is expected to grow.
|
||||
public static class LegacyUiRedirects
|
||||
{
|
||||
// Blazor route -> SPA route. EXACT paths only (no prefix matching); a single
|
||||
// trailing slash on the request is normalized away before lookup.
|
||||
public static readonly IReadOnlyDictionary<string, string> Map =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["/"] = "/app",
|
||||
["/channels"] = "/app/channels",
|
||||
["/channels/add"] = "/app/new-channel",
|
||||
["/schedules"] = "/app/schedules",
|
||||
["/playouts"] = "/app/playouts",
|
||||
["/media/libraries"] = "/app/libraries",
|
||||
["/settings/ffmpeg"] = "/app/settings/streaming",
|
||||
["/settings/hdhr"] = "/app/settings/system",
|
||||
["/settings/logging"] = "/app/settings/logging",
|
||||
["/settings/playout"] = "/app/settings/playout",
|
||||
["/settings/scanner"] = "/app/settings/scanner",
|
||||
["/settings/ui"] = "/app/settings/general",
|
||||
["/settings/xmltv"] = "/app/settings/xmltv"
|
||||
};
|
||||
|
||||
public static bool TryGetRedirect(PathString path, out string target)
|
||||
{
|
||||
target = string.Empty;
|
||||
|
||||
string value = path.Value;
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalize a single trailing slash so "/channels/" matches "/channels"
|
||||
// (but keep root "/" intact). Guard against a path of all slashes (e.g.
|
||||
// "//") collapsing down to "/" and falsely matching the root entry.
|
||||
if (value.Length > 1 && value.EndsWith('/'))
|
||||
{
|
||||
string trimmed = value[..^1];
|
||||
if (trimmed == "/")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = trimmed;
|
||||
}
|
||||
|
||||
if (Map.TryGetValue(value, out string mapped))
|
||||
{
|
||||
target = mapped;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -726,6 +726,28 @@ public class Startup
|
||||
ctx => !IsIptvPath(ctx.Request.Path) && !IsSpaPath(ctx.Request.Path),
|
||||
blazor =>
|
||||
{
|
||||
// ersatztv#91 phase (a): make the ChicoryTV SPA the default UI by
|
||||
// redirecting migrated legacy Blazor routes to their /app equivalents.
|
||||
// 302 (not 301): this map grows as pages migrate, and permanent-redirect
|
||||
// browser caching would make rollback painful. UsePathBase (ETV_BASE_URL)
|
||||
// only rewrites the request side (Request.Path/PathBase); it never touches
|
||||
// redirect Location headers, so the PathBase prefix must be re-applied here.
|
||||
blazor.Use(async (context, next) =>
|
||||
{
|
||||
if (HttpMethods.IsGet(context.Request.Method) ||
|
||||
HttpMethods.IsHead(context.Request.Method))
|
||||
{
|
||||
if (LegacyUiRedirects.TryGetRedirect(context.Request.Path, out string target))
|
||||
{
|
||||
context.Response.Redirect(
|
||||
context.Request.PathBase + target + context.Request.QueryString);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await next(context);
|
||||
});
|
||||
|
||||
blazor.UseRouting();
|
||||
|
||||
if (OidcHelper.IsEnabled)
|
||||
|
||||
@@ -8,9 +8,12 @@ builds are limited to 2–3 concurrent, never wide fan-outs. Backend gaps all la
|
||||
2026-07-04 (PRs #113–#119); merge pass PR #120; live-data screens: #109 Dashboard (PR #123),
|
||||
#84 Channels (PR #124), #86 Schedule editor (PR #125), #87 Playouts (PR #127), #88 Libraries
|
||||
(PR #128), #85 Guide/EPG (PR #129); #62 prerequisites: #65 (PR #130), #64 (PR #133), #63
|
||||
(PR #134) — epic #62 COMPLETE; #89 Channel Builder (PR #136); **#93 Settings (PR #138) —
|
||||
(PR #134) — epic #62 COMPLETE; #89 Channel Builder (PR #136); #93 Settings (PR #138) —
|
||||
first screen through the full design-first workflow; #92 design-sync round-trip verified and
|
||||
documented (`docs/design-sync.md`)**.
|
||||
documented (`docs/design-sync.md`); **#90 rebrand (PR #139) — SPA fully presents as
|
||||
ChicoryTV; v26.4.0 tagged at this merge (first app-change release → prod)**; **#91 phase (a)
|
||||
root flip (PR #148) — SPA is the default UI; phase (b) Blazor removal blocked on parity
|
||||
#140–#147**.
|
||||
|
||||
**PROCESS (2026-07-06, binding — supersedes 07-05)**: Claude Code ONLY — Codex is retired
|
||||
(usage exhausted). Fable is the orchestrator in the main session and is EXPENSIVE — use it
|
||||
@@ -31,32 +34,46 @@ can't see (see Lessons: local-run recipe).
|
||||
**RELEASE CHECKPOINT (standing, added 2026-07-06)**: prod cutover to the fork is DONE —
|
||||
prod container `ersatztv` on bumblebee runs `192.168.1.95:3000/timothy/ersatztv:prod`
|
||||
(= v26.3.1, app-identical to upstream 26.3.0); `ersatztv-test` tracks `:latest` (main).
|
||||
Prod only advances on `v*` tags. At every milestone merge, FLAG THE USER: is this slice
|
||||
worth tagging **v26.4.0** (reserved for the first app-change release)? Latest sensible tag
|
||||
point is #91 (cutover); earlier if a stable API slice should reach prod sooner. Tagging
|
||||
needs explicit user consent; NEVER `[skip ci]` a commit you'll tag. (Flagged again at the
|
||||
#93 merge, 2026-07-07 — builder + settings + full API is a very plausible v26.4.0 slice;
|
||||
#90 rebrand would make it present as ChicoryTV.)
|
||||
Prod only advances on `v*` tags. **v26.4.0 TAGGED 2026-07-07** on 65b1a5e3 (the #90 merge,
|
||||
user-consented) — first app-change release; prod image = full API + all SPA screens +
|
||||
ChicoryTV branding. At future milestone merges, flag the user for the NEXT tag
|
||||
(v26.4.1/v26.5.0 — #91 cutover is the obvious next tag point). Tagging needs explicit user
|
||||
consent; NEVER `[skip ci]` a commit you'll tag.
|
||||
|
||||
**Session state (2026-07-07, post-#93)**: main = 17f49304 (PR #138 merged): **Settings** is
|
||||
live at `/app/settings/<section>` — backend `GET/PUT /api/settings/{ffmpeg,playout,xmltv,
|
||||
scanner,logging,ui,hdhr}` + `/api/settings/resolutions` CRUD (`SettingsController`, DTOs in
|
||||
`ErsatzTV.Core/Api/Settings`), frontend `web/src/screens/SettingsScreen.tsx` +
|
||||
`web/src/api/settings.ts` (tiered loader: settings groups gate the screen, reference data
|
||||
via allSettled with per-resource failure notes). App.tsx `ScreenRoute` gained an opt-in
|
||||
`allowSubPaths` flag (settings only); the screen parses its own URL suffix. Design-first
|
||||
workflow proven end-to-end: prototype `design-system/templates/chicorytv-admin/Settings.jsx`
|
||||
+ handoff bundle `design-system/design_handoff_settings/` synced both directions with the
|
||||
DesignSync MCP (main session only) — workflow documented in `docs/design-sync.md`, #92
|
||||
CLOSED. Also this session: **PR #137** — Scriban.Signed 6.5.2→7.2.5 (GHSA-5wr9-m6jw-xx44
|
||||
sandbox escape; the fresh advisory made NuGetAudit fail EVERY CI restore, merged first to
|
||||
unblock); media-sources fresh-DB 500 fixed (Dapper→EF, see Lessons). Review: 3 lenses →
|
||||
11 findings (2 backend contract clusters + frontend silent-failure/partial-save cluster),
|
||||
fixed by 2 parallel subagents, fork-verified SHIP. Baselines: ErsatzTV.Tests **495**,
|
||||
Core.Tests **493** (+1 skip), **web tests 145** (was 112). All #93 worktrees removed;
|
||||
main checkout still sits on docs/59-ui-redesign-brief — do NOT touch it.
|
||||
**Session state (2026-07-07, post-#91 phase a)**: main = **d04769cc** (PR #148 merged).
|
||||
**#91 phase (a) LANDED**: root `/` + 12 legacy Blazor routes with SPA equivalents 302 to
|
||||
`/app/...` via `ErsatzTV/LegacyUiRedirects.cs` (exact-match map, single source of truth for
|
||||
"what has migrated") + middleware in Startup's blazor branch before UseRouting; query strings
|
||||
+ `ETV_BASE_URL` PathBase preserved (302 NOT 301 — deliberate, rollback-safe); docker smoke
|
||||
now asserts `/app/` serves ChicoryTV. **#91 stays OPEN — phase (b) (delete Blazor/MudBlazor)
|
||||
is BLOCKED on SPA parity**: recon found ~55 Blazor-only routes; gaps filed as **#140
|
||||
(collections — /app/collections is a placeholder!), #141 (media browse/search/trash), #142
|
||||
(trakt), #143 (ffmpeg profiles/filler/watermarks), #144 (blocks/decos/templates + playout
|
||||
detail editors), #145 (logs/troubleshooting), #146 (channel edit + numbers), #147 (SPA
|
||||
escape-hatch link to legacy UI)**. Blazor home escape hatch = `/system/health` (deliberately
|
||||
un-redirected). OIDC note (correctness fork): default landing changes from challenged-Blazor
|
||||
to open SPA — no NEW exposure (GET /api/* + /app were already unauthenticated), but SPA auth
|
||||
is a phase-(b) design gap. Baselines: **ErsatzTV.Tests 527** (495 + 32 redirect tests),
|
||||
Core.Tests **493** (+1 skip), **web tests 145** (web/ untouched this session). CLAUDE.md
|
||||
architecture/conventions updated for the SPA-default reality (Blazor sections of
|
||||
docs/contributing.md left for phase (b)). Worktree .worktrees/issue-91-cutover now sits on
|
||||
main (doc commit); issue-90-rebrand worktree removed. Main checkout still sits on
|
||||
docs/59-ui-redesign-brief — do NOT touch it.
|
||||
|
||||
**Lessons for all remaining prompts** (accumulated):
|
||||
- NEW (#91) — `UsePathBase` only rewrites the REQUEST side (Request.Path/PathBase); it never
|
||||
touches redirect `Location` headers — any `Response.Redirect` to an absolute path must
|
||||
prepend `context.Request.PathBase` (precedent: IptvController.cs:56,69,305).
|
||||
- NEW (#91) — Blazor's MainLayout has a not-ready gate (`MainLayout.razor:391`): while the
|
||||
DB/search index initializes, EVERY non-root Blazor page prerender 302s to `/`. Live-E2E
|
||||
probes must wait for FULL readiness (log line "Done migrating search index"), not just
|
||||
`/api/health` 200 — probing early produces phantom `302 → /` results.
|
||||
- NEW (#91) — the local-run host guard (`Startup.cs:679`) matches `Host.StartsWith("localhost")`;
|
||||
curling `127.0.0.1:8409` 404s everything except IPTV — always curl `localhost` in the #93
|
||||
live-E2E recipe.
|
||||
- NEW (#91) — SPA channel edit is a DEAD END: the Channels pencil navigates to
|
||||
`/app/new-channel?edit={id}` but ChannelBuilderScreen never reads `edit` (noted on #146);
|
||||
PlayoutsScreen has no path to playout creation/detail editors (noted on #144).
|
||||
- NEW (#93) — Local live-E2E recipe: `npm run build` (outputs to gitignored
|
||||
`ErsatzTV/wwwroot/app/`), then `ln -sfn <worktree>/ErsatzTV/wwwroot/app
|
||||
ErsatzTV/bin/Debug/net10.0/wwwroot/app` (Program.cs sets ContentRoot to the ASSEMBLY dir,
|
||||
@@ -193,61 +210,64 @@ main checkout still sits on docs/59-ui-redesign-brief — do NOT touch it.
|
||||
|
||||
---
|
||||
|
||||
# PROMPT — #90: ChicoryTV rebrand (assets + naming)
|
||||
# PROMPT — Post-cutover housekeeping + parity kickoff
|
||||
|
||||
You are Fable, the ORCHESTRATOR in the main Claude Code session (Codex is retired — Claude
|
||||
Code only). Fable is EXPENSIVE: delegate implementation to fitting subagents (this issue is
|
||||
mostly mechanical — haiku/sonnet territory; fable only for the review fork). Read CLAUDE.md
|
||||
and the PROCESS + Lessons sections of this file first.
|
||||
You are Fable, the ORCHESTRATOR in the main Claude Code session (Claude Code only). Fable is
|
||||
EXPENSIVE: delegate to fitting subagents (recon → Explore/haiku; mechanical work → sonnet;
|
||||
judgment-heavy code → opus; fable for the hardest calls + review forks). Read CLAUDE.md and
|
||||
the PROCESS + Lessons sections of this file first.
|
||||
|
||||
HARD CONSTRAINTS:
|
||||
- `git worktree add .worktrees/issue-90-rebrand -b feat/90-rebrand origin/main`; never touch
|
||||
the main checkout (it sits on docs/59-ui-redesign-brief). `cd web && npm ci` first.
|
||||
- Max 2–3 concurrent builds; ONE dotnet build at a time. Frontend-only expected — if backend
|
||||
strings turn out to be in scope, they're NOT (deeper product rebrand is epic #59).
|
||||
- NEVER set ETV_UPDATE_GOLDENS.
|
||||
- Review before PR: this is a small mechanical issue — a single fable correctness/design fork
|
||||
over the diff suffices (skip the 3-lens panel unless the diff grows); fixes via subagents.
|
||||
Merge needs an in-conversation consent question.
|
||||
- Work in a worktree off origin/main (`git worktree add .worktrees/<name> -b <branch>
|
||||
origin/main`); never touch the main checkout (docs/59-ui-redesign-brief). Remove the
|
||||
now-merged .worktrees/issue-91-cutover worktree first (it sits on main after the doc
|
||||
commit). `cd web && npm ci` in fresh worktrees before web verification.
|
||||
- Max 2–3 concurrent builds; ONE dotnet build at a time. NEVER set ETV_UPDATE_GOLDENS.
|
||||
- Merge consent in-conversation per PR. Live-E2E new screens per the #93 recipe (curl
|
||||
`localhost`, NOT 127.0.0.1 — host guard; wait for "Done migrating search index").
|
||||
|
||||
## Task
|
||||
Issue #90: apply the ChicoryTV rebrand across the SPA surface. Naming (ErsatzTV → ChicoryTV
|
||||
in user-facing UI copy), favicon/app icon/wordmark from `design-system/assets/`
|
||||
(chicory-mark.svg, chicorytv-icon.svg, chicorytv-wordmark.svg), page title/meta.
|
||||
Acceptance: the SPA presents consistently as ChicoryTV. (Product-wide rebrand = epic #59.)
|
||||
|
||||
## Approach notes
|
||||
1. Inventory first (delegate to Explore): every user-facing "ErsatzTV" in web/ (index.html
|
||||
title/meta/favicon, shell header, empty states, aria-labels, App.test.tsx copy
|
||||
assertions), what web/index.html currently ships as favicon, how the shell renders the
|
||||
brand mark today (the admin template uses chicory-mark.svg + "Chicory<accent>TV</accent>"
|
||||
— mirror that), and whether vite needs assets in web/public/ vs imported.
|
||||
2. Do NOT rename API strings, C# namespaces, Docker images, or docs — SPA surface only.
|
||||
The legacy Blazor UI stays ErsatzTV.
|
||||
3. Favicon: derive from chicorytv-icon.svg (SVG favicon is fine for modern browsers; add a
|
||||
PNG fallback only if trivial).
|
||||
4. Tests: update copy assertions; baseline 145 web tests must stay green (some assert brand
|
||||
strings). dotnet suites should be untouched (frontend-only): ErsatzTV.Tests 495,
|
||||
Core.Tests 493 (+1 skip).
|
||||
5. PR → main: "feat(web): ChicoryTV rebrand (#90)", `closes #90`; poll CI by head SHA; ask
|
||||
"merge?"; verify main post-merge run.
|
||||
6. RELEASE CHECKPOINT: after #90 merges, the SPA is feature-complete-enough AND branded —
|
||||
this is the strongest v26.4.0 tag point before #91. Ask the user explicitly whether to
|
||||
tag v26.4.0 now (never `[skip ci]` the tagged commit).
|
||||
7. Update THIS handoff: pop #90, next = #91 cutover; record PR + main SHA + baselines.
|
||||
Commit to main. Print the next prompt in a fenced block.
|
||||
## Task (in order; each item is small — batch several into this session)
|
||||
1. RELEASE CHECK: **DONE 2026-07-07** — v26.5.0 tagged (21ede492, run 546) AND deployed to
|
||||
prod via Komodo GitOps: the server-management compose
|
||||
(docker/bumblebee/stacks/media-servers/compose.yaml) now PINS
|
||||
`ersatztv:26.5.0` (was floating `:prod`); future releases = bump that pin + push
|
||||
(pre-deploy backup hook fires on the ersatztv block change; snapshot 20260707T093726Z
|
||||
taken). Verified live: appVersion 26.5.0, / 302→/app ChicoryTV, 43 channels, Blazor-only
|
||||
routes intact. Nothing to do unless prod misbehaves.
|
||||
2. Dep-PR batch pass: open Renovate/dep PRs (#21, #48, #49, #61, #131 security, #132) —
|
||||
check freshness, rebase/retrigger, merge the green ones (consent per PR).
|
||||
3. MCP PR #76 (#58): rebase/refresh onto current main (post-cutover); it predates the full
|
||||
API surface.
|
||||
4. Then START PARITY (unblocks #91 phase b — work top-down by user value): #147 (SPA
|
||||
escape-hatch link — tiny web/ change, do first), #146 (channel edit dead-end — the
|
||||
Channels pencil sends `edit=` that ChannelBuilderScreen ignores), then #140 (collections
|
||||
screen — biggest gap, /app/collections is a placeholder).
|
||||
5. Update THIS handoff: record what merged (PRs + main SHA + baselines), pop done items,
|
||||
write the next prompt (likely: continue parity queue #140–#145). Commit to main. Print
|
||||
the next prompt in a fenced block.
|
||||
|
||||
---
|
||||
|
||||
## Issue queue (work top-down)
|
||||
0. HOUSEKEEPING: #99 stays open for the final /api/channels/state onAir wiring; #126 (OpenAPI
|
||||
polymorphism) + #135 (advanced clear-to-none) are backend slot-fillers between screens.
|
||||
Renovate/dep PRs (#21, #48, #49, #61, #131 security, #132) — cheap batch-merge pass when
|
||||
convenient (note: Scriban already bumped to 7.2.5 by PR #137). MCP PR #76 (#58) still
|
||||
needs its rebase/refresh pass — good parallel track.
|
||||
1. #90 rebrand ← PROMPT above (small, mechanical; strongest v26.4.0 tag point at its merge).
|
||||
2. #91 cutover (+ tag v26.4.0 at the latest here — see RELEASE CHECKPOINT).
|
||||
Cross-refs: #66/#67 remain open image-pipeline nice-to-haves; #68 unblocked-independent.
|
||||
Done recently: PR #136 (#89 Channel Builder), **PR #138 (#93 Settings — closed 2026-07-07,
|
||||
main 17f49304; web tests 112 → 145, ErsatzTV.Tests 447 → 495), PR #137 (Scriban GHSA
|
||||
CI-unblock), #92 design-sync round-trip closed (docs/design-sync.md)**.
|
||||
0. HOUSEKEEPING ← PROMPT above (v26.5.0 tag check; dep PRs #21/#48/#49/#61/#131/#132; MCP
|
||||
PR #76 refresh; #99 stays open for /api/channels/state onAir wiring; #126 + #135 remain
|
||||
backend slot-fillers).
|
||||
1. SPA parity for #91 phase (b) — order: #147 (escape hatch, tiny) → #146 (channel edit) →
|
||||
#140 (collections) → #144 (blocks/decos/templates + playout editors) → #143 (ffmpeg
|
||||
profiles/filler/watermarks) → #141 (media browse/search/trash) → #145 (logs/
|
||||
troubleshooting) → #142 (trakt). Each: SPA screen over existing/gap-filling API,
|
||||
then REMOVE the now-covered routes from Blazor-only status by ADDING them to
|
||||
`ErsatzTV/LegacyUiRedirects.cs` (the map = the single source of truth for migration).
|
||||
2. #91 phase (b): delete Blazor/MudBlazor once #140–#146 are covered (recon report is in
|
||||
the 2026-07-07 session; key facts: delete Startup.cs:368-381 service regs +
|
||||
MapBlazorHub/MapFallbackToPage only, KEEP MapControllers/MapOpenApi/MapScalarApiReference
|
||||
/OIDC//callback/AccountController/hosted services; drop MudBlazor+BlazorSortable+
|
||||
Blazored.FluentValidation+Heron.MudCalendar pkg refs, RequiresAspNetWebAssets, razor
|
||||
NoWarn block, Locals/ resx, wwwroot css/lib Blazor assets; update
|
||||
StartupSpaHostingTests + docs/contributing.md Blazor sections; goldens must NOT change).
|
||||
Closes #91; then flag next release tag.
|
||||
Cross-refs: #66/#67 image-pipeline nice-to-haves; #68 independent; #25 (razor Sonar
|
||||
burn-down) becomes MOOT at phase (b) — close it then.
|
||||
Done recently: **PR #148 (#91 phase a root flip — merged 2026-07-07, main d04769cc; #91
|
||||
stays open for phase b)**, PR #139 (#90 rebrand, TAGGED v26.4.0), PR #138 (#93 Settings),
|
||||
PR #137 (Scriban GHSA CI-unblock).
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
# ErsatzTV MCP Server
|
||||
|
||||
`ErsatzTV.Mcp` is a v0 MCP foundation over the current stable ErsatzTV/ChicoryTV REST API. It is intentionally read-first and maps explicit MCP tools to current OpenAPI-backed endpoints in `ErsatzTV/wwwroot/openapi/v1.json`.
|
||||
|
||||
It does not scrape the web UI and does not read or write SQLite directly.
|
||||
|
||||
## Running
|
||||
|
||||
Build the server:
|
||||
|
||||
```bash
|
||||
dotnet build ErsatzTV.Mcp/ErsatzTV.Mcp.csproj
|
||||
```
|
||||
|
||||
Configure an MCP client to start:
|
||||
|
||||
```bash
|
||||
dotnet run --project /path/to/ersatztv/ErsatzTV.Mcp/ErsatzTV.Mcp.csproj
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---:|---|
|
||||
| `ERSATZTV_URL` | `http://localhost:8409` | Base URL for the ErsatzTV API. A reverse-proxy path prefix (e.g. `https://host/etv/`) is preserved. |
|
||||
| `ERSATZTV_API_KEY` | unset | Sent as `X-Api-Key` on every API request when configured. |
|
||||
| `ERSATZTV_ALLOW_WRITES` | `false` | Read-only posture. While `false`, the executor refuses any non-GET tool before it reaches the API — the runtime backstop behind the read-only catalog. Set `true` only once write/operational tools exist and are wanted. |
|
||||
| `ERSATZTV_MAX_RESPONSE_BYTES` | `1048576` | Cap on the API response body buffered back to the model; larger responses are truncated with a marker. |
|
||||
| `ERSATZTV_REQUEST_TIMEOUT_SECONDS` | `30` | Per-request HTTP timeout. |
|
||||
|
||||
**`ERSATZTV_API_KEY` is now effectively required.** The ErsatzTV API gates every `/api/*` route (reads included) behind `X-Api-Key` by default (`Api:RequireKeyForReads`); without a key the MCP server's tool calls return `401`. The client sends the key on every request when configured. (`/iptv/*` and `/artwork/*` remain open — those are outside the MCP surface.)
|
||||
|
||||
## Security posture
|
||||
|
||||
- **Read-only by default (runtime-enforced).** The tool catalog is all-GET, and the executor additionally refuses any non-GET tool unless `ERSATZTV_ALLOW_WRITES=true` — so a single wrong catalog entry cannot mutate or delete. Future write/operational tools (#58) slot in behind that opt-in and must still be individually safe/idempotent.
|
||||
- **Malformed input never crashes the session.** Invalid JSON is answered with a JSON-RPC `-32700` parse error (id `null`); a malformed request object gets `-32600`; the `Program.Main` read loop also catches any unexpected per-line error so one bad line can't terminate the server.
|
||||
- **Bounded responses.** API bodies are read up to `ERSATZTV_MAX_RESPONSE_BYTES` and truncated, and every request has an HTTP timeout — a large or slow upstream response can't exhaust memory or hang the session.
|
||||
- **Arguments are validated** against each tool's declared `InputSchema` (required present, no unknown args, basic types) before a request is built.
|
||||
- **Tool results are untrusted data.** Response bodies (media titles, file paths, etc.) can be attacker-influenced and are returned to the model verbatim. Treat all tool output as data, never as instructions; the consuming agent's system prompt should frame it as such. This is the standard prompt-injection caveat for any tool that surfaces external content.
|
||||
|
||||
## v0 Tools
|
||||
|
||||
Current v0 tools are explicit and narrow:
|
||||
|
||||
| Tool | API route |
|
||||
|---|---|
|
||||
| `ersatztv_list_channels` | `GET /api/channels` |
|
||||
| `ersatztv_get_channel` | `GET /api/channels/{id}` |
|
||||
| `ersatztv_list_collections` | `GET /api/collections` |
|
||||
| `ersatztv_get_collection` | `GET /api/collections/{id}` |
|
||||
| `ersatztv_list_smart_collections` | `GET /api/smart-collections` |
|
||||
| `ersatztv_get_smart_collection` | `GET /api/smart-collections/{id}` |
|
||||
| `ersatztv_list_schedules` | `GET /api/schedules` |
|
||||
| `ersatztv_get_schedule` | `GET /api/schedules/{id}` |
|
||||
| `ersatztv_list_schedule_items` | `GET /api/schedules/{id}/items` |
|
||||
| `ersatztv_get_playout` | `GET /api/playouts/{id}` |
|
||||
| `ersatztv_list_ffmpeg_profiles` | `GET /api/ffmpeg/profiles` |
|
||||
| `ersatztv_get_ffmpeg_profile` | `GET /api/ffmpeg/profiles/{id}` |
|
||||
| `ersatztv_get_resolution_by_name` | `GET /api/ffmpeg/resolution/by-name/{name}` |
|
||||
| `ersatztv_list_sessions` | `GET /api/sessions` |
|
||||
| `ersatztv_get_version` | `GET /api/version` |
|
||||
|
||||
Write and operational action tools are deferred until the API contract and tool ergonomics are reviewed for v0.1.
|
||||
|
||||
## Deferred
|
||||
|
||||
The MCP server deliberately does not include redesign-aware workflow tools yet:
|
||||
|
||||
- create-channel-from-lineup workflow: deferred until #63 exists
|
||||
- Channel Templates: deferred until #64 exists
|
||||
- library browse/search with artwork: deferred until #65 exists
|
||||
- image, logo, and watermark workflows: deferred until #66/#67 exist
|
||||
- resume/bookmark playback tools: deferred until #68 exists
|
||||
|
||||
These should wrap future backend endpoints once those contracts exist instead of recreating workflows inside MCP.
|
||||
@@ -0,0 +1,54 @@
|
||||
# ErsatzTV MCP v0 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a v0 MCP server that exposes safe read tools over the current stable ErsatzTV REST/OpenAPI surface.
|
||||
|
||||
**Architecture:** Add a small `ErsatzTV.Mcp` console project that speaks MCP JSON-RPC over stdio and calls the configured ErsatzTV HTTP API through explicit tool definitions. Keep contracts narrow and OpenAPI-aligned, with no UI scraping, no SQLite access, and no redesign workflow tools.
|
||||
|
||||
**Tech Stack:** .NET 10, `System.Text.Json`, `HttpClient`, NUnit/Shouldly tests, Central Package Management.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: MCP Tool Catalog
|
||||
|
||||
**Files:**
|
||||
- Create: `ErsatzTV.Mcp/ToolCatalog.cs`
|
||||
- Test: `ErsatzTV.Mcp.Tests/ToolCatalogTests.cs`
|
||||
|
||||
- [x] Write tests proving the catalog exposes read-first tools for channels, collections, smart collections, schedules/items, playouts, FFmpeg profiles/resolution, sessions, and version.
|
||||
- [x] Implement explicit tool metadata with names, descriptions, input schemas, HTTP method, and path templates.
|
||||
- [x] Verify deferred #63-#68 workflow tools are absent.
|
||||
|
||||
### Task 2: API Client
|
||||
|
||||
**Files:**
|
||||
- Create: `ErsatzTV.Mcp/ErsatzTvApiClient.cs`
|
||||
- Test: `ErsatzTV.Mcp.Tests/ErsatzTvApiClientTests.cs`
|
||||
|
||||
- [x] Write tests for base URL normalization, path parameter substitution, query parameter encoding, API key header application, and non-success error payloads.
|
||||
- [x] Implement a small HTTP client wrapper returning structured `ToolCallResult` content.
|
||||
|
||||
### Task 3: MCP JSON-RPC Server
|
||||
|
||||
**Files:**
|
||||
- Create: `ErsatzTV.Mcp/McpServer.cs`
|
||||
- Create: `ErsatzTV.Mcp/JsonRpcModels.cs`
|
||||
- Modify: `ErsatzTV.Mcp/Program.cs`
|
||||
- Test: `ErsatzTV.Mcp.Tests/McpServerTests.cs`
|
||||
|
||||
- [x] Write tests for `initialize`, `tools/list`, `tools/call`, unknown tools, and notifications.
|
||||
- [x] Implement stdio-friendly JSON-RPC handling with MCP protocol version `2024-11-05`.
|
||||
- [x] Return tool call results as JSON text content.
|
||||
|
||||
### Task 4: Project Wiring And Docs
|
||||
|
||||
**Files:**
|
||||
- Create: `ErsatzTV.Mcp/ErsatzTV.Mcp.csproj`
|
||||
- Create: `ErsatzTV.Mcp.Tests/ErsatzTV.Mcp.Tests.csproj`
|
||||
- Modify: `ErsatzTV.sln`
|
||||
- Add: `docs/mcp.md`
|
||||
|
||||
- [x] Wire projects into the solution.
|
||||
- [x] Document configuration, usage, v0 tools, auth header behavior, and deferred redesign workflows.
|
||||
- [x] Run restore, build, focused tests, and full tests.
|
||||
Reference in New Issue
Block a user