Compare commits

..
Author SHA1 Message Date
timothy 65d88b5167 test(#289): pin BoundedLineReader cap/CRLF boundaries + document per-segment path assumption
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m42s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Non-blocking polish from the fix-commit re-review (verdict MERGEABLE):
- BuildPath: comment the assumption that each {param} is its own path segment
  (revalidate the whole path if a template ever concatenates adjacent params).
- Two additive BoundedLineReader tests: exactly-at-cap line returned intact
  (inclusive max), and \r\n carriage-return stripping. No logic change.

36 tests (was 34).

Refs #289
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:24:35 +02:00
timothy 4f68805d9a security(#289): review fixes — body-read timeout, transport-error response, bounded stdin, env clamps
Folds in findings from the independent reviews of the initial hardening diff
(a cold adversarial pass + a Codex pass — complementary catches).

Fork (HIGH/MED):
- Body-read timeout regression: ResponseHeadersRead moved the body read outside
  HttpClient.Timeout and it used CancellationToken.None, so a slow-drip upstream
  hung the single-threaded session. Now a per-request linked CTS (options
  .EffectiveRequestTimeout) covers headers + body; HttpClient.Timeout set to
  Infinite so one timer owns it. Verified live against a black-hole upstream:
  1s timeout → -32603 in ~1s, no hang.
- Transport/timeout exceptions escaped HandleAsync's catch filter → no response
  for the id → client hangs. Added a broad final catch → -32603 when hasId.

Codex (env/input robustness):
- ERSATZTV_REQUEST_TIMEOUT_SECONDS / ERSATZTV_MAX_RESPONSE_BYTES at int.MaxValue
  crashed at startup / overflowed `cap+1` to a negative alloc. ParseInt now
  clamps to [min,max]; the client also hard-ceils the cap (MaxAllowedResponseBytes).
- A ".."/"." path param collapsed the URL onto another route under Uri
  canonicalization — rejected in BuildPath.

Both reviewers (stdin OOM): ReadLineAsync buffered an unbounded line before any
guard. New BoundedLineReader drains+drops oversized lines (memory-bounded),
keeping subsequent lines aligned.

Nits: case-insensitive ParseBool fallback; UTF-8 boundary backoff so a mid-
codepoint truncation doesn't emit U+FFFD; renamed a misleading test.

34 tests (was 26); +8 covering the transport-error path, dot-segment rejection,
cap-overflow fallback, UTF-8 seam, and the bounded reader.

Refs #289 #197
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:20:10 +02:00
timothyandClaude Opus 4.8 55fc210385 security(#289): harden MCP server (read-only posture, JSON-DoS, response cap, arg validation)
From the #197 cold review's MCP-consumer pass. Hardens `ErsatzTV.Mcp` (PR #76)
before it can go live against a soon-remote-exposed API.

- HIGH verb-guard: runtime read-only *posture* (ERSATZTV_ALLOW_WRITES, default
  false) enforced in the executor — a wrong catalog entry can't mutate/delete,
  and future write tools (#58) slot in behind the opt-in. Not a hardcoded
  GET-only clamp, so the non-read-only final design stays reachable.
- HIGH JSON-crash DoS: guard JsonDocument.Parse in HandleAsync (-32700, id null),
  handle missing/non-string method (-32600) instead of throwing, and wrap the
  Program.Main read loop so one bad stdin line can never kill the session.
- MED unbounded response / LOW no-timeout: cap the buffered body
  (ERSATZTV_MAX_RESPONSE_BYTES, truncation marker) + explicit HttpClient.Timeout.
- MED args unvalidated: ToolArgumentValidator checks caller args against each
  tool's InputSchema (required present, no unknown args, basic types) before a
  request is built.
- MED prompt injection: documented untrusted-data posture in docs/mcp.md.
- LOW base-URL prefix: preserve a reverse-proxy path prefix in the URL join.

Also documents that ERSATZTV_API_KEY is now effectively required (Bundle A gates
all /api reads behind X-Api-Key). 14 new tests (26 total); live stdin smoke
confirms malformed/no-method/non-object lines no longer crash the loop.

Refs #289 #197 #58

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:08:07 +02:00
timothy d1c04030af chore: retrigger CI (MySQL service port collision with concurrent run)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m0s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m29s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-07 11:58:29 +02:00
timothy 945d108334 feat(mcp): add read-only API server foundation refs #58
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Failing after 0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-07 11:44:44 +02:00
36 changed files with 1771 additions and 3061 deletions
+4 -4
View File
@@ -6,18 +6,18 @@
<ItemGroup>
<PackageVersion Include="AsyncFixer" Version="2.1.0" />
<PackageVersion Include="Blazored.FluentValidation" Version="2.2.0" />
<PackageVersion Include="BlazorSortable" Version="6.0.2" />
<PackageVersion Include="BlazorSortable" Version="5.2.1" />
<PackageVersion Include="Blurhash.SkiaSharp" Version="2.0.0" />
<PackageVersion Include="Chronic.Core" Version="0.4.0" />
<PackageVersion Include="CliWrap" Version="3.10.2" />
<PackageVersion Include="CliWrap" Version="3.10.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Dapper" Version="2.1.79" />
<PackageVersion Include="Dapper" Version="2.1.66" />
<PackageVersion Include="Destructurama.Attributed" Version="5.2.0" />
<PackageVersion Include="EFCore.BulkExtensions" Version="[9.0.2,10)" />
<PackageVersion Include="EFCore.BulkExtensions.MySql" Version="[9.0.2,10)" />
<PackageVersion Include="EFCore.BulkExtensions.Sqlite" Version="[9.0.2,10)" />
<PackageVersion Include="Elastic.Clients.Elasticsearch" Version="9.3.0" />
<PackageVersion Include="EntityFrameworkProfiler.Appender" Version="6.0.6053" />
<PackageVersion Include="EntityFrameworkProfiler.Appender" Version="6.0.6049" />
<PackageVersion Include="FluentValidation" Version="12.1.1" />
<PackageVersion Include="FluentValidation.AspNetCore" Version="11.3.1" />
<PackageVersion Include="Flurl" Version="4.0.0" />
@@ -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)
});
}
}
}
+149
View File
@@ -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));
}
}
+63
View File
@@ -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();
}
}
+56
View File
@@ -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());
}
}
+10
View File
@@ -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>
+154
View File
@@ -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();
}
+166
View File
@@ -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;
}
}
+87
View File
@@ -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;
}
+76
View File
@@ -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
};
}
+45
View File
@@ -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);
}
+40
View File
@@ -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);
}
+42
View File
@@ -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());
}
}
+40
View File
@@ -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
-3
View File
@@ -141,9 +141,6 @@
<data name="ButtonSettingsXmltv" xml:space="preserve">
<value>XMLTV</value>
</data>
<data name="ButtonOpensInChicoryTv" xml:space="preserve">
<value>Opens in ChicoryTV</value>
</data>
<data name="ButtonSupport" xml:space="preserve">
<value>Support</value>
</data>
+10 -20
View File
@@ -117,7 +117,7 @@
{
<MudDrawer @bind-Open="@_drawerIsOpen" Elevation="2" ClipMode="DrawerClipMode.Always">
<MudNavMenu>
<MudNavLink Href="channels">@StringLocalizer["ButtonChannels"]@ChicoryCue</MudNavLink>
<MudNavLink Href="channels">@StringLocalizer["ButtonChannels"]</MudNavLink>
<MudNavLink Href="ffmpeg">@StringLocalizer["ButtonFFmpegProfiles"]</MudNavLink>
<MudNavLink Href="watermarks">@StringLocalizer["ButtonWatermarks"]</MudNavLink>
<MudNavGroup Title="@StringLocalizer["ButtonMediaSources"]">
@@ -127,7 +127,7 @@
<MudNavLink Href="media/sources/plex">@StringLocalizer["ButtonMediaSourcesPlex"]</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="@StringLocalizer["ButtonMedia"]">
<MudNavLink Href="media/libraries">@StringLocalizer["ButtonMediaLibraries"]@ChicoryCue</MudNavLink>
<MudNavLink Href="media/libraries">@StringLocalizer["ButtonMediaLibraries"]</MudNavLink>
<MudNavLink Href="media/trash">@StringLocalizer["ButtonMediaTrash"]</MudNavLink>
<MudNavLink Href="media/tv/shows">@StringLocalizer["ButtonMediaTvShows"]</MudNavLink>
<MudNavLink Href="media/movies">@StringLocalizer["ButtonMediaMovies"]</MudNavLink>
@@ -161,7 +161,7 @@
}
</TitleContent>
<ChildContent>
<MudNavLink Href="schedules">@StringLocalizer["ButtonSchedulingSchedules"]@ChicoryCue</MudNavLink>
<MudNavLink Href="schedules">@StringLocalizer["ButtonSchedulingSchedules"]</MudNavLink>
<MudNavLink Href="blocks">@StringLocalizer["ButtonSchedulingBlocks"]</MudNavLink>
<MudNavLink Href="templates">@StringLocalizer["ButtonSchedulingTemplates"]</MudNavLink>
<MudNavLink Href="decos">@StringLocalizer["ButtonSchedulingDecos"]</MudNavLink>
@@ -180,18 +180,17 @@
{
@StringLocalizer["ButtonSchedulingPlayouts"]
}
@ChicoryCue
</MudNavLink>
</ChildContent>
</MudNavGroup>
<MudNavGroup Title="@StringLocalizer["ButtonSettings"]">
<MudNavLink Href="settings/ffmpeg">@StringLocalizer["ButtonSettingsFFmpeg"]@ChicoryCue</MudNavLink>
<MudNavLink Href="settings/logging">@StringLocalizer["ButtonSettingsLogging"]@ChicoryCue</MudNavLink>
<MudNavLink Href="settings/hdhr">@StringLocalizer["ButtonSettingsHdHomeRun"]@ChicoryCue</MudNavLink>
<MudNavLink Href="settings/scanner">@StringLocalizer["ButtonSettingsScanner"]@ChicoryCue</MudNavLink>
<MudNavLink Href="settings/playout">@StringLocalizer["ButtonSettingsPlayout"]@ChicoryCue</MudNavLink>
<MudNavLink Href="settings/ui">@StringLocalizer["ButtonSettingsUi"]@ChicoryCue</MudNavLink>
<MudNavLink Href="settings/xmltv">@StringLocalizer["ButtonSettingsXmltv"]@ChicoryCue</MudNavLink>
<MudNavLink Href="settings/ffmpeg">@StringLocalizer["ButtonSettingsFFmpeg"]</MudNavLink>
<MudNavLink Href="settings/logging">@StringLocalizer["ButtonSettingsLogging"]</MudNavLink>
<MudNavLink Href="settings/hdhr">@StringLocalizer["ButtonSettingsHdHomeRun"]</MudNavLink>
<MudNavLink Href="settings/scanner">@StringLocalizer["ButtonSettingsScanner"]</MudNavLink>
<MudNavLink Href="settings/playout">@StringLocalizer["ButtonSettingsPlayout"]</MudNavLink>
<MudNavLink Href="settings/ui">@StringLocalizer["ButtonSettingsUi"]</MudNavLink>
<MudNavLink Href="settings/xmltv">@StringLocalizer["ButtonSettingsXmltv"]</MudNavLink>
</MudNavGroup>
<MudNavGroup Expanded="true">
<TitleContent>
@@ -261,15 +260,6 @@
</MudLayout>
@code {
// Lightweight cue (ersatztv#147): marks nav entries that 302-redirect into the
// ChicoryTV SPA (see LegacyUiRedirects.Map) so it's clear before clicking that the
// entry leaves this Blazor UI. Non-redirecting entries in the same MudNavGroup are
// left bare.
private RenderFragment ChicoryCue =>
@<MudTooltip Text="@StringLocalizer["ButtonOpensInChicoryTv"]">
<MudIcon Icon="@Icons.Material.Filled.OpenInNew" Size="Size.Small" Class="ml-1" Style="opacity:.55;vertical-align:text-bottom"/>
</MudTooltip>;
private static readonly string InfoVersion = Assembly.GetEntryAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "unknown";
private static readonly string BuildConfiguration = Assembly.GetEntryAssembly().GetCustomAttribute<AssemblyConfigurationAttribute>()?.Configuration?.ToLower() ?? "unset";
+253 -94
View File
@@ -2,113 +2,272 @@
Paste the prompt below into a fresh session to work the next item. Each session ends by
UPDATING THIS FILE in place (rewrite the state section and the queue for the next item) so it
always holds the current handoff. History: created 2026-07-02 after the plan audit (#59 epic);
all backend gap issues (#100#111) and all SPA screens (#84#89, #93, #109), the rebrand (#90)
and the cutover root-flip (#91 phase a, PR #148) are MERGED. v26.5.0 tagged + DEPLOYED to prod
2026-07-07 (verified live: image :26.5.0, / 302→/app, ChicoryTV title, /api/version 26.5.0,
M3U serving). Blazor removal (#91 phase b) is blocked on parity issues #140#147.
always holds the current handoff. History: created 2026-07-02 after the plan audit (#59 epic)
filed backend gap issues #100#111; a 79-way parallel workflow build once exhausted RAM, so
builds are limited to 23 concurrent, never wide fan-outs. Backend gaps all landed by
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) —
first screen through the full design-first workflow; #92 design-sync round-trip verified and
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**.
**Session state (2026-07-07, post-housekeeping)**: main = 7e5a6b75 (+ this doc commit).
Housekeeping session did:
- Dep batch: PR #61 (BlazorSortable 6.0.2 + CliWrap 3.10.2 + Dapper 2.1.79, rebased) and
PR #132 (EFProfiler.Appender 6.0.6053, rebased) MERGED; #21/#48/#49 closed as superseded by
#61; #131 (Scriban 7.2.0) closed obsolete — main already has Scriban 7.2.5 (47c3c3b5).
- MCP PR #76 (#58): rebased onto main (clean, purely additive ErsatzTV.Mcp project), 12 tests
green locally, pushed (head d1c04030). Still OPEN — needs a content refresh against the full
API surface before merge, plus review.
- Filed #149: CI flake — migrations job MySQL service publishes fixed host port 3306:3306, so
two concurrent runs collide ("port is already allocated"). Fix = drop the `ports:` mapping
(job connects via `Server=mysql` service name); verify MySql drift/apply still passes.
- Worktrees: removed issue-91-cutover; .worktrees/deps was this session's scratch (remove it).
**.worktrees/parity-147 exists** (branch feat/147-classic-ui-link off 1cf4a7a9, web/ npm ci
already done) — rebase onto current main and use it for #147.
- PARITY NOT STARTED — recon done though; facts baked into the prompt below.
**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
SPARINGLY: delegate implementation to the best-fitting subagent models (haiku for mechanical
churn, sonnet for standard components/tests, opus for judgment-heavy logic/orchestration
code; fable only for the hardest design calls and the final review fork). Reviews stay
multi-lens via subagents (fable correctness fork + cheaper contract/tests lens + a
design-system lens for frontend work), plus a fork verification pass over any fix diff.
Review fixes are applied by fitting subagents, never inline. `npm ci` in each fresh worktree
before web/ verification. Merges need explicit user consent per PR — NOTE: the permission
classifier requires consent IN-CONVERSATION; the standing consent written here does not
satisfy it, so ask a quick merge question each time (learned #134). Subagents killed by
transient API errors CAN be resumed via SendMessage with their agentId — resume instead of
relaunching (their edits are saved; learned #89). NEW (#93): LIVE-E2E the new screen against
a real fresh local server BEFORE the review lenses — it caught two ship-blockers jsdom
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. **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-#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):
- The main checkout (/Users/timothy/ersatztv) sits on the STALE docs/59-ui-redesign-brief
branch. NEVER recon/edit there — point subagents at a worktree pinned to origin/main, or
they report placeholders that no longer exist (this bit a recon agent on 2026-07-07).
- Gitea 1.24 has no rerun-run API; retrigger CI with an empty commit. Runs on the single
runner queue mostly sequentially; concurrent migration jobs collide on port 3306 until #149
lands.
- DTO records in ErsatzTV.Core/Api MUST get file-scoped `#nullable enable` (else OpenAPI emits
`["null","string"]` and SPA typegen degrades). ErsatzTV.Application has NO nullable context —
`string?` there trips CS8632.
- NSubstitute + ConfigElementKey: keys are fresh instances per access — stub with
`Arg.Any<ConfigElementKey>()` disambiguated by the generic `<T>`.
- `Option<T>.ToNullable()` doesn't exist; use `MatchUnsafe(v => (T?)v, () => null)`.
- ./scripts/update-openapi.sh needs a prior normal `dotnet build ErsatzTV/ErsatzTV.csproj`.
- Id-taking child-collection GETs 404 on unknown parent via pre-check + ApiResults + an
OpenApiErrorResponseContractTests [TestCase] entry.
- Backlog nits: pageSize unclamped on paged endpoints; artwork content-type trusted (#66);
GET /api/guide runs the full 21-include eager-load per channel per request; `fillerKind`
almost always `None` in JSON guide (merged into adjacent programmes).
- 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,
so the SPA static-file provider reads bin's wwwroot — publish/Docker copy it, `dotnet run`
doesn't), then `ETV_CONFIG_FOLDER=<scratch> ASPNETCORE_URLS=http://127.0.0.1:8409 dotnet
run --project ErsatzTV`. Fresh DB migrates in seconds; ffmpeg autodetected from PATH.
update-openapi.sh FAILS while an instance runs (single-instance mutex) — kill it first.
- NEW (#93) — Dapper + Microsoft.Data.Sqlite infers expression columns (COUNT(*)) as BLOB
when the result set is EMPTY → incompatible deserializer → 500 on every fresh DB. Prefer
EF LINQ GroupBy for aggregates in Api handlers; regression-test the empty-DB path.
- NEW (#93) — Screen hooks must TIER their loads: the screen's own resources gate
loading/error; reference data (pickers, sources, health, version) settles per-resource
(allSettled) with inline "Couldn't load X" notes. Precedent: settings.ts. Also: render the
error branch BEFORE the loading branch — a draft-null loading guard ahead of the error
check made the error state unreachable (infinite spinner).
- NEW (#93) — ApiResults maps ONLY NotFoundError→404; plain BaseError→422. Handlers that
collapse "missing" and "invalid state" into one query filter can't 404 — split the lookup
(precedent: DeleteCustomResolutionHandler). Request DTOs in Controllers/Api/Requests
deliberately have NO `#nullable enable` (only RESPONSE DTOs get it). Startup's
UseStringEnumSchemas registers non-Core enums (OutputFormatKind, LogEventLevel)
INDIVIDUALLY — assembly-wide reflection over ErsatzTV.FFmpeg throws
ReflectionTypeLoadException (optional NvEncSharp natives). Core↔Application enum twins
bridge via exhaustive switch expressions, never int casts.
- NEW (#93) — `npm run check:api` diff-guards v1.d.ts against the LAST COMMIT — it fails
mid-branch after a backend OpenAPI change until the regenerated file is committed;
regen-idempotence (running generate:api twice → no diff) is the real sync check.
- NEW (#93) — OSV advisories can break ALL CI overnight: NuGetAudit + warnings-as-errors
turns a fresh critical advisory into NU1904 restore failures on every branch. Fix = a
one-line central bump PR straight off main, merged before feature PRs (PR #137,
Scriban 6→7 validated by both suites + unchanged XMLTV goldens).
- NEW (#89) — Dialog/portal components: key open-effects on `[open]` ONLY and read callbacks
through a latest-ref; an effect depending on an inline `onClose` re-runs (and re-focuses)
on every parent render — the focus-steal makes dialog inputs untypeable, and jsdom tests
can't catch it (fireEvent.change needs no focus).
- NEW (#89) — before offering a "None"/clear affordance for any field the backend resolves
with `x ?? fallback`, check whether null actually MEANS clear — for from-lineup advanced
overrides null = INHERIT (see #135), so honest UI is "Inherit from template", not "None".
- NEW (#89) — `<label onClick={...}>` wrapping a labelable control (button/input) double-fires
in real browsers (label activation forwarding + bubble); jsdom does not emulate it, tests
stay green. Use a `<div>` row with the control as the single accessible element.
- NEW (#89) — `/api/library/browse` `mediaType` is single-valued: a Collections-style picker
needs 5 typed parallel calls (Collection/Smart/Multi/Rerun/Playlist) merged client-side.
ApiResults 422 title is ALWAYS "Validation failed" — fixtures must not invent titles.
- Multiple Dynamic-start Flood schedule items are NON-VIABLE (#134): PlayoutModeSchedulerFlood
only yields to a next item with StartType.Fixed (`PlayoutModeSchedulerFlood.cs:50-53`) and
never advances on the hard stop — an ordered multi-source lineup must be ONE generated
`IsSystem` Playlist (PlayAll=true per entry, entries in Index order) behind a single Flood
item. PlaylistItem supports Movie/Show/Season/Artist/Collection/Smart/Multi but has NO
RerunCollectionId and NO nested-playlist support (CollectionKey.ForPlaylistItem +
MediaCollectionRepository.GetPlaylistItemMap are the two switches that define support).
- Validation must see the SAME data the build path uses (#134): normalizing on a `with {}`
copy inside the validator let raw request values reach persistence (FK violation → opaque
422). Normalize the whole input once up front; both validation and build consume the
normalized form.
- Any handler that SYNTHESIZES names into unique-indexed columns needs de-collision (" 2",
" 3"…, max-length-safe) — deleting a channel doesn't cascade its generated
schedule/playlist, so recreate-after-delete is a routine path, not an edge case (#134).
- `SelectOneAsync` re-applies `.OrderBy(keySelector)` INTERNALLY, which REPLACES any ordering
the caller composed before it (#133) — never pre-`OrderBy` into SelectOneAsync; write the
explicit `.Where(...).OrderBy(...).FirstOrDefaultAsync(...)` when ordering matters.
- Normalize user input ONCE (#133): validate uniqueness/lengths against the SAME normalized
(e.g. trimmed) value you persist, or a whitespace variant slips past validation and dies on
the unique index as an unhandled 500.
- Application command/query records + handlers live in `<Domain>/Commands/` and
`<Domain>/Queries/` subfolders (contributing §2); namespace stays
`ErsatzTV.Application.<Domain>` regardless of subfolder (#133).
- Deferral wording must ENUMERATE what is deferred (#130): "aggregate collection metadata is
deferred" quietly swallowed manual collections, which are a cheap direct join — the review
had to split the deferral. Cheap-vs-expensive is per collection kind, not per feature.
- Merged-source paging pattern (#130): Lucene supplies media ids+total, EF supplies
collection-likes; page = media first, then a skip cascade through each collection type
(`remainingSkip`/`take` threading). Stale Lucene entries can drift collection paging for a
scan window — accepted, documented in-code. Any similar dual-source endpoint should copy
the GetLibraryBrowseItemsHandler pattern AND its multi-type-overflow paging test.
- User text into BOTH Lucene and SQL needs per-side treatment (#130): raw query text is the
established Lucene idiom (parser falls back to escaped-literal on ParseException — malformed
input degrades to empty/literal results, never throws), but the same text in EF LIKE needs
`%`/`_`/escape-char escaping or semantics diverge between the two halves.
- Direct `*Metadata` DbSet queries need a deterministic winner (#130): items can carry >1
metadata row; either go through the navigation + HeadOrNone() idiom or
GroupBy(itemId).OrderBy(Id).First().
- NULL FIELDS ARE OMITTED ON THE WIRE (#129): Startup.cs sets Newtonsoft
`NullValueHandling.Ignore` globally, so any null DTO property is ABSENT from the JSON →
`undefined` in the browser, even though generated types say `| null`. Frontend guards must
use truthiness (`!x`), NEVER `=== null`; fixtures for null cases must OMIT the key (test
precedent: "renders the Guide screen when an on-air channel omits nowPlaying").
- Cross-endpoint correlation needs shared ids (#129): /api/guide titles are show-only
(ChannelGuideMetadata.GetTitle) while /api/channels/state nowPlaying uses GetDisplayTitle
("Show - s01e01 - Ep") — string matching across endpoints can never work for episodes.
Live match is now timestamps-only; the real fix is a shared programme/playout-item id on
both endpoints (backlog).
- Fixture fidelity (#109/#127/#128): fixtures must be what the actually-called endpoint
returns UNDER THE QUERY THE CLIENT SENDS. `percent` is a 01 fraction despite its name.
Enum-with-None fields are never truthiness-checked. Verify UNITS/scale of numeric wire
fields against the producing code, not the field name.
- Trigger≠started (#128): a 202/200 on a trigger endpoint means QUEUED; poll while
pendingactive nonempty with a grace window; drain grace on persistent errors.
- setState updaters must be PURE — no fetches (#127), no ref mutations (#128); StrictMode
double-invokes updaters and the test renderer doesn't, so reviewers must catch it.
- OpenAPI can UNDER-report the wire (#125/#126); check the serializer before widening types.
- Every new multi-column grid → the @media (max-width: 980px) collapse block; var() fallback
= the token's resolved value; verify the token EXISTS (--ctv-surface-1, --text-faint don't;
#93: handoff said --status-warn-soft, the SPA token is --ctv-warn-soft — Toast precedent).
- Prototype affordances: implemented or VISIBLY deferred — never silently dropped.
- Actionable = visible (#84); disable all mutation triggers while mutating; ref-based
double-submit guards; mutations never refetch the world (#125/#127).
- Honest tests: no scenarios the backend can't produce (no exception middleware → failures
are BARE 4xx/5xx unless the controller returns ProblemDetails); mount call-counts assert
the DELTA across navigation; status-dot state never color-only (StatusDot has a label).
- The image-build job runs ONLY on main pushes. DTO records in Core/Api need `#nullable
enable`; Application has NO nullable context. NSubstitute+ConfigElementKey:
`Arg.Any<ConfigElementKey>()` + `<T>`. `Option<T>.ToNullable()` → `MatchUnsafe`.
update-openapi.sh needs a prior normal build. Child GETs 404 unknown parents via pre-check.
Validation.Apply ERASES NotFoundError subtypes (#44 gotcha) — multi-check validation that
must 404 stays early-return.
- Backlog nits (unfiled): unclamped pageSize (browse is clamped; older endpoints aren't);
PlayoutController Create/Delete lack Name=; heavy GetItems pre-check; >30 MB uploads →
bare 413; artwork content-type trusted (#66); schedule estimator materializes collections
per GET; /api/health TTL cache; `LibraryScanStatusResponseModel.percent` 01 under a
percent name; 1 pre-existing --text-faint usage in shell.css. From #129: shared
programme/playout-item id on /api/guide + /api/channels/state; extract the duplicated
channel-state poll loop into a shared helper; EPG grid re-renders unmemoized on every tick;
/api/guide 21-include eager-load untrimmed. From #130: the two manual-collection metadata
helpers each fetch CollectionItems (share one fetch); very large manual collections make
the browse duration sum heavy. From #134: pre-existing non-system PlaylistGroup named
"Channel Lineups" breaks multi-item creates with a generic 422; non-DbUpdateException
create failures surface as bare 500. From #89: undefined-vs-null lineup keys in the create
body; reduced-motion block lists a now-no-op .ctv-builder-libcard. From #93: enumeration
endpoints missing for MPEG-TS scripts / audio language codes / UI cultures (settings fields
are free-text meanwhile); media sources have no status/reachability signal in the API
(Settings omits the StatusDot); full FFmpeg profile editor screen; edits made to an
already-saved group DURING an in-flight save can be overwritten by the returned DTO
(narrow race, noted by the verification fork). Filed: #126, #135.
---
# PROMPT — Parity kickoff: #147 escape hatch → #146 channel edit → #140 collections
# PROMPT — Post-cutover housekeeping + parity kickoff
You are Fable, the ORCHESTRATOR in the main Claude Code session (Claude Code only). Fable is
EXPENSIVE: delegate (recon → Explore/haiku; mechanical → sonnet; judgment-heavy code → opus;
fable forks for review). Read CLAUDE.md + the Lessons above first.
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:
- Work in worktrees off origin/main; NEVER touch /Users/timothy/ersatztv (stale branch).
.worktrees/parity-147 exists (npm ci done) — rebase feat/147-classic-ui-link onto
origin/main first. `cd web && npm ci` in any NEW worktree.
- 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 23 concurrent builds; ONE dotnet build at a time. NEVER set ETV_UPDATE_GOLDENS.
- Merge consent in-conversation per PR. CI reruns = empty commit (no rerun API).
- Live-E2E new screens per the #93 recipe (curl `localhost`, NOT 127.0.0.1 — host guard;
wait for "Done migrating search index").
- 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").
## Recon facts (2026-07-07, verified against main)
- Routing: web/src/App.tsx (~150252). /app/collections renders PlaceholderScreen
(App.tsx ~2776). Manual pushState routing, no router lib.
- #147: web/src/screens/SettingsScreen.tsx, System pane About card (~852) is the natural home
for a "Classic UI" link to /system/health; reuse the existing `LegacyCallout` component
(ExternalLink icon + "Legacy UI" badge, used by the Media sources card ~840). Remove again
in #91 phase (b).
- #146: Channels screen edit pencil already navigates
navigateToPath(`/app/new-channel?edit=${channel.id}`) (App.tsx ~1216) but
web/src/builder/ChannelBuilder.tsx never reads query params → dead-end. Backend ready:
GET /api/channels/{id} → full ChannelViewModel; PUT /api/channels/{id} takes
UpdateChannelRequest (ChannelController.cs). Issue #146 also covers /channels/numbers bulk
renumbering — bulkRenumberChannels already exists in web/src/api/channels.ts (check what UI
exposes it). Decide: hydrate the builder for edit vs a dedicated edit view — the builder's
lineup model (from-lineup composite create) does NOT map 1:1 onto editing an existing
channel's 30+ settings; a form-style edit view calling PUT may be the honest shape.
- #140: manual + smart collections have FULL API CRUD (CollectionController incl. items
add/remove; SmartCollectionController). NO API exists for multi-collections, playlists,
rerun collections → those need backend gap issues/endpoints for full Blazor parity
(/media/multi-collections, /media/playlists, /media/rerun-collections). SPA has
getCollections()/getSmartCollections() in web/src/api/schedules.ts (no collections.ts yet).
Tests: Vitest + testing-library; App.test.tsx mocks fetch by path (mockDashboardApi).
## Task (in order)
1. #147 [small, sonnet]: Classic UI link in Settings → System About card (+ optional
"opens in ChicoryTV" cue on migrated Blazor sidebar links, per the issue). Vitest
coverage. PR, CI, consent, merge.
2. #146 [opus]: channel edit path. Scope call first (builder hydration vs edit form —
recommend and confirm in an issue comment), then implement + tests + live E2E. Include or
explicitly defer /channels/numbers with an issue comment. PR, consent, merge.
3. #140 [opus, biggest]: collections screen for manual + smart collections (list/create/
edit/delete/items). File follow-up issues for multi/playlist/rerun API gaps and cover
those types read-only if feasible (they surface in GET /api/collections by CollectionType).
PR, consent, merge.
4. Adversarial review fork over each PR diff before asking merge consent.
5. Update THIS handoff: merged PRs + main SHA, pop done items, next prompt = remaining parity
(#141#145). 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)
1. #147 SPA escape-hatch link (tiny) ← PROMPT above
2. #146 channel edit + /channels/numbers (blocker for #91b)
3. #140 collections management (biggest gap; spawns multi/playlist/rerun API follow-ups)
4. #141#145 remaining parity gaps (media browse/search, trakt, filler, watermarks, ffmpeg
profiles, blocks/decos/templates, playout editors, logs, troubleshooting — see each issue)
5. #91 phase (b): remove Blazor once #140#147 close; also remove the #147 link then.
6. MCP PR #76 (#58): refresh tool catalog against the full API surface, review, merge.
7. #149 CI flake (migrations job host-port collision) — small, slot into any session.
Cross-refs: #99 (TS/HLS-Direct sessions) stays backlog. Done this pass: dep PRs #61/#132
merged; #21/#48/#49/#131 closed; prod verified on v26.5.0.
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
View File
@@ -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.
-9
View File
@@ -2728,15 +2728,6 @@ describe('Settings screen (#93)', () => {
expect(await screen.findByText('Local')).toBeInTheDocument();
});
it('shows a Classic UI link to the legacy Blazor app in the System pane (#147)', async () => {
mockDashboardApi();
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /^System/ }));
const link = await screen.findByRole('link', { name: /Open Classic UI/ });
expect(link).toHaveAttribute('href', '/system/health');
});
it('renders and stays editable when media sources (tier-2 reference data) fail to load', async () => {
mockDashboardApi({ mediaSourcesFailuresBeforeSuccess: 99 });
await openSettings();
+1 -27
View File
@@ -54,8 +54,6 @@ import {
} from 'lucide-react';
import chicoryMarkUrl from '../../design-system/assets/chicory-mark.svg';
import { ChannelBuilderScreen } from './builder/ChannelBuilder';
import { ChannelEditScreen } from './screens/ChannelEditScreen';
import { CollectionsScreen } from './screens/CollectionsScreen';
import { SettingsScreen } from './screens/SettingsScreen';
import { navigateToPath } from './routing';
import {
@@ -126,7 +124,6 @@ type ScreenId =
| 'dashboard'
| 'channels'
| 'builder'
| 'editChannel'
| 'guide'
| 'schedules'
| 'playouts'
@@ -184,21 +181,6 @@ const routes: ScreenRoute[] = [
primaryAction: 'Create Channel',
placeholder: 'Channel builder workspace'
},
{
// Not in the sidebar nav; reached via the edit pencil on the channel table. The
// screen owns parsing the {id} suffix (see ChannelEditScreen), so it opts into
// sub-paths like /app/edit-channel/5.
id: 'editChannel',
path: '/app/edit-channel',
label: 'Edit Channel',
title: 'Edit Channel',
kicker: 'Channel',
description: 'Full channel editor: identity, playout, streaming, selection and branding.',
icon: <Tv aria-hidden="true" size={16} />,
primaryAction: 'Save Changes',
placeholder: 'Channel editor workspace',
allowSubPaths: true
},
{
id: 'guide',
path: '/app/guide',
@@ -1231,7 +1213,7 @@ function ChannelTableRow({
<IconButton disabled size="sm" title={`Preview unavailable for ${channel.name}`}>
<Play aria-hidden="true" size={15} />
</IconButton>
<IconButton onClick={() => navigateToPath(`/app/edit-channel/${channel.id}`)} size="sm" title={`Edit ${channel.name}`}>
<IconButton onClick={() => navigateToPath(`/app/new-channel?edit=${channel.id}`)} size="sm" title={`Edit ${channel.name}`}>
<Pencil aria-hidden="true" size={15} />
</IconButton>
<IconButton disabled size="sm" title={`Troubleshoot ${channel.name}`}>
@@ -2859,10 +2841,6 @@ function ScreenContent({
return <ChannelBuilderScreen />;
}
if (route.id === 'editChannel') {
return <ChannelEditScreen key={window.location.pathname} />;
}
if (route.id === 'guide') {
return <GuideScreen />;
}
@@ -2883,10 +2861,6 @@ function ScreenContent({
return <SettingsScreen />;
}
if (route.id === 'collections') {
return <CollectionsScreen />;
}
return <PlaceholderScreen route={route} />;
}
-92
View File
@@ -1,92 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getChannelById, updateChannel, type UpdateChannelRequest } from './channels';
const sampleChannel = {
id: 5,
number: '5',
name: 'Cartoons',
group: 'ChicoryTV',
categories: '',
fFmpegProfileId: 1,
slugSeconds: null,
logo: { path: '', contentType: '' },
streamSelectorMode: 'Default',
streamSelector: null,
preferredAudioLanguageCode: null,
preferredAudioTitle: null,
playoutSource: 'Generated',
playoutMode: 'Continuous',
mirrorSourceChannelId: null,
playoutOffset: null,
streamingMode: 'TransportStreamHybrid',
watermarkId: null,
fallbackFillerId: null,
playoutCount: 0,
preferredSubtitleLanguageCode: null,
subtitleMode: 'None',
musicVideoCreditsMode: 'None',
musicVideoCreditsTemplate: null,
songVideoMode: 'Default',
transcodeMode: 'OnDemand',
idleBehavior: 'StopOnDisconnect',
isEnabled: true,
showInEpg: true
};
describe('getChannelById', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('GETs the channel by id and returns the view model', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(sampleChannel), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
await expect(getChannelById(5)).resolves.toMatchObject({ id: 5, name: 'Cartoons' });
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/channels/5');
expect((init?.method ?? 'GET').toUpperCase()).toBe('GET');
});
it('rejects with the ApiError status on 404', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ status: 404, title: 'Not Found' }), {
headers: { 'Content-Type': 'application/json' },
status: 404
})
);
await expect(getChannelById(999)).rejects.toMatchObject({ status: 404 });
});
});
describe('updateChannel', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('PUTs the request body to the channel id and returns the updated view model', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ ...sampleChannel, name: 'Renamed' }), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
const body = { ...sampleChannel, name: 'Renamed' } as unknown as UpdateChannelRequest;
await expect(updateChannel(5, body)).resolves.toMatchObject({ name: 'Renamed' });
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/channels/5');
expect((init?.method ?? '').toUpperCase()).toBe('PUT');
expect(JSON.parse(init?.body as string)).toMatchObject({ name: 'Renamed', number: '5' });
});
});
-13
View File
@@ -4,8 +4,6 @@ import type { components } from './generated/v1';
export type ChannelSummary = components['schemas']['ChannelResponseModel'];
export type ChannelState = components['schemas']['ChannelStateResponseModel'];
export type Channel = components['schemas']['ChannelViewModel'];
export type UpdateChannelRequest = components['schemas']['UpdateChannelRequest'];
export type BulkRenumberChannelsRequest = components['schemas']['BulkRenumberChannelsRequest'];
export type BulkMoveChannelsToGroupRequest = components['schemas']['BulkMoveChannelsToGroupRequest'];
export type BulkDeleteChannelsRequest = components['schemas']['BulkDeleteChannelsRequest'];
@@ -35,17 +33,6 @@ export function getChannelStates(): Promise<ChannelState[]> {
return request<ChannelState[]>('/api/channels/state');
}
export function getChannelById(channelId: number): Promise<Channel> {
return request<Channel>(`/api/channels/${channelId}`);
}
export function updateChannel(channelId: number, body: UpdateChannelRequest): Promise<Channel> {
return request<Channel>(`/api/channels/${channelId}`, {
body,
method: 'PUT'
});
}
export async function getChannelsScreenData(): Promise<ChannelsScreenData> {
const [channels, channelStates] = await Promise.all([
getChannels(),
-223
View File
@@ -1,223 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
addItemsToCollection,
createCollection,
createSmartCollection,
deleteCollection,
deleteSmartCollection,
emptyAddItemsRequest,
getCollectionItemsPreview,
getCollections,
getSmartCollections,
removeItemFromCollection,
toAddItemsRequest,
updateCollection,
updateSmartCollection
} from './collections';
import type { LibraryBrowseItem } from './libraryBrowse';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
status
});
}
function noContent(): Response {
return new Response(null, { status: 204 });
}
function browseItem(id: number, mediaType: LibraryBrowseItem['mediaType']): LibraryBrowseItem {
return {
artwork: '',
collectionId: null,
collectionKind: null,
collectionType: 'Collection',
duration: null,
id,
itemCount: null,
libraryId: null,
libraryName: null,
mediaItemId: id,
mediaType,
multiCollectionId: null,
playlistId: null,
rerunCollectionId: null,
smartCollectionId: null,
title: 'Item'
};
}
describe('collections api client', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('getCollections fetches the manual collections list', async () => {
const fetchMock = vi
.spyOn(window, 'fetch')
.mockResolvedValue(jsonResponse([{ id: 1, name: 'Movies', useCustomPlaybackOrder: false }]));
await expect(getCollections()).resolves.toHaveLength(1);
expect(fetchMock).toHaveBeenCalledWith('/api/collections', expect.objectContaining({ method: 'GET' }));
});
it('createCollection POSTs the name and returns the created collection', async () => {
const fetchMock = vi
.spyOn(window, 'fetch')
.mockResolvedValue(jsonResponse({ id: 7, name: 'New', useCustomPlaybackOrder: false }, 201));
await expect(createCollection({ name: 'New' })).resolves.toMatchObject({ id: 7 });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/collections');
expect(init).toMatchObject({ method: 'POST' });
expect(JSON.parse(String(init?.body))).toEqual({ name: 'New' });
});
it('updateCollection PUTs name and useCustomPlaybackOrder', async () => {
const fetchMock = vi
.spyOn(window, 'fetch')
.mockResolvedValue(jsonResponse({ id: 3, name: 'Renamed', useCustomPlaybackOrder: true }));
await updateCollection(3, { name: 'Renamed', useCustomPlaybackOrder: true });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/collections/3');
expect(init).toMatchObject({ method: 'PUT' });
expect(JSON.parse(String(init?.body))).toEqual({ name: 'Renamed', useCustomPlaybackOrder: true });
});
it('deleteCollection issues a DELETE and resolves on 204', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await expect(deleteCollection(9)).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith('/api/collections/9', expect.objectContaining({ method: 'DELETE' }));
});
it('addItemsToCollection POSTs the bucketed request', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
const body = { ...emptyAddItemsRequest(), movieIds: [11, 12] };
await addItemsToCollection(5, body);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/collections/5/items');
expect(init).toMatchObject({ method: 'POST' });
expect(JSON.parse(String(init?.body)).movieIds).toEqual([11, 12]);
});
it('removeItemFromCollection DELETEs the item by media-item id', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await removeItemFromCollection(5, 42);
expect(fetchMock).toHaveBeenCalledWith('/api/collections/5/items/42', expect.objectContaining({ method: 'DELETE' }));
});
it('rethrows API errors (e.g. 422 on delete)', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(
jsonResponse({ status: 422, title: 'In use' }, 422)
);
await expect(deleteCollection(1)).rejects.toMatchObject({ status: 422 });
});
it('getSmartCollections / create / update / delete hit the smart-collection routes', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
const method = (init?.method ?? 'GET').toUpperCase();
if (url === '/api/smart-collections' && method === 'GET') {
return Promise.resolve(jsonResponse([{ id: 1, name: 'Action', query: 'genre:action' }]));
}
if (url === '/api/smart-collections' && method === 'POST') {
return Promise.resolve(jsonResponse({ id: 2, name: 'Sci-Fi', query: 'genre:scifi' }, 201));
}
if (url === '/api/smart-collections/2' && method === 'PUT') {
return Promise.resolve(jsonResponse({ id: 2, name: 'Sci-Fi', query: 'genre:"science fiction"' }));
}
if (url === '/api/smart-collections/2' && method === 'DELETE') {
return Promise.resolve(noContent());
}
throw new Error(`unexpected ${method} ${url}`);
});
await expect(getSmartCollections()).resolves.toHaveLength(1);
await expect(createSmartCollection({ name: 'Sci-Fi', query: 'genre:scifi' })).resolves.toMatchObject({ id: 2 });
await expect(
updateSmartCollection(2, { name: 'Sci-Fi', query: 'genre:"science fiction"' })
).resolves.toMatchObject({ query: 'genre:"science fiction"' });
await expect(deleteSmartCollection(2)).resolves.toBeUndefined();
const createCall = fetchMock.mock.calls.find(([, init]) => (init?.method ?? '').toUpperCase() === 'POST');
expect(JSON.parse(String(createCall?.[1]?.body))).toEqual({ name: 'Sci-Fi', query: 'genre:scifi' });
});
it('getCollectionItemsPreview issues a quoted collection: Lucene query', async () => {
const fetchMock = vi
.spyOn(window, 'fetch')
.mockResolvedValue(jsonResponse({ totalCount: 0, page: [] }));
await getCollectionItemsPreview('The Office');
const url = new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost');
expect(url.pathname).toBe('/api/library/browse');
expect(url.searchParams.get('query')).toBe('collection:"The Office"');
});
it('getCollectionItemsPreview returns [] for a blank name without calling the API', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ totalCount: 0, page: [] }));
await expect(getCollectionItemsPreview(' ')).resolves.toEqual([]);
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe('toAddItemsRequest bucket mapping', () => {
it('routes each browse media type into its own bucket using item.id', () => {
const result = toAddItemsRequest([
browseItem(10, 'Movie'),
browseItem(11, 'Movie'),
browseItem(20, 'TelevisionShow'),
browseItem(30, 'TelevisionSeason'),
browseItem(40, 'Artist')
]);
expect(result.movieIds).toEqual([10, 11]);
expect(result.showIds).toEqual([20]);
expect(result.seasonIds).toEqual([30]);
expect(result.artistIds).toEqual([40]);
});
it('skips kinds that are not addable media items (collections, playlists, etc.)', () => {
const result = toAddItemsRequest([
browseItem(1, 'Collection'),
browseItem(2, 'SmartCollection'),
browseItem(3, 'MultiCollection'),
browseItem(4, 'RerunCollection'),
browseItem(5, 'Playlist'),
browseItem(6, 'Movie')
]);
expect(result.movieIds).toEqual([6]);
expect(result.showIds).toEqual([]);
expect(result.seasonIds).toEqual([]);
expect(result.artistIds).toEqual([]);
});
it('emptyAddItemsRequest leaves the picker-unreachable buckets empty', () => {
const empty = emptyAddItemsRequest();
expect(empty.episodeIds).toEqual([]);
expect(empty.musicVideoIds).toEqual([]);
expect(empty.otherVideoIds).toEqual([]);
expect(empty.songIds).toEqual([]);
expect(empty.imageIds).toEqual([]);
expect(empty.remoteStreamIds).toEqual([]);
});
});
-145
View File
@@ -1,145 +0,0 @@
import { ApiError, request } from './client';
import type { components } from './generated/v1';
import { getLibraryBrowseItems, type LibraryBrowseItem } from './libraryBrowse';
export type MediaCollection = components['schemas']['MediaCollectionViewModel'];
export type SmartCollection = components['schemas']['SmartCollectionViewModel'];
export type CreateCollectionRequest = components['schemas']['CreateCollectionRequest'];
export type UpdateCollectionRequest = components['schemas']['UpdateCollectionRequest'];
export type AddItemsToCollectionRequest = components['schemas']['AddItemsToCollectionRequest'];
export type CreateSmartCollectionRequest = components['schemas']['CreateSmartCollectionRequest'];
export type UpdateSmartCollectionRequest = components['schemas']['UpdateSmartCollectionRequest'];
/* ---------- manual collections ---------- */
export function getCollections(): Promise<MediaCollection[]> {
return request<MediaCollection[]>('/api/collections');
}
export function getCollection(id: number): Promise<MediaCollection> {
return request<MediaCollection>(`/api/collections/${id}`);
}
export function createCollection(body: CreateCollectionRequest): Promise<MediaCollection> {
return request<MediaCollection>('/api/collections', { body, method: 'POST' });
}
export function updateCollection(id: number, body: UpdateCollectionRequest): Promise<MediaCollection> {
return request<MediaCollection>(`/api/collections/${id}`, { body, method: 'PUT' });
}
export function deleteCollection(id: number): Promise<void> {
return request<void>(`/api/collections/${id}`, { method: 'DELETE' });
}
export function addItemsToCollection(id: number, body: AddItemsToCollectionRequest): Promise<void> {
return request<void>(`/api/collections/${id}/items`, { body, method: 'POST' });
}
export function removeItemFromCollection(id: number, mediaItemId: number): Promise<void> {
return request<void>(`/api/collections/${id}/items/${mediaItemId}`, { method: 'DELETE' });
}
/* ---------- smart collections ---------- */
export function getSmartCollections(): Promise<SmartCollection[]> {
return request<SmartCollection[]>('/api/smart-collections');
}
export function getSmartCollection(id: number): Promise<SmartCollection> {
return request<SmartCollection>(`/api/smart-collections/${id}`);
}
export function createSmartCollection(body: CreateSmartCollectionRequest): Promise<SmartCollection> {
return request<SmartCollection>('/api/smart-collections', { body, method: 'POST' });
}
export function updateSmartCollection(id: number, body: UpdateSmartCollectionRequest): Promise<SmartCollection> {
return request<SmartCollection>(`/api/smart-collections/${id}`, { body, method: 'PUT' });
}
export function deleteSmartCollection(id: number): Promise<void> {
return request<void>(`/api/smart-collections/${id}`, { method: 'DELETE' });
}
/* ---------- add-items bucket mapping ---------- */
// The add-items request buckets media-item ids by kind. The library-browse search only
// surfaces four kinds (Movie / TelevisionShow / TelevisionSeason / Artist), so those are
// the only buckets reachable from the picker. The remaining buckets (episodes, music
// videos, songs, images, other videos, remote streams) can't be produced by browse and
// are left empty here. See CollectionsScreen for the honest note about this limit.
export function emptyAddItemsRequest(): AddItemsToCollectionRequest {
return {
artistIds: [],
episodeIds: [],
imageIds: [],
movieIds: [],
musicVideoIds: [],
otherVideoIds: [],
remoteStreamIds: [],
seasonIds: [],
showIds: [],
songIds: []
};
}
// Buckets a set of browse results into an AddItemsToCollectionRequest. Movie / Show /
// Season / Artist are the only kinds library-browse can return as concrete media; each
// carries its media-item id in `id` (which equals `mediaItemId` for these types, since
// they are all MediaItem subclasses). Ids must go into their type-specific bucket, since
// the server validates each bucket against that entity type (a Show id in movieIds fails
// validation). Any other kind (collections, smart/multi/rerun collections, playlists) is
// not an addable media item and is skipped.
export function toAddItemsRequest(items: LibraryBrowseItem[]): AddItemsToCollectionRequest {
const requestBody = emptyAddItemsRequest();
for (const item of items) {
switch (item.mediaType) {
case 'Movie':
requestBody.movieIds?.push(item.id);
break;
case 'TelevisionShow':
requestBody.showIds?.push(item.id);
break;
case 'TelevisionSeason':
requestBody.seasonIds?.push(item.id);
break;
case 'Artist':
requestBody.artistIds?.push(item.id);
break;
default:
break;
}
}
return requestBody;
}
// Best-effort partial listing of a manual collection's members. No API returns a manual
// collection's items by id; the only path is the Lucene `collection:"name"` search field
// via library-browse, which covers Movie / Show / Season / Artist only. Callers must treat
// this as an incomplete preview, never as the authoritative contents.
export async function getCollectionItemsPreview(name: string): Promise<LibraryBrowseItem[]> {
const trimmed = name.trim();
if (!trimmed) {
return [];
}
const escaped = trimmed.replace(/"/g, '\\"');
const result = await getLibraryBrowseItems({ pageSize: 100, query: `collection:"${escaped}"` });
return result.page ?? [];
}
export function messageFromCollectionError(error: unknown, fallback = 'Unable to load collections'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
-1
View File
@@ -3,7 +3,6 @@ export * from './auth';
export * from './channels';
export * from './channelTemplates';
export * from './client';
export * from './collections';
export * from './dashboard';
export * from './guide';
export * from './libraries';
+10 -1
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
import { getCollections, getSmartCollections, type MediaCollection, type SmartCollection } from './collections';
import { getFillerPresets, getWatermarks } from './pickers';
import type { FillerPreset, Watermark } from './pickers';
@@ -22,6 +21,8 @@ export type ProgramScheduleItem = components['schemas']['ProgramScheduleItemView
export type ProgramScheduleItemsWithDuration = components['schemas']['ProgramScheduleItemsWithDurationViewModel'];
export type ScheduleItemRequest = components['schemas']['ScheduleItemRequest'];
export type ReplaceScheduleItemsRequest = components['schemas']['ReplaceScheduleItemsRequest'];
export type MediaCollection = components['schemas']['MediaCollectionViewModel'];
export type SmartCollection = components['schemas']['SmartCollectionViewModel'];
export type { FillerPreset, Watermark };
export interface SchedulePickerData {
@@ -65,6 +66,14 @@ export function getScheduleItems(scheduleId: number): Promise<ProgramScheduleIte
return request<ProgramScheduleItemsWithDuration>(`/api/schedules/${scheduleId}/items`);
}
export function getCollections(): Promise<MediaCollection[]> {
return request<MediaCollection[]>('/api/collections');
}
export function getSmartCollections(): Promise<SmartCollection[]> {
return request<SmartCollection[]>('/api/smart-collections');
}
export function addScheduleItem(scheduleId: number, body: ScheduleItemRequest): Promise<ProgramScheduleItem> {
return request<ProgramScheduleItem>(`/api/schedules/${scheduleId}/items`, {
body,
-161
View File
@@ -1,161 +0,0 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ChannelEditScreen } from './ChannelEditScreen';
const channel = {
id: 5,
number: '5',
name: 'Cartoons',
group: 'ChicoryTV',
categories: 'Kids',
fFmpegProfileId: 1,
slugSeconds: null,
logo: { path: '', contentType: '' },
streamSelectorMode: 'Default',
streamSelector: null,
preferredAudioLanguageCode: null,
preferredAudioTitle: null,
playoutSource: 'Generated',
playoutMode: 'Continuous',
mirrorSourceChannelId: null,
playoutOffset: null,
streamingMode: 'TransportStreamHybrid',
watermarkId: null,
fallbackFillerId: null,
playoutCount: 0,
preferredSubtitleLanguageCode: null,
subtitleMode: 'None',
musicVideoCreditsMode: 'None',
musicVideoCreditsTemplate: null,
songVideoMode: 'Default',
transcodeMode: 'OnDemand',
idleBehavior: 'StopOnDisconnect',
isEnabled: true,
showInEpg: true
};
// The PUT body is the GET response minus the read-only id / playoutCount fields.
function expectedPutBody(overrides: Record<string, unknown> = {}): Record<string, unknown> {
const { id, playoutCount, ...rest } = channel;
void id;
void playoutCount;
return { ...rest, ...overrides };
}
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
status
});
}
interface FetchOptions {
channelStatus?: number;
onPut?: (body: unknown) => void;
}
function mockApi({ channelStatus = 200, onPut }: FetchOptions = {}) {
return vi.spyOn(window, 'fetch').mockImplementation((input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url;
const method = (init?.method ?? 'GET').toUpperCase();
if (url === '/api/channels/5' && method === 'PUT') {
onPut?.(JSON.parse(init?.body as string));
return Promise.resolve(json({ ...channel, name: 'Renamed' }));
}
if (url === '/api/channels/5') {
return channelStatus === 200
? Promise.resolve(json(channel))
: Promise.resolve(json({ status: channelStatus, title: 'Not Found' }, channelStatus));
}
if (url === '/api/ffmpeg/profiles') {
return Promise.resolve(json([{ id: 1, name: 'Default profile' }]));
}
if (url === '/api/watermarks') {
return Promise.resolve(json([{ id: 2, name: 'Corner bug' }]));
}
if (url === '/api/filler-presets') {
return Promise.resolve(json([{ id: 3, name: 'Bumpers' }]));
}
if (url === '/api/channels') {
return Promise.resolve(json([{ id: 5, number: '5', name: 'Cartoons', group: 'ChicoryTV' }]));
}
return Promise.resolve(json({ status: 404, title: 'Not Found' }, 404));
});
}
describe('ChannelEditScreen', () => {
beforeEach(() => {
window.localStorage.clear();
window.history.replaceState(null, '', '/app/edit-channel/5');
vi.restoreAllMocks();
});
afterEach(() => {
cleanup();
window.history.replaceState(null, '', '/');
});
it('hydrates the form from the loaded channel', async () => {
mockApi();
render(<ChannelEditScreen />);
expect(await screen.findByDisplayValue('Cartoons')).toBeInTheDocument();
expect(screen.getByDisplayValue('5')).toBeInTheDocument();
expect(screen.getByDisplayValue('Kids')).toBeInTheDocument();
});
it('saves an edited field with a PUT carrying the updated body', async () => {
const puts: unknown[] = [];
mockApi({ onPut: (body) => puts.push(body) });
render(<ChannelEditScreen />);
const nameInput = await screen.findByDisplayValue('Cartoons');
fireEvent.change(nameInput, { target: { value: 'Renamed' } });
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
fireEvent.click(saveButton);
await waitFor(() => expect(puts).toHaveLength(1));
expect(puts[0]).toEqual(expectedPutBody({ name: 'Renamed' }));
expect(await screen.findByText('Channel saved')).toBeInTheDocument();
});
it('forces showInEpg off in the PUT body when Enabled is turned off', async () => {
const puts: unknown[] = [];
mockApi({ onPut: (body) => puts.push(body) });
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
// The General pane renders exactly two switches, in document order: Enabled, then Show in EPG.
const [enabledSwitch, showInEpgSwitch] = screen.getAllByRole('switch');
fireEvent.click(enabledSwitch);
expect(showInEpgSwitch).toHaveAttribute('aria-checked', 'false');
expect(showInEpgSwitch).toHaveAttribute('aria-disabled', 'true');
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
fireEvent.click(saveButton);
await waitFor(() => expect(puts).toHaveLength(1));
expect(puts[0]).toEqual(expectedPutBody({ isEnabled: false, showInEpg: false }));
});
it('shows an error state when the channel is not found', async () => {
mockApi({ channelStatus: 404 });
render(<ChannelEditScreen />);
expect(await screen.findByText('Channel not found.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Back to channels' })).toBeInTheDocument();
});
});
-903
View File
@@ -1,903 +0,0 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import {
AudioLines,
Check,
CircleCheck,
Image as ImageIcon,
ListVideo,
Music,
SlidersHorizontal,
Tv,
TriangleAlert,
Upload
} from 'lucide-react';
import { navigateToPath } from '../routing';
import { Badge, Button, Card, ChannelLogo, Input, Select, Switch } from '../components';
import {
ApiError,
getChannelById,
getChannels,
getFFmpegProfiles,
getFillerPresets,
getWatermarks,
messageFromError,
updateChannel,
uploadArtwork,
type Channel,
type ChannelSummary,
type FFmpegProfile,
type FillerPreset,
type UpdateChannelRequest,
type Watermark
} from '../api';
const EDIT_BASE_PATH = '/app/edit-channel';
type SectionId = 'general' | 'playout' | 'streaming' | 'selection' | 'music' | 'branding';
interface SectionDef {
hint: string;
icon: ReactNode;
id: SectionId;
label: string;
}
const SECTIONS: SectionDef[] = [
{ hint: 'Number, name, group', icon: <Tv aria-hidden="true" size={16} />, id: 'general', label: 'General' },
{ hint: 'Source & scheduling', icon: <ListVideo aria-hidden="true" size={16} />, id: 'playout', label: 'Playout' },
{ hint: 'FFmpeg & output', icon: <SlidersHorizontal aria-hidden="true" size={16} />, id: 'streaming', label: 'Streaming' },
{ hint: 'Audio & subtitles', icon: <AudioLines aria-hidden="true" size={16} />, id: 'selection', label: 'Stream selection' },
{ hint: 'Credits & songs', icon: <Music aria-hidden="true" size={16} />, id: 'music', label: 'Music video' },
{ hint: 'Logo & watermark', icon: <ImageIcon aria-hidden="true" size={16} />, id: 'branding', label: 'Branding' }
];
const SLUG_SECONDS_OPTIONS = [
{ label: '(none)', value: '' },
{ label: '0.5 seconds', value: '0.5' },
{ label: '1 second', value: '1' },
{ label: '2 seconds', value: '2' },
{ label: '3 seconds', value: '3' },
{ label: '5 seconds', value: '5' }
];
// Server serializes TimeSpan with the constant ("c") format: [-][d.]hh:mm:ss. The Blazor
// editor exposes the offset as whole hours, so we translate to/from that here. Hours >= 24
// must use the days component, or the value fails to round-trip on the wire.
function timeSpanToHours(value: null | string | undefined): number {
if (!value) {
return 0;
}
const negative = value.startsWith('-');
const body = negative ? value.slice(1) : value;
const dotIndex = body.indexOf('.');
const colonIndex = body.indexOf(':');
let days = 0;
let rest = body;
if (dotIndex !== -1 && (colonIndex === -1 || dotIndex < colonIndex)) {
days = Number(body.slice(0, dotIndex));
rest = body.slice(dotIndex + 1);
}
const hours = Number(rest.split(':')[0] ?? '0');
const total = days * 24 + hours;
return negative ? -total : total;
}
function hoursToTimeSpan(hours: number): null | string {
if (!Number.isFinite(hours) || hours === 0) {
return null;
}
const negative = hours < 0;
const abs = Math.abs(Math.trunc(hours));
const days = Math.floor(abs / 24);
const rem = abs % 24;
const core = days > 0
? `${days}.${String(rem).padStart(2, '0')}:00:00`
: `${rem}:00:00`;
return `${negative ? '-' : ''}${core}`;
}
function channelIdFromPathname(pathname: string): number | null {
const normalized = pathname.replace(/\/+$/, '');
if (!normalized.startsWith(`${EDIT_BASE_PATH}/`)) {
return null;
}
const id = Number(normalized.slice(EDIT_BASE_PATH.length + 1).split('/')[0]);
return Number.isInteger(id) && id > 0 ? id : null;
}
// The PUT body is the GET response minus the read-only id / playoutCount / webEncodedName
// fields, so hydration is a straight field copy.
function draftFromChannel(channel: Channel): UpdateChannelRequest {
return {
name: channel.name,
number: channel.number,
group: channel.group,
categories: channel.categories,
fFmpegProfileId: channel.fFmpegProfileId,
slugSeconds: channel.slugSeconds,
logo: channel.logo,
streamSelectorMode: channel.streamSelectorMode,
streamSelector: channel.streamSelector,
preferredAudioLanguageCode: channel.preferredAudioLanguageCode,
preferredAudioTitle: channel.preferredAudioTitle,
playoutSource: channel.playoutSource,
playoutMode: channel.playoutMode,
mirrorSourceChannelId: channel.mirrorSourceChannelId,
playoutOffset: channel.playoutOffset,
streamingMode: channel.streamingMode,
watermarkId: channel.watermarkId,
fallbackFillerId: channel.fallbackFillerId,
preferredSubtitleLanguageCode: channel.preferredSubtitleLanguageCode,
subtitleMode: channel.subtitleMode,
musicVideoCreditsMode: channel.musicVideoCreditsMode,
musicVideoCreditsTemplate: channel.musicVideoCreditsTemplate,
songVideoMode: channel.songVideoMode,
transcodeMode: channel.transcodeMode,
idleBehavior: channel.idleBehavior,
isEnabled: channel.isEnabled,
showInEpg: channel.showInEpg
};
}
function numberOrEmpty(value: null | number | undefined): string {
return value == null ? '' : String(value);
}
function sortByChannelNumber(channels: ChannelSummary[]): ChannelSummary[] {
return [...channels].sort((left, right) => {
const leftNumber = Number.parseFloat(left.number);
const rightNumber = Number.parseFloat(right.number);
if (Number.isNaN(leftNumber) || Number.isNaN(rightNumber)) {
return left.number.localeCompare(right.number, undefined, { numeric: true });
}
return leftNumber - rightNumber;
});
}
/* ---------- shared row primitives (mirror SettingsScreen) ---------- */
function Row({
children,
control = 300,
first = false,
help,
label
}: {
children: ReactNode;
control?: number;
first?: boolean;
help?: string;
label: string;
}) {
return (
<div className="ctv-settings-row" style={first ? { borderTop: 'none' } : undefined}>
<div className="ctv-settings-row-main">
<div className="ctv-settings-row-label">{label}</div>
{help && <div className="ctv-settings-row-help">{help}</div>}
</div>
<div className="ctv-settings-row-control" style={{ flex: `0 0 ${control}px` }}>
{children}
</div>
</div>
);
}
function Pane({ children, subtitle, title }: { children: ReactNode; subtitle: string; title: string }) {
return (
<div className="ctv-settings-pane">
<div className="ctv-settings-pane-header">
<div className="ctv-settings-pane-title">{title}</div>
<div className="ctv-settings-pane-subtitle">{subtitle}</div>
</div>
{children}
</div>
);
}
interface ReferenceData {
channels: ChannelSummary[];
fillerPresets: FillerPreset[];
watermarks: Watermark[];
ffmpegProfiles: FFmpegProfile[];
}
/* ---------- panes ---------- */
function GeneralPane({
draft,
set
}: {
draft: UpdateChannelRequest;
set: (patch: Partial<UpdateChannelRequest>) => void;
}) {
return (
<Pane subtitle="Identity shown in the lineup, guide and IPTV playlist." title="General">
<Card padded={false}>
<Row control={200} first help="Displayed channel number, e.g. 5 or 5.1." label="Number">
<Input
error={draft.number?.trim() ? null : 'Number is required'}
fullWidth
onChange={(event) => set({ number: event.target.value })}
size="sm"
value={draft.number ?? ''}
/>
</Row>
<Row help="Channel display name." label="Name">
<Input
error={draft.name?.trim() ? null : 'Name is required'}
fullWidth
onChange={(event) => set({ name: event.target.value })}
size="sm"
value={draft.name ?? ''}
/>
</Row>
<Row help="Groups channels in clients that support it." label="Group">
<Input fullWidth onChange={(event) => set({ group: event.target.value })} size="sm" value={draft.group ?? ''} />
</Row>
<Row help="Comma-separated list of categories." label="Categories">
<Input
fullWidth
onChange={(event) => set({ categories: event.target.value })}
placeholder="News, Sports"
size="sm"
value={draft.categories ?? ''}
/>
</Row>
<Row help="Whether this channel is served to clients." label="Enabled">
<Switch
checked={draft.isEnabled}
onChange={(next) => set(next ? { isEnabled: next } : { isEnabled: next, showInEpg: false })}
size="sm"
/>
</Row>
<Row help="Include this channel in the XMLTV guide." label="Show in EPG">
<Switch
checked={draft.showInEpg}
disabled={!draft.isEnabled}
onChange={(next) => set({ showInEpg: next })}
size="sm"
/>
</Row>
</Card>
</Pane>
);
}
function PlayoutPane({
channelId,
data,
draft,
playoutLocked,
set
}: {
channelId: number;
data: ReferenceData;
draft: UpdateChannelRequest;
playoutLocked: boolean;
set: (patch: Partial<UpdateChannelRequest>) => void;
}) {
const mirrorOptions = [
{ label: '(none)', value: '' },
...sortByChannelNumber(data.channels.filter((channel) => channel.id !== channelId)).map((channel) => ({
label: `(${channel.number}) - ${channel.name}`,
value: String(channel.id)
}))
];
return (
<Pane subtitle="Where the channel's content comes from and how it progresses." title="Playout">
<Card padded={false}>
<Row
first
help={
playoutLocked
? 'Cannot be changed once a generated channel has a playout.'
: 'Generated builds its own schedule; Mirror follows another channel.'
}
label="Playout source"
>
<Select
disabled={playoutLocked}
fullWidth
onChange={(event) => set({ playoutSource: event.target.value as UpdateChannelRequest['playoutSource'] })}
options={[
{ label: 'Generated', value: 'Generated' },
{ label: 'Mirror', value: 'Mirror' }
]}
size="sm"
value={draft.playoutSource}
/>
</Row>
{draft.playoutSource === 'Mirror' ? (
<>
<Row help="The generated channel this channel mirrors." label="Mirror source channel">
<Select
fullWidth
onChange={(event) =>
set({ mirrorSourceChannelId: event.target.value === '' ? null : Number(event.target.value) })
}
options={mirrorOptions}
size="sm"
value={draft.mirrorSourceChannelId == null ? '' : String(draft.mirrorSourceChannelId)}
/>
</Row>
<Row control={160} help="Shift the mirrored playout by this many hours." label="Playout offset">
<Input
fullWidth
onChange={(event) => set({ playoutOffset: hoursToTimeSpan(Number(event.target.value)) })}
size="sm"
style={{ fontFamily: 'var(--font-mono)' }}
trailing={<span className="ctv-settings-unit">hours</span>}
type="number"
value={String(timeSpanToHours(draft.playoutOffset))}
/>
</Row>
</>
) : (
<Row help="Controls how the generated playout progresses." label="Playout mode">
<Select
fullWidth
onChange={(event) => set({ playoutMode: event.target.value as UpdateChannelRequest['playoutMode'] })}
options={[
{ label: 'Continuous', value: 'Continuous' },
{ label: 'On Demand', value: 'OnDemand' }
]}
size="sm"
value={draft.playoutMode}
/>
</Row>
)}
<Row help="How the transcoder behaves once all clients disconnect." label="Idle behavior">
<Select
fullWidth
onChange={(event) => set({ idleBehavior: event.target.value as UpdateChannelRequest['idleBehavior'] })}
options={[
{ label: 'Stop On Disconnect', value: 'StopOnDisconnect' },
{ label: 'Keep Running', value: 'KeepRunning' }
]}
size="sm"
value={draft.idleBehavior}
/>
</Row>
<Row help="When the transcoding process is active. Only On Demand is supported." label="Transcode mode">
<Select disabled fullWidth options={[{ label: 'On Demand', value: 'OnDemand' }]} size="sm" value={draft.transcodeMode} />
</Row>
</Card>
</Pane>
);
}
function StreamingPane({
data,
draft,
set
}: {
data: ReferenceData;
draft: UpdateChannelRequest;
set: (patch: Partial<UpdateChannelRequest>) => void;
}) {
const hlsDirect = draft.streamingMode === 'HttpLiveStreamingDirect';
return (
<Pane subtitle="Output container and the transcoding profile applied to this channel." title="Streaming">
<Card padded={false}>
<Row first help="Delivery container / protocol served to clients." label="Streaming mode">
<Select
fullWidth
onChange={(event) => set({ streamingMode: event.target.value as UpdateChannelRequest['streamingMode'] })}
options={[
{ label: 'MPEG-TS', value: 'TransportStreamHybrid' },
{ label: 'MPEG-TS (Legacy)', value: 'TransportStream' },
{ label: 'HLS Direct', value: 'HttpLiveStreamingDirect' },
{ label: 'HLS Segmenter', value: 'HttpLiveStreamingSegmenter' }
]}
size="sm"
value={draft.streamingMode}
/>
</Row>
<Row help={hlsDirect ? 'Not used in HLS Direct mode.' : 'Transcoding preset for this channel.'} label="FFmpeg profile">
<Select
disabled={hlsDirect}
fullWidth
onChange={(event) => set({ fFmpegProfileId: Number(event.target.value) })}
options={data.ffmpegProfiles.map((profile) => ({
label: profile.name ?? `Profile ${profile.id}`,
value: String(profile.id)
}))}
size="sm"
value={String(draft.fFmpegProfileId)}
/>
</Row>
<Row help="Black video / silent audio inserted between every playout item." label="Slug seconds">
<Select
fullWidth
onChange={(event) => set({ slugSeconds: event.target.value === '' ? null : Number(event.target.value) })}
options={SLUG_SECONDS_OPTIONS}
size="sm"
value={numberOrEmpty(draft.slugSeconds)}
/>
</Row>
</Card>
</Pane>
);
}
function SelectionPane({
draft,
set
}: {
draft: UpdateChannelRequest;
set: (patch: Partial<UpdateChannelRequest>) => void;
}) {
const isDefault = draft.streamSelectorMode === 'Default';
return (
<Pane subtitle="How audio and subtitle tracks are chosen for playback." title="Stream selection">
<Card padded={false}>
<Row first help="Default picks tracks by preference; Custom uses a selector script." label="Stream selector mode">
<Select
fullWidth
onChange={(event) =>
set({ streamSelectorMode: event.target.value as UpdateChannelRequest['streamSelectorMode'] })
}
options={[
{ label: 'Default', value: 'Default' },
{ label: 'Custom', value: 'Custom' }
]}
size="sm"
value={draft.streamSelectorMode}
/>
</Row>
{isDefault ? (
<>
<Row help="ISO 639-2 code, e.g. eng. Blank keeps the file order." label="Preferred audio language">
<Input
fullWidth
onChange={(event) => set({ preferredAudioLanguageCode: event.target.value || null })}
placeholder="eng"
size="sm"
style={{ fontFamily: 'var(--font-mono)' }}
value={draft.preferredAudioLanguageCode ?? ''}
/>
</Row>
<Row help="Prefer an audio track whose title contains this text." label="Preferred audio title">
<Input
fullWidth
onChange={(event) => set({ preferredAudioTitle: event.target.value || null })}
size="sm"
value={draft.preferredAudioTitle ?? ''}
/>
</Row>
<Row help="ISO 639-2 code, e.g. eng. Blank disables preference." label="Preferred subtitle language">
<Input
fullWidth
onChange={(event) => set({ preferredSubtitleLanguageCode: event.target.value || null })}
placeholder="eng"
size="sm"
style={{ fontFamily: 'var(--font-mono)' }}
value={draft.preferredSubtitleLanguageCode ?? ''}
/>
</Row>
<Row help="When subtitle tracks are burned in." label="Subtitle mode">
<Select
fullWidth
onChange={(event) => set({ subtitleMode: event.target.value as UpdateChannelRequest['subtitleMode'] })}
options={[
{ label: 'None', value: 'None' },
{ label: 'Forced', value: 'Forced' },
{ label: 'Default', value: 'Default' },
{ label: 'Any', value: 'Any' }
]}
size="sm"
value={draft.subtitleMode}
/>
</Row>
</>
) : (
<Row help="Name of a custom stream-selector script in the config folder." label="Stream selector">
<Input
fullWidth
onChange={(event) => set({ streamSelector: event.target.value || null })}
size="sm"
style={{ fontFamily: 'var(--font-mono)' }}
value={draft.streamSelector ?? ''}
/>
</Row>
)}
</Card>
</Pane>
);
}
function MusicPane({
draft,
set
}: {
draft: UpdateChannelRequest;
set: (patch: Partial<UpdateChannelRequest>) => void;
}) {
const creditsOn = draft.musicVideoCreditsMode === 'GenerateSubtitles';
return (
<Pane subtitle="Overlays and progress behavior for music video and song content." title="Music video">
<Card padded={false}>
<Row first help="Generate on-screen credits for music videos." label="Credits mode">
<Select
fullWidth
onChange={(event) =>
set({ musicVideoCreditsMode: event.target.value as UpdateChannelRequest['musicVideoCreditsMode'] })
}
options={[
{ label: 'None', value: 'None' },
{ label: 'Generate Subtitles', value: 'GenerateSubtitles' }
]}
size="sm"
value={draft.musicVideoCreditsMode}
/>
</Row>
<Row help="Name of the credits template. Only used when credits are generated." label="Credits template">
<Input
disabled={!creditsOn}
fullWidth
onChange={(event) => set({ musicVideoCreditsTemplate: event.target.value || null })}
size="sm"
style={{ fontFamily: 'var(--font-mono)' }}
value={draft.musicVideoCreditsTemplate ?? ''}
/>
</Row>
<Row help="Optional progress bar overlay for song content." label="Song video mode">
<Select
fullWidth
onChange={(event) => set({ songVideoMode: event.target.value as UpdateChannelRequest['songVideoMode'] })}
options={[
{ label: 'Default', value: 'Default' },
{ label: 'With Progress', value: 'WithProgress' }
]}
size="sm"
value={draft.songVideoMode}
/>
</Row>
</Card>
</Pane>
);
}
function BrandingPane({
data,
draft,
logoPreview,
onUploadLogo,
set,
uploadError,
uploading
}: {
data: ReferenceData;
draft: UpdateChannelRequest;
logoPreview: string | null;
onUploadLogo: (file: File) => void;
set: (patch: Partial<UpdateChannelRequest>) => void;
uploadError: string | null;
uploading: boolean;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
const hlsDirect = draft.streamingMode === 'HttpLiveStreamingDirect';
const existingLogo = draft.logo?.urlWithContentType ?? draft.logo?.path ?? null;
const previewSrc = logoPreview ?? existingLogo;
return (
<Pane subtitle="Channel logo and the overlays applied while streaming." title="Branding">
<Card padded={false}>
<Row control={340} first help="Shown in the guide and as the on-screen bug." label="Logo">
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<ChannelLogo name={draft.name ?? ''} size={48} src={previewSrc} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<Button
loading={uploading}
onClick={() => fileInputRef.current?.click()}
size="sm"
startIcon={<Upload aria-hidden="true" size={14} />}
variant="secondary"
>
Upload logo
</Button>
{uploadError && (
<span className="ctv-field-error" role="alert">
{uploadError}
</span>
)}
</div>
<input
accept="image/*"
onChange={(event) => {
const file = event.target.files && event.target.files[0];
if (file) {
onUploadLogo(file);
}
event.target.value = '';
}}
ref={fileInputRef}
style={{ display: 'none' }}
type="file"
/>
</div>
</Row>
<Row help={hlsDirect ? 'Not used in HLS Direct mode.' : 'Overlay applied to the channel.'} label="Watermark">
<Select
disabled={hlsDirect}
fullWidth
onChange={(event) => set({ watermarkId: event.target.value === '' ? null : Number(event.target.value) })}
options={[
{ label: '(none)', value: '' },
...data.watermarks.map((watermark) => ({
label: watermark.name ?? `Watermark ${watermark.id}`,
value: String(watermark.id)
}))
]}
size="sm"
value={draft.watermarkId == null ? '' : String(draft.watermarkId)}
/>
</Row>
<Row help="Plays when the playout has nothing scheduled." label="Fallback filler">
<Select
fullWidth
onChange={(event) => set({ fallbackFillerId: event.target.value === '' ? null : Number(event.target.value) })}
options={[
{ label: '(none)', value: '' },
...data.fillerPresets.map((preset) => ({
label: preset.name ?? `Filler ${preset.id}`,
value: String(preset.id)
}))
]}
size="sm"
value={draft.fallbackFillerId == null ? '' : String(draft.fallbackFillerId)}
/>
</Row>
</Card>
</Pane>
);
}
/* ---------- screen ---------- */
interface LoadedState {
channelId: number;
data: ReferenceData;
playoutLocked: boolean;
}
export function ChannelEditScreen() {
const [channelId] = useState<number | null>(() => channelIdFromPathname(window.location.pathname));
const [loaded, setLoaded] = useState<LoadedState | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [draft, setDraft] = useState<UpdateChannelRequest | null>(null);
const [saved, setSaved] = useState<UpdateChannelRequest | null>(null);
const [section, setSection] = useState<SectionId>('general');
const [saving, setSaving] = useState(false);
const [justSaved, setJustSaved] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null);
useEffect(() => {
if (channelId === null) {
return;
}
let active = true;
Promise.all([getChannelById(channelId), getFFmpegProfiles(), getWatermarks(), getFillerPresets(), getChannels()])
.then(([channel, ffmpegProfiles, watermarks, fillerPresets, channels]) => {
if (!active) {
return;
}
const initial = draftFromChannel(channel);
setDraft(initial);
setSaved(initial);
setLoaded({
channelId,
data: { channels, ffmpegProfiles, fillerPresets, watermarks },
playoutLocked: channel.playoutSource === 'Generated' && channel.playoutCount > 0
});
})
.catch((error: unknown) => {
if (!active) {
return;
}
if (error instanceof ApiError && error.status === 404) {
setLoadError('Channel not found.');
} else {
setLoadError(messageFromError(error));
}
});
return () => {
active = false;
};
}, [channelId]);
const effectiveError = loadError ?? (channelId === null ? 'Invalid channel.' : null);
if (effectiveError) {
return (
<div className="ctv-settings-error" role="alert">
<TriangleAlert aria-hidden="true" size={16} />
<span>{effectiveError}</span>
<Button onClick={() => navigateToPath('/app/channels')} size="sm" variant="secondary">
Back to channels
</Button>
</div>
);
}
if (loaded === null || draft === null || saved === null) {
return (
<div className="ctv-settings-loading">
<span>Loading channel</span>
</div>
);
}
const set = (patch: Partial<UpdateChannelRequest>) => {
setJustSaved(false);
setDraft((current) => (current ? { ...current, ...patch } : current));
};
const uploadLogo = async (file: File) => {
if (!file.type.startsWith('image/')) {
setUploadError('Choose an image file.');
return;
}
setUploading(true);
setUploadError(null);
try {
const uploaded = await uploadArtwork(file, 'logo');
setJustSaved(false);
setDraft((current) =>
current ? { ...current, logo: { path: uploaded.path, contentType: uploaded.contentType } } : current
);
const reader = new FileReader();
reader.onload = () => setLogoPreview(typeof reader.result === 'string' ? reader.result : null);
reader.readAsDataURL(file);
} catch (error) {
setUploadError(messageFromError(error));
} finally {
setUploading(false);
}
};
const nameValid = Boolean(draft.name?.trim());
const numberValid = Boolean(draft.number?.trim());
const valid = nameValid && numberValid;
const dirty = JSON.stringify(draft) !== JSON.stringify(saved);
const discard = () => {
setDraft(saved);
setLogoPreview(null);
setSaveError(null);
};
const save = async () => {
if (!valid || !dirty || saving) {
return;
}
setSaving(true);
setSaveError(null);
try {
const updated = await updateChannel(loaded.channelId, draft);
const next = draftFromChannel(updated);
setSaved(next);
setDraft(next);
setLogoPreview(null);
setJustSaved(true);
window.setTimeout(() => setJustSaved(false), 1800);
} catch (error) {
setSaveError(messageFromError(error));
} finally {
setSaving(false);
}
};
return (
<div className="ctv-settings">
<nav aria-label="Channel sections" className="ctv-settings-rail">
{SECTIONS.map((entry) => {
const active = entry.id === section;
return (
<button
aria-current={active ? 'true' : undefined}
className={`ctv-settings-rail-item ctv-press${active ? ' ctv-settings-rail-item-active' : ''}`}
key={entry.id}
onClick={() => setSection(entry.id)}
type="button"
>
<span className="ctv-settings-rail-icon" style={{ color: active ? 'var(--ctv-accent)' : 'var(--text-secondary)' }}>
{entry.icon}
</span>
<span className="ctv-settings-rail-text">
<span className="ctv-settings-rail-label" style={{ color: active ? 'var(--text-primary)' : 'var(--text-secondary)' }}>
{entry.label}
</span>
<span className="ctv-settings-rail-hint">{entry.hint}</span>
</span>
</button>
);
})}
</nav>
<div className="ctv-settings-body">
<div className="ctv-settings-scroll">
{section === 'general' && <GeneralPane draft={draft} set={set} />}
{section === 'playout' && (
<PlayoutPane channelId={loaded.channelId} data={loaded.data} draft={draft} playoutLocked={loaded.playoutLocked} set={set} />
)}
{section === 'streaming' && <StreamingPane data={loaded.data} draft={draft} set={set} />}
{section === 'selection' && <SelectionPane draft={draft} set={set} />}
{section === 'music' && <MusicPane draft={draft} set={set} />}
{section === 'branding' && (
<BrandingPane
data={loaded.data}
draft={draft}
logoPreview={logoPreview}
onUploadLogo={(file) => void uploadLogo(file)}
set={set}
uploadError={uploadError}
uploading={uploading}
/>
)}
</div>
{(dirty || justSaved) && (
<div className="ctv-settings-savebar-wrap">
<div className="ctv-settings-savebar ctv-lift">
{justSaved ? (
<>
<CircleCheck aria-hidden="true" color="var(--status-ok)" size={15} />
<span className="ctv-settings-savebar-text">Channel saved</span>
</>
) : (
<>
<span className="ctv-settings-savebar-text">Unsaved changes</span>
{!valid && <Badge tone="neutral">Name and number are required</Badge>}
{saveError && <span className="ctv-settings-savebar-error">{saveError}</span>}
<span className="ctv-settings-savebar-divider" />
<Button onClick={discard} size="sm" variant="ghost">
Discard
</Button>
<Button
disabled={!valid || saving}
loading={saving}
onClick={() => void save()}
size="sm"
startIcon={<Check aria-hidden="true" size={14} />}
variant="primary"
>
Save changes
</Button>
</>
)}
</div>
</div>
)}
</div>
</div>
);
}
-268
View File
@@ -1,268 +0,0 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CollectionsScreen } from './CollectionsScreen';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
status
});
}
const manualCollections = [
{ collectionType: 'Collection', id: 1, name: 'Favorites', state: 'Normal', useCustomPlaybackOrder: false },
{ collectionType: 'Collection', id: 2, name: 'Kids', state: 'Normal', useCustomPlaybackOrder: true }
];
const smartCollections = [{ id: 10, name: 'Action', query: 'genre:action' }];
interface MockOptions {
manual?: unknown[];
smart?: unknown[];
onRequest?: (url: string, method: string, body: unknown) => Response | null;
}
function mockApi(options: MockOptions = {}) {
const manual = options.manual ?? manualCollections;
const smart = options.smart ?? smartCollections;
return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
const method = (init?.method ?? 'GET').toUpperCase();
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
if (options.onRequest) {
const override = options.onRequest(url, method, body);
if (override) {
return Promise.resolve(override);
}
}
if (url === '/api/collections' && method === 'GET') {
return Promise.resolve(jsonResponse(manual));
}
if (url === '/api/smart-collections' && method === 'GET') {
return Promise.resolve(jsonResponse(smart));
}
if (url.startsWith('/api/library/browse')) {
return Promise.resolve(jsonResponse({ page: [], totalCount: 0 }));
}
return Promise.resolve(new Response(null, { status: 204 }));
});
}
describe('CollectionsScreen', () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
beforeEach(() => {
window.localStorage.clear();
});
it('renders both manual and smart collections', async () => {
mockApi();
render(<CollectionsScreen />);
expect(await screen.findByText('Favorites')).toBeInTheDocument();
expect(screen.getByText('Kids')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Smart/ }));
expect(await screen.findByText('Action')).toBeInTheDocument();
expect(screen.getByText('genre:action')).toBeInTheDocument();
});
it('shows an error state when loading fails', async () => {
mockApi({
onRequest: (url) => (url === '/api/collections' ? jsonResponse({ status: 500, title: 'Boom' }, 500) : null)
});
render(<CollectionsScreen />);
expect(await screen.findByText('Boom')).toBeInTheDocument();
});
it('creates a manual collection via POST', async () => {
const fetchMock = mockApi({
onRequest: (url, method) =>
url === '/api/collections' && method === 'POST'
? jsonResponse({ collectionType: 'Collection', id: 3, name: 'New One', state: 'Normal', useCustomPlaybackOrder: false }, 201)
: null
});
render(<CollectionsScreen />);
await screen.findByText('Favorites');
fireEvent.click(screen.getByRole('button', { name: 'New collection' }));
const dialog = screen.getByRole('dialog');
fireEvent.change(within(dialog).getByPlaceholderText('Collection name'), { target: { value: 'New One' } });
fireEvent.click(within(dialog).getByRole('button', { name: 'Create' }));
await waitFor(() => {
expect(
fetchMock.mock.calls.some(
([u, init]) => u === '/api/collections' && (init?.method ?? '').toUpperCase() === 'POST'
)
).toBe(true);
});
const postCall = fetchMock.mock.calls.find(
([u, init]) => u === '/api/collections' && (init?.method ?? '').toUpperCase() === 'POST'
);
expect(JSON.parse(String(postCall?.[1]?.body))).toEqual({ name: 'New One' });
});
it('renames a manual collection via PUT', async () => {
const fetchMock = mockApi({
onRequest: (url, method) =>
url === '/api/collections/1' && method === 'PUT'
? jsonResponse({ collectionType: 'Collection', id: 1, name: 'Renamed', state: 'Normal', useCustomPlaybackOrder: false })
: null
});
render(<CollectionsScreen />);
await screen.findByText('Favorites');
fireEvent.click(screen.getAllByRole('button', { name: 'Rename' })[0]);
const dialog = screen.getByRole('dialog');
const input = within(dialog).getByPlaceholderText('Collection name') as HTMLInputElement;
expect(input.value).toBe('Favorites');
fireEvent.change(input, { target: { value: 'Renamed' } });
fireEvent.click(within(dialog).getByRole('button', { name: 'Save' }));
await waitFor(() => {
const putCall = fetchMock.mock.calls.find(
([u, init]) => u === '/api/collections/1' && (init?.method ?? '').toUpperCase() === 'PUT'
);
expect(putCall).toBeDefined();
expect(JSON.parse(String(putCall?.[1]?.body))).toMatchObject({ name: 'Renamed' });
});
});
it('toggles custom playback order via PUT', async () => {
const fetchMock = mockApi({
onRequest: (url, method) =>
url === '/api/collections/1' && method === 'PUT'
? jsonResponse({ collectionType: 'Collection', id: 1, name: 'Favorites', state: 'Normal', useCustomPlaybackOrder: true })
: null
});
render(<CollectionsScreen />);
await screen.findByText('Favorites');
// First switch in the list belongs to "Favorites".
const switches = screen.getAllByRole('switch');
fireEvent.click(switches[0]);
await waitFor(() => {
const putCall = fetchMock.mock.calls.find(
([u, init]) => u === '/api/collections/1' && (init?.method ?? '').toUpperCase() === 'PUT'
);
expect(putCall).toBeDefined();
expect(JSON.parse(String(putCall?.[1]?.body))).toMatchObject({ useCustomPlaybackOrder: true });
});
});
it('confirms and DELETEs a manual collection', async () => {
const fetchMock = mockApi();
render(<CollectionsScreen />);
await screen.findByText('Favorites');
const rows = screen.getAllByRole('button', { name: 'Delete' });
fireEvent.click(rows[0]);
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText(/Delete "Favorites"/)).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' }));
await waitFor(() => {
expect(
fetchMock.mock.calls.some(
([u, init]) => u === '/api/collections/1' && (init?.method ?? '').toUpperCase() === 'DELETE'
)
).toBe(true);
});
});
it('creates a smart collection and previews the query against browse', async () => {
const fetchMock = mockApi({
onRequest: (url, method) => {
if (url.startsWith('/api/library/browse')) {
return jsonResponse({ page: [{ id: 1, mediaType: 'Movie', title: 'The Matrix' }], totalCount: 1 });
}
if (url === '/api/smart-collections' && method === 'POST') {
return jsonResponse({ id: 11, name: 'Sci-Fi', query: 'genre:scifi' }, 201);
}
return null;
}
});
render(<CollectionsScreen />);
await screen.findByText('Favorites');
fireEvent.click(screen.getByRole('button', { name: /Smart/ }));
fireEvent.click(screen.getByRole('button', { name: 'New smart collection' }));
const dialog = screen.getByRole('dialog');
fireEvent.change(within(dialog).getByPlaceholderText('Smart collection name'), { target: { value: 'Sci-Fi' } });
fireEvent.change(within(dialog).getByPlaceholderText(/genre/), { target: { value: 'genre:scifi' } });
fireEvent.click(within(dialog).getByRole('button', { name: 'Preview results' }));
expect(await within(dialog).findByText('The Matrix')).toBeInTheDocument();
expect(within(dialog).getByText('1 matches')).toBeInTheDocument();
const browseCall = fetchMock.mock.calls.find(([u]) => u.toString().startsWith('/api/library/browse'));
expect(new URL(String(browseCall?.[0]), 'http://localhost').searchParams.get('query')).toBe('genre:scifi');
fireEvent.click(within(dialog).getByRole('button', { name: 'Create' }));
await waitFor(() => {
const postCall = fetchMock.mock.calls.find(
([u, init]) => u === '/api/smart-collections' && (init?.method ?? '').toUpperCase() === 'POST'
);
expect(postCall).toBeDefined();
expect(JSON.parse(String(postCall?.[1]?.body))).toEqual({ name: 'Sci-Fi', query: 'genre:scifi' });
});
});
it('opens a manual collection and previews its items via a collection: query', async () => {
const fetchMock = mockApi({
onRequest: (url) => {
if (url.startsWith('/api/library/browse')) {
const q = new URL(url, 'http://localhost').searchParams.get('query') ?? '';
if (q.startsWith('collection:')) {
return jsonResponse({ page: [{ id: 5, mediaItemId: 5, mediaType: 'Movie', title: 'Inception' }], totalCount: 1 });
}
}
return null;
}
});
render(<CollectionsScreen />);
await screen.findByText('Favorites');
fireEvent.click(screen.getByText('Favorites'));
expect(await screen.findByText('Inception')).toBeInTheDocument();
expect(screen.getByText(/best-effort search preview/)).toBeInTheDocument();
const browseCall = fetchMock.mock.calls.find(([u]) =>
u.toString().includes('collection%3A')
);
expect(new URL(String(browseCall?.[0]), 'http://localhost').searchParams.get('query')).toBe('collection:"Favorites"');
});
});
-926
View File
@@ -1,926 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
ArrowLeft,
Check,
FolderTree,
Info,
ListVideo,
Pencil,
Plus,
Search,
Sparkles,
Trash2,
TriangleAlert
} from 'lucide-react';
import {
Badge,
Button,
Card,
ConfirmDialog,
Dialog,
IconButton,
Input,
Spinner,
Switch
} from '../components';
import {
addItemsToCollection,
createCollection,
createSmartCollection,
deleteCollection,
deleteSmartCollection,
getCollectionItemsPreview,
getCollections,
getLibraryBrowseItems,
getSmartCollections,
messageFromCollectionError,
removeItemFromCollection,
toAddItemsRequest,
updateCollection,
updateSmartCollection,
type LibraryBrowseItem,
type MediaCollection,
type SmartCollection
} from '../api';
type Tab = 'manual' | 'smart';
const ADDABLE_TYPES = new Set<LibraryBrowseItem['mediaType']>([
'Movie',
'TelevisionShow',
'TelevisionSeason',
'Artist'
]);
const TYPE_LABEL: Record<LibraryBrowseItem['mediaType'], string> = {
Artist: 'Artist',
Collection: 'Collection',
Movie: 'Movie',
MultiCollection: 'Multi',
Playlist: 'Playlist',
RerunCollection: 'Rerun',
SmartCollection: 'Smart',
TelevisionSeason: 'Season',
TelevisionShow: 'Show'
};
function sortByName<T extends { name?: null | string }>(items: T[]): T[] {
return [...items].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
}
/* ---------- data hook ---------- */
interface CollectionsData {
manual: MediaCollection[];
smart: SmartCollection[];
}
type CollectionsState =
| { data: CollectionsData; error: null; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
function useCollectionsData() {
const [state, setState] = useState<CollectionsState>({ data: null, error: null, status: 'loading' });
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
// Fetch only; state updates happen in the async callbacks (never synchronously in the
// effect body) so the initial 'loading' default stands until data resolves.
const load = useCallback(() => {
Promise.all([getCollections(), getSmartCollections()])
.then(([manual, smart]) => {
if (activeRef.current) {
setState({ data: { manual, smart }, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromCollectionError(error), status: 'error' });
}
});
}, []);
useEffect(() => {
load();
}, [load]);
const refresh = useCallback(
(quiet = false) => {
if (!quiet) {
setState({ data: null, error: null, status: 'loading' });
}
load();
},
[load]
);
return { refresh, state };
}
/* ---------- create / rename dialogs ---------- */
function NameDialog({
busy,
confirmLabel,
error,
initialName = '',
onCancel,
onSubmit,
open,
title
}: {
busy: boolean;
confirmLabel: string;
error: string | null;
initialName?: string;
onCancel: () => void;
onSubmit: (name: string) => void;
open: boolean;
title: string;
}) {
// Parent remounts this via `key` on each open, so `initialName` seeds fresh state
// without a reset effect.
const [name, setName] = useState(initialName);
const trimmed = name.trim();
return (
<Dialog
footer={
<>
<Button disabled={busy} onClick={onCancel} variant="secondary">
Cancel
</Button>
<Button disabled={busy || trimmed.length === 0} loading={busy} onClick={() => onSubmit(trimmed)} variant="primary">
{confirmLabel}
</Button>
</>
}
onClose={onCancel}
open={open}
title={title}
width={440}
>
<Input label="Name" onChange={(event) => setName(event.target.value)} placeholder="Collection name" value={name} />
{error && (
<span className="ctv-field-error" role="alert">
{error}
</span>
)}
</Dialog>
);
}
function SmartDialog({
busy,
error,
initial,
onCancel,
onSubmit,
open
}: {
busy: boolean;
error: string | null;
initial: { name: string; query: string } | null;
onCancel: () => void;
onSubmit: (values: { name: string; query: string }) => void;
open: boolean;
}) {
// Parent remounts this via `key` on each open, so `initial` seeds fresh state.
const [name, setName] = useState(initial?.name ?? '');
const [query, setQuery] = useState(initial?.query ?? '');
const [preview, setPreview] = useState<{ count: number; sample: LibraryBrowseItem[] } | null>(null);
const [previewing, setPreviewing] = useState(false);
const [previewError, setPreviewError] = useState<string | null>(null);
const runPreview = async () => {
const trimmed = query.trim();
if (!trimmed) {
return;
}
setPreviewing(true);
setPreviewError(null);
try {
const result = await getLibraryBrowseItems({ pageSize: 24, query: trimmed });
setPreview({ count: result.totalCount ?? result.page?.length ?? 0, sample: result.page ?? [] });
} catch (error) {
setPreviewError(messageFromCollectionError(error, 'Unable to preview query'));
} finally {
setPreviewing(false);
}
};
const trimmedName = name.trim();
const trimmedQuery = query.trim();
return (
<Dialog
footer={
<>
<Button disabled={busy} onClick={onCancel} variant="secondary">
Cancel
</Button>
<Button
disabled={busy || trimmedName.length === 0 || trimmedQuery.length === 0}
loading={busy}
onClick={() => onSubmit({ name: trimmedName, query: trimmedQuery })}
variant="primary"
>
{initial ? 'Save' : 'Create'}
</Button>
</>
}
onClose={onCancel}
open={open}
title={initial ? 'Edit smart collection' : 'New smart collection'}
width={560}
>
<Input label="Name" onChange={(event) => setName(event.target.value)} placeholder="Smart collection name" value={name} />
<div style={{ marginTop: 12 }}>
<Input
label="Search query"
onChange={(event) => setQuery(event.target.value)}
placeholder='e.g. genre:"action" AND released:2000-2010'
style={{ fontFamily: 'var(--font-mono)' }}
value={query}
/>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<Button
disabled={previewing || trimmedQuery.length === 0}
loading={previewing}
onClick={() => void runPreview()}
size="sm"
startIcon={<Search aria-hidden="true" size={14} />}
variant="secondary"
>
Preview results
</Button>
</div>
{previewError && (
<span className="ctv-field-error" role="alert">
{previewError}
</span>
)}
{preview && (
<div className="ctv-collections-preview">
<div className="ctv-collections-preview-head">
<Badge tone="accent">{preview.count} matches</Badge>
</div>
<ul className="ctv-collections-preview-list">
{preview.sample.slice(0, 12).map((item) => (
<li key={`${item.mediaType}:${item.id}`}>
<span className="ctv-collections-preview-title">{item.title}</span>
<span className="ctv-collections-preview-type">{TYPE_LABEL[item.mediaType]}</span>
</li>
))}
{preview.sample.length === 0 && <li className="ctv-collections-preview-empty">No matches</li>}
</ul>
</div>
)}
{error && (
<span className="ctv-field-error" role="alert">
{error}
</span>
)}
</Dialog>
);
}
/* ---------- add-items picker ---------- */
function AddItemsDialog({
collection,
onAdded,
onClose,
open
}: {
collection: MediaCollection;
onAdded: () => void;
onClose: () => void;
open: boolean;
}) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<LibraryBrowseItem[]>([]);
const [searching, setSearching] = useState(false);
const [selected, setSelected] = useState<Map<string, LibraryBrowseItem>>(() => new Map());
const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const runSearch = async () => {
setSearching(true);
setError(null);
try {
const result = await getLibraryBrowseItems({ pageSize: 50, query: query.trim() });
setResults((result.page ?? []).filter((item) => ADDABLE_TYPES.has(item.mediaType)));
} catch (searchError) {
setError(messageFromCollectionError(searchError, 'Unable to search library'));
} finally {
setSearching(false);
}
};
const toggle = (item: LibraryBrowseItem) => {
const key = `${item.mediaType}:${item.id}`;
setSelected((current) => {
const next = new Map(current);
if (next.has(key)) {
next.delete(key);
} else {
next.set(key, item);
}
return next;
});
};
const submit = async () => {
if (selected.size === 0) {
return;
}
setAdding(true);
setError(null);
try {
await addItemsToCollection(collection.id, toAddItemsRequest([...selected.values()]));
onAdded();
onClose();
} catch (addError) {
setError(messageFromCollectionError(addError, 'Unable to add items'));
} finally {
setAdding(false);
}
};
return (
<Dialog
footer={
<>
<Button disabled={adding} onClick={onClose} variant="secondary">
Cancel
</Button>
<Button
disabled={adding || selected.size === 0}
loading={adding}
onClick={() => void submit()}
startIcon={<Plus aria-hidden="true" size={14} />}
variant="primary"
>
Add {selected.size > 0 ? selected.size : ''} item{selected.size === 1 ? '' : 's'}
</Button>
</>
}
onClose={onClose}
open={open}
title={`Add items to ${collection.name ?? 'collection'}`}
width={620}
>
<form
onSubmit={(event) => {
event.preventDefault();
void runSearch();
}}
style={{ display: 'flex', gap: 8 }}
>
<Input
leadingIcon={<Search aria-hidden="true" size={14} />}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search movies, shows, seasons, artists…"
value={query}
/>
<Button loading={searching} size="sm" type="submit" variant="secondary">
Search
</Button>
</form>
<p className="ctv-collections-picker-note">
The library search returns movies, shows, seasons and artists. Episodes, music, images and other item kinds
can't be added from here yet.
</p>
{error && (
<span className="ctv-field-error" role="alert">
{error}
</span>
)}
<div className="ctv-collections-picker-results">
{results.length === 0 && !searching ? (
<div className="ctv-collections-picker-empty">No results try a search above.</div>
) : (
results.map((item) => {
const key = `${item.mediaType}:${item.id}`;
const isSelected = selected.has(key);
return (
<button
aria-pressed={isSelected}
className={`ctv-collections-picker-row ctv-press${isSelected ? ' ctv-collections-picker-row-active' : ''}`}
key={key}
onClick={() => toggle(item)}
type="button"
>
<span className="ctv-collections-picker-check">
{isSelected ? <Check aria-hidden="true" size={14} /> : null}
</span>
<span className="ctv-collections-picker-row-title">{item.title}</span>
<Badge tone="neutral">{TYPE_LABEL[item.mediaType]}</Badge>
</button>
);
})
)}
</div>
</Dialog>
);
}
/* ---------- manual collection items view ---------- */
function ManualItemsView({
collection,
onBack
}: {
collection: MediaCollection;
onBack: () => void;
}) {
const [items, setItems] = useState<LibraryBrowseItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [removing, setRemoving] = useState<number | null>(null);
const activeRef = useRef(true);
// No synchronous setState here: `loading` starts true and flips false in `finally`, so
// this is safe to call from an effect. Reloads (after add/remove) keep the list visible.
const load = useCallback(() => {
getCollectionItemsPreview(collection.name ?? '')
.then((preview) => {
if (activeRef.current) {
setItems(preview);
setError(null);
}
})
.catch((loadError: unknown) => {
if (activeRef.current) {
setError(messageFromCollectionError(loadError, 'Unable to load collection items'));
}
})
.finally(() => {
if (activeRef.current) {
setLoading(false);
}
});
}, [collection.name]);
useEffect(() => {
activeRef.current = true;
load();
return () => {
activeRef.current = false;
};
}, [load]);
const remove = async (item: LibraryBrowseItem) => {
const mediaItemId = item.mediaItemId ?? item.id;
setRemoving(mediaItemId);
try {
await removeItemFromCollection(collection.id, mediaItemId);
load();
} catch (removeError) {
setError(messageFromCollectionError(removeError, 'Unable to remove item'));
} finally {
setRemoving(null);
}
};
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
<Button onClick={onBack} size="sm" startIcon={<ArrowLeft aria-hidden="true" size={14} />} variant="ghost">
All collections
</Button>
<span className="ctv-collections-detail-title">{collection.name}</span>
<span className="ctv-channels-spacer" />
<Button onClick={() => setPickerOpen(true)} size="sm" startIcon={<Plus aria-hidden="true" size={14} />}>
Add items
</Button>
</div>
<div className="ctv-settings-warn-callout" role="note">
<Info aria-hidden="true" color="var(--status-warn)" size={14} />
<span>
The API has no endpoint to list a manual collection's items. This is a best-effort search preview covering
movies, shows, seasons and artists only other kinds in this collection won't appear. Adding items works for
all shown kinds.
</span>
</div>
{error && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{error}</span>
</div>
)}
<Card padded={false}>
{loading ? (
<div className="ctv-collections-loading">
<Spinner size={18} />
<span>Loading items</span>
</div>
) : items.length === 0 ? (
<div className="ctv-collections-empty">No previewable items in this collection.</div>
) : (
items.map((item, index) => (
<div
className="ctv-settings-flush-row"
key={`${item.mediaType}:${item.id}`}
style={index === 0 ? { borderTop: 'none' } : undefined}
>
<ListVideo aria-hidden="true" color="var(--text-disabled)" size={15} />
<span className="ctv-settings-flush-row-main ctv-settings-flush-row-title">{item.title}</span>
<Badge tone="neutral">{TYPE_LABEL[item.mediaType]}</Badge>
<IconButton
onClick={() => void remove(item)}
size="sm"
title={`Remove ${item.title}`}
variant="ghost"
>
{removing === (item.mediaItemId ?? item.id) ? (
<Spinner size={14} />
) : (
<Trash2 aria-hidden="true" size={14} />
)}
</IconButton>
</div>
))
)}
</Card>
<AddItemsDialog
collection={collection}
key={`add-${pickerOpen}`}
onAdded={load}
onClose={() => setPickerOpen(false)}
open={pickerOpen}
/>
</div>
);
}
/* ---------- screen ---------- */
export function CollectionsScreen() {
const { refresh, state } = useCollectionsData();
const [tab, setTab] = useState<Tab>('manual');
const [selected, setSelected] = useState<MediaCollection | null>(null);
// dialogs
const [createOpen, setCreateOpen] = useState(false);
const [renameTarget, setRenameTarget] = useState<MediaCollection | null>(null);
const [smartTarget, setSmartTarget] = useState<{ collection: SmartCollection | null; open: boolean }>({
collection: null,
open: false
});
const [deleteTarget, setDeleteTarget] = useState<
{ kind: 'manual'; item: MediaCollection } | { kind: 'smart'; item: SmartCollection } | null
>(null);
const [dialogBusy, setDialogBusy] = useState(false);
const [dialogError, setDialogError] = useState<string | null>(null);
const [rowError, setRowError] = useState<string | null>(null);
const [togglingId, setTogglingId] = useState<number | null>(null);
if (state.status === 'loading') {
return (
<div className="ctv-collections-loading" role="status">
<Spinner size={18} />
<span>Loading collections</span>
</div>
);
}
if (state.status === 'error') {
return (
<div className="ctv-collections">
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{state.error}</span>
<span className="ctv-channels-spacer" />
<Button onClick={() => refresh()} size="sm" variant="secondary">
Retry
</Button>
</div>
</div>
);
}
if (selected) {
// Keep the selected reference fresh across refreshes (e.g. after a rename elsewhere).
const current = state.data.manual.find((collection) => collection.id === selected.id) ?? selected;
return <ManualItemsView collection={current} onBack={() => setSelected(null)} />;
}
const manual = sortByName(state.data.manual);
const smart = sortByName(state.data.smart);
const runDialog = async (operation: () => Promise<void>, onSuccess: () => void) => {
setDialogBusy(true);
setDialogError(null);
try {
await operation();
onSuccess();
refresh(true);
} catch (error) {
setDialogError(messageFromCollectionError(error, 'Operation failed'));
} finally {
setDialogBusy(false);
}
};
const toggleCustomOrder = async (collection: MediaCollection, next: boolean) => {
setRowError(null);
setTogglingId(collection.id);
try {
await updateCollection(collection.id, { name: collection.name, useCustomPlaybackOrder: next });
refresh(true);
} catch (error) {
setRowError(messageFromCollectionError(error, 'Unable to update collection'));
} finally {
setTogglingId(null);
}
};
const confirmDelete = () => {
if (!deleteTarget) {
return;
}
const target = deleteTarget;
void runDialog(
() =>
target.kind === 'manual'
? deleteCollection(target.item.id)
: deleteSmartCollection(target.item.id),
() => setDeleteTarget(null)
);
};
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
<div aria-label="Collection type" className="ctv-segmented" role="group">
<button aria-pressed={tab === 'manual'} onClick={() => setTab('manual')} type="button">
Manual <code>{manual.length}</code>
</button>
<button aria-pressed={tab === 'smart'} onClick={() => setTab('smart')} type="button">
Smart <code>{smart.length}</code>
</button>
</div>
<span className="ctv-channels-spacer" />
{tab === 'manual' ? (
<Button
onClick={() => {
setDialogError(null);
setCreateOpen(true);
}}
size="sm"
startIcon={<Plus aria-hidden="true" size={14} />}
>
New collection
</Button>
) : (
<Button
onClick={() => {
setDialogError(null);
setSmartTarget({ collection: null, open: true });
}}
size="sm"
startIcon={<Plus aria-hidden="true" size={14} />}
>
New smart collection
</Button>
)}
</div>
{rowError && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{rowError}</span>
</div>
)}
{tab === 'manual' && (
<Card padded={false}>
{manual.length === 0 ? (
<div className="ctv-collections-empty">No manual collections yet.</div>
) : (
manual.map((collection, index) => (
<div
className="ctv-settings-flush-row"
key={collection.id}
style={index === 0 ? { borderTop: 'none' } : undefined}
>
<FolderTree aria-hidden="true" color="var(--ctv-accent)" size={15} />
<button
className="ctv-settings-flush-row-main ctv-settings-flush-row-title ctv-collections-linkbtn"
onClick={() => setSelected(collection)}
type="button"
>
{collection.name}
</button>
<label className="ctv-collections-order-toggle" title="Use custom playback order">
<Switch
checked={collection.useCustomPlaybackOrder}
disabled={togglingId === collection.id}
onChange={(next) => void toggleCustomOrder(collection, next)}
size="sm"
/>
<span>Custom order</span>
</label>
<IconButton onClick={() => setSelected(collection)} size="sm" title="Manage items" variant="ghost">
<ListVideo aria-hidden="true" size={14} />
</IconButton>
<IconButton
onClick={() => {
setDialogError(null);
setRenameTarget(collection);
}}
size="sm"
title="Rename"
variant="ghost"
>
<Pencil aria-hidden="true" size={14} />
</IconButton>
<IconButton
onClick={() => setDeleteTarget({ item: collection, kind: 'manual' })}
size="sm"
title="Delete"
variant="ghost"
>
<Trash2 aria-hidden="true" size={14} />
</IconButton>
</div>
))
)}
</Card>
)}
{tab === 'smart' && (
<Card padded={false}>
{smart.length === 0 ? (
<div className="ctv-collections-empty">No smart collections yet.</div>
) : (
smart.map((collection, index) => (
<div
className="ctv-settings-flush-row"
key={collection.id}
style={index === 0 ? { borderTop: 'none' } : undefined}
>
<Sparkles aria-hidden="true" color="var(--ctv-accent)" size={15} />
<div className="ctv-settings-flush-row-main">
<div className="ctv-settings-flush-row-title">{collection.name}</div>
<div className="ctv-collections-query">{collection.query}</div>
</div>
<IconButton
onClick={() => {
setDialogError(null);
setSmartTarget({ collection, open: true });
}}
size="sm"
title="Edit"
variant="ghost"
>
<Pencil aria-hidden="true" size={14} />
</IconButton>
<IconButton
onClick={() => setDeleteTarget({ item: collection, kind: 'smart' })}
size="sm"
title="Delete"
variant="ghost"
>
<Trash2 aria-hidden="true" size={14} />
</IconButton>
</div>
))
)}
</Card>
)}
<ClassicNote />
{/* create manual */}
<NameDialog
busy={dialogBusy}
confirmLabel="Create"
error={createOpen ? dialogError : null}
key={`create-${createOpen}`}
onCancel={() => setCreateOpen(false)}
onSubmit={(name) => void runDialog(() => createCollection({ name }).then(() => undefined), () => setCreateOpen(false))}
open={createOpen}
title="New collection"
/>
{/* rename manual */}
<NameDialog
busy={dialogBusy}
confirmLabel="Save"
error={renameTarget ? dialogError : null}
initialName={renameTarget?.name ?? ''}
key={`rename-${renameTarget?.id ?? 'none'}`}
onCancel={() => setRenameTarget(null)}
onSubmit={(name) =>
renameTarget &&
void runDialog(
() =>
updateCollection(renameTarget.id, {
name,
useCustomPlaybackOrder: renameTarget.useCustomPlaybackOrder
}).then(() => undefined),
() => setRenameTarget(null)
)
}
open={renameTarget !== null}
title="Rename collection"
/>
{/* create / edit smart */}
<SmartDialog
busy={dialogBusy}
error={dialogError}
key={`smart-${smartTarget.open}-${smartTarget.collection?.id ?? 'new'}`}
initial={
smartTarget.collection
? { name: smartTarget.collection.name ?? '', query: smartTarget.collection.query ?? '' }
: null
}
onCancel={() => setSmartTarget({ collection: null, open: false })}
onSubmit={(values) => {
const editing = smartTarget.collection;
void runDialog(
() =>
(editing
? updateSmartCollection(editing.id, values)
: createSmartCollection(values)
).then(() => undefined),
() => setSmartTarget({ collection: null, open: false })
);
}}
open={smartTarget.open}
/>
<ConfirmDialog
busy={dialogBusy}
confirmLabel="Delete"
message={
deleteTarget ? (
<>
<span>{`Delete "${deleteTarget.item.name ?? 'this collection'}"? This cannot be undone.`}</span>
{dialogError && (
<span className="ctv-field-error" role="alert">
{dialogError}
</span>
)}
</>
) : (
''
)
}
onCancel={() => {
setDeleteTarget(null);
setDialogError(null);
}}
onConfirm={confirmDelete}
open={deleteTarget !== null}
title={deleteTarget?.kind === 'smart' ? 'Delete smart collection' : 'Delete collection'}
tone="danger"
/>
</div>
);
}
function ClassicNote() {
return (
<div className="ctv-settings-callout">
<Info aria-hidden="true" size={14} />
<span>
Multi-collections, rerun collections and playlists aren't available here yet manage them in the Classic UI
for now.
</span>
</div>
);
}
-6
View File
@@ -854,12 +854,6 @@ function SystemPane({
Open Dashboard
</Button>
</Row>
<Row control={220} help="Collections, media browse/search, trakt, filler, watermarks, ffmpeg profiles, blocks/decos/templates, playout editors, logs and troubleshooting still live here." label="Classic UI">
<a className="ctv-button ctv-button-secondary ctv-button-sm" href="/system/health">
<span>Open Classic UI</span>
<ExternalLink aria-hidden="true" size={13} />
</a>
</Row>
</Card>
</Pane>
);
-165
View File
@@ -3428,168 +3428,3 @@
}
}
/* ---------- collections screen (#140) ---------- */
.ctv-collections {
display: flex;
flex-direction: column;
gap: var(--space-5, 10px);
}
.ctv-collections-loading,
.ctv-collections-empty {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-4, 8px);
padding: var(--space-8, 20px);
color: var(--text-secondary);
font-size: var(--text-sm, 13px);
}
.ctv-collections-detail-title {
font-size: var(--text-sm, 13px);
font-weight: var(--weight-medium, 500);
color: var(--text-primary);
}
.ctv-collections-linkbtn {
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
padding: 0;
color: var(--text-primary);
font: inherit;
font-weight: var(--weight-medium, 500);
}
.ctv-collections-linkbtn:hover {
color: var(--ctv-accent);
}
.ctv-collections-order-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-3, 6px);
color: var(--text-secondary);
font-size: var(--text-xs, 12px);
cursor: pointer;
}
.ctv-collections-query {
margin-top: 3px;
font: var(--text-2xs) / 1.3 var(--font-mono);
color: var(--text-disabled);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 520px;
}
/* smart-collection preview + picker */
.ctv-collections-preview {
margin-top: 12px;
border: 1px solid var(--border-hairline);
border-radius: var(--radius-sm, 5px);
background: var(--ctv-bg-sunken);
padding: var(--space-5, 10px);
}
.ctv-collections-preview-head {
margin-bottom: var(--space-4, 8px);
}
.ctv-collections-preview-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
max-height: 220px;
overflow-y: auto;
}
.ctv-collections-preview-list li {
display: flex;
align-items: center;
gap: var(--space-4, 8px);
font-size: var(--text-xs, 12px);
color: var(--text-secondary);
}
.ctv-collections-preview-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-primary);
}
.ctv-collections-preview-type,
.ctv-collections-preview-empty {
font: var(--text-2xs) / 1 var(--font-mono);
color: var(--text-disabled);
}
.ctv-collections-picker-note {
margin: 10px 0 0;
font: var(--text-2xs) / 1.4 var(--font-sans);
color: var(--text-disabled);
}
.ctv-collections-picker-results {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 4px;
max-height: 320px;
overflow-y: auto;
}
.ctv-collections-picker-empty {
padding: var(--space-7, 16px);
text-align: center;
color: var(--text-disabled);
font-size: var(--text-xs, 12px);
}
.ctv-collections-picker-row {
display: flex;
align-items: center;
gap: var(--space-4, 8px);
width: 100%;
border: 1px solid var(--border-hairline);
border-radius: var(--radius-sm, 5px);
background: var(--surface-card);
padding: var(--space-4, 8px) var(--space-5, 10px);
cursor: pointer;
font: inherit;
color: var(--text-primary);
text-align: left;
}
.ctv-collections-picker-row-active {
background: color-mix(in srgb, var(--action-primary) 12%, transparent);
border-color: color-mix(in srgb, var(--action-primary) 35%, transparent);
}
.ctv-collections-picker-row-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--text-sm, 13px);
}
.ctv-collections-picker-check {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: var(--action-primary);
}