Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65d88b5167 | ||
|
|
4f68805d9a | ||
|
|
55fc210385 | ||
|
|
d1c04030af | ||
|
|
945d108334 |
@@ -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)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.Text.Json;
|
||||
using ErsatzTV.Mcp;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Mcp.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class McpServerTests
|
||||
{
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_Return_Server_Capabilities_For_Initialize()
|
||||
{
|
||||
using JsonDocument response = await HandleAsync("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}""");
|
||||
|
||||
JsonElement result = response.RootElement.GetProperty("result");
|
||||
result.GetProperty("protocolVersion").GetString().ShouldBe("2024-11-05");
|
||||
result.GetProperty("serverInfo").GetProperty("name").GetString().ShouldBe("ersatztv-mcp");
|
||||
result.GetProperty("capabilities").TryGetProperty("tools", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_List_Tools()
|
||||
{
|
||||
using JsonDocument response = await HandleAsync("""{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}""");
|
||||
|
||||
string[] toolNames = response.RootElement
|
||||
.GetProperty("result")
|
||||
.GetProperty("tools")
|
||||
.EnumerateArray()
|
||||
.Select(t => t.GetProperty("name").GetString())
|
||||
.OfType<string>()
|
||||
.ToArray();
|
||||
|
||||
toolNames.ShouldContain("ersatztv_list_channels");
|
||||
toolNames.ShouldContain("ersatztv_get_version");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_Call_Tool_And_Return_Text_Content()
|
||||
{
|
||||
using JsonDocument response = await HandleAsync(
|
||||
"""{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ersatztv_get_version","arguments":{}}}""",
|
||||
new StubToolExecutor("""{"apiVersion":3,"appVersion":"develop"}"""));
|
||||
|
||||
JsonElement content = response.RootElement.GetProperty("result").GetProperty("content").EnumerateArray().Single();
|
||||
content.GetProperty("type").GetString().ShouldBe("text");
|
||||
content.GetProperty("text").GetString().ShouldBe("""{"apiVersion":3,"appVersion":"develop"}""");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_Return_Method_Not_Found_For_Unknown_Tool()
|
||||
{
|
||||
using JsonDocument response = await HandleAsync(
|
||||
"""{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"missing","arguments":{}}}""");
|
||||
|
||||
response.RootElement.GetProperty("error").GetProperty("code").GetInt32().ShouldBe(-32602);
|
||||
string message = response.RootElement.GetProperty("error").GetProperty("message").GetString().ShouldNotBeNull();
|
||||
message.ShouldContain("Unknown tool");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_Return_Parse_Error_For_Malformed_Json()
|
||||
{
|
||||
McpServer server = new(new StubToolExecutor("{}"));
|
||||
|
||||
// A malformed line must be answered with a JSON-RPC parse error, never crash the loop.
|
||||
string? response = await server.HandleAsync("{ this is not json", CancellationToken.None);
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(response.ShouldNotBeNull());
|
||||
document.RootElement.GetProperty("error").GetProperty("code").GetInt32().ShouldBe(-32700);
|
||||
document.RootElement.GetProperty("id").ValueKind.ShouldBe(JsonValueKind.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_Return_Invalid_Request_For_Missing_Method()
|
||||
{
|
||||
McpServer server = new(new StubToolExecutor("{}"));
|
||||
|
||||
string? response = await server.HandleAsync("""{"jsonrpc":"2.0","id":7}""", CancellationToken.None);
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(response.ShouldNotBeNull());
|
||||
document.RootElement.GetProperty("error").GetProperty("code").GetInt32().ShouldBe(-32600);
|
||||
document.RootElement.GetProperty("id").GetInt32().ShouldBe(7);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_Return_Invalid_Request_For_Non_Object_Request()
|
||||
{
|
||||
McpServer server = new(new StubToolExecutor("{}"));
|
||||
|
||||
string? response = await server.HandleAsync("5", CancellationToken.None);
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(response.ShouldNotBeNull());
|
||||
document.RootElement.GetProperty("error").GetProperty("code").GetInt32().ShouldBe(-32600);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_Ignore_Notifications()
|
||||
{
|
||||
McpServer server = new(new StubToolExecutor("{}"));
|
||||
|
||||
string? response = await server.HandleAsync("""{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}""", CancellationToken.None);
|
||||
|
||||
response.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_Return_Internal_Error_When_Executor_Throws_Transport_Error()
|
||||
{
|
||||
// A network/transport failure must still yield a JSON-RPC error for the id, not escape
|
||||
// HandleAsync (which would leave a compliant client hanging).
|
||||
McpServer server = new(new ThrowingToolExecutor(new HttpRequestException("connection refused")));
|
||||
|
||||
string? response = await server.HandleAsync(
|
||||
"""{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"ersatztv_get_version","arguments":{}}}""",
|
||||
CancellationToken.None);
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(response.ShouldNotBeNull());
|
||||
document.RootElement.GetProperty("error").GetProperty("code").GetInt32().ShouldBe(-32603);
|
||||
document.RootElement.GetProperty("id").GetInt32().ShouldBe(9);
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> HandleAsync(string request, IToolExecutor? executor = null)
|
||||
{
|
||||
McpServer server = new(executor ?? new StubToolExecutor("{}"));
|
||||
string? response = await server.HandleAsync(request, CancellationToken.None);
|
||||
response.ShouldNotBeNull();
|
||||
return JsonDocument.Parse(response);
|
||||
}
|
||||
|
||||
private sealed class StubToolExecutor(string response) : IToolExecutor
|
||||
{
|
||||
public Task<ToolCallResult> CallToolAsync(
|
||||
ToolDefinition tool,
|
||||
JsonElement arguments,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new ToolCallResult(false, response));
|
||||
}
|
||||
|
||||
private sealed class ThrowingToolExecutor(Exception exception) : IToolExecutor
|
||||
{
|
||||
public Task<ToolCallResult> CallToolAsync(
|
||||
ToolDefinition tool,
|
||||
JsonElement arguments,
|
||||
CancellationToken cancellationToken) =>
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Text.Json;
|
||||
using ErsatzTV.Mcp;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Mcp.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class ToolArgumentValidatorTests
|
||||
{
|
||||
private static ToolDefinition IdTool() => new(
|
||||
"ersatztv_get_channel",
|
||||
"Get channel",
|
||||
HttpMethod.Get,
|
||||
"/api/channels/{id}",
|
||||
ToolInputSchemas.Object(("id", "integer", "Channel id", true)));
|
||||
|
||||
[Test]
|
||||
public void Validate_Should_Accept_Well_Formed_Arguments()
|
||||
{
|
||||
Should.NotThrow(() =>
|
||||
ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("""{"id":12}""").RootElement));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validate_Should_Accept_Empty_Arguments_For_No_Param_Tool()
|
||||
{
|
||||
ToolDefinition tool = new("ersatztv_get_version", "Version", HttpMethod.Get, "/api/version", ToolInputSchemas.Empty);
|
||||
|
||||
Should.NotThrow(() =>
|
||||
ToolArgumentValidator.Validate(tool, JsonDocument.Parse("{}").RootElement));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validate_Should_Reject_Unknown_Argument()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() =>
|
||||
ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("""{"id":12,"extra":1}""").RootElement));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validate_Should_Reject_Missing_Required_Argument()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() =>
|
||||
ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("{}").RootElement));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validate_Should_Reject_Wrong_Type()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() =>
|
||||
ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("""{"id":"twelve"}""").RootElement));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validate_Should_Reject_Non_Object_Arguments()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() =>
|
||||
ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("[]").RootElement));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using ErsatzTV.Mcp;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Mcp.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class ToolCatalogTests
|
||||
{
|
||||
[Test]
|
||||
public void All_Should_Expose_Read_First_Current_Api_Tools()
|
||||
{
|
||||
string[] names = ToolCatalog.All.Select(t => t.Name).ToArray();
|
||||
|
||||
names.ShouldContain("ersatztv_list_channels");
|
||||
names.ShouldContain("ersatztv_get_channel");
|
||||
names.ShouldContain("ersatztv_list_collections");
|
||||
names.ShouldContain("ersatztv_get_collection");
|
||||
names.ShouldContain("ersatztv_list_smart_collections");
|
||||
names.ShouldContain("ersatztv_get_smart_collection");
|
||||
names.ShouldContain("ersatztv_list_schedules");
|
||||
names.ShouldContain("ersatztv_get_schedule");
|
||||
names.ShouldContain("ersatztv_list_schedule_items");
|
||||
names.ShouldContain("ersatztv_get_playout");
|
||||
names.ShouldContain("ersatztv_list_ffmpeg_profiles");
|
||||
names.ShouldContain("ersatztv_get_ffmpeg_profile");
|
||||
names.ShouldContain("ersatztv_get_resolution_by_name");
|
||||
names.ShouldContain("ersatztv_list_sessions");
|
||||
names.ShouldContain("ersatztv_get_version");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void All_Should_Not_Expose_Deferred_Redesign_Workflow_Tools()
|
||||
{
|
||||
string[] names = ToolCatalog.All.Select(t => t.Name).ToArray();
|
||||
|
||||
names.ShouldNotContain("ersatztv_create_channel_from_lineup");
|
||||
names.ShouldNotContain("ersatztv_list_channel_templates");
|
||||
names.ShouldNotContain("ersatztv_browse_library");
|
||||
names.ShouldNotContain("ersatztv_upload_channel_logo");
|
||||
names.ShouldNotContain("ersatztv_resume_playback");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void All_Should_Only_Expose_Get_Tools_In_V0()
|
||||
{
|
||||
ToolCatalog.All
|
||||
.Where(t => t.HttpMethod != HttpMethod.Get)
|
||||
.Select(t => t.Name)
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Get_Channel_Tool_Should_Have_OpenApi_Aligned_Path_And_Id_Input()
|
||||
{
|
||||
ToolDefinition tool = ToolCatalog.Find("ersatztv_get_channel").ShouldNotBeNull();
|
||||
|
||||
tool.HttpMethod.ShouldBe(HttpMethod.Get);
|
||||
tool.PathTemplate.ShouldBe("/api/channels/{id}");
|
||||
tool.InputSchema.RootElement.GetProperty("required").EnumerateArray().Single().GetString().ShouldBe("id");
|
||||
tool.InputSchema.RootElement.GetProperty("properties").TryGetProperty("id", out _).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace ErsatzTV.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Reads newline-delimited lines from a <see cref="TextReader"/> with a hard character cap, so a
|
||||
/// hostile client cannot exhaust memory by sending an enormous line with no newline. A line longer
|
||||
/// than the cap is drained (not buffered) and reported as overflowed rather than returned.
|
||||
/// </summary>
|
||||
public static class BoundedLineReader
|
||||
{
|
||||
public readonly record struct Line(bool EndOfStream, bool Overflowed, string Text);
|
||||
|
||||
public const int DefaultMaxChars = 1024 * 1024;
|
||||
|
||||
public static async Task<Line> ReadLineAsync(TextReader reader, int maxChars = DefaultMaxChars)
|
||||
{
|
||||
int cap = maxChars > 0 ? maxChars : DefaultMaxChars;
|
||||
var builder = new System.Text.StringBuilder();
|
||||
var buffer = new char[1];
|
||||
bool sawAny = false;
|
||||
bool overflowed = false;
|
||||
|
||||
while (await reader.ReadAsync(buffer, 0, 1) == 1)
|
||||
{
|
||||
sawAny = true;
|
||||
char c = buffer[0];
|
||||
if (c == '\n')
|
||||
{
|
||||
return new Line(false, overflowed, overflowed ? string.Empty : builder.ToString());
|
||||
}
|
||||
|
||||
if (c == '\r')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (overflowed || builder.Length >= cap)
|
||||
{
|
||||
// Past the cap: stop buffering and free what we have, but keep draining to the
|
||||
// newline so the next line stays aligned.
|
||||
overflowed = true;
|
||||
builder.Clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append(c);
|
||||
}
|
||||
|
||||
if (!sawAny)
|
||||
{
|
||||
return new Line(true, false, string.Empty);
|
||||
}
|
||||
|
||||
// Final line with no trailing newline.
|
||||
return new Line(false, overflowed, overflowed ? string.Empty : builder.ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ErsatzTV.Mcp;
|
||||
|
||||
public sealed partial class ErsatzTvApiClient(HttpClient httpClient, ErsatzTvApiClientOptions options) : IToolExecutor
|
||||
{
|
||||
public async Task<ToolCallResult> CallToolAsync(
|
||||
ToolDefinition tool,
|
||||
JsonElement arguments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Runtime backstop for the read-only posture: even if a catalog entry is wrong,
|
||||
// a non-GET tool cannot execute unless writes are explicitly enabled. This is the
|
||||
// forward-compatible seam for future write/operational tools (#58) — they run only
|
||||
// when the operator opts in via ERSATZTV_ALLOW_WRITES.
|
||||
if (!options.AllowWrites && tool.HttpMethod != HttpMethod.Get)
|
||||
{
|
||||
return new ToolCallResult(
|
||||
true,
|
||||
$"Refused: tool '{tool.Name}' uses HTTP {tool.HttpMethod.Method}, but this MCP server is "
|
||||
+ "read-only. Set ERSATZTV_ALLOW_WRITES=true to enable write/operational tools.");
|
||||
}
|
||||
|
||||
ToolArgumentValidator.Validate(tool, arguments);
|
||||
|
||||
string path = BuildPath(tool.PathTemplate, arguments);
|
||||
using var request = new HttpRequestMessage(tool.HttpMethod, CombineUri(options.BaseUrl, path));
|
||||
if (!string.IsNullOrWhiteSpace(options.ApiKey))
|
||||
{
|
||||
request.Headers.Add("X-Api-Key", options.ApiKey);
|
||||
}
|
||||
|
||||
// ResponseHeadersRead streams the body so we can cap it without buffering the whole
|
||||
// thing — but that moves the body read outside HttpClient.Timeout, so a per-request
|
||||
// timeout token must cover the entire operation (headers + body) or a slow-drip
|
||||
// upstream would hang the single-threaded session.
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(options.EffectiveRequestTimeout);
|
||||
CancellationToken token = timeoutCts.Token;
|
||||
|
||||
using HttpResponseMessage response = await httpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
token);
|
||||
string body = await ReadCappedBodyAsync(response.Content, token);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return new ToolCallResult(false, body);
|
||||
}
|
||||
|
||||
string message = $"{(int)response.StatusCode} {response.ReasonPhrase}: {body}";
|
||||
return new ToolCallResult(true, message);
|
||||
}
|
||||
|
||||
// Read at most MaxResponseBytes from the response, truncating gracefully with a marker
|
||||
// rather than buffering an unbounded body into memory / the model's context.
|
||||
private async Task<string> ReadCappedBodyAsync(HttpContent content, CancellationToken cancellationToken)
|
||||
{
|
||||
int cap = options.MaxResponseBytes is > 0 and <= ErsatzTvApiClientOptions.MaxAllowedResponseBytes
|
||||
? options.MaxResponseBytes
|
||||
: ErsatzTvApiClientOptions.DefaultMaxResponseBytes;
|
||||
await using Stream stream = await content.ReadAsStreamAsync(cancellationToken);
|
||||
|
||||
// One extra byte lets us detect (but not keep) overflow past the cap.
|
||||
byte[] buffer = new byte[cap + 1];
|
||||
int total = 0;
|
||||
int read;
|
||||
while (total < buffer.Length
|
||||
&& (read = await stream.ReadAsync(buffer.AsMemory(total, buffer.Length - total), cancellationToken)) > 0)
|
||||
{
|
||||
total += read;
|
||||
}
|
||||
|
||||
bool truncated = total > cap;
|
||||
int length = truncated ? TrimToUtf8Boundary(buffer, cap) : total;
|
||||
string text = Encoding.UTF8.GetString(buffer, 0, length);
|
||||
return truncated
|
||||
? text + $"\n…[truncated: response exceeded {cap} bytes]"
|
||||
: text;
|
||||
}
|
||||
|
||||
// When cutting at a fixed byte cap, back off any incomplete trailing UTF-8 sequence so the
|
||||
// decoded text ends on a complete code point instead of a U+FFFD replacement char.
|
||||
private static int TrimToUtf8Boundary(byte[] buffer, int length)
|
||||
{
|
||||
int i = length;
|
||||
while (i > 0 && (buffer[i - 1] & 0b1100_0000) == 0b1000_0000)
|
||||
{
|
||||
i--; // step back over UTF-8 continuation bytes (10xxxxxx)
|
||||
}
|
||||
|
||||
if (i == 0)
|
||||
{
|
||||
return length; // all continuation bytes (not valid UTF-8) — leave the cut as-is
|
||||
}
|
||||
|
||||
byte lead = buffer[i - 1];
|
||||
int expected = lead switch
|
||||
{
|
||||
< 0x80 => 1,
|
||||
>= 0xF0 => 4,
|
||||
>= 0xE0 => 3,
|
||||
>= 0xC0 => 2,
|
||||
_ => 1 // stray continuation byte as "lead"; leave the cut as-is
|
||||
};
|
||||
|
||||
// Keep the sequence if it is complete within the cap; otherwise drop the incomplete lead.
|
||||
return length - (i - 1) >= expected ? length : i - 1;
|
||||
}
|
||||
|
||||
private static Uri CombineUri(Uri baseUrl, string absolutePath)
|
||||
{
|
||||
// absolutePath is a root-relative "/api/..." path. new Uri(baseUrl, "/api/...") would
|
||||
// discard any path prefix on baseUrl (e.g. a reverse-proxy mount like http://host/etv/),
|
||||
// so combine on the base's full path instead to preserve the prefix.
|
||||
string prefix = baseUrl.GetLeftPart(UriPartial.Path).TrimEnd('/');
|
||||
return new Uri(prefix + absolutePath);
|
||||
}
|
||||
|
||||
private static string BuildPath(string pathTemplate, JsonElement arguments)
|
||||
{
|
||||
string path = pathTemplate;
|
||||
foreach (JsonProperty property in arguments.EnumerateObject())
|
||||
{
|
||||
string value = property.Value.ValueKind == JsonValueKind.String
|
||||
? property.Value.GetString() ?? string.Empty
|
||||
: property.Value.GetRawText();
|
||||
|
||||
// EscapeDataString escapes '/', but bare "." / ".." survive and would collapse the
|
||||
// URL onto a different route during Uri canonicalization — reject them. This assumes
|
||||
// each {param} is its own path segment (true for every current template); if a template
|
||||
// ever concatenates two adjacent params, revalidate the substituted path as a whole.
|
||||
if (value is "." or "..")
|
||||
{
|
||||
throw new ArgumentException($"Invalid value for argument '{property.Name}': '{value}'.");
|
||||
}
|
||||
|
||||
path = path.Replace("{" + property.Name + "}", Uri.EscapeDataString(value), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
Match unresolved = PathParameterRegex().Match(path);
|
||||
if (unresolved.Success)
|
||||
{
|
||||
throw new ArgumentException($"Missing required argument '{unresolved.Groups[1].Value}'");
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\{([^}]+)\}")]
|
||||
private static partial Regex PathParameterRegex();
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ErsatzTV.Mcp;
|
||||
|
||||
public sealed class McpServer(IToolExecutor toolExecutor)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
// Process-lifetime document so NullId stays valid; used as the JSON-RPC id for
|
||||
// parse errors / requests with no usable id.
|
||||
private static readonly JsonDocument NullIdDocument = JsonDocument.Parse("null");
|
||||
private static readonly JsonElement NullId = NullIdDocument.RootElement;
|
||||
|
||||
public async Task<string?> HandleAsync(string requestJson, CancellationToken cancellationToken)
|
||||
{
|
||||
JsonDocument request;
|
||||
try
|
||||
{
|
||||
request = JsonDocument.Parse(requestJson);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// A malformed line must never crash the session loop (JSON-RPC parse error, id null).
|
||||
return SerializeError(NullId, -32700, "Parse error: invalid JSON.");
|
||||
}
|
||||
|
||||
using (request)
|
||||
{
|
||||
JsonElement root = request.RootElement;
|
||||
JsonElement id = NullId;
|
||||
bool hasId = false;
|
||||
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("id", out JsonElement idValue))
|
||||
{
|
||||
id = idValue;
|
||||
hasId = true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new JsonRpcException(-32600, "Invalid Request: expected a JSON-RPC object.");
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("method", out JsonElement methodElement)
|
||||
|| methodElement.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
throw new JsonRpcException(-32600, "Invalid Request: missing or non-string 'method'.");
|
||||
}
|
||||
|
||||
// No id ⇒ notification ⇒ no response.
|
||||
if (!hasId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? method = methodElement.GetString();
|
||||
object result = method switch
|
||||
{
|
||||
"initialize" => InitializeResult(),
|
||||
"tools/list" => ToolsListResult(),
|
||||
"tools/call" => await CallToolAsync(RequireParams(root), cancellationToken),
|
||||
_ => throw new JsonRpcException(-32601, $"Method not found: {method}")
|
||||
};
|
||||
|
||||
return SerializeResponse(id, result);
|
||||
}
|
||||
catch (JsonRpcException ex)
|
||||
{
|
||||
return SerializeError(id, ex.Code, ex.Message);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or KeyNotFoundException or InvalidOperationException)
|
||||
{
|
||||
return hasId ? SerializeError(id, -32602, ex.Message) : null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Transport/timeout/unexpected failures (HttpRequestException, a fired request
|
||||
// timeout, etc.) must still return a JSON-RPC error for the id — otherwise a
|
||||
// compliant client blocks forever awaiting a response that never comes.
|
||||
return hasId ? SerializeError(id, -32603, $"Internal error: {ex.Message}") : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonElement RequireParams(JsonElement root) =>
|
||||
root.TryGetProperty("params", out JsonElement parameters)
|
||||
? parameters
|
||||
: throw new JsonRpcException(-32602, "Invalid params: missing 'params'.");
|
||||
|
||||
private static object InitializeResult() => new
|
||||
{
|
||||
protocolVersion = "2024-11-05",
|
||||
capabilities = new
|
||||
{
|
||||
tools = new { }
|
||||
},
|
||||
serverInfo = new
|
||||
{
|
||||
name = "ersatztv-mcp",
|
||||
version = "0.1.0"
|
||||
}
|
||||
};
|
||||
|
||||
private static object ToolsListResult() => new
|
||||
{
|
||||
tools = ToolCatalog.All.Select(t => new
|
||||
{
|
||||
name = t.Name,
|
||||
description = t.Description,
|
||||
inputSchema = t.InputSchema.RootElement
|
||||
})
|
||||
};
|
||||
|
||||
private async Task<object> CallToolAsync(JsonElement parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
string name = parameters.GetProperty("name").GetString() ?? throw new ArgumentException("Tool name is required");
|
||||
ToolDefinition tool = ToolCatalog.Find(name) ?? throw new ArgumentException($"Unknown tool: {name}");
|
||||
JsonElement arguments = parameters.TryGetProperty("arguments", out JsonElement args)
|
||||
? args
|
||||
: JsonDocument.Parse("{}").RootElement;
|
||||
|
||||
ToolCallResult result = await toolExecutor.CallToolAsync(tool, arguments, cancellationToken);
|
||||
return new
|
||||
{
|
||||
content = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "text",
|
||||
text = result.Text
|
||||
}
|
||||
},
|
||||
isError = result.IsError
|
||||
};
|
||||
}
|
||||
|
||||
private static string SerializeResponse(JsonElement id, object result) =>
|
||||
JsonSerializer.Serialize(
|
||||
new
|
||||
{
|
||||
jsonrpc = "2.0",
|
||||
id = id.Clone(),
|
||||
result
|
||||
},
|
||||
JsonOptions);
|
||||
|
||||
private static string SerializeError(JsonElement id, int code, string message) =>
|
||||
JsonSerializer.Serialize(
|
||||
new
|
||||
{
|
||||
jsonrpc = "2.0",
|
||||
id = id.Clone(),
|
||||
error = new
|
||||
{
|
||||
code,
|
||||
message
|
||||
}
|
||||
},
|
||||
JsonOptions);
|
||||
|
||||
private sealed class JsonRpcException(int code, string message) : Exception(message)
|
||||
{
|
||||
public int Code { get; } = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
namespace ErsatzTV.Mcp;
|
||||
|
||||
public static class Program
|
||||
{
|
||||
public static async Task Main()
|
||||
{
|
||||
string baseUrl = Environment.GetEnvironmentVariable("ERSATZTV_URL") ?? "http://localhost:8409";
|
||||
string? apiKey = Environment.GetEnvironmentVariable("ERSATZTV_API_KEY");
|
||||
bool allowWrites = ParseBool(Environment.GetEnvironmentVariable("ERSATZTV_ALLOW_WRITES"));
|
||||
int maxResponseBytes = ParseInt(
|
||||
Environment.GetEnvironmentVariable("ERSATZTV_MAX_RESPONSE_BYTES"),
|
||||
fallback: ErsatzTvApiClientOptions.DefaultMaxResponseBytes,
|
||||
min: 1024,
|
||||
max: ErsatzTvApiClientOptions.MaxAllowedResponseBytes);
|
||||
int timeoutSeconds = ParseInt(
|
||||
Environment.GetEnvironmentVariable("ERSATZTV_REQUEST_TIMEOUT_SECONDS"),
|
||||
fallback: 30,
|
||||
min: 1,
|
||||
max: 3600);
|
||||
var requestTimeout = TimeSpan.FromSeconds(timeoutSeconds);
|
||||
|
||||
// The per-request timeout is enforced via a CancellationToken inside the client (it must
|
||||
// cover the streamed body read too), so leave HttpClient's own timeout off to avoid a
|
||||
// second, header-only timer racing it.
|
||||
using var httpClient = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };
|
||||
var apiClient = new ErsatzTvApiClient(
|
||||
httpClient,
|
||||
new ErsatzTvApiClientOptions(new Uri(baseUrl), apiKey, allowWrites, maxResponseBytes, requestTimeout));
|
||||
var server = new McpServer(apiClient);
|
||||
|
||||
while (true)
|
||||
{
|
||||
BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(Console.In);
|
||||
if (line.EndOfStream)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (line.Overflowed)
|
||||
{
|
||||
await Console.Error.WriteLineAsync("[ersatztv-mcp] dropped oversized request line.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(line.Text))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string? response = await server.HandleAsync(line.Text, CancellationToken.None);
|
||||
if (response is not null)
|
||||
{
|
||||
await Console.Out.WriteLineAsync(response);
|
||||
await Console.Out.FlushAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Last-resort guard: a single failed request must never terminate the session.
|
||||
await Console.Error.WriteLineAsync($"[ersatztv-mcp] error handling request: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ParseBool(string? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (bool.TryParse(value, out bool parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
string trimmed = value.Trim();
|
||||
return trimmed is "1"
|
||||
|| string.Equals(trimmed, "yes", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(trimmed, "on", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static int ParseInt(string? value, int fallback, int min, int max) =>
|
||||
int.TryParse(value, out int parsed) ? Math.Clamp(parsed, min, max) : fallback;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ErsatzTV.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight validation of caller-supplied tool arguments against a tool's declared
|
||||
/// <c>InputSchema</c>. Not a full JSON-Schema implementation — it enforces the shapes the
|
||||
/// catalog actually emits (typed properties, a required list, <c>additionalProperties:false</c>)
|
||||
/// so unknown/malformed arguments are rejected before an API request is built.
|
||||
/// Throws <see cref="ArgumentException"/> (mapped to JSON-RPC -32602 by the server).
|
||||
/// </summary>
|
||||
public static class ToolArgumentValidator
|
||||
{
|
||||
public static void Validate(ToolDefinition tool, JsonElement arguments)
|
||||
{
|
||||
if (arguments.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new ArgumentException($"Arguments for tool '{tool.Name}' must be a JSON object.");
|
||||
}
|
||||
|
||||
JsonElement schema = tool.InputSchema.RootElement;
|
||||
JsonElement properties = schema.TryGetProperty("properties", out JsonElement props)
|
||||
? props
|
||||
: default;
|
||||
bool additionalAllowed = !schema.TryGetProperty("additionalProperties", out JsonElement additional)
|
||||
|| additional.ValueKind != JsonValueKind.False;
|
||||
|
||||
foreach (JsonProperty arg in arguments.EnumerateObject())
|
||||
{
|
||||
if (properties.ValueKind != JsonValueKind.Object
|
||||
|| !properties.TryGetProperty(arg.Name, out JsonElement propertySchema))
|
||||
{
|
||||
if (!additionalAllowed)
|
||||
{
|
||||
throw new ArgumentException($"Unknown argument '{arg.Name}' for tool '{tool.Name}'.");
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
string? type = propertySchema.TryGetProperty("type", out JsonElement typeElement)
|
||||
? typeElement.GetString()
|
||||
: null;
|
||||
if (!MatchesType(type, arg.Value))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Argument '{arg.Name}' for tool '{tool.Name}' must be of type '{type}'.");
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.TryGetProperty("required", out JsonElement required)
|
||||
&& required.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement name in required.EnumerateArray())
|
||||
{
|
||||
string? propertyName = name.GetString();
|
||||
if (propertyName is not null && !arguments.TryGetProperty(propertyName, out _))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Missing required argument '{propertyName}' for tool '{tool.Name}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool MatchesType(string? type, JsonElement value) => type switch
|
||||
{
|
||||
"integer" => value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out _),
|
||||
"number" => value.ValueKind == JsonValueKind.Number,
|
||||
"string" => value.ValueKind == JsonValueKind.String,
|
||||
"boolean" => value.ValueKind is JsonValueKind.True or JsonValueKind.False,
|
||||
"array" => value.ValueKind == JsonValueKind.Array,
|
||||
"object" => value.ValueKind == JsonValueKind.Object,
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace ErsatzTV.Mcp;
|
||||
|
||||
public static class ToolCatalog
|
||||
{
|
||||
public static IReadOnlyList<ToolDefinition> All { get; } =
|
||||
[
|
||||
Get("ersatztv_list_channels", "List channels.", "/api/channels"),
|
||||
Get("ersatztv_get_channel", "Get a channel by id.", "/api/channels/{id}", Id("id", "Channel id.")),
|
||||
Get("ersatztv_list_collections", "List collections.", "/api/collections"),
|
||||
Get("ersatztv_get_collection", "Get a collection by id.", "/api/collections/{id}", Id("id", "Collection id.")),
|
||||
Get("ersatztv_list_smart_collections", "List smart collections.", "/api/smart-collections"),
|
||||
Get("ersatztv_get_smart_collection", "Get a smart collection by id.", "/api/smart-collections/{id}", Id("id", "Smart collection id.")),
|
||||
Get("ersatztv_list_schedules", "List schedules.", "/api/schedules"),
|
||||
Get("ersatztv_get_schedule", "Get a schedule by id.", "/api/schedules/{id}", Id("id", "Schedule id.")),
|
||||
Get("ersatztv_list_schedule_items", "List schedule items.", "/api/schedules/{id}/items", Id("id", "Schedule id.")),
|
||||
Get("ersatztv_get_playout", "Get a playout by id.", "/api/playouts/{id}", Id("id", "Playout id.")),
|
||||
Get("ersatztv_list_ffmpeg_profiles", "List FFmpeg profiles.", "/api/ffmpeg/profiles"),
|
||||
Get("ersatztv_get_ffmpeg_profile", "Get an FFmpeg profile by id.", "/api/ffmpeg/profiles/{id}", Id("id", "FFmpeg profile id.")),
|
||||
Get(
|
||||
"ersatztv_get_resolution_by_name",
|
||||
"Get an FFmpeg resolution by name.",
|
||||
"/api/ffmpeg/resolution/by-name/{name}",
|
||||
("name", "string", "Resolution name.", true)),
|
||||
Get("ersatztv_list_sessions", "List active HLS sessions.", "/api/sessions"),
|
||||
Get("ersatztv_get_version", "Get API and app version.", "/api/version")
|
||||
];
|
||||
|
||||
public static ToolDefinition? Find(string name) =>
|
||||
All.FirstOrDefault(t => string.Equals(t.Name, name, StringComparison.Ordinal));
|
||||
|
||||
private static ToolDefinition Get(
|
||||
string name,
|
||||
string description,
|
||||
string path,
|
||||
params (string Name, string Type, string Description, bool Required)[] properties) =>
|
||||
new(
|
||||
name,
|
||||
description,
|
||||
HttpMethod.Get,
|
||||
path,
|
||||
properties.Length == 0 ? ToolInputSchemas.Empty : ToolInputSchemas.Object(properties));
|
||||
|
||||
private static (string Name, string Type, string Description, bool Required) Id(string name, string description) =>
|
||||
(name, "integer", description, true);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ErsatzTV.Mcp;
|
||||
|
||||
public sealed record ToolDefinition(
|
||||
string Name,
|
||||
string Description,
|
||||
HttpMethod HttpMethod,
|
||||
string PathTemplate,
|
||||
JsonDocument InputSchema);
|
||||
|
||||
public sealed record ToolCallResult(bool IsError, string Text);
|
||||
|
||||
public sealed record ErsatzTvApiClientOptions(
|
||||
Uri BaseUrl,
|
||||
string? ApiKey,
|
||||
bool AllowWrites = false,
|
||||
int MaxResponseBytes = ErsatzTvApiClientOptions.DefaultMaxResponseBytes,
|
||||
TimeSpan RequestTimeout = default)
|
||||
{
|
||||
// Cap the response body buffered back to the model so a large/hostile API
|
||||
// response cannot exhaust memory or flood the context window.
|
||||
public const int DefaultMaxResponseBytes = 1024 * 1024;
|
||||
|
||||
// Hard ceiling so a hostile/typo'd cap can't request a huge (or overflowing) allocation.
|
||||
public const int MaxAllowedResponseBytes = 64 * 1024 * 1024;
|
||||
|
||||
public static readonly TimeSpan DefaultRequestTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
// The per-request timeout, covering headers *and* body (see ErsatzTvApiClient).
|
||||
public TimeSpan EffectiveRequestTimeout => RequestTimeout > TimeSpan.Zero ? RequestTimeout : DefaultRequestTimeout;
|
||||
}
|
||||
|
||||
public interface IToolExecutor
|
||||
{
|
||||
Task<ToolCallResult> CallToolAsync(
|
||||
ToolDefinition tool,
|
||||
JsonElement arguments,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ErsatzTV.Mcp;
|
||||
|
||||
public static class ToolInputSchemas
|
||||
{
|
||||
public static JsonDocument Empty { get; } = JsonDocument.Parse(
|
||||
"""
|
||||
{"type":"object","properties":{},"additionalProperties":false}
|
||||
""");
|
||||
|
||||
public static JsonDocument Object(params (string Name, string Type, string Description, bool Required)[] properties)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (var writer = new Utf8JsonWriter(stream))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "object");
|
||||
writer.WriteStartObject("properties");
|
||||
foreach ((string name, string type, string description, bool _) in properties)
|
||||
{
|
||||
writer.WriteStartObject(name);
|
||||
writer.WriteString("type", type);
|
||||
writer.WriteString("description", description);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
writer.WriteStartArray("required");
|
||||
foreach ((string name, string _, string _, bool required) in properties.Where(p => p.Required))
|
||||
{
|
||||
writer.WriteStringValue(name);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
writer.WriteBoolean("additionalProperties", false);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
return JsonDocument.Parse(stream.ToArray());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 7–9-way parallel workflow build once exhausted RAM, so
|
||||
builds are limited to 2–3 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 0–1 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
|
||||
pending∪active 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` 0–1 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 2–3 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 (~150–252). /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
@@ -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.
|
||||
@@ -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
@@ -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} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user