feat(api): #286 — mount the whole /api surface at /api/v1
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 3m4s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 3m4s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Version every /api route to /api/v1 (251 controller routes + ~24 Location
headers + the scanner callback URL + the Startup request-log literal),
uniform across the machine API, auth, scanner and scripted-build surfaces.
Add ApiVersionRewriteMiddleware: a legacy unversioned /api/* request is
rewritten (NOT redirected) to /api/v1/* in-pipeline — method, body, auth
headers and query survive — carrying RFC 8594 Deprecation/Sunset headers,
so curl / the future MCP server / bookmarks keep working. An already-
versioned path passes through; a future /api/v2 is never forced to v1.
Standardize the route convention (leading-slash absolute route per method,
no class-[Route] — except the two Scanner/Scripted controllers whose ~all
actions share a parametrized {id} prefix), enforced by ApiRouteVersioningTests
(^/api/v\d+/ over the whole Controllers.Api surface; browser-nav
/auth/oidc/login is out of scope).
Regenerate v1.json (160 paths, all /api/v1)/endpoint-index/v1.d.ts; sweep 945
SPA request literals + the test mocks (regex + positional URL parsers). /api/v1
is additive-only after freeze; the legacy-rewrite shim sunsets in ~2 releases
(owner decision) with removal tracked as a Phase-3 follow-up.
Docs: decisions.md 2026-07-13, api-conventions §1/§9, rest-api/spa-conventions/
blazor-route-parity/e2e-local/domain-model.
fixes #286
refs #197
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,7 +22,7 @@ public abstract class CallLibraryScannerHandler<TRequest>(
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger logger)
|
||||
{
|
||||
protected static string GetBaseUrl(Guid scanId) => $"http://localhost:{Settings.UiPort}/api/scan/{scanId}";
|
||||
protected static string GetBaseUrl(Guid scanId) => $"http://localhost:{Settings.UiPort}/api/v1/scan/{scanId}";
|
||||
|
||||
protected async Task<Either<BaseError, string>> PerformScan(
|
||||
ScanParameters parameters,
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ErsatzTV.Application.Search;
|
||||
|
||||
// Fans out to the shared library-browse query once per media kind (mirroring the legacy Search.razor page,
|
||||
// which sends one `type:{kind} AND ({query})` query per kind). Reusing GetLibraryBrowseItems keeps hydration,
|
||||
// artwork resolution and the response shape identical to /api/library/browse. Raw Lucene queries pass through
|
||||
// artwork resolution and the response shape identical to /api/v1/library/browse. Raw Lucene queries pass through
|
||||
// unchanged, so state filters such as `state:FileNotFound` (the Trash screen) work here too.
|
||||
public class GetSearchResultsHandler(IMediator mediator)
|
||||
: IRequestHandler<GetSearchResults, SearchResultsResponseModel>
|
||||
|
||||
@@ -3,7 +3,7 @@ using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
|
||||
// Faithful channel-detail DTO for the SPA channel editor (GET/POST/PUT /api/channels[/{id}]).
|
||||
// Faithful channel-detail DTO for the SPA channel editor (GET/POST/PUT /api/v1/channels[/{id}]).
|
||||
// Exposes the raw editable field set the editor reads (raw FFmpegProfileId / WatermarkId /
|
||||
// FallbackFillerId ids and the mode enums), unlike the lean list ChannelResponseModel which
|
||||
// resolves the profile to a display name and drops the editable ids. Mirrors the effective
|
||||
|
||||
@@ -77,7 +77,7 @@ public class ApiControllerSecurityTests
|
||||
public void Only_Scanner_And_Auth_Controllers_Should_Be_Auth_Exempt()
|
||||
{
|
||||
// ScannerController: internal loopback callback, gated by [LocalhostOnly] instead of a credential.
|
||||
// AuthController: the /api/auth/* surface itself must be reachable before a caller is authenticated
|
||||
// AuthController: the /api/v1/auth/* surface itself must be reachable before a caller is authenticated
|
||||
// (config/session/login/setup) — its sensitive action (password change) self-checks the principal.
|
||||
// Any OTHER [SkipApiAuthorization] controller is a fail-open hole and must be caught here.
|
||||
Type[] exemptControllers = typeof(ScannerController)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Middleware;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Enforces the #286 route convention: every API-controller action's effective route is
|
||||
/// versioned and absolute (<c>^/api/v{n}/</c>). This is the standing net for the leading-slash +
|
||||
/// versioning standardization — a new controller that drifts (a relative or unversioned route)
|
||||
/// fails CI here, the "fix it while you're in the file" gate the format rules use.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ApiRouteVersioningTests
|
||||
{
|
||||
private static readonly Regex VersionedAbsolute = new(@"^/api/v\d+/", RegexOptions.Compiled);
|
||||
|
||||
[Test]
|
||||
public void Every_Api_Controller_Action_Route_Should_Be_Versioned_And_Absolute()
|
||||
{
|
||||
// Same reflective scan as ApiControllerSecurityTests: [ApiController] concrete classes in the
|
||||
// Controllers.Api namespace, so a new controller is covered automatically (no hand list).
|
||||
Type[] apiControllers = typeof(CollectionController)
|
||||
.Assembly
|
||||
.GetTypes()
|
||||
.Where(t => t.Namespace == typeof(CollectionController).Namespace)
|
||||
.Where(t => t is { IsClass: true, IsAbstract: false })
|
||||
.Where(t => t.GetCustomAttributes<ApiControllerAttribute>(inherit: true).Any())
|
||||
.ToArray();
|
||||
|
||||
apiControllers.Length.ShouldBeGreaterThanOrEqualTo(20);
|
||||
|
||||
var assertedRoutes = 0;
|
||||
foreach (Type controllerType in apiControllers)
|
||||
{
|
||||
// A class-level [Route] prefix is allowed (the shared-{id} pattern used by Scanner/Scripted);
|
||||
// the effective route is what must be versioned + absolute.
|
||||
string controllerTemplate = controllerType
|
||||
.GetCustomAttributes<RouteAttribute>(inherit: true)
|
||||
.Select(r => r.Template)
|
||||
.FirstOrDefault();
|
||||
|
||||
foreach (MethodInfo action in controllerType
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
foreach (HttpMethodAttribute httpAttribute in action
|
||||
.GetCustomAttributes<HttpMethodAttribute>(inherit: true))
|
||||
{
|
||||
string? effective = CombineRoute(controllerTemplate, httpAttribute.Template);
|
||||
if (effective is null)
|
||||
{
|
||||
// No route on the controller or the action → not a routable API endpoint; skip.
|
||||
continue;
|
||||
}
|
||||
|
||||
// A few actions on API controllers deliberately live OUTSIDE /api because they are
|
||||
// browser-navigation endpoints, not part of the JSON API surface — e.g.
|
||||
// AuthController's GET /auth/oidc/login OIDC challenge (api-conventions §9). The
|
||||
// versioning convention only governs the /api surface; a non-/api nav route is a
|
||||
// different surface and must NOT be forced to /api/v1.
|
||||
if (!effective.StartsWith("/api/", StringComparison.OrdinalIgnoreCase) &&
|
||||
!effective.Equals("/api", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
VersionedAbsolute.IsMatch(effective).ShouldBeTrue(
|
||||
$"{controllerType.Name}.{action.Name} route '{effective}' must be versioned + absolute (^/api/v{{n}}/)");
|
||||
assertedRoutes++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Non-vacuous guard: the whole surface is ~250 routes, so a scan that asserted almost nothing
|
||||
// (a reflection regression) would otherwise pass green.
|
||||
assertedRoutes.ShouldBeGreaterThanOrEqualTo(150);
|
||||
}
|
||||
|
||||
// Mirror ASP.NET Core's attribute-route combination: an action template starting with '/' or '~/'
|
||||
// is absolute (ignores the controller prefix); otherwise it is appended to the controller prefix.
|
||||
private static string? CombineRoute(string? controllerTemplate, string? actionTemplate)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(actionTemplate) &&
|
||||
(actionTemplate.StartsWith('/') || actionTemplate.StartsWith("~/")))
|
||||
{
|
||||
return actionTemplate.TrimStart('~');
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(controllerTemplate))
|
||||
{
|
||||
string prefix = "/" + controllerTemplate.Trim('/');
|
||||
return string.IsNullOrEmpty(actionTemplate) ? prefix : prefix + "/" + actionTemplate.TrimStart('/');
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(actionTemplate))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return "/" + actionTemplate.TrimStart('/');
|
||||
}
|
||||
}
|
||||
|
||||
[TestFixture]
|
||||
public class ApiVersionRewriteMiddlewareTests
|
||||
{
|
||||
[TestCase("/api/channels", "/api/v1/channels")]
|
||||
[TestCase("/api/channels/5", "/api/v1/channels/5")]
|
||||
[TestCase("/api/scan/00000000-0000-0000-0000-000000000000/progress",
|
||||
"/api/v1/scan/00000000-0000-0000-0000-000000000000/progress")]
|
||||
[TestCase("/api/version", "/api/v1/version")] // 'version' must not be mistaken for a version token
|
||||
[TestCase("/API/Channels", "/api/v1/Channels")] // case-insensitive on the /api segment
|
||||
public void Should_Rewrite_Legacy_Unversioned_Api_Path(string input, string expected)
|
||||
{
|
||||
ApiVersionRewriteMiddleware.TryRewriteLegacyApiPath(new PathString(input), out PathString rewritten)
|
||||
.ShouldBeTrue();
|
||||
rewritten.Value.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[TestCase("/api/v1/channels")] // already versioned
|
||||
[TestCase("/api/v2/channels")] // a future version is passed through, never forced back to v1
|
||||
[TestCase("/iptv/channels.m3u")] // non-/api
|
||||
[TestCase("/app/")]
|
||||
[TestCase("/artwork/posters/1.jpg")]
|
||||
[TestCase("/api")] // no trailing segment
|
||||
[TestCase("")]
|
||||
public void Should_Not_Rewrite(string input)
|
||||
{
|
||||
ApiVersionRewriteMiddleware.TryRewriteLegacyApiPath(new PathString(input), out PathString rewritten)
|
||||
.ShouldBeFalse();
|
||||
rewritten.Value.ShouldBe(new PathString(input).Value);
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ public class ArtworkUploadControllerTests
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain("POST");
|
||||
attribute.Template.ShouldBe("/api/artwork/uploads");
|
||||
attribute.Template.ShouldBe("/api/v1/artwork/uploads");
|
||||
attribute.Name.ShouldBe("UploadArtwork");
|
||||
}
|
||||
|
||||
|
||||
@@ -41,17 +41,17 @@ public class BlockControllerTests
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(BlockController.GetGroups), "GET", "/api/blocks/groups");
|
||||
ShouldHaveActionRoute(nameof(BlockController.CreateGroup), "POST", "/api/blocks/groups");
|
||||
ShouldHaveActionRoute(nameof(BlockController.DeleteGroup), "DELETE", "/api/blocks/groups/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(BlockController.GetAll), "GET", "/api/blocks");
|
||||
ShouldHaveActionRoute(nameof(BlockController.GetById), "GET", "/api/blocks/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(BlockController.Create), "POST", "/api/blocks");
|
||||
ShouldHaveActionRoute(nameof(BlockController.Delete), "DELETE", "/api/blocks/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(BlockController.GetItems), "GET", "/api/blocks/{id:int}/items");
|
||||
ShouldHaveActionRoute(nameof(BlockController.Replace), "PUT", "/api/blocks/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(BlockController.Preview), "POST", "/api/blocks/{id:int}/preview");
|
||||
ShouldHaveActionRoute(nameof(BlockController.Copy), "POST", "/api/blocks/{id:int}/copy");
|
||||
ShouldHaveActionRoute(nameof(BlockController.GetGroups), "GET", "/api/v1/blocks/groups");
|
||||
ShouldHaveActionRoute(nameof(BlockController.CreateGroup), "POST", "/api/v1/blocks/groups");
|
||||
ShouldHaveActionRoute(nameof(BlockController.DeleteGroup), "DELETE", "/api/v1/blocks/groups/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(BlockController.GetAll), "GET", "/api/v1/blocks");
|
||||
ShouldHaveActionRoute(nameof(BlockController.GetById), "GET", "/api/v1/blocks/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(BlockController.Create), "POST", "/api/v1/blocks");
|
||||
ShouldHaveActionRoute(nameof(BlockController.Delete), "DELETE", "/api/v1/blocks/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(BlockController.GetItems), "GET", "/api/v1/blocks/{id:int}/items");
|
||||
ShouldHaveActionRoute(nameof(BlockController.Replace), "PUT", "/api/v1/blocks/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(BlockController.Preview), "POST", "/api/v1/blocks/{id:int}/preview");
|
||||
ShouldHaveActionRoute(nameof(BlockController.Copy), "POST", "/api/v1/blocks/{id:int}/copy");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -65,7 +65,7 @@ public class BlockControllerTests
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/blocks/groups/5");
|
||||
created.Location.ShouldBe("/api/v1/blocks/groups/5");
|
||||
created.Value.ShouldBeOfType<BlockGroupResponseModel>().Name.ShouldBe("Prime Time");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreateBlockGroup>(c => c.Name == "Prime Time"),
|
||||
@@ -138,7 +138,7 @@ public class BlockControllerTests
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/blocks/8");
|
||||
created.Location.ShouldBe("/api/v1/blocks/8");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreateBlock>(c => c.BlockGroupId == 2 && c.Name == "Morning"),
|
||||
Arg.Any<CancellationToken>());
|
||||
@@ -440,7 +440,7 @@ public class BlockControllerTests
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/blocks/9");
|
||||
created.Location.ShouldBe("/api/v1/blocks/9");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CopyBlock>(c => c.BlockId == 4 && c.NewBlockGroupId == 3 && c.NewBlockName == "Morning Copy"),
|
||||
Arg.Any<CancellationToken>());
|
||||
|
||||
@@ -67,11 +67,11 @@ public class ChannelControllerTests
|
||||
{
|
||||
MethodInfo mvct = typeof(ChannelController).GetMethod(nameof(ChannelController.GetMusicVideoCreditsTemplates))!;
|
||||
mvct.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single().Template
|
||||
.ShouldBe("/api/channels/music-video-credits-templates");
|
||||
.ShouldBe("/api/v1/channels/music-video-credits-templates");
|
||||
|
||||
MethodInfo selectors = typeof(ChannelController).GetMethod(nameof(ChannelController.GetStreamSelectors))!;
|
||||
selectors.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single().Template
|
||||
.ShouldBe("/api/channels/stream-selectors");
|
||||
.ShouldBe("/api/v1/channels/stream-selectors");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -109,7 +109,7 @@ public class ChannelControllerTests
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.StatusCode.ShouldBe(201);
|
||||
created.Location.ShouldBe("/api/channels/5");
|
||||
created.Location.ShouldBe("/api/v1/channels/5");
|
||||
created.Value.ShouldBe(model);
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ public class ChannelControllerTests
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.StatusCode.ShouldBe(201);
|
||||
created.Location.ShouldBe("/api/channels/5");
|
||||
created.Location.ShouldBe("/api/v1/channels/5");
|
||||
created.Value.ShouldBe(response);
|
||||
}
|
||||
|
||||
@@ -389,7 +389,7 @@ public class ChannelControllerTests
|
||||
{
|
||||
MethodInfo reset = typeof(ChannelController).GetMethod(nameof(ChannelController.ResetPlayout))!;
|
||||
reset.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single().Template
|
||||
.ShouldBe("/api/channels/{id:int}/playout/reset");
|
||||
.ShouldBe("/api/v1/channels/{id:int}/playout/reset");
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -38,37 +38,37 @@ public class ChannelTemplateControllerTests
|
||||
ShouldHaveActionRoute(
|
||||
nameof(ChannelTemplateController.GetAll),
|
||||
"GET",
|
||||
"/api/channel-templates",
|
||||
"/api/v1/channel-templates",
|
||||
"GetChannelTemplates");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(ChannelTemplateController.GetDefault),
|
||||
"GET",
|
||||
"/api/channel-templates/default",
|
||||
"/api/v1/channel-templates/default",
|
||||
"GetDefaultChannelTemplate");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(ChannelTemplateController.SetDefault),
|
||||
"PUT",
|
||||
"/api/channel-templates/default/{id:int}",
|
||||
"/api/v1/channel-templates/default/{id:int}",
|
||||
"SetDefaultChannelTemplate");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(ChannelTemplateController.GetById),
|
||||
"GET",
|
||||
"/api/channel-templates/{id:int}",
|
||||
"/api/v1/channel-templates/{id:int}",
|
||||
"GetChannelTemplateById");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(ChannelTemplateController.Create),
|
||||
"POST",
|
||||
"/api/channel-templates",
|
||||
"/api/v1/channel-templates",
|
||||
"CreateChannelTemplate");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(ChannelTemplateController.Update),
|
||||
"PUT",
|
||||
"/api/channel-templates/{id:int}",
|
||||
"/api/v1/channel-templates/{id:int}",
|
||||
"UpdateChannelTemplate");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(ChannelTemplateController.Delete),
|
||||
"DELETE",
|
||||
"/api/channel-templates/{id:int}",
|
||||
"/api/v1/channel-templates/{id:int}",
|
||||
"DeleteChannelTemplate");
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ public class ChannelTemplateControllerTests
|
||||
IActionResult result = await _controller.Create(MakeCreateRequest("Custom"), CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/channel-templates/7");
|
||||
created.Location.ShouldBe("/api/v1/channel-templates/7");
|
||||
created.Value.ShouldBe(vm);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,21 +40,21 @@ public class CollectionControllerTests
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(CollectionController.GetAll), "GET", "/api/collections");
|
||||
ShouldHaveActionRoute(nameof(CollectionController.GetById), "GET", "/api/collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(CollectionController.GetItems), "GET", "/api/collections/{id:int}/items");
|
||||
ShouldHaveActionRoute(nameof(CollectionController.Create), "POST", "/api/collections");
|
||||
ShouldHaveActionRoute(nameof(CollectionController.Update), "PUT", "/api/collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(CollectionController.Delete), "DELETE", "/api/collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(CollectionController.AddItems), "POST", "/api/collections/{id:int}/items");
|
||||
ShouldHaveActionRoute(nameof(CollectionController.GetAll), "GET", "/api/v1/collections");
|
||||
ShouldHaveActionRoute(nameof(CollectionController.GetById), "GET", "/api/v1/collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(CollectionController.GetItems), "GET", "/api/v1/collections/{id:int}/items");
|
||||
ShouldHaveActionRoute(nameof(CollectionController.Create), "POST", "/api/v1/collections");
|
||||
ShouldHaveActionRoute(nameof(CollectionController.Update), "PUT", "/api/v1/collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(CollectionController.Delete), "DELETE", "/api/v1/collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(CollectionController.AddItems), "POST", "/api/v1/collections/{id:int}/items");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(CollectionController.RemoveItem),
|
||||
"DELETE",
|
||||
"/api/collections/{id:int}/items/{mediaItemId:int}");
|
||||
"/api/v1/collections/{id:int}/items/{mediaItemId:int}");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(CollectionController.UpdateCustomOrder),
|
||||
"PUT",
|
||||
"/api/collections/{id:int}/custom-order");
|
||||
"/api/v1/collections/{id:int}/custom-order");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -124,7 +124,7 @@ public class CollectionControllerTests
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.StatusCode.ShouldBe(201);
|
||||
created.Location.ShouldBe("/api/collections/5");
|
||||
created.Location.ShouldBe("/api/v1/collections/5");
|
||||
created.Value.ShouldBe(new MediaCollectionResponseModel(5, "Movies", CollectionType.Collection, false));
|
||||
}
|
||||
|
||||
|
||||
@@ -33,14 +33,14 @@ public class DecoControllerTests
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(DecoController.GetGroups), "GET", "/api/decos/groups");
|
||||
ShouldHaveActionRoute(nameof(DecoController.CreateGroup), "POST", "/api/decos/groups");
|
||||
ShouldHaveActionRoute(nameof(DecoController.DeleteGroup), "DELETE", "/api/decos/groups/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(DecoController.GetAll), "GET", "/api/decos");
|
||||
ShouldHaveActionRoute(nameof(DecoController.GetById), "GET", "/api/decos/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(DecoController.Create), "POST", "/api/decos");
|
||||
ShouldHaveActionRoute(nameof(DecoController.Delete), "DELETE", "/api/decos/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(DecoController.Replace), "PUT", "/api/decos/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(DecoController.GetGroups), "GET", "/api/v1/decos/groups");
|
||||
ShouldHaveActionRoute(nameof(DecoController.CreateGroup), "POST", "/api/v1/decos/groups");
|
||||
ShouldHaveActionRoute(nameof(DecoController.DeleteGroup), "DELETE", "/api/v1/decos/groups/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(DecoController.GetAll), "GET", "/api/v1/decos");
|
||||
ShouldHaveActionRoute(nameof(DecoController.GetById), "GET", "/api/v1/decos/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(DecoController.Create), "POST", "/api/v1/decos");
|
||||
ShouldHaveActionRoute(nameof(DecoController.Delete), "DELETE", "/api/v1/decos/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(DecoController.Replace), "PUT", "/api/v1/decos/{id:int}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -68,7 +68,7 @@ public class DecoControllerTests
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/decos/groups/5");
|
||||
created.Location.ShouldBe("/api/v1/decos/groups/5");
|
||||
created.Value.ShouldBeOfType<DecoGroupResponseModel>().Name.ShouldBe("Bumpers");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreateDecoGroup>(c => c.Name == "Bumpers"),
|
||||
@@ -193,7 +193,7 @@ public class DecoControllerTests
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/decos/8");
|
||||
created.Location.ShouldBe("/api/v1/decos/8");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreateDeco>(c => c.DecoGroupId == 2 && c.Name == "Movie Night"),
|
||||
Arg.Any<CancellationToken>());
|
||||
|
||||
@@ -38,18 +38,18 @@ public class DecoTemplateControllerTests
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(DecoTemplateController.GetGroups), "GET", "/api/deco-templates/groups");
|
||||
ShouldHaveActionRoute(nameof(DecoTemplateController.CreateGroup), "POST", "/api/deco-templates/groups");
|
||||
ShouldHaveActionRoute(nameof(DecoTemplateController.GetGroups), "GET", "/api/v1/deco-templates/groups");
|
||||
ShouldHaveActionRoute(nameof(DecoTemplateController.CreateGroup), "POST", "/api/v1/deco-templates/groups");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(DecoTemplateController.DeleteGroup),
|
||||
"DELETE",
|
||||
"/api/deco-templates/groups/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(DecoTemplateController.GetAll), "GET", "/api/deco-templates");
|
||||
ShouldHaveActionRoute(nameof(DecoTemplateController.GetById), "GET", "/api/deco-templates/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(DecoTemplateController.Create), "POST", "/api/deco-templates");
|
||||
ShouldHaveActionRoute(nameof(DecoTemplateController.Delete), "DELETE", "/api/deco-templates/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(DecoTemplateController.GetItems), "GET", "/api/deco-templates/{id:int}/items");
|
||||
ShouldHaveActionRoute(nameof(DecoTemplateController.Replace), "PUT", "/api/deco-templates/{id:int}");
|
||||
"/api/v1/deco-templates/groups/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(DecoTemplateController.GetAll), "GET", "/api/v1/deco-templates");
|
||||
ShouldHaveActionRoute(nameof(DecoTemplateController.GetById), "GET", "/api/v1/deco-templates/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(DecoTemplateController.Create), "POST", "/api/v1/deco-templates");
|
||||
ShouldHaveActionRoute(nameof(DecoTemplateController.Delete), "DELETE", "/api/v1/deco-templates/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(DecoTemplateController.GetItems), "GET", "/api/v1/deco-templates/{id:int}/items");
|
||||
ShouldHaveActionRoute(nameof(DecoTemplateController.Replace), "PUT", "/api/v1/deco-templates/{id:int}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -63,7 +63,7 @@ public class DecoTemplateControllerTests
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/deco-templates/groups/5");
|
||||
created.Location.ShouldBe("/api/v1/deco-templates/groups/5");
|
||||
created.Value.ShouldBeOfType<DecoTemplateGroupResponseModel>().Name.ShouldBe("Weekday");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreateDecoTemplateGroup>(c => c.Name == "Weekday"),
|
||||
@@ -163,7 +163,7 @@ public class DecoTemplateControllerTests
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/deco-templates/8");
|
||||
created.Location.ShouldBe("/api/v1/deco-templates/8");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreateDecoTemplate>(c => c.DecoTemplateGroupId == 2 && c.Name == "Morning"),
|
||||
Arg.Any<CancellationToken>());
|
||||
|
||||
@@ -42,43 +42,43 @@ public class EmbyMediaSourcesControllerTests
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(EmbyMediaSourcesController.GetState), "GET", "/api/media-sources/emby");
|
||||
ShouldHaveActionRoute(nameof(EmbyMediaSourcesController.GetState), "GET", "/api/v1/media-sources/emby");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(EmbyMediaSourcesController.GetConnection),
|
||||
"GET",
|
||||
"/api/media-sources/emby/connection");
|
||||
"/api/v1/media-sources/emby/connection");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(EmbyMediaSourcesController.SaveConnection),
|
||||
"PUT",
|
||||
"/api/media-sources/emby/connection");
|
||||
"/api/v1/media-sources/emby/connection");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(EmbyMediaSourcesController.Disconnect),
|
||||
"POST",
|
||||
"/api/media-sources/emby/disconnect");
|
||||
"/api/v1/media-sources/emby/disconnect");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(EmbyMediaSourcesController.GetLibraries),
|
||||
"GET",
|
||||
"/api/media-sources/emby/{id:int}/libraries");
|
||||
"/api/v1/media-sources/emby/{id:int}/libraries");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(EmbyMediaSourcesController.ReplaceLibraryPreferences),
|
||||
"PUT",
|
||||
"/api/media-sources/emby/{id:int}/libraries");
|
||||
"/api/v1/media-sources/emby/{id:int}/libraries");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(EmbyMediaSourcesController.GetPathReplacements),
|
||||
"GET",
|
||||
"/api/media-sources/emby/{id:int}/path-replacements");
|
||||
"/api/v1/media-sources/emby/{id:int}/path-replacements");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(EmbyMediaSourcesController.ReplacePathReplacements),
|
||||
"PUT",
|
||||
"/api/media-sources/emby/{id:int}/path-replacements");
|
||||
"/api/v1/media-sources/emby/{id:int}/path-replacements");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(EmbyMediaSourcesController.RefreshLibraries),
|
||||
"POST",
|
||||
"/api/media-sources/emby/{id:int}/refresh-libraries");
|
||||
"/api/v1/media-sources/emby/{id:int}/refresh-libraries");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(EmbyMediaSourcesController.ScanCollections),
|
||||
"POST",
|
||||
"/api/media-sources/emby/{id:int}/scan-collections");
|
||||
"/api/v1/media-sources/emby/{id:int}/scan-collections");
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -35,11 +35,11 @@ public class FFmpegProfileControllerTests
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute("GET", "/api/ffmpeg/profiles");
|
||||
ShouldHaveActionRoute("GET", "/api/ffmpeg/profiles/{id:int}");
|
||||
ShouldHaveActionRoute("POST", "/api/ffmpeg/profiles");
|
||||
ShouldHaveActionRoute("PUT", "/api/ffmpeg/profiles/{id:int}");
|
||||
ShouldHaveActionRoute("DELETE", "/api/ffmpeg/profiles/{id:int}");
|
||||
ShouldHaveActionRoute("GET", "/api/v1/ffmpeg/profiles");
|
||||
ShouldHaveActionRoute("GET", "/api/v1/ffmpeg/profiles/{id:int}");
|
||||
ShouldHaveActionRoute("POST", "/api/v1/ffmpeg/profiles");
|
||||
ShouldHaveActionRoute("PUT", "/api/v1/ffmpeg/profiles/{id:int}");
|
||||
ShouldHaveActionRoute("DELETE", "/api/v1/ffmpeg/profiles/{id:int}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -97,7 +97,7 @@ public class FFmpegProfileControllerTests
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.StatusCode.ShouldBe(201);
|
||||
created.Location.ShouldBe("/api/ffmpeg/profiles/7");
|
||||
created.Location.ShouldBe("/api/v1/ffmpeg/profiles/7");
|
||||
created.Value.ShouldBe(vm);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,11 +35,11 @@ public class FillerPresetControllerTests
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute("GET", "/api/filler-presets");
|
||||
ShouldHaveActionRoute("GET", "/api/filler-presets/{id:int}");
|
||||
ShouldHaveActionRoute("POST", "/api/filler-presets");
|
||||
ShouldHaveActionRoute("PUT", "/api/filler-presets/{id:int}");
|
||||
ShouldHaveActionRoute("DELETE", "/api/filler-presets/{id:int}");
|
||||
ShouldHaveActionRoute("GET", "/api/v1/filler-presets");
|
||||
ShouldHaveActionRoute("GET", "/api/v1/filler-presets/{id:int}");
|
||||
ShouldHaveActionRoute("POST", "/api/v1/filler-presets");
|
||||
ShouldHaveActionRoute("PUT", "/api/v1/filler-presets/{id:int}");
|
||||
ShouldHaveActionRoute("DELETE", "/api/v1/filler-presets/{id:int}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -125,7 +125,7 @@ public class FillerPresetControllerTests
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.StatusCode.ShouldBe(201);
|
||||
created.Location.ShouldBe("/api/filler-presets/7");
|
||||
created.Location.ShouldBe("/api/v1/filler-presets/7");
|
||||
created.Value.ShouldBe(vm);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ public class GraphicsElementControllerTests
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain("GET");
|
||||
attribute.Template.ShouldBe("/api/graphics-elements");
|
||||
attribute.Template.ShouldBe("/api/v1/graphics-elements");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -43,7 +43,7 @@ public class GraphicsElementControllerTests
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain("POST");
|
||||
attribute.Template.ShouldBe("/api/graphics-elements/refresh");
|
||||
attribute.Template.ShouldBe("/api/v1/graphics-elements/refresh");
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -31,7 +31,7 @@ public class HealthControllerTests
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain("GET");
|
||||
attribute.Template.ShouldBe("/api/health");
|
||||
attribute.Template.ShouldBe("/api/v1/health");
|
||||
attribute.Name.ShouldBe("GetHealthChecks");
|
||||
}
|
||||
|
||||
|
||||
@@ -29,11 +29,11 @@ public class ImagesControllerTests
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(ImagesController.GetFolders), "GET", "/api/images/folders");
|
||||
ShouldHaveActionRoute(nameof(ImagesController.GetFolders), "GET", "/api/v1/images/folders");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(ImagesController.UpdateDuration),
|
||||
"PUT",
|
||||
"/api/images/folders/{id:int}/duration");
|
||||
"/api/v1/images/folders/{id:int}/duration");
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -42,43 +42,43 @@ public class JellyfinMediaSourcesControllerTests
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(JellyfinMediaSourcesController.GetState), "GET", "/api/media-sources/jellyfin");
|
||||
ShouldHaveActionRoute(nameof(JellyfinMediaSourcesController.GetState), "GET", "/api/v1/media-sources/jellyfin");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(JellyfinMediaSourcesController.GetConnection),
|
||||
"GET",
|
||||
"/api/media-sources/jellyfin/connection");
|
||||
"/api/v1/media-sources/jellyfin/connection");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(JellyfinMediaSourcesController.SaveConnection),
|
||||
"PUT",
|
||||
"/api/media-sources/jellyfin/connection");
|
||||
"/api/v1/media-sources/jellyfin/connection");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(JellyfinMediaSourcesController.Disconnect),
|
||||
"POST",
|
||||
"/api/media-sources/jellyfin/disconnect");
|
||||
"/api/v1/media-sources/jellyfin/disconnect");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(JellyfinMediaSourcesController.GetLibraries),
|
||||
"GET",
|
||||
"/api/media-sources/jellyfin/{id:int}/libraries");
|
||||
"/api/v1/media-sources/jellyfin/{id:int}/libraries");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(JellyfinMediaSourcesController.ReplaceLibraryPreferences),
|
||||
"PUT",
|
||||
"/api/media-sources/jellyfin/{id:int}/libraries");
|
||||
"/api/v1/media-sources/jellyfin/{id:int}/libraries");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(JellyfinMediaSourcesController.GetPathReplacements),
|
||||
"GET",
|
||||
"/api/media-sources/jellyfin/{id:int}/path-replacements");
|
||||
"/api/v1/media-sources/jellyfin/{id:int}/path-replacements");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(JellyfinMediaSourcesController.ReplacePathReplacements),
|
||||
"PUT",
|
||||
"/api/media-sources/jellyfin/{id:int}/path-replacements");
|
||||
"/api/v1/media-sources/jellyfin/{id:int}/path-replacements");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(JellyfinMediaSourcesController.RefreshLibraries),
|
||||
"POST",
|
||||
"/api/media-sources/jellyfin/{id:int}/refresh-libraries");
|
||||
"/api/v1/media-sources/jellyfin/{id:int}/refresh-libraries");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(JellyfinMediaSourcesController.ScanCollections),
|
||||
"POST",
|
||||
"/api/media-sources/jellyfin/{id:int}/scan-collections");
|
||||
"/api/v1/media-sources/jellyfin/{id:int}/scan-collections");
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -29,7 +29,7 @@ public class LanguagesControllerTests
|
||||
MethodInfo action = typeof(LanguagesController).GetMethod(nameof(LanguagesController.GetLanguages))!;
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain("GET");
|
||||
attribute.Template.ShouldBe("/api/languages");
|
||||
attribute.Template.ShouldBe("/api/v1/languages");
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -37,7 +37,7 @@ public class LibrariesControllerTests
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain("GET");
|
||||
attribute.Template.ShouldBe("/api/libraries/scan-status");
|
||||
attribute.Template.ShouldBe("/api/v1/libraries/scan-status");
|
||||
attribute.Name.ShouldBe("GetLibraryScanStatus");
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ public class LibraryBrowseControllerTests
|
||||
?? throw new AssertionException($"Missing action {nameof(LibraryBrowseController.Browse)}");
|
||||
|
||||
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
|
||||
attribute.Template.ShouldBe("/api/library/browse");
|
||||
attribute.Template.ShouldBe("/api/v1/library/browse");
|
||||
attribute.Name.ShouldBe("BrowseLibrary");
|
||||
}
|
||||
|
||||
|
||||
@@ -43,19 +43,19 @@ public class LocalLibrariesControllerTests
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(LocalLibrariesController.GetAll), "GET", "/api/libraries/local");
|
||||
ShouldHaveActionRoute(nameof(LocalLibrariesController.GetById), "GET", "/api/libraries/local/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(LocalLibrariesController.Create), "POST", "/api/libraries/local");
|
||||
ShouldHaveActionRoute(nameof(LocalLibrariesController.Update), "PUT", "/api/libraries/local/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(LocalLibrariesController.Delete), "DELETE", "/api/libraries/local/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(LocalLibrariesController.GetAll), "GET", "/api/v1/libraries/local");
|
||||
ShouldHaveActionRoute(nameof(LocalLibrariesController.GetById), "GET", "/api/v1/libraries/local/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(LocalLibrariesController.Create), "POST", "/api/v1/libraries/local");
|
||||
ShouldHaveActionRoute(nameof(LocalLibrariesController.Update), "PUT", "/api/v1/libraries/local/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(LocalLibrariesController.Delete), "DELETE", "/api/v1/libraries/local/{id:int}");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(LocalLibrariesController.MovePath),
|
||||
"POST",
|
||||
"/api/libraries/local/paths/{pathId:int}/move");
|
||||
"/api/v1/libraries/local/paths/{pathId:int}/move");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(LocalLibrariesController.CheckPathExists),
|
||||
"POST",
|
||||
"/api/libraries/local/path-exists");
|
||||
"/api/v1/libraries/local/path-exists");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -122,7 +122,7 @@ public class LocalLibrariesControllerTests
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/libraries/local/5");
|
||||
created.Location.ShouldBe("/api/v1/libraries/local/5");
|
||||
created.Value.ShouldBeOfType<LocalLibraryResponseModel>().Name.ShouldBe("Movies");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreateLocalLibrary>(c => c.Name == "Movies" && c.MediaKind == LibraryMediaKind.Movies),
|
||||
|
||||
@@ -32,7 +32,7 @@ public class LogsControllerTests
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain("GET");
|
||||
attribute.Template.ShouldBe("/api/logs");
|
||||
attribute.Template.ShouldBe("/api/v1/logs");
|
||||
attribute.Name.ShouldBe("GetLogs");
|
||||
}
|
||||
|
||||
|
||||
@@ -35,14 +35,14 @@ public class MediaDetailControllerTests
|
||||
[Test]
|
||||
public void Controllers_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute<MoviesController>(nameof(MoviesController.GetById), "GET", "/api/movies/{id:int}");
|
||||
ShouldHaveActionRoute<ShowsController>(nameof(ShowsController.GetById), "GET", "/api/shows/{id:int}");
|
||||
ShouldHaveActionRoute<SeasonsController>(nameof(SeasonsController.GetById), "GET", "/api/seasons/{id:int}");
|
||||
ShouldHaveActionRoute<ArtistsController>(nameof(ArtistsController.GetById), "GET", "/api/artists/{id:int}");
|
||||
ShouldHaveActionRoute<MoviesController>(nameof(MoviesController.GetById), "GET", "/api/v1/movies/{id:int}");
|
||||
ShouldHaveActionRoute<ShowsController>(nameof(ShowsController.GetById), "GET", "/api/v1/shows/{id:int}");
|
||||
ShouldHaveActionRoute<SeasonsController>(nameof(SeasonsController.GetById), "GET", "/api/v1/seasons/{id:int}");
|
||||
ShouldHaveActionRoute<ArtistsController>(nameof(ArtistsController.GetById), "GET", "/api/v1/artists/{id:int}");
|
||||
ShouldHaveActionRoute<MediaItemsController>(
|
||||
nameof(MediaItemsController.GetInfo),
|
||||
"GET",
|
||||
"/api/media-items/{id:int}/info");
|
||||
"/api/v1/media-items/{id:int}/info");
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -34,7 +34,7 @@ public class MediaItemsControllerTests
|
||||
?? throw new AssertionException($"Missing action {nameof(MediaItemsController.Delete)}");
|
||||
|
||||
var attribute = action.GetCustomAttributes<HttpDeleteAttribute>().Single();
|
||||
attribute.Template.ShouldBe("/api/media-items");
|
||||
attribute.Template.ShouldBe("/api/v1/media-items");
|
||||
attribute.Name.ShouldBe("DeleteMediaItems");
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ public class MediaSourcesControllerTests
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain("GET");
|
||||
attribute.Template.ShouldBe("/api/media-sources");
|
||||
attribute.Template.ShouldBe("/api/v1/media-sources");
|
||||
attribute.Name.ShouldBe("GetMediaSources");
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ public class MediaSourcesControllerTests
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain("GET");
|
||||
attribute.Template.ShouldBe("/api/media-sources/collections-scan-status");
|
||||
attribute.Template.ShouldBe("/api/v1/media-sources/collections-scan-status");
|
||||
attribute.Name.ShouldBe("GetCollectionsScanStatus");
|
||||
}
|
||||
|
||||
|
||||
@@ -38,11 +38,11 @@ public class MultiCollectionControllerTests
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(MultiCollectionController.GetAll), "GET", "/api/multi-collections");
|
||||
ShouldHaveActionRoute(nameof(MultiCollectionController.GetById), "GET", "/api/multi-collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(MultiCollectionController.Create), "POST", "/api/multi-collections");
|
||||
ShouldHaveActionRoute(nameof(MultiCollectionController.Update), "PUT", "/api/multi-collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(MultiCollectionController.Delete), "DELETE", "/api/multi-collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(MultiCollectionController.GetAll), "GET", "/api/v1/multi-collections");
|
||||
ShouldHaveActionRoute(nameof(MultiCollectionController.GetById), "GET", "/api/v1/multi-collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(MultiCollectionController.Create), "POST", "/api/v1/multi-collections");
|
||||
ShouldHaveActionRoute(nameof(MultiCollectionController.Update), "PUT", "/api/v1/multi-collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(MultiCollectionController.Delete), "DELETE", "/api/v1/multi-collections/{id:int}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -104,7 +104,7 @@ public class MultiCollectionControllerTests
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/multi-collections/8");
|
||||
created.Location.ShouldBe("/api/v1/multi-collections/8");
|
||||
created.Value.ShouldBeOfType<MultiCollectionResponseModel>().Name.ShouldBe("Kids");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreateMultiCollection>(c =>
|
||||
|
||||
@@ -48,7 +48,7 @@ public class OpenApiErrorResponseContractTests
|
||||
|
||||
JsonElement getState = document.RootElement
|
||||
.GetProperty("paths")
|
||||
.GetProperty("/api/channels/state")
|
||||
.GetProperty("/api/v1/channels/state")
|
||||
.GetProperty("get");
|
||||
|
||||
JsonElement schema = getState
|
||||
@@ -99,184 +99,184 @@ public class OpenApiErrorResponseContractTests
|
||||
finishUtc.GetProperty("format").GetString().ShouldBe("date-time");
|
||||
}
|
||||
|
||||
[TestCase("/api/channels/{id}", "get", "404")]
|
||||
[TestCase("/api/channels", "post", "404")]
|
||||
[TestCase("/api/channels", "post", "422")]
|
||||
[TestCase("/api/channels/from-lineup", "post", "404")]
|
||||
[TestCase("/api/channels/from-lineup", "post", "422")]
|
||||
[TestCase("/api/channels/{id}", "put", "404")]
|
||||
[TestCase("/api/channels/{id}", "put", "422")]
|
||||
[TestCase("/api/channels/{id}", "delete", "404")]
|
||||
[TestCase("/api/channels/{id}", "delete", "422")]
|
||||
[TestCase("/api/channels/bulk/renumber", "post", "404")]
|
||||
[TestCase("/api/channels/bulk/renumber", "post", "422")]
|
||||
[TestCase("/api/channels/bulk/group", "post", "404")]
|
||||
[TestCase("/api/channels/bulk/group", "post", "422")]
|
||||
[TestCase("/api/channels/bulk/delete", "post", "404")]
|
||||
[TestCase("/api/channels/bulk/delete", "post", "422")]
|
||||
[TestCase("/api/channels/{id}/playout/reset", "post", "404")]
|
||||
[TestCase("/api/channels/{id}/playout/reset", "post", "409")]
|
||||
[TestCase("/api/channel-templates/default", "get", "404")]
|
||||
[TestCase("/api/channel-templates/default/{id}", "put", "404")]
|
||||
[TestCase("/api/channel-templates/default/{id}", "put", "422")]
|
||||
[TestCase("/api/channel-templates/{id}", "get", "404")]
|
||||
[TestCase("/api/channel-templates", "post", "404")]
|
||||
[TestCase("/api/channel-templates", "post", "422")]
|
||||
[TestCase("/api/channel-templates/{id}", "put", "404")]
|
||||
[TestCase("/api/channel-templates/{id}", "put", "422")]
|
||||
[TestCase("/api/channel-templates/{id}", "delete", "404")]
|
||||
[TestCase("/api/channel-templates/{id}", "delete", "422")]
|
||||
[TestCase("/api/collections/{id}", "get", "404")]
|
||||
[TestCase("/api/collections/{id}/items", "get", "404")]
|
||||
[TestCase("/api/collections", "post", "404")]
|
||||
[TestCase("/api/collections", "post", "422")]
|
||||
[TestCase("/api/collections/{id}", "put", "404")]
|
||||
[TestCase("/api/collections/{id}", "put", "422")]
|
||||
[TestCase("/api/collections/{id}", "delete", "404")]
|
||||
[TestCase("/api/collections/{id}", "delete", "422")]
|
||||
[TestCase("/api/collections/{id}/items", "post", "404")]
|
||||
[TestCase("/api/collections/{id}/items", "post", "422")]
|
||||
[TestCase("/api/collections/{id}/items/{mediaItemId}", "delete", "404")]
|
||||
[TestCase("/api/collections/{id}/items/{mediaItemId}", "delete", "422")]
|
||||
[TestCase("/api/smart-collections/{id}", "get", "404")]
|
||||
[TestCase("/api/smart-collections", "post", "404")]
|
||||
[TestCase("/api/smart-collections", "post", "422")]
|
||||
[TestCase("/api/smart-collections/{id}", "put", "404")]
|
||||
[TestCase("/api/smart-collections/{id}", "put", "422")]
|
||||
[TestCase("/api/smart-collections/{id}", "delete", "404")]
|
||||
[TestCase("/api/smart-collections/{id}", "delete", "422")]
|
||||
[TestCase("/api/multi-collections/{id}", "get", "404")]
|
||||
[TestCase("/api/multi-collections", "post", "404")]
|
||||
[TestCase("/api/multi-collections", "post", "422")]
|
||||
[TestCase("/api/multi-collections/{id}", "put", "404")]
|
||||
[TestCase("/api/multi-collections/{id}", "put", "422")]
|
||||
[TestCase("/api/multi-collections/{id}", "delete", "404")]
|
||||
[TestCase("/api/multi-collections/{id}", "delete", "422")]
|
||||
[TestCase("/api/playlists/groups", "post", "422")]
|
||||
[TestCase("/api/playlists/groups/{id}", "put", "404")]
|
||||
[TestCase("/api/playlists/groups/{id}", "put", "422")]
|
||||
[TestCase("/api/playlists/groups/{id}", "delete", "404")]
|
||||
[TestCase("/api/playlists/groups/{id}", "delete", "422")]
|
||||
[TestCase("/api/playlists/{id}", "get", "404")]
|
||||
[TestCase("/api/playlists/{id}/items", "get", "404")]
|
||||
[TestCase("/api/playlists", "post", "422")]
|
||||
[TestCase("/api/playlists/{id}", "put", "404")]
|
||||
[TestCase("/api/playlists/{id}", "put", "422")]
|
||||
[TestCase("/api/playlists/{id}", "delete", "404")]
|
||||
[TestCase("/api/playlists/{id}", "delete", "422")]
|
||||
[TestCase("/api/playlists/preview", "post", "422")]
|
||||
[TestCase("/api/rerun-collections/{id}", "get", "404")]
|
||||
[TestCase("/api/rerun-collections", "post", "404")]
|
||||
[TestCase("/api/rerun-collections", "post", "422")]
|
||||
[TestCase("/api/rerun-collections/{id}", "put", "404")]
|
||||
[TestCase("/api/rerun-collections/{id}", "put", "422")]
|
||||
[TestCase("/api/rerun-collections/{id}", "delete", "404")]
|
||||
[TestCase("/api/rerun-collections/{id}", "delete", "422")]
|
||||
[TestCase("/api/schedules/{id}", "get", "404")]
|
||||
[TestCase("/api/schedules", "post", "404")]
|
||||
[TestCase("/api/schedules", "post", "422")]
|
||||
[TestCase("/api/schedules/{id}", "put", "404")]
|
||||
[TestCase("/api/schedules/{id}", "put", "422")]
|
||||
[TestCase("/api/schedules/{id}", "delete", "404")]
|
||||
[TestCase("/api/schedules/{id}", "delete", "422")]
|
||||
[TestCase("/api/schedules/{id}/items", "get", "404")]
|
||||
[TestCase("/api/schedules/{id}/items", "post", "404")]
|
||||
[TestCase("/api/schedules/{id}/items", "post", "422")]
|
||||
[TestCase("/api/schedules/{id}/items", "put", "404")]
|
||||
[TestCase("/api/schedules/{id}/items", "put", "422")]
|
||||
[TestCase("/api/schedules/{id}/items/{itemId}", "delete", "404")]
|
||||
[TestCase("/api/schedules/{id}/items/{itemId}", "delete", "422")]
|
||||
[TestCase("/api/playouts/{id}", "get", "404")]
|
||||
[TestCase("/api/playouts", "post", "404")]
|
||||
[TestCase("/api/playouts", "post", "422")]
|
||||
[TestCase("/api/playouts/{id}", "put", "404")]
|
||||
[TestCase("/api/playouts/{id}", "put", "409")]
|
||||
[TestCase("/api/playouts/{id}", "put", "422")]
|
||||
[TestCase("/api/playouts/{id}", "delete", "404")]
|
||||
[TestCase("/api/playouts/{id}", "delete", "409")]
|
||||
[TestCase("/api/playouts/{id}", "delete", "422")]
|
||||
[TestCase("/api/playouts/{id}/deco", "put", "409")]
|
||||
[TestCase("/api/playouts/{id}/alternate-schedules", "put", "409")]
|
||||
[TestCase("/api/playouts/{id}/templates", "put", "409")]
|
||||
[TestCase("/api/playouts/{id}/items", "get", "404")]
|
||||
[TestCase("/api/playouts/{id}/erase-items", "post", "404")]
|
||||
[TestCase("/api/playouts/{id}/erase-items", "post", "409")]
|
||||
[TestCase("/api/playouts/{id}/erase-items", "post", "422")]
|
||||
[TestCase("/api/playouts/{id}/erase-items-and-history", "post", "404")]
|
||||
[TestCase("/api/playouts/{id}/erase-items-and-history", "post", "409")]
|
||||
[TestCase("/api/playouts/{id}/erase-items-and-history", "post", "422")]
|
||||
[TestCase("/api/playouts/items/{id}/scheduling-context", "get", "404")]
|
||||
[TestCase("/api/collections/{id}/custom-order", "put", "404")]
|
||||
[TestCase("/api/collections/{id}/custom-order", "put", "422")]
|
||||
[TestCase("/api/artwork/uploads", "post", "422")]
|
||||
[TestCase("/api/ffmpeg/profiles/{id}", "get", "404")]
|
||||
[TestCase("/api/ffmpeg/profiles", "post", "404")]
|
||||
[TestCase("/api/ffmpeg/profiles", "post", "401")]
|
||||
[TestCase("/api/ffmpeg/profiles", "post", "422")]
|
||||
[TestCase("/api/ffmpeg/profiles/{id}", "put", "404")]
|
||||
[TestCase("/api/ffmpeg/profiles/{id}", "put", "401")]
|
||||
[TestCase("/api/ffmpeg/profiles/{id}", "put", "422")]
|
||||
[TestCase("/api/ffmpeg/profiles/{id}", "delete", "404")]
|
||||
[TestCase("/api/ffmpeg/profiles/{id}", "delete", "401")]
|
||||
[TestCase("/api/ffmpeg/profiles/{id}", "delete", "422")]
|
||||
[TestCase("/api/filler-presets/{id}", "get", "404")]
|
||||
[TestCase("/api/filler-presets", "post", "401")]
|
||||
[TestCase("/api/filler-presets", "post", "404")]
|
||||
[TestCase("/api/filler-presets", "post", "422")]
|
||||
[TestCase("/api/filler-presets/{id}", "put", "401")]
|
||||
[TestCase("/api/filler-presets/{id}", "put", "404")]
|
||||
[TestCase("/api/filler-presets/{id}", "put", "422")]
|
||||
[TestCase("/api/filler-presets/{id}", "delete", "401")]
|
||||
[TestCase("/api/filler-presets/{id}", "delete", "404")]
|
||||
[TestCase("/api/filler-presets/{id}", "delete", "422")]
|
||||
[TestCase("/api/watermarks/{id}", "get", "404")]
|
||||
[TestCase("/api/watermarks", "post", "401")]
|
||||
[TestCase("/api/watermarks", "post", "404")]
|
||||
[TestCase("/api/watermarks", "post", "422")]
|
||||
[TestCase("/api/watermarks/{id}", "put", "401")]
|
||||
[TestCase("/api/watermarks/{id}", "put", "404")]
|
||||
[TestCase("/api/watermarks/{id}", "put", "422")]
|
||||
[TestCase("/api/watermarks/{id}", "delete", "401")]
|
||||
[TestCase("/api/watermarks/{id}", "delete", "404")]
|
||||
[TestCase("/api/watermarks/{id}", "delete", "422")]
|
||||
[TestCase("/api/settings/ffmpeg", "put", "401")]
|
||||
[TestCase("/api/settings/ffmpeg", "put", "422")]
|
||||
[TestCase("/api/settings/playout", "put", "401")]
|
||||
[TestCase("/api/settings/playout", "put", "422")]
|
||||
[TestCase("/api/settings/xmltv", "put", "401")]
|
||||
[TestCase("/api/settings/xmltv", "put", "422")]
|
||||
[TestCase("/api/settings/scanner", "put", "401")]
|
||||
[TestCase("/api/settings/scanner", "put", "422")]
|
||||
[TestCase("/api/settings/logging", "put", "401")]
|
||||
[TestCase("/api/settings/logging", "put", "422")]
|
||||
[TestCase("/api/settings/ui", "put", "401")]
|
||||
[TestCase("/api/settings/ui", "put", "422")]
|
||||
[TestCase("/api/settings/hdhr", "put", "401")]
|
||||
[TestCase("/api/settings/hdhr", "put", "422")]
|
||||
[TestCase("/api/settings/resolutions", "post", "401")]
|
||||
[TestCase("/api/settings/resolutions", "post", "422")]
|
||||
[TestCase("/api/settings/resolutions/{id}", "delete", "401")]
|
||||
[TestCase("/api/settings/resolutions/{id}", "delete", "404")]
|
||||
[TestCase("/api/settings/resolutions/{id}", "delete", "422")]
|
||||
[TestCase("/api/search", "get", "422")]
|
||||
[TestCase("/api/media-items", "delete", "422")]
|
||||
[TestCase("/api/trakt/lists/{id}", "get", "404")]
|
||||
[TestCase("/api/trakt/lists", "post", "422")]
|
||||
[TestCase("/api/trakt/lists", "post", "409")]
|
||||
[TestCase("/api/trakt/lists/{id}/match", "post", "404")]
|
||||
[TestCase("/api/trakt/lists/{id}/match", "post", "409")]
|
||||
[TestCase("/api/trakt/lists/{id}", "delete", "404")]
|
||||
[TestCase("/api/trakt/lists/{id}", "delete", "409")]
|
||||
[TestCase("/api/trakt/lists/{id}", "put", "404")]
|
||||
[TestCase("/api/trakt/lists/{id}", "put", "422")]
|
||||
[TestCase("/api/troubleshoot/playback/subtitles/{mediaItemId}", "get", "404")]
|
||||
[TestCase("/api/troubleshoot/playback/start", "post", "404")]
|
||||
[TestCase("/api/troubleshoot/playback/start", "post", "409")]
|
||||
[TestCase("/api/troubleshoot/playback/start", "post", "422")]
|
||||
[TestCase("/api/libraries/{id}/scan-show", "post", "404")]
|
||||
[TestCase("/api/libraries/{id}/scan", "post", "404")]
|
||||
[TestCase("/api/libraries/{id}/scan", "post", "409")]
|
||||
[TestCase("/api/libraries/{id}/scan", "post", "422")]
|
||||
[TestCase("/api/v1/channels/{id}", "get", "404")]
|
||||
[TestCase("/api/v1/channels", "post", "404")]
|
||||
[TestCase("/api/v1/channels", "post", "422")]
|
||||
[TestCase("/api/v1/channels/from-lineup", "post", "404")]
|
||||
[TestCase("/api/v1/channels/from-lineup", "post", "422")]
|
||||
[TestCase("/api/v1/channels/{id}", "put", "404")]
|
||||
[TestCase("/api/v1/channels/{id}", "put", "422")]
|
||||
[TestCase("/api/v1/channels/{id}", "delete", "404")]
|
||||
[TestCase("/api/v1/channels/{id}", "delete", "422")]
|
||||
[TestCase("/api/v1/channels/bulk/renumber", "post", "404")]
|
||||
[TestCase("/api/v1/channels/bulk/renumber", "post", "422")]
|
||||
[TestCase("/api/v1/channels/bulk/group", "post", "404")]
|
||||
[TestCase("/api/v1/channels/bulk/group", "post", "422")]
|
||||
[TestCase("/api/v1/channels/bulk/delete", "post", "404")]
|
||||
[TestCase("/api/v1/channels/bulk/delete", "post", "422")]
|
||||
[TestCase("/api/v1/channels/{id}/playout/reset", "post", "404")]
|
||||
[TestCase("/api/v1/channels/{id}/playout/reset", "post", "409")]
|
||||
[TestCase("/api/v1/channel-templates/default", "get", "404")]
|
||||
[TestCase("/api/v1/channel-templates/default/{id}", "put", "404")]
|
||||
[TestCase("/api/v1/channel-templates/default/{id}", "put", "422")]
|
||||
[TestCase("/api/v1/channel-templates/{id}", "get", "404")]
|
||||
[TestCase("/api/v1/channel-templates", "post", "404")]
|
||||
[TestCase("/api/v1/channel-templates", "post", "422")]
|
||||
[TestCase("/api/v1/channel-templates/{id}", "put", "404")]
|
||||
[TestCase("/api/v1/channel-templates/{id}", "put", "422")]
|
||||
[TestCase("/api/v1/channel-templates/{id}", "delete", "404")]
|
||||
[TestCase("/api/v1/channel-templates/{id}", "delete", "422")]
|
||||
[TestCase("/api/v1/collections/{id}", "get", "404")]
|
||||
[TestCase("/api/v1/collections/{id}/items", "get", "404")]
|
||||
[TestCase("/api/v1/collections", "post", "404")]
|
||||
[TestCase("/api/v1/collections", "post", "422")]
|
||||
[TestCase("/api/v1/collections/{id}", "put", "404")]
|
||||
[TestCase("/api/v1/collections/{id}", "put", "422")]
|
||||
[TestCase("/api/v1/collections/{id}", "delete", "404")]
|
||||
[TestCase("/api/v1/collections/{id}", "delete", "422")]
|
||||
[TestCase("/api/v1/collections/{id}/items", "post", "404")]
|
||||
[TestCase("/api/v1/collections/{id}/items", "post", "422")]
|
||||
[TestCase("/api/v1/collections/{id}/items/{mediaItemId}", "delete", "404")]
|
||||
[TestCase("/api/v1/collections/{id}/items/{mediaItemId}", "delete", "422")]
|
||||
[TestCase("/api/v1/smart-collections/{id}", "get", "404")]
|
||||
[TestCase("/api/v1/smart-collections", "post", "404")]
|
||||
[TestCase("/api/v1/smart-collections", "post", "422")]
|
||||
[TestCase("/api/v1/smart-collections/{id}", "put", "404")]
|
||||
[TestCase("/api/v1/smart-collections/{id}", "put", "422")]
|
||||
[TestCase("/api/v1/smart-collections/{id}", "delete", "404")]
|
||||
[TestCase("/api/v1/smart-collections/{id}", "delete", "422")]
|
||||
[TestCase("/api/v1/multi-collections/{id}", "get", "404")]
|
||||
[TestCase("/api/v1/multi-collections", "post", "404")]
|
||||
[TestCase("/api/v1/multi-collections", "post", "422")]
|
||||
[TestCase("/api/v1/multi-collections/{id}", "put", "404")]
|
||||
[TestCase("/api/v1/multi-collections/{id}", "put", "422")]
|
||||
[TestCase("/api/v1/multi-collections/{id}", "delete", "404")]
|
||||
[TestCase("/api/v1/multi-collections/{id}", "delete", "422")]
|
||||
[TestCase("/api/v1/playlists/groups", "post", "422")]
|
||||
[TestCase("/api/v1/playlists/groups/{id}", "put", "404")]
|
||||
[TestCase("/api/v1/playlists/groups/{id}", "put", "422")]
|
||||
[TestCase("/api/v1/playlists/groups/{id}", "delete", "404")]
|
||||
[TestCase("/api/v1/playlists/groups/{id}", "delete", "422")]
|
||||
[TestCase("/api/v1/playlists/{id}", "get", "404")]
|
||||
[TestCase("/api/v1/playlists/{id}/items", "get", "404")]
|
||||
[TestCase("/api/v1/playlists", "post", "422")]
|
||||
[TestCase("/api/v1/playlists/{id}", "put", "404")]
|
||||
[TestCase("/api/v1/playlists/{id}", "put", "422")]
|
||||
[TestCase("/api/v1/playlists/{id}", "delete", "404")]
|
||||
[TestCase("/api/v1/playlists/{id}", "delete", "422")]
|
||||
[TestCase("/api/v1/playlists/preview", "post", "422")]
|
||||
[TestCase("/api/v1/rerun-collections/{id}", "get", "404")]
|
||||
[TestCase("/api/v1/rerun-collections", "post", "404")]
|
||||
[TestCase("/api/v1/rerun-collections", "post", "422")]
|
||||
[TestCase("/api/v1/rerun-collections/{id}", "put", "404")]
|
||||
[TestCase("/api/v1/rerun-collections/{id}", "put", "422")]
|
||||
[TestCase("/api/v1/rerun-collections/{id}", "delete", "404")]
|
||||
[TestCase("/api/v1/rerun-collections/{id}", "delete", "422")]
|
||||
[TestCase("/api/v1/schedules/{id}", "get", "404")]
|
||||
[TestCase("/api/v1/schedules", "post", "404")]
|
||||
[TestCase("/api/v1/schedules", "post", "422")]
|
||||
[TestCase("/api/v1/schedules/{id}", "put", "404")]
|
||||
[TestCase("/api/v1/schedules/{id}", "put", "422")]
|
||||
[TestCase("/api/v1/schedules/{id}", "delete", "404")]
|
||||
[TestCase("/api/v1/schedules/{id}", "delete", "422")]
|
||||
[TestCase("/api/v1/schedules/{id}/items", "get", "404")]
|
||||
[TestCase("/api/v1/schedules/{id}/items", "post", "404")]
|
||||
[TestCase("/api/v1/schedules/{id}/items", "post", "422")]
|
||||
[TestCase("/api/v1/schedules/{id}/items", "put", "404")]
|
||||
[TestCase("/api/v1/schedules/{id}/items", "put", "422")]
|
||||
[TestCase("/api/v1/schedules/{id}/items/{itemId}", "delete", "404")]
|
||||
[TestCase("/api/v1/schedules/{id}/items/{itemId}", "delete", "422")]
|
||||
[TestCase("/api/v1/playouts/{id}", "get", "404")]
|
||||
[TestCase("/api/v1/playouts", "post", "404")]
|
||||
[TestCase("/api/v1/playouts", "post", "422")]
|
||||
[TestCase("/api/v1/playouts/{id}", "put", "404")]
|
||||
[TestCase("/api/v1/playouts/{id}", "put", "409")]
|
||||
[TestCase("/api/v1/playouts/{id}", "put", "422")]
|
||||
[TestCase("/api/v1/playouts/{id}", "delete", "404")]
|
||||
[TestCase("/api/v1/playouts/{id}", "delete", "409")]
|
||||
[TestCase("/api/v1/playouts/{id}", "delete", "422")]
|
||||
[TestCase("/api/v1/playouts/{id}/deco", "put", "409")]
|
||||
[TestCase("/api/v1/playouts/{id}/alternate-schedules", "put", "409")]
|
||||
[TestCase("/api/v1/playouts/{id}/templates", "put", "409")]
|
||||
[TestCase("/api/v1/playouts/{id}/items", "get", "404")]
|
||||
[TestCase("/api/v1/playouts/{id}/erase-items", "post", "404")]
|
||||
[TestCase("/api/v1/playouts/{id}/erase-items", "post", "409")]
|
||||
[TestCase("/api/v1/playouts/{id}/erase-items", "post", "422")]
|
||||
[TestCase("/api/v1/playouts/{id}/erase-items-and-history", "post", "404")]
|
||||
[TestCase("/api/v1/playouts/{id}/erase-items-and-history", "post", "409")]
|
||||
[TestCase("/api/v1/playouts/{id}/erase-items-and-history", "post", "422")]
|
||||
[TestCase("/api/v1/playouts/items/{id}/scheduling-context", "get", "404")]
|
||||
[TestCase("/api/v1/collections/{id}/custom-order", "put", "404")]
|
||||
[TestCase("/api/v1/collections/{id}/custom-order", "put", "422")]
|
||||
[TestCase("/api/v1/artwork/uploads", "post", "422")]
|
||||
[TestCase("/api/v1/ffmpeg/profiles/{id}", "get", "404")]
|
||||
[TestCase("/api/v1/ffmpeg/profiles", "post", "404")]
|
||||
[TestCase("/api/v1/ffmpeg/profiles", "post", "401")]
|
||||
[TestCase("/api/v1/ffmpeg/profiles", "post", "422")]
|
||||
[TestCase("/api/v1/ffmpeg/profiles/{id}", "put", "404")]
|
||||
[TestCase("/api/v1/ffmpeg/profiles/{id}", "put", "401")]
|
||||
[TestCase("/api/v1/ffmpeg/profiles/{id}", "put", "422")]
|
||||
[TestCase("/api/v1/ffmpeg/profiles/{id}", "delete", "404")]
|
||||
[TestCase("/api/v1/ffmpeg/profiles/{id}", "delete", "401")]
|
||||
[TestCase("/api/v1/ffmpeg/profiles/{id}", "delete", "422")]
|
||||
[TestCase("/api/v1/filler-presets/{id}", "get", "404")]
|
||||
[TestCase("/api/v1/filler-presets", "post", "401")]
|
||||
[TestCase("/api/v1/filler-presets", "post", "404")]
|
||||
[TestCase("/api/v1/filler-presets", "post", "422")]
|
||||
[TestCase("/api/v1/filler-presets/{id}", "put", "401")]
|
||||
[TestCase("/api/v1/filler-presets/{id}", "put", "404")]
|
||||
[TestCase("/api/v1/filler-presets/{id}", "put", "422")]
|
||||
[TestCase("/api/v1/filler-presets/{id}", "delete", "401")]
|
||||
[TestCase("/api/v1/filler-presets/{id}", "delete", "404")]
|
||||
[TestCase("/api/v1/filler-presets/{id}", "delete", "422")]
|
||||
[TestCase("/api/v1/watermarks/{id}", "get", "404")]
|
||||
[TestCase("/api/v1/watermarks", "post", "401")]
|
||||
[TestCase("/api/v1/watermarks", "post", "404")]
|
||||
[TestCase("/api/v1/watermarks", "post", "422")]
|
||||
[TestCase("/api/v1/watermarks/{id}", "put", "401")]
|
||||
[TestCase("/api/v1/watermarks/{id}", "put", "404")]
|
||||
[TestCase("/api/v1/watermarks/{id}", "put", "422")]
|
||||
[TestCase("/api/v1/watermarks/{id}", "delete", "401")]
|
||||
[TestCase("/api/v1/watermarks/{id}", "delete", "404")]
|
||||
[TestCase("/api/v1/watermarks/{id}", "delete", "422")]
|
||||
[TestCase("/api/v1/settings/ffmpeg", "put", "401")]
|
||||
[TestCase("/api/v1/settings/ffmpeg", "put", "422")]
|
||||
[TestCase("/api/v1/settings/playout", "put", "401")]
|
||||
[TestCase("/api/v1/settings/playout", "put", "422")]
|
||||
[TestCase("/api/v1/settings/xmltv", "put", "401")]
|
||||
[TestCase("/api/v1/settings/xmltv", "put", "422")]
|
||||
[TestCase("/api/v1/settings/scanner", "put", "401")]
|
||||
[TestCase("/api/v1/settings/scanner", "put", "422")]
|
||||
[TestCase("/api/v1/settings/logging", "put", "401")]
|
||||
[TestCase("/api/v1/settings/logging", "put", "422")]
|
||||
[TestCase("/api/v1/settings/ui", "put", "401")]
|
||||
[TestCase("/api/v1/settings/ui", "put", "422")]
|
||||
[TestCase("/api/v1/settings/hdhr", "put", "401")]
|
||||
[TestCase("/api/v1/settings/hdhr", "put", "422")]
|
||||
[TestCase("/api/v1/settings/resolutions", "post", "401")]
|
||||
[TestCase("/api/v1/settings/resolutions", "post", "422")]
|
||||
[TestCase("/api/v1/settings/resolutions/{id}", "delete", "401")]
|
||||
[TestCase("/api/v1/settings/resolutions/{id}", "delete", "404")]
|
||||
[TestCase("/api/v1/settings/resolutions/{id}", "delete", "422")]
|
||||
[TestCase("/api/v1/search", "get", "422")]
|
||||
[TestCase("/api/v1/media-items", "delete", "422")]
|
||||
[TestCase("/api/v1/trakt/lists/{id}", "get", "404")]
|
||||
[TestCase("/api/v1/trakt/lists", "post", "422")]
|
||||
[TestCase("/api/v1/trakt/lists", "post", "409")]
|
||||
[TestCase("/api/v1/trakt/lists/{id}/match", "post", "404")]
|
||||
[TestCase("/api/v1/trakt/lists/{id}/match", "post", "409")]
|
||||
[TestCase("/api/v1/trakt/lists/{id}", "delete", "404")]
|
||||
[TestCase("/api/v1/trakt/lists/{id}", "delete", "409")]
|
||||
[TestCase("/api/v1/trakt/lists/{id}", "put", "404")]
|
||||
[TestCase("/api/v1/trakt/lists/{id}", "put", "422")]
|
||||
[TestCase("/api/v1/troubleshoot/playback/subtitles/{mediaItemId}", "get", "404")]
|
||||
[TestCase("/api/v1/troubleshoot/playback/start", "post", "404")]
|
||||
[TestCase("/api/v1/troubleshoot/playback/start", "post", "409")]
|
||||
[TestCase("/api/v1/troubleshoot/playback/start", "post", "422")]
|
||||
[TestCase("/api/v1/libraries/{id}/scan-show", "post", "404")]
|
||||
[TestCase("/api/v1/libraries/{id}/scan", "post", "404")]
|
||||
[TestCase("/api/v1/libraries/{id}/scan", "post", "409")]
|
||||
[TestCase("/api/v1/libraries/{id}/scan", "post", "422")]
|
||||
public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses(
|
||||
string path,
|
||||
string method,
|
||||
|
||||
@@ -42,18 +42,18 @@ public class PlaylistControllerTests
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.GetGroups), "GET", "/api/playlists/groups");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.CreateGroup), "POST", "/api/playlists/groups");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.UpdateGroup), "PUT", "/api/playlists/groups/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.DeleteGroup), "DELETE", "/api/playlists/groups/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.GetAll), "GET", "/api/playlists");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.GetById), "GET", "/api/playlists/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.GetItems), "GET", "/api/playlists/{id:int}/items");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.Create), "POST", "/api/playlists");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.Update), "PUT", "/api/playlists/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.Delete), "DELETE", "/api/playlists/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.AddItems), "POST", "/api/playlists/{id:int}/items");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.Preview), "POST", "/api/playlists/preview");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.GetGroups), "GET", "/api/v1/playlists/groups");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.CreateGroup), "POST", "/api/v1/playlists/groups");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.UpdateGroup), "PUT", "/api/v1/playlists/groups/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.DeleteGroup), "DELETE", "/api/v1/playlists/groups/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.GetAll), "GET", "/api/v1/playlists");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.GetById), "GET", "/api/v1/playlists/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.GetItems), "GET", "/api/v1/playlists/{id:int}/items");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.Create), "POST", "/api/v1/playlists");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.Update), "PUT", "/api/v1/playlists/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.Delete), "DELETE", "/api/v1/playlists/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.AddItems), "POST", "/api/v1/playlists/{id:int}/items");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.Preview), "POST", "/api/v1/playlists/preview");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -80,7 +80,7 @@ public class PlaylistControllerTests
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/playlists/groups/8");
|
||||
created.Location.ShouldBe("/api/v1/playlists/groups/8");
|
||||
created.Value.ShouldBeOfType<PlaylistGroupResponseModel>().Name.ShouldBe("Kids");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreatePlaylistGroup>(c => c.Name == "Kids"),
|
||||
@@ -311,7 +311,7 @@ public class PlaylistControllerTests
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/playlists/9");
|
||||
created.Location.ShouldBe("/api/v1/playlists/9");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreatePlaylist>(c => c.PlaylistGroupId == 1 && c.Name == "Kids"),
|
||||
Arg.Any<CancellationToken>());
|
||||
|
||||
@@ -50,30 +50,30 @@ public class PlayoutControllerTests
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetAll), "GET", "/api/playouts");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetById), "GET", "/api/playouts/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetItems), "GET", "/api/playouts/{id:int}/items");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetWarningsCount), "GET", "/api/playouts/warnings/count");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.Create), "POST", "/api/playouts");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.Update), "PUT", "/api/playouts/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.ResetAll), "POST", "/api/playouts/reset-all");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.Delete), "DELETE", "/api/playouts/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetAlternateSchedules), "GET", "/api/playouts/{id:int}/alternate-schedules");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.ReplaceAlternateSchedules), "PUT", "/api/playouts/{id:int}/alternate-schedules");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetTemplates), "GET", "/api/playouts/{id:int}/templates");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.ReplaceTemplates), "PUT", "/api/playouts/{id:int}/templates");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetBlocks), "GET", "/api/playouts/{id:int}/blocks");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetBlockHistory), "GET", "/api/playouts/{id:int}/blocks/{blockId:int}/history");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetHistoryDetails), "GET", "/api/playouts/history/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.EraseItems), "POST", "/api/playouts/{id:int}/erase-items");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetAll), "GET", "/api/v1/playouts");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetById), "GET", "/api/v1/playouts/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetItems), "GET", "/api/v1/playouts/{id:int}/items");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetWarningsCount), "GET", "/api/v1/playouts/warnings/count");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.Create), "POST", "/api/v1/playouts");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.Update), "PUT", "/api/v1/playouts/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.ResetAll), "POST", "/api/v1/playouts/reset-all");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.Delete), "DELETE", "/api/v1/playouts/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetAlternateSchedules), "GET", "/api/v1/playouts/{id:int}/alternate-schedules");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.ReplaceAlternateSchedules), "PUT", "/api/v1/playouts/{id:int}/alternate-schedules");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetTemplates), "GET", "/api/v1/playouts/{id:int}/templates");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.ReplaceTemplates), "PUT", "/api/v1/playouts/{id:int}/templates");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetBlocks), "GET", "/api/v1/playouts/{id:int}/blocks");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetBlockHistory), "GET", "/api/v1/playouts/{id:int}/blocks/{blockId:int}/history");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetHistoryDetails), "GET", "/api/v1/playouts/history/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.EraseItems), "POST", "/api/v1/playouts/{id:int}/erase-items");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(PlayoutController.EraseItemsAndHistory),
|
||||
"POST",
|
||||
"/api/playouts/{id:int}/erase-items-and-history");
|
||||
"/api/v1/playouts/{id:int}/erase-items-and-history");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(PlayoutController.GetItemSchedulingContext),
|
||||
"GET",
|
||||
"/api/playouts/items/{id:int}/scheduling-context");
|
||||
"/api/v1/playouts/items/{id:int}/scheduling-context");
|
||||
}
|
||||
|
||||
// ----- Build-lock guard (#215): id-keyed mutations return 409 while the build lock is held -----
|
||||
@@ -291,7 +291,7 @@ public class PlayoutControllerTests
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.StatusCode.ShouldBe(201);
|
||||
created.Location.ShouldBe("/api/playouts/9");
|
||||
created.Location.ShouldBe("/api/v1/playouts/9");
|
||||
created.Value.ShouldBe(ToResponse(vm));
|
||||
}
|
||||
|
||||
|
||||
@@ -44,36 +44,36 @@ public class PlexMediaSourcesControllerTests
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(PlexMediaSourcesController.GetState), "GET", "/api/media-sources/plex");
|
||||
ShouldHaveActionRoute(nameof(PlexMediaSourcesController.GetState), "GET", "/api/v1/media-sources/plex");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(PlexMediaSourcesController.StartPinFlow),
|
||||
"POST",
|
||||
"/api/media-sources/plex/pin-flow");
|
||||
ShouldHaveActionRoute(nameof(PlexMediaSourcesController.SignOutOfPlex), "POST", "/api/media-sources/plex/sign-out");
|
||||
"/api/v1/media-sources/plex/pin-flow");
|
||||
ShouldHaveActionRoute(nameof(PlexMediaSourcesController.SignOutOfPlex), "POST", "/api/v1/media-sources/plex/sign-out");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(PlexMediaSourcesController.GetLibraries),
|
||||
"GET",
|
||||
"/api/media-sources/plex/{id:int}/libraries");
|
||||
"/api/v1/media-sources/plex/{id:int}/libraries");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(PlexMediaSourcesController.ReplaceLibraryPreferences),
|
||||
"PUT",
|
||||
"/api/media-sources/plex/{id:int}/libraries");
|
||||
"/api/v1/media-sources/plex/{id:int}/libraries");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(PlexMediaSourcesController.GetPathReplacements),
|
||||
"GET",
|
||||
"/api/media-sources/plex/{id:int}/path-replacements");
|
||||
"/api/v1/media-sources/plex/{id:int}/path-replacements");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(PlexMediaSourcesController.ReplacePathReplacements),
|
||||
"PUT",
|
||||
"/api/media-sources/plex/{id:int}/path-replacements");
|
||||
"/api/v1/media-sources/plex/{id:int}/path-replacements");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(PlexMediaSourcesController.RefreshLibraries),
|
||||
"POST",
|
||||
"/api/media-sources/plex/{id:int}/refresh-libraries");
|
||||
"/api/v1/media-sources/plex/{id:int}/refresh-libraries");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(PlexMediaSourcesController.ScanCollections),
|
||||
"POST",
|
||||
"/api/media-sources/plex/{id:int}/scan-collections");
|
||||
"/api/v1/media-sources/plex/{id:int}/scan-collections");
|
||||
}
|
||||
|
||||
// ----- P1 GetState -----
|
||||
|
||||
@@ -38,11 +38,11 @@ public class RerunCollectionControllerTests
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(RerunCollectionController.GetAll), "GET", "/api/rerun-collections");
|
||||
ShouldHaveActionRoute(nameof(RerunCollectionController.GetById), "GET", "/api/rerun-collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(RerunCollectionController.Create), "POST", "/api/rerun-collections");
|
||||
ShouldHaveActionRoute(nameof(RerunCollectionController.Update), "PUT", "/api/rerun-collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(RerunCollectionController.Delete), "DELETE", "/api/rerun-collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(RerunCollectionController.GetAll), "GET", "/api/v1/rerun-collections");
|
||||
ShouldHaveActionRoute(nameof(RerunCollectionController.GetById), "GET", "/api/v1/rerun-collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(RerunCollectionController.Create), "POST", "/api/v1/rerun-collections");
|
||||
ShouldHaveActionRoute(nameof(RerunCollectionController.Update), "PUT", "/api/v1/rerun-collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(RerunCollectionController.Delete), "DELETE", "/api/v1/rerun-collections/{id:int}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -105,7 +105,7 @@ public class RerunCollectionControllerTests
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/rerun-collections/8");
|
||||
created.Location.ShouldBe("/api/v1/rerun-collections/8");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreateRerunCollection>(c =>
|
||||
c.Name == "Nightly" &&
|
||||
|
||||
@@ -84,7 +84,7 @@ public class ResolutionControllerTests
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.StatusCode.ShouldBe(201);
|
||||
created.Location.ShouldBe("/api/settings/resolutions/9");
|
||||
created.Location.ShouldBe("/api/v1/settings/resolutions/9");
|
||||
created.Value.ShouldBe(new ResolutionResponseModel(9, "640x480", 640, 480, true));
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreateCustomResolution>(c => c.Width == 640 && c.Height == 480),
|
||||
|
||||
@@ -42,18 +42,18 @@ public class ScheduleControllerTests
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(ScheduleController.GetAll), "GET", "/api/schedules");
|
||||
ShouldHaveActionRoute(nameof(ScheduleController.GetById), "GET", "/api/schedules/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(ScheduleController.Create), "POST", "/api/schedules");
|
||||
ShouldHaveActionRoute(nameof(ScheduleController.Update), "PUT", "/api/schedules/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(ScheduleController.Delete), "DELETE", "/api/schedules/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(ScheduleController.GetItems), "GET", "/api/schedules/{id:int}/items");
|
||||
ShouldHaveActionRoute(nameof(ScheduleController.AddItem), "POST", "/api/schedules/{id:int}/items");
|
||||
ShouldHaveActionRoute(nameof(ScheduleController.ReplaceItems), "PUT", "/api/schedules/{id:int}/items");
|
||||
ShouldHaveActionRoute(nameof(ScheduleController.GetAll), "GET", "/api/v1/schedules");
|
||||
ShouldHaveActionRoute(nameof(ScheduleController.GetById), "GET", "/api/v1/schedules/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(ScheduleController.Create), "POST", "/api/v1/schedules");
|
||||
ShouldHaveActionRoute(nameof(ScheduleController.Update), "PUT", "/api/v1/schedules/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(ScheduleController.Delete), "DELETE", "/api/v1/schedules/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(ScheduleController.GetItems), "GET", "/api/v1/schedules/{id:int}/items");
|
||||
ShouldHaveActionRoute(nameof(ScheduleController.AddItem), "POST", "/api/v1/schedules/{id:int}/items");
|
||||
ShouldHaveActionRoute(nameof(ScheduleController.ReplaceItems), "PUT", "/api/v1/schedules/{id:int}/items");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(ScheduleController.DeleteItem),
|
||||
"DELETE",
|
||||
"/api/schedules/{id:int}/items/{itemId:int}");
|
||||
"/api/v1/schedules/{id:int}/items/{itemId:int}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -78,7 +78,7 @@ public class ScheduleControllerTests
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.StatusCode.ShouldBe(201);
|
||||
created.Location.ShouldBe("/api/schedules/5");
|
||||
created.Location.ShouldBe("/api/v1/schedules/5");
|
||||
created.Value.ShouldBe(
|
||||
new ProgramScheduleResponseModel(5, "Daily", true, true, false, false, FixedStartTimeBehavior.Flexible));
|
||||
}
|
||||
@@ -238,7 +238,7 @@ public class ScheduleControllerTests
|
||||
IActionResult result = await _controller.AddItem(4, MakeItemRequest(PlayoutMode.One), CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/schedules/4/items/12");
|
||||
created.Location.ShouldBe("/api/v1/schedules/4/items/12");
|
||||
created.Value.ShouldBeOfType<ScheduleItemResponseModel>().Id.ShouldBe(12);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<AddProgramScheduleItem>(c => c.ProgramScheduleId == 4 && c.PlayoutMode == PlayoutMode.One),
|
||||
|
||||
@@ -35,7 +35,7 @@ public class SearchControllerTests
|
||||
?? throw new AssertionException($"Missing action {nameof(SearchController.Search)}");
|
||||
|
||||
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
|
||||
attribute.Template.ShouldBe("/api/search");
|
||||
attribute.Template.ShouldBe("/api/v1/search");
|
||||
attribute.Name.ShouldBe("Search");
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ public class SearchControllerTests
|
||||
?? throw new AssertionException($"Missing action {nameof(SearchController.SearchAllItems)}");
|
||||
|
||||
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
|
||||
attribute.Template.ShouldBe("/api/search/all-items");
|
||||
attribute.Template.ShouldBe("/api/v1/search/all-items");
|
||||
attribute.Name.ShouldBe("SearchAllItems");
|
||||
}
|
||||
|
||||
|
||||
@@ -33,11 +33,11 @@ public class SmartCollectionControllerTests
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(SmartCollectionController.GetAll), "GET", "/api/smart-collections");
|
||||
ShouldHaveActionRoute(nameof(SmartCollectionController.GetById), "GET", "/api/smart-collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(SmartCollectionController.Create), "POST", "/api/smart-collections");
|
||||
ShouldHaveActionRoute(nameof(SmartCollectionController.Update), "PUT", "/api/smart-collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(SmartCollectionController.Delete), "DELETE", "/api/smart-collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(SmartCollectionController.GetAll), "GET", "/api/v1/smart-collections");
|
||||
ShouldHaveActionRoute(nameof(SmartCollectionController.GetById), "GET", "/api/v1/smart-collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(SmartCollectionController.Create), "POST", "/api/v1/smart-collections");
|
||||
ShouldHaveActionRoute(nameof(SmartCollectionController.Update), "PUT", "/api/v1/smart-collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(SmartCollectionController.Delete), "DELETE", "/api/v1/smart-collections/{id:int}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -53,7 +53,7 @@ public class SmartCollectionControllerTests
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.StatusCode.ShouldBe(201);
|
||||
created.Location.ShouldBe("/api/smart-collections/7");
|
||||
created.Location.ShouldBe("/api/v1/smart-collections/7");
|
||||
created.Value.ShouldBe(new SmartCollectionResponseModel(7, "Kids", "tag:family"));
|
||||
}
|
||||
|
||||
|
||||
@@ -38,16 +38,16 @@ public class TemplateControllerTests
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(TemplateController.GetGroups), "GET", "/api/templates/groups");
|
||||
ShouldHaveActionRoute(nameof(TemplateController.CreateGroup), "POST", "/api/templates/groups");
|
||||
ShouldHaveActionRoute(nameof(TemplateController.DeleteGroup), "DELETE", "/api/templates/groups/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(TemplateController.GetAll), "GET", "/api/templates");
|
||||
ShouldHaveActionRoute(nameof(TemplateController.GetById), "GET", "/api/templates/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(TemplateController.Create), "POST", "/api/templates");
|
||||
ShouldHaveActionRoute(nameof(TemplateController.Delete), "DELETE", "/api/templates/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(TemplateController.GetItems), "GET", "/api/templates/{id:int}/items");
|
||||
ShouldHaveActionRoute(nameof(TemplateController.Replace), "PUT", "/api/templates/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(TemplateController.Copy), "POST", "/api/templates/{id:int}/copy");
|
||||
ShouldHaveActionRoute(nameof(TemplateController.GetGroups), "GET", "/api/v1/templates/groups");
|
||||
ShouldHaveActionRoute(nameof(TemplateController.CreateGroup), "POST", "/api/v1/templates/groups");
|
||||
ShouldHaveActionRoute(nameof(TemplateController.DeleteGroup), "DELETE", "/api/v1/templates/groups/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(TemplateController.GetAll), "GET", "/api/v1/templates");
|
||||
ShouldHaveActionRoute(nameof(TemplateController.GetById), "GET", "/api/v1/templates/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(TemplateController.Create), "POST", "/api/v1/templates");
|
||||
ShouldHaveActionRoute(nameof(TemplateController.Delete), "DELETE", "/api/v1/templates/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(TemplateController.GetItems), "GET", "/api/v1/templates/{id:int}/items");
|
||||
ShouldHaveActionRoute(nameof(TemplateController.Replace), "PUT", "/api/v1/templates/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(TemplateController.Copy), "POST", "/api/v1/templates/{id:int}/copy");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -61,7 +61,7 @@ public class TemplateControllerTests
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/templates/groups/5");
|
||||
created.Location.ShouldBe("/api/v1/templates/groups/5");
|
||||
created.Value.ShouldBeOfType<TemplateGroupResponseModel>().Name.ShouldBe("Prime Time");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreateTemplateGroup>(c => c.Name == "Prime Time"),
|
||||
@@ -150,7 +150,7 @@ public class TemplateControllerTests
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/templates/8");
|
||||
created.Location.ShouldBe("/api/v1/templates/8");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreateTemplate>(c => c.TemplateGroupId == 2 && c.Name == "Morning"),
|
||||
Arg.Any<CancellationToken>());
|
||||
@@ -416,7 +416,7 @@ public class TemplateControllerTests
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/templates/9");
|
||||
created.Location.ShouldBe("/api/v1/templates/9");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CopyTemplate>(c => c.TemplateId == 4 && c.NewTemplateGroupId == 3 && c.NewTemplateName == "Morning Copy"),
|
||||
Arg.Any<CancellationToken>());
|
||||
|
||||
@@ -79,7 +79,7 @@ public class TroubleshootControllerTests
|
||||
?? throw new AssertionException("Missing action GetInfo");
|
||||
|
||||
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
|
||||
attribute.Template.ShouldBe("api/troubleshoot/info");
|
||||
attribute.Template.ShouldBe("/api/v1/troubleshoot/info");
|
||||
attribute.Name.ShouldBe("GetTroubleshootingInfo");
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ public class TroubleshootControllerTests
|
||||
?? throw new AssertionException("Missing action ValidateSchedule");
|
||||
|
||||
var attribute = action.GetCustomAttributes<HttpPostAttribute>().Single();
|
||||
attribute.Template.ShouldBe("api/troubleshoot/validate-schedule");
|
||||
attribute.Template.ShouldBe("/api/v1/troubleshoot/validate-schedule");
|
||||
attribute.Name.ShouldBe("ValidateSequentialSchedule");
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ public class TroubleshootControllerTests
|
||||
?? throw new AssertionException("Missing action GetStreamSelectors");
|
||||
|
||||
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
|
||||
attribute.Template.ShouldBe("api/troubleshoot/playback/stream-selectors");
|
||||
attribute.Template.ShouldBe("/api/v1/troubleshoot/playback/stream-selectors");
|
||||
attribute.Name.ShouldBe("GetTroubleshootingStreamSelectors");
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ public class TroubleshootControllerTests
|
||||
?? throw new AssertionException("Missing action GetSubtitles");
|
||||
|
||||
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
|
||||
attribute.Template.ShouldBe("api/troubleshoot/playback/subtitles/{mediaItemId:int}");
|
||||
attribute.Template.ShouldBe("/api/v1/troubleshoot/playback/subtitles/{mediaItemId:int}");
|
||||
attribute.Name.ShouldBe("GetTroubleshootingSubtitles");
|
||||
}
|
||||
|
||||
@@ -246,7 +246,7 @@ public class TroubleshootControllerTests
|
||||
?? throw new AssertionException("Missing action GetPlaybackStatus");
|
||||
|
||||
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
|
||||
attribute.Template.ShouldBe("api/troubleshoot/playback/status");
|
||||
attribute.Template.ShouldBe("/api/v1/troubleshoot/playback/status");
|
||||
attribute.Name.ShouldBe("GetTroubleshootingPlaybackStatus");
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@ public class TroubleshootControllerTests
|
||||
?? throw new AssertionException("Missing action TroubleshootPlayback");
|
||||
|
||||
var attribute = action.GetCustomAttributes<HttpPostAttribute>().Single();
|
||||
attribute.Template.ShouldBe("api/troubleshoot/playback/start");
|
||||
attribute.Template.ShouldBe("/api/v1/troubleshoot/playback/start");
|
||||
attribute.Name.ShouldBe("StartTroubleshootingPlayback");
|
||||
action.GetParameters()[0].ParameterType.ShouldBe(typeof(StartTroubleshootingPlaybackRequest));
|
||||
}
|
||||
@@ -282,7 +282,7 @@ public class TroubleshootControllerTests
|
||||
?? throw new AssertionException("Missing action TroubleshootPlaybackArchive");
|
||||
|
||||
var attribute = action.GetCustomAttributes<HttpPostAttribute>().Single();
|
||||
attribute.Template.ShouldBe("api/troubleshoot/playback/archive");
|
||||
attribute.Template.ShouldBe("/api/v1/troubleshoot/playback/archive");
|
||||
attribute.Name.ShouldBe("DownloadTroubleshootingArchive");
|
||||
action.GetCustomAttributes<HttpGetAttribute>().ShouldBeEmpty();
|
||||
action.GetCustomAttributes<HttpHeadAttribute>().ShouldBeEmpty();
|
||||
@@ -295,7 +295,7 @@ public class TroubleshootControllerTests
|
||||
?? throw new AssertionException("Missing action TroubleshootPlaybackSample");
|
||||
|
||||
var attribute = action.GetCustomAttributes<HttpPostAttribute>().Single();
|
||||
attribute.Template.ShouldBe("api/troubleshoot/playback/sample/{mediaItemId:int}");
|
||||
attribute.Template.ShouldBe("/api/v1/troubleshoot/playback/sample/{mediaItemId:int}");
|
||||
attribute.Name.ShouldBe("DownloadTroubleshootingMediaSample");
|
||||
action.GetCustomAttributes<HttpGetAttribute>().ShouldBeEmpty();
|
||||
action.GetCustomAttributes<HttpHeadAttribute>().ShouldBeEmpty();
|
||||
|
||||
@@ -35,11 +35,11 @@ public class WatermarkControllerTests
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute("GET", "/api/watermarks");
|
||||
ShouldHaveActionRoute("GET", "/api/watermarks/{id:int}");
|
||||
ShouldHaveActionRoute("POST", "/api/watermarks");
|
||||
ShouldHaveActionRoute("PUT", "/api/watermarks/{id:int}");
|
||||
ShouldHaveActionRoute("DELETE", "/api/watermarks/{id:int}");
|
||||
ShouldHaveActionRoute("GET", "/api/v1/watermarks");
|
||||
ShouldHaveActionRoute("GET", "/api/v1/watermarks/{id:int}");
|
||||
ShouldHaveActionRoute("POST", "/api/v1/watermarks");
|
||||
ShouldHaveActionRoute("PUT", "/api/v1/watermarks/{id:int}");
|
||||
ShouldHaveActionRoute("DELETE", "/api/v1/watermarks/{id:int}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -112,7 +112,7 @@ public class WatermarkControllerTests
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.StatusCode.ShouldBe(201);
|
||||
created.Location.ShouldBe("/api/watermarks/7");
|
||||
created.Location.ShouldBe("/api/v1/watermarks/7");
|
||||
created.Value.ShouldBe(vm);
|
||||
}
|
||||
|
||||
|
||||
@@ -80,11 +80,11 @@ public class ApiResultsTests
|
||||
{
|
||||
Either<BaseError, int> either = Right<BaseError, int>(5);
|
||||
|
||||
IActionResult result = either.ToCreatedResult(id => $"/api/channels/{id}", id => $"body-{id}");
|
||||
IActionResult result = either.ToCreatedResult(id => $"/api/v1/channels/{id}", id => $"body-{id}");
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.StatusCode.ShouldBe(201);
|
||||
created.Location.ShouldBe("/api/channels/5");
|
||||
created.Location.ShouldBe("/api/v1/channels/5");
|
||||
created.Value.ShouldBe("body-5");
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ public class ApiResultsTests
|
||||
{
|
||||
Either<BaseError, int> either = Left<BaseError, int>(new NotFoundError("nope"));
|
||||
|
||||
IActionResult result = either.ToCreatedResult(id => $"/api/channels/{id}", id => id);
|
||||
IActionResult result = either.ToCreatedResult(id => $"/api/v1/channels/{id}", id => id);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ public class ApiAuthorizationFilterTests
|
||||
private static AuthorizationFilterContext MakeContext(
|
||||
string method,
|
||||
string? apiKeyHeader,
|
||||
string path = "/api/channels",
|
||||
string path = "/api/v1/channels",
|
||||
bool skipApiKeyAuthorization = false,
|
||||
bool requiresApiKey = false,
|
||||
bool authenticatedSession = false,
|
||||
@@ -172,7 +172,7 @@ public class ApiAuthorizationFilterTests
|
||||
AuthorizationFilterContext context = MakeContext(
|
||||
"POST",
|
||||
apiKeyHeader: null,
|
||||
path: "/api/scan/0f8fad5b-d9cb-469f-a165-70867728950e/progress",
|
||||
path: "/api/v1/scan/0f8fad5b-d9cb-469f-a165-70867728950e/progress",
|
||||
skipApiKeyAuthorization: true);
|
||||
|
||||
MakeFilter().OnAuthorization(context);
|
||||
|
||||
@@ -119,7 +119,7 @@ public class ApiKeyEndpointRequiresKeyTests
|
||||
string method,
|
||||
bool requiresApiKey,
|
||||
bool skip,
|
||||
string path = "/api/channels")
|
||||
string path = "/api/v1/channels")
|
||||
{
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.Method = method;
|
||||
|
||||
@@ -43,7 +43,7 @@ public class SecurityHeadersMiddlewareTests
|
||||
[Test]
|
||||
public async Task Should_Enforce_Csp_On_Spa_And_Api_Responses()
|
||||
{
|
||||
foreach (string path in new[] { "/", "/app", "/api/channels", "/artwork/x.jpg", "/iptv/channels.m3u" })
|
||||
foreach (string path in new[] { "/", "/app", "/api/v1/channels", "/artwork/x.jpg", "/iptv/channels.m3u" })
|
||||
{
|
||||
HttpContext context = await Invoke(path);
|
||||
string csp = context.Response.Headers["Content-Security-Policy"].ToString();
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiController]
|
||||
public class ArtistsController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/artists/{id:int}", Name = "GetArtistById")]
|
||||
[HttpGet("/api/v1/artists/{id:int}", Name = "GetArtistById")]
|
||||
[Tags("Artists")]
|
||||
[EndpointSummary("Get an artist by id")]
|
||||
[EndpointGroupName("general")]
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiController]
|
||||
public class ArtworkUploadController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpPost("/api/artwork/uploads", Name = "UploadArtwork")]
|
||||
[HttpPost("/api/v1/artwork/uploads", Name = "UploadArtwork")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[Tags("Artwork")]
|
||||
[EndpointSummary("Upload channel logo or watermark artwork")]
|
||||
@@ -22,7 +22,7 @@ public class ArtworkUploadController(IMediator mediator) : ControllerBase
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Upload(
|
||||
IFormFile file,
|
||||
[FromForm] [Description("Artwork target: 'logo' (default) or 'watermark'")] string target,
|
||||
[FromForm][Description("Artwork target: 'logo' (default) or 'watermark'")] string target,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (file is null || file.Length == 0)
|
||||
|
||||
@@ -31,7 +31,7 @@ public class AuthController(IMediator mediator, IConfiguration configuration, IA
|
||||
private bool EnvSeedConfigured => !string.IsNullOrWhiteSpace(configuration["Auth:LocalAdmin:Password"]);
|
||||
|
||||
/// <summary>Public: what auth options exist + whether first-run setup is still needed (drives the SPA boot gate).</summary>
|
||||
[HttpGet("/api/auth/config")]
|
||||
[HttpGet("/api/v1/auth/config")]
|
||||
public async Task<IActionResult> Config(CancellationToken cancellationToken)
|
||||
{
|
||||
bool configured = await mediator.Send(new IsLocalAdminConfigured(), cancellationToken);
|
||||
@@ -39,7 +39,7 @@ public class AuthController(IMediator mediator, IConfiguration configuration, IA
|
||||
}
|
||||
|
||||
/// <summary>The current session (anonymous is a 200 with authenticated=false, never a 401).</summary>
|
||||
[HttpGet("/api/auth/session")]
|
||||
[HttpGet("/api/v1/auth/session")]
|
||||
public IActionResult Session()
|
||||
{
|
||||
if (User.Identity?.IsAuthenticated != true)
|
||||
@@ -56,7 +56,7 @@ public class AuthController(IMediator mediator, IConfiguration configuration, IA
|
||||
/// 401 otherwise. Never CSRF-gated — it's a GET, and SOP already blocks a cross-site page from reading a
|
||||
/// credentialed response body.
|
||||
/// </summary>
|
||||
[HttpGet("/api/auth/machine-key")]
|
||||
[HttpGet("/api/v1/auth/machine-key")]
|
||||
public IActionResult MachineKey()
|
||||
{
|
||||
if (User.Identity?.IsAuthenticated != true)
|
||||
@@ -75,7 +75,7 @@ public class AuthController(IMediator mediator, IConfiguration configuration, IA
|
||||
}
|
||||
|
||||
/// <summary>First-run setup-claim: create the local admin. Fails 409 if one already exists.</summary>
|
||||
[HttpPost("/api/auth/setup")]
|
||||
[HttpPost("/api/v1/auth/setup")]
|
||||
[EnableRateLimiting("auth")]
|
||||
public async Task<IActionResult> Setup([FromBody] SetupRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -114,7 +114,7 @@ public class AuthController(IMediator mediator, IConfiguration configuration, IA
|
||||
}
|
||||
|
||||
/// <summary>Local username/password login. A generic 401 on any failure (no username enumeration).</summary>
|
||||
[HttpPost("/api/auth/login")]
|
||||
[HttpPost("/api/v1/auth/login")]
|
||||
[EnableRateLimiting("auth")]
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -142,7 +142,7 @@ public class AuthController(IMediator mediator, IConfiguration configuration, IA
|
||||
/// sessions ("log out everywhere"). Gated on an authenticated session so an unauthenticated caller
|
||||
/// can't force-revoke the admin.
|
||||
/// </summary>
|
||||
[HttpPost("/api/auth/logout")]
|
||||
[HttpPost("/api/v1/auth/logout")]
|
||||
public async Task<IActionResult> Logout(CancellationToken cancellationToken)
|
||||
{
|
||||
// Prevent forced-logout CSRF: a same-site form POST carries the Lax cookie but can't set a custom
|
||||
@@ -166,7 +166,7 @@ public class AuthController(IMediator mediator, IConfiguration configuration, IA
|
||||
}
|
||||
|
||||
/// <summary>Change the local admin password (requires a local-login session); rotates the stamp, revoking other sessions.</summary>
|
||||
[HttpPost("/api/auth/password")]
|
||||
[HttpPost("/api/v1/auth/password")]
|
||||
[EnableRateLimiting("auth")]
|
||||
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiController]
|
||||
public class BlockController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/blocks/groups", Name = "GetBlockGroups")]
|
||||
[HttpGet("/api/v1/blocks/groups", Name = "GetBlockGroups")]
|
||||
[Tags("Blocks")]
|
||||
[EndpointSummary("Get all block groups")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -24,7 +24,7 @@ public class BlockController(IMediator mediator) : ControllerBase
|
||||
return groups.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
|
||||
[HttpPost("/api/blocks/groups")]
|
||||
[HttpPost("/api/v1/blocks/groups")]
|
||||
[Tags("Blocks")]
|
||||
[EndpointSummary("Create a block group")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -38,11 +38,11 @@ public class BlockController(IMediator mediator) : ControllerBase
|
||||
Either<BaseError, BlockGroupViewModel> result =
|
||||
await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(
|
||||
vm => $"/api/blocks/groups/{vm.Id}",
|
||||
vm => $"/api/v1/blocks/groups/{vm.Id}",
|
||||
vm => ProjectToResponseModel(vm));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/blocks/groups/{id:int}")]
|
||||
[HttpDelete("/api/v1/blocks/groups/{id:int}")]
|
||||
[Tags("Blocks")]
|
||||
[EndpointSummary("Delete a block group")]
|
||||
[EndpointDescription(
|
||||
@@ -64,7 +64,7 @@ public class BlockController(IMediator mediator) : ControllerBase
|
||||
None: () => new NoContentResult());
|
||||
}
|
||||
|
||||
[HttpGet("/api/blocks")]
|
||||
[HttpGet("/api/v1/blocks")]
|
||||
[Tags("Blocks")]
|
||||
[EndpointSummary("Get all blocks")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -75,7 +75,7 @@ public class BlockController(IMediator mediator) : ControllerBase
|
||||
return blocks.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
|
||||
[HttpGet("/api/blocks/{id:int}", Name = "GetBlockById")]
|
||||
[HttpGet("/api/v1/blocks/{id:int}", Name = "GetBlockById")]
|
||||
[Tags("Blocks")]
|
||||
[EndpointSummary("Get a block by id")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -87,7 +87,7 @@ public class BlockController(IMediator mediator) : ControllerBase
|
||||
return result.Map(ProjectToResponseModel).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/blocks")]
|
||||
[HttpPost("/api/v1/blocks")]
|
||||
[Tags("Blocks")]
|
||||
[EndpointSummary("Create a block")]
|
||||
[EndpointDescription("Creates an empty block in the given block group. The block defaults to 30 minutes.")]
|
||||
@@ -101,11 +101,11 @@ public class BlockController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
Either<BaseError, BlockViewModel> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(
|
||||
vm => $"/api/blocks/{vm.Id}",
|
||||
vm => $"/api/v1/blocks/{vm.Id}",
|
||||
vm => ProjectToResponseModel(vm));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/blocks/{id:int}")]
|
||||
[HttpDelete("/api/v1/blocks/{id:int}")]
|
||||
[Tags("Blocks")]
|
||||
[EndpointSummary("Delete a block")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -125,7 +125,7 @@ public class BlockController(IMediator mediator) : ControllerBase
|
||||
None: () => new NoContentResult());
|
||||
}
|
||||
|
||||
[HttpGet("/api/blocks/{id:int}/items")]
|
||||
[HttpGet("/api/v1/blocks/{id:int}/items")]
|
||||
[Tags("Blocks")]
|
||||
[EndpointSummary("Get block items")]
|
||||
[EndpointDescription(
|
||||
@@ -149,7 +149,7 @@ public class BlockController(IMediator mediator) : ControllerBase
|
||||
return new OkObjectResult(items.OrderBy(i => i.Index).Map(ProjectToResponseModel).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("/api/blocks/{id:int}")]
|
||||
[HttpPut("/api/v1/blocks/{id:int}")]
|
||||
[Tags("Blocks")]
|
||||
[EndpointSummary("Replace a block and its items")]
|
||||
[EndpointDescription(
|
||||
@@ -208,7 +208,7 @@ public class BlockController(IMediator mediator) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/api/blocks/{id:int}/preview")]
|
||||
[HttpPost("/api/v1/blocks/{id:int}/preview")]
|
||||
[Tags("Blocks")]
|
||||
[EndpointSummary("Preview a block playout")]
|
||||
[EndpointDescription(
|
||||
@@ -237,7 +237,7 @@ public class BlockController(IMediator mediator) : ControllerBase
|
||||
return new OkObjectResult(preview.Map(ProjectToResponseModel).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("/api/blocks/{id:int}/copy")]
|
||||
[HttpPost("/api/v1/blocks/{id:int}/copy")]
|
||||
[Tags("Blocks")]
|
||||
[EndpointSummary("Copy a block")]
|
||||
[EndpointDescription("Copies the block and its items into another (or the same) block group under a new name.")]
|
||||
@@ -258,7 +258,7 @@ public class BlockController(IMediator mediator) : ControllerBase
|
||||
|
||||
Either<BaseError, BlockViewModel> result = await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return result.ToCreatedResult(
|
||||
vm => $"/api/blocks/{vm.Id}",
|
||||
vm => $"/api/v1/blocks/{vm.Id}",
|
||||
vm => ProjectToResponseModel(vm));
|
||||
}
|
||||
|
||||
|
||||
@@ -23,18 +23,18 @@ public class ChannelController(
|
||||
IMediator mediator,
|
||||
IEntityLocker entityLocker)
|
||||
{
|
||||
[HttpGet("/api/channels")]
|
||||
[HttpGet("/api/v1/channels")]
|
||||
[EndpointGroupName("general")]
|
||||
public async Task<List<ChannelResponseModel>> GetAll() => await mediator.Send(new GetAllChannelsForApi());
|
||||
|
||||
[HttpGet("/api/channels/state")]
|
||||
[HttpGet("/api/v1/channels/state")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Get channel runtime state")]
|
||||
[EndpointGroupName("general")]
|
||||
public async Task<List<ChannelStateResponseModel>> GetState(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetChannelStatesForApi(DateTime.UtcNow), cancellationToken);
|
||||
|
||||
[HttpGet("/api/guide")]
|
||||
[HttpGet("/api/v1/guide")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Get the JSON channel guide (EPG)")]
|
||||
[EndpointDescription(
|
||||
@@ -48,7 +48,7 @@ public class ChannelController(
|
||||
CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetChannelGuideData(start, end), cancellationToken);
|
||||
|
||||
[HttpGet("/api/channels/music-video-credits-templates", Name = "GetMusicVideoCreditsTemplates")]
|
||||
[HttpGet("/api/v1/channels/music-video-credits-templates", Name = "GetMusicVideoCreditsTemplates")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Get available music video credits template names")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -56,7 +56,7 @@ public class ChannelController(
|
||||
public async Task<List<string>> GetMusicVideoCreditsTemplates(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetMusicVideoCreditTemplates(), cancellationToken);
|
||||
|
||||
[HttpGet("/api/channels/stream-selectors", Name = "GetChannelStreamSelectors")]
|
||||
[HttpGet("/api/v1/channels/stream-selectors", Name = "GetChannelStreamSelectors")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Get available channel stream selector names")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -64,7 +64,7 @@ public class ChannelController(
|
||||
public async Task<List<string>> GetStreamSelectors(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetChannelStreamSelectors(), cancellationToken);
|
||||
|
||||
[HttpGet("/api/channels/{id:int}", Name = "GetChannelById")]
|
||||
[HttpGet("/api/v1/channels/{id:int}", Name = "GetChannelById")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Get a channel by id")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -76,7 +76,7 @@ public class ChannelController(
|
||||
return result.ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/channels")]
|
||||
[HttpPost("/api/v1/channels")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Create a channel")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -84,7 +84,7 @@ public class ChannelController(
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Create(
|
||||
[Required] [FromBody] CreateChannelRequest request,
|
||||
[Required][FromBody] CreateChannelRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, CreateChannelResult> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
@@ -95,12 +95,12 @@ public class ChannelController(
|
||||
Option<ChannelDetailResponseModel> channel =
|
||||
await mediator.Send(new GetChannelByIdForApi(created.ChannelId), cancellationToken);
|
||||
return channel.Match(
|
||||
Some: model => (IActionResult)new CreatedResult($"/api/channels/{model.Id}", model),
|
||||
Some: model => (IActionResult)new CreatedResult($"/api/v1/channels/{model.Id}", model),
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/api/channels/from-lineup", Name = "CreateChannelFromLineup")]
|
||||
[HttpPost("/api/v1/channels/from-lineup", Name = "CreateChannelFromLineup")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Create a channel from a library lineup")]
|
||||
[EndpointDescription(
|
||||
@@ -118,15 +118,15 @@ public class ChannelController(
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> CreateFromLineup(
|
||||
[Required] [FromBody] CreateChannelFromLineupRequest request,
|
||||
[Required][FromBody] CreateChannelFromLineupRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, CreateChannelFromLineupResponseModel> result =
|
||||
await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(response => $"/api/channels/{response.ChannelId}", response => response);
|
||||
return result.ToCreatedResult(response => $"/api/v1/channels/{response.ChannelId}", response => response);
|
||||
}
|
||||
|
||||
[HttpPut("/api/channels/{id:int}")]
|
||||
[HttpPut("/api/v1/channels/{id:int}")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Update a channel")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -135,7 +135,7 @@ public class ChannelController(
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Update(
|
||||
int id,
|
||||
[Required] [FromBody] UpdateChannelRequest request,
|
||||
[Required][FromBody] UpdateChannelRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, ChannelViewModel> result =
|
||||
@@ -154,7 +154,7 @@ public class ChannelController(
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/channels/{id:int}")]
|
||||
[HttpDelete("/api/v1/channels/{id:int}")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Delete a channel")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -167,7 +167,7 @@ public class ChannelController(
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/channels/bulk/renumber")]
|
||||
[HttpPost("/api/v1/channels/bulk/renumber")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Renumber channels")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -175,7 +175,7 @@ public class ChannelController(
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> BulkRenumber(
|
||||
[Required] [FromBody] BulkRenumberChannelsRequest request,
|
||||
[Required][FromBody] BulkRenumberChannelsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<BaseError> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
@@ -184,7 +184,7 @@ public class ChannelController(
|
||||
None: () => new NoContentResult());
|
||||
}
|
||||
|
||||
[HttpPost("/api/channels/bulk/group")]
|
||||
[HttpPost("/api/v1/channels/bulk/group")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Move channels to a group")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -192,14 +192,14 @@ public class ChannelController(
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> BulkMoveToGroup(
|
||||
[Required] [FromBody] BulkMoveChannelsToGroupRequest request,
|
||||
[Required][FromBody] BulkMoveChannelsToGroupRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/channels/bulk/delete")]
|
||||
[HttpPost("/api/v1/channels/bulk/delete")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Delete channels")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -207,14 +207,14 @@ public class ChannelController(
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> BulkDelete(
|
||||
[Required] [FromBody] BulkDeleteChannelsRequest request,
|
||||
[Required][FromBody] BulkDeleteChannelsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/channels/{id:int}/playout/reset")]
|
||||
[HttpPost("/api/v1/channels/{id:int}/playout/reset")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Reset channel playout")]
|
||||
[EndpointDescription(
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiController]
|
||||
public class ChannelTemplateController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/channel-templates", Name = "GetChannelTemplates")]
|
||||
[HttpGet("/api/v1/channel-templates", Name = "GetChannelTemplates")]
|
||||
[Tags("Channel Templates")]
|
||||
[EndpointSummary("Get all channel templates")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -21,7 +21,7 @@ public class ChannelTemplateController(IMediator mediator) : ControllerBase
|
||||
public async Task<List<ChannelTemplateResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllChannelTemplates(), cancellationToken);
|
||||
|
||||
[HttpGet("/api/channel-templates/default", Name = "GetDefaultChannelTemplate")]
|
||||
[HttpGet("/api/v1/channel-templates/default", Name = "GetDefaultChannelTemplate")]
|
||||
[Tags("Channel Templates")]
|
||||
[EndpointSummary("Get the default channel template")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -34,7 +34,7 @@ public class ChannelTemplateController(IMediator mediator) : ControllerBase
|
||||
return result.ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPut("/api/channel-templates/default/{id:int}", Name = "SetDefaultChannelTemplate")]
|
||||
[HttpPut("/api/v1/channel-templates/default/{id:int}", Name = "SetDefaultChannelTemplate")]
|
||||
[Tags("Channel Templates")]
|
||||
[EndpointSummary("Set the default channel template")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -48,7 +48,7 @@ public class ChannelTemplateController(IMediator mediator) : ControllerBase
|
||||
return result.ToUpdatedResult();
|
||||
}
|
||||
|
||||
[HttpGet("/api/channel-templates/{id:int}", Name = "GetChannelTemplateById")]
|
||||
[HttpGet("/api/v1/channel-templates/{id:int}", Name = "GetChannelTemplateById")]
|
||||
[Tags("Channel Templates")]
|
||||
[EndpointSummary("Get a channel template by id")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -61,7 +61,7 @@ public class ChannelTemplateController(IMediator mediator) : ControllerBase
|
||||
return result.ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/channel-templates", Name = "CreateChannelTemplate")]
|
||||
[HttpPost("/api/v1/channel-templates", Name = "CreateChannelTemplate")]
|
||||
[Tags("Channel Templates")]
|
||||
[EndpointSummary("Create a channel template")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -69,15 +69,15 @@ public class ChannelTemplateController(IMediator mediator) : ControllerBase
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Create(
|
||||
[Required] [FromBody] CreateChannelTemplateRequest request,
|
||||
[Required][FromBody] CreateChannelTemplateRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, ChannelTemplateResponseModel> result =
|
||||
await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(vm => $"/api/channel-templates/{vm.Id}", vm => vm);
|
||||
return result.ToCreatedResult(vm => $"/api/v1/channel-templates/{vm.Id}", vm => vm);
|
||||
}
|
||||
|
||||
[HttpPut("/api/channel-templates/{id:int}", Name = "UpdateChannelTemplate")]
|
||||
[HttpPut("/api/v1/channel-templates/{id:int}", Name = "UpdateChannelTemplate")]
|
||||
[Tags("Channel Templates")]
|
||||
[EndpointSummary("Update a channel template")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -86,7 +86,7 @@ public class ChannelTemplateController(IMediator mediator) : ControllerBase
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Update(
|
||||
int id,
|
||||
[Required] [FromBody] UpdateChannelTemplateRequest request,
|
||||
[Required][FromBody] UpdateChannelTemplateRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, ChannelTemplateResponseModel> result =
|
||||
@@ -94,7 +94,7 @@ public class ChannelTemplateController(IMediator mediator) : ControllerBase
|
||||
return result.ToUpdatedResult();
|
||||
}
|
||||
|
||||
[HttpDelete("/api/channel-templates/{id:int}", Name = "DeleteChannelTemplate")]
|
||||
[HttpDelete("/api/v1/channel-templates/{id:int}", Name = "DeleteChannelTemplate")]
|
||||
[Tags("Channel Templates")]
|
||||
[EndpointSummary("Delete a channel template")]
|
||||
[EndpointGroupName("general")]
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiController]
|
||||
public class CollectionController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/collections")]
|
||||
[HttpGet("/api/v1/collections")]
|
||||
[Tags("Collections")]
|
||||
[EndpointSummary("Get all collections")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -25,7 +25,7 @@ public class CollectionController(IMediator mediator) : ControllerBase
|
||||
return collections.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
|
||||
[HttpGet("/api/collections/{id:int}", Name = "GetCollectionById")]
|
||||
[HttpGet("/api/v1/collections/{id:int}", Name = "GetCollectionById")]
|
||||
[Tags("Collections")]
|
||||
[EndpointSummary("Get a collection by id")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -37,7 +37,7 @@ public class CollectionController(IMediator mediator) : ControllerBase
|
||||
return result.Map(ProjectToResponseModel).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpGet("/api/collections/{id:int}/items", Name = "GetCollectionItems")]
|
||||
[HttpGet("/api/v1/collections/{id:int}/items", Name = "GetCollectionItems")]
|
||||
[Tags("Collections")]
|
||||
[EndpointSummary("Get the items in a manual collection")]
|
||||
[EndpointDescription("Returns a manual collection's full contents (all media kinds), paged.")]
|
||||
@@ -68,7 +68,7 @@ public class CollectionController(IMediator mediator) : ControllerBase
|
||||
return result.ToUpdatedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/collections")]
|
||||
[HttpPost("/api/v1/collections")]
|
||||
[Tags("Collections")]
|
||||
[EndpointSummary("Create a collection")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -81,10 +81,10 @@ public class CollectionController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
Either<BaseError, MediaCollectionViewModel> result =
|
||||
await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(vm => $"/api/collections/{vm.Id}", ProjectToResponseModel);
|
||||
return result.ToCreatedResult(vm => $"/api/v1/collections/{vm.Id}", ProjectToResponseModel);
|
||||
}
|
||||
|
||||
[HttpPut("/api/collections/{id:int}")]
|
||||
[HttpPut("/api/v1/collections/{id:int}")]
|
||||
[Tags("Collections")]
|
||||
[EndpointSummary("Update a collection")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -109,7 +109,7 @@ public class CollectionController(IMediator mediator) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("/api/collections/{id:int}/custom-order")]
|
||||
[HttpPut("/api/v1/collections/{id:int}/custom-order")]
|
||||
[Tags("Collections")]
|
||||
[EndpointSummary("Set a collection's custom playback order")]
|
||||
[EndpointDescription(
|
||||
@@ -154,7 +154,7 @@ public class CollectionController(IMediator mediator) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/collections/{id:int}")]
|
||||
[HttpDelete("/api/v1/collections/{id:int}")]
|
||||
[Tags("Collections")]
|
||||
[EndpointSummary("Delete a collection")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -167,7 +167,7 @@ public class CollectionController(IMediator mediator) : ControllerBase
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/collections/{id:int}/items")]
|
||||
[HttpPost("/api/v1/collections/{id:int}/items")]
|
||||
[Tags("Collections")]
|
||||
[EndpointSummary("Add items to a collection")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -183,7 +183,7 @@ public class CollectionController(IMediator mediator) : ControllerBase
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpDelete("/api/collections/{id:int}/items/{mediaItemId:int}")]
|
||||
[HttpDelete("/api/v1/collections/{id:int}/items/{mediaItemId:int}")]
|
||||
[Tags("Collections")]
|
||||
[EndpointSummary("Remove an item from a collection")]
|
||||
[EndpointGroupName("general")]
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiController]
|
||||
public class DecoController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/decos/groups", Name = "GetDecoGroups")]
|
||||
[HttpGet("/api/v1/decos/groups", Name = "GetDecoGroups")]
|
||||
[Tags("Decos")]
|
||||
[EndpointSummary("Get all deco groups")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -24,7 +24,7 @@ public class DecoController(IMediator mediator) : ControllerBase
|
||||
return groups.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
|
||||
[HttpPost("/api/decos/groups")]
|
||||
[HttpPost("/api/v1/decos/groups")]
|
||||
[Tags("Decos")]
|
||||
[EndpointSummary("Create a deco group")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -32,17 +32,17 @@ public class DecoController(IMediator mediator) : ControllerBase
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> CreateGroup(
|
||||
[Required] [FromBody] CreateDecoGroupRequest request,
|
||||
[Required][FromBody] CreateDecoGroupRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, DecoGroupViewModel> result =
|
||||
await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(
|
||||
vm => $"/api/decos/groups/{vm.Id}",
|
||||
vm => $"/api/v1/decos/groups/{vm.Id}",
|
||||
vm => ProjectToResponseModel(vm));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/decos/groups/{id:int}")]
|
||||
[HttpDelete("/api/v1/decos/groups/{id:int}")]
|
||||
[Tags("Decos")]
|
||||
[EndpointSummary("Delete a deco group")]
|
||||
[EndpointDescription(
|
||||
@@ -66,7 +66,7 @@ public class DecoController(IMediator mediator) : ControllerBase
|
||||
None: () => new NoContentResult());
|
||||
}
|
||||
|
||||
[HttpGet("/api/decos")]
|
||||
[HttpGet("/api/v1/decos")]
|
||||
[Tags("Decos")]
|
||||
[EndpointSummary("Get all decos")]
|
||||
[EndpointDescription("Returns every deco as a flat list, ordered by group name then deco name.")]
|
||||
@@ -85,7 +85,7 @@ public class DecoController(IMediator mediator) : ControllerBase
|
||||
return result;
|
||||
}
|
||||
|
||||
[HttpGet("/api/decos/{id:int}", Name = "GetDecoById")]
|
||||
[HttpGet("/api/v1/decos/{id:int}", Name = "GetDecoById")]
|
||||
[Tags("Decos")]
|
||||
[EndpointSummary("Get a deco by id")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -97,7 +97,7 @@ public class DecoController(IMediator mediator) : ControllerBase
|
||||
return result.Map(ProjectToResponseModel).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/decos")]
|
||||
[HttpPost("/api/v1/decos")]
|
||||
[Tags("Decos")]
|
||||
[EndpointSummary("Create a deco")]
|
||||
[EndpointDescription("Creates a deco in the given deco group. The deco inherits everything by default.")]
|
||||
@@ -106,16 +106,16 @@ public class DecoController(IMediator mediator) : ControllerBase
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Create(
|
||||
[Required] [FromBody] CreateDecoRequest request,
|
||||
[Required][FromBody] CreateDecoRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, DecoViewModel> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(
|
||||
vm => $"/api/decos/{vm.Id}",
|
||||
vm => $"/api/v1/decos/{vm.Id}",
|
||||
vm => ProjectToResponseModel(vm));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/decos/{id:int}")]
|
||||
[HttpDelete("/api/v1/decos/{id:int}")]
|
||||
[Tags("Decos")]
|
||||
[EndpointSummary("Delete a deco")]
|
||||
[EndpointDescription(
|
||||
@@ -137,7 +137,7 @@ public class DecoController(IMediator mediator) : ControllerBase
|
||||
None: () => new NoContentResult());
|
||||
}
|
||||
|
||||
[HttpPut("/api/decos/{id:int}")]
|
||||
[HttpPut("/api/v1/decos/{id:int}")]
|
||||
[Tags("Decos")]
|
||||
[EndpointSummary("Replace a deco")]
|
||||
[EndpointDescription(
|
||||
@@ -150,7 +150,7 @@ public class DecoController(IMediator mediator) : ControllerBase
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Replace(
|
||||
int id,
|
||||
[Required] [FromBody] ReplaceDecoRequest request,
|
||||
[Required][FromBody] ReplaceDecoRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<DecoViewModel> maybeDeco = await mediator.Send(new GetDecoById(id), cancellationToken);
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiController]
|
||||
public class DecoTemplateController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/deco-templates/groups", Name = "GetDecoTemplateGroups")]
|
||||
[HttpGet("/api/v1/deco-templates/groups", Name = "GetDecoTemplateGroups")]
|
||||
[Tags("DecoTemplates")]
|
||||
[EndpointSummary("Get all deco template groups")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -25,7 +25,7 @@ public class DecoTemplateController(IMediator mediator) : ControllerBase
|
||||
return groups.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
|
||||
[HttpPost("/api/deco-templates/groups")]
|
||||
[HttpPost("/api/v1/deco-templates/groups")]
|
||||
[Tags("DecoTemplates")]
|
||||
[EndpointSummary("Create a deco template group")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -39,11 +39,11 @@ public class DecoTemplateController(IMediator mediator) : ControllerBase
|
||||
Either<BaseError, DecoTemplateGroupViewModel> result =
|
||||
await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(
|
||||
vm => $"/api/deco-templates/groups/{vm.Id}",
|
||||
vm => $"/api/v1/deco-templates/groups/{vm.Id}",
|
||||
vm => ProjectToResponseModel(vm));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/deco-templates/groups/{id:int}")]
|
||||
[HttpDelete("/api/v1/deco-templates/groups/{id:int}")]
|
||||
[Tags("DecoTemplates")]
|
||||
[EndpointSummary("Delete a deco template group")]
|
||||
[EndpointDescription(
|
||||
@@ -67,7 +67,7 @@ public class DecoTemplateController(IMediator mediator) : ControllerBase
|
||||
None: () => new NoContentResult());
|
||||
}
|
||||
|
||||
[HttpGet("/api/deco-templates")]
|
||||
[HttpGet("/api/v1/deco-templates")]
|
||||
[Tags("DecoTemplates")]
|
||||
[EndpointSummary("Get all deco templates")]
|
||||
[EndpointDescription(
|
||||
@@ -89,7 +89,7 @@ public class DecoTemplateController(IMediator mediator) : ControllerBase
|
||||
return result;
|
||||
}
|
||||
|
||||
[HttpGet("/api/deco-templates/{id:int}", Name = "GetDecoTemplateById")]
|
||||
[HttpGet("/api/v1/deco-templates/{id:int}", Name = "GetDecoTemplateById")]
|
||||
[Tags("DecoTemplates")]
|
||||
[EndpointSummary("Get a deco template by id")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -101,7 +101,7 @@ public class DecoTemplateController(IMediator mediator) : ControllerBase
|
||||
return result.Map(ProjectToResponseModel).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/deco-templates")]
|
||||
[HttpPost("/api/v1/deco-templates")]
|
||||
[Tags("DecoTemplates")]
|
||||
[EndpointSummary("Create a deco template")]
|
||||
[EndpointDescription("Creates an empty deco template in the given deco template group.")]
|
||||
@@ -116,11 +116,11 @@ public class DecoTemplateController(IMediator mediator) : ControllerBase
|
||||
Either<BaseError, DecoTemplateViewModel> result =
|
||||
await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(
|
||||
vm => $"/api/deco-templates/{vm.Id}",
|
||||
vm => $"/api/v1/deco-templates/{vm.Id}",
|
||||
vm => ProjectToResponseModel(vm));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/deco-templates/{id:int}")]
|
||||
[HttpDelete("/api/v1/deco-templates/{id:int}")]
|
||||
[Tags("DecoTemplates")]
|
||||
[EndpointSummary("Delete a deco template")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -140,7 +140,7 @@ public class DecoTemplateController(IMediator mediator) : ControllerBase
|
||||
None: () => new NoContentResult());
|
||||
}
|
||||
|
||||
[HttpGet("/api/deco-templates/{id:int}/items")]
|
||||
[HttpGet("/api/v1/deco-templates/{id:int}/items")]
|
||||
[Tags("DecoTemplates")]
|
||||
[EndpointSummary("Get deco template items")]
|
||||
[EndpointDescription(
|
||||
@@ -165,7 +165,7 @@ public class DecoTemplateController(IMediator mediator) : ControllerBase
|
||||
items.OrderBy(i => i.StartTime.TimeOfDay).Map(ProjectToResponseModel).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("/api/deco-templates/{id:int}")]
|
||||
[HttpPut("/api/v1/deco-templates/{id:int}")]
|
||||
[Tags("DecoTemplates")]
|
||||
[EndpointSummary("Replace a deco template and its items")]
|
||||
[EndpointDescription(
|
||||
|
||||
@@ -22,7 +22,7 @@ public class EmbyMediaSourcesController(
|
||||
IEntityLocker entityLocker,
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/media-sources/emby", Name = "GetEmbyState")]
|
||||
[HttpGet("/api/v1/media-sources/emby", Name = "GetEmbyState")]
|
||||
[Tags("Emby")]
|
||||
[EndpointSummary("Get Emby connection state and discovered servers")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -42,7 +42,7 @@ public class EmbyMediaSourcesController(
|
||||
sources.Map(s => new RemoteMediaSourceItemResponseModel(s.Id, s.Name, s.Address)).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/media-sources/emby/connection", Name = "GetEmbyConnection")]
|
||||
[HttpGet("/api/v1/media-sources/emby/connection", Name = "GetEmbyConnection")]
|
||||
[Tags("Emby")]
|
||||
[EndpointSummary("Get the Emby connection address")]
|
||||
[EndpointDescription(
|
||||
@@ -58,7 +58,7 @@ public class EmbyMediaSourcesController(
|
||||
!string.IsNullOrWhiteSpace(secrets.ApiKey));
|
||||
}
|
||||
|
||||
[HttpPut("/api/media-sources/emby/connection", Name = "SaveEmbyConnection")]
|
||||
[HttpPut("/api/v1/media-sources/emby/connection", Name = "SaveEmbyConnection")]
|
||||
[Tags("Emby")]
|
||||
[EndpointSummary("Connect, reconnect, or edit the Emby connection")]
|
||||
[EndpointDescription(
|
||||
@@ -69,7 +69,7 @@ public class EmbyMediaSourcesController(
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> SaveConnection(
|
||||
[Required] [FromBody] SaveRemoteConnectionRequest request,
|
||||
[Required][FromBody] SaveRemoteConnectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (entityLocker.IsRemoteMediaSourceLocked<EmbyMediaSource>())
|
||||
@@ -107,7 +107,7 @@ public class EmbyMediaSourcesController(
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/api/media-sources/emby/disconnect", Name = "DisconnectEmby")]
|
||||
[HttpPost("/api/v1/media-sources/emby/disconnect", Name = "DisconnectEmby")]
|
||||
[Tags("Emby")]
|
||||
[EndpointSummary("Disconnect Emby")]
|
||||
[EndpointDescription("Purges the Emby connection, discovered servers, and all synced Emby content.")]
|
||||
@@ -129,7 +129,7 @@ public class EmbyMediaSourcesController(
|
||||
Right: _ => (IActionResult)new NoContentResult());
|
||||
}
|
||||
|
||||
[HttpGet("/api/media-sources/emby/{id:int}/libraries", Name = "GetEmbyLibraries")]
|
||||
[HttpGet("/api/v1/media-sources/emby/{id:int}/libraries", Name = "GetEmbyLibraries")]
|
||||
[Tags("Emby")]
|
||||
[EndpointSummary("Get an Emby source's libraries")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -149,7 +149,7 @@ public class EmbyMediaSourcesController(
|
||||
return new OkObjectResult(libraries.Map(ProjectToResponseModel).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("/api/media-sources/emby/{id:int}/libraries", Name = "ReplaceEmbyLibraryPreferences")]
|
||||
[HttpPut("/api/v1/media-sources/emby/{id:int}/libraries", Name = "ReplaceEmbyLibraryPreferences")]
|
||||
[Tags("Emby")]
|
||||
[EndpointSummary("Replace an Emby source's library sync preferences")]
|
||||
[EndpointDescription(
|
||||
@@ -162,7 +162,7 @@ public class EmbyMediaSourcesController(
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ReplaceLibraryPreferences(
|
||||
int id,
|
||||
[Required] [FromBody] ReplaceRemoteLibraryPreferencesRequest request,
|
||||
[Required][FromBody] ReplaceRemoteLibraryPreferencesRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<EmbyMediaSourceViewModel> maybeSource =
|
||||
@@ -199,7 +199,7 @@ public class EmbyMediaSourcesController(
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/api/media-sources/emby/{id:int}/path-replacements", Name = "GetEmbyPathReplacements")]
|
||||
[HttpGet("/api/v1/media-sources/emby/{id:int}/path-replacements", Name = "GetEmbyPathReplacements")]
|
||||
[Tags("Emby")]
|
||||
[EndpointSummary("Get an Emby source's path replacements")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -219,7 +219,7 @@ public class EmbyMediaSourcesController(
|
||||
return new OkObjectResult(replacements.Map(ProjectToResponseModel).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("/api/media-sources/emby/{id:int}/path-replacements", Name = "ReplaceEmbyPathReplacements")]
|
||||
[HttpPut("/api/v1/media-sources/emby/{id:int}/path-replacements", Name = "ReplaceEmbyPathReplacements")]
|
||||
[Tags("Emby")]
|
||||
[EndpointSummary("Replace an Emby source's path replacements")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -228,7 +228,7 @@ public class EmbyMediaSourcesController(
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ReplacePathReplacements(
|
||||
int id,
|
||||
[Required] [FromBody] ReplacePathReplacementsRequest request,
|
||||
[Required][FromBody] ReplacePathReplacementsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<EmbyMediaSourceViewModel> maybeSource =
|
||||
@@ -251,7 +251,7 @@ public class EmbyMediaSourcesController(
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/api/media-sources/emby/{id:int}/refresh-libraries", Name = "RefreshEmbyLibraries")]
|
||||
[HttpPost("/api/v1/media-sources/emby/{id:int}/refresh-libraries", Name = "RefreshEmbyLibraries")]
|
||||
[Tags("Emby")]
|
||||
[EndpointSummary("Refresh an Emby source's libraries")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -278,7 +278,7 @@ public class EmbyMediaSourcesController(
|
||||
return new AcceptedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/media-sources/emby/{id:int}/scan-collections", Name = "ScanEmbyCollections")]
|
||||
[HttpPost("/api/v1/media-sources/emby/{id:int}/scan-collections", Name = "ScanEmbyCollections")]
|
||||
[Tags("Emby")]
|
||||
[EndpointSummary("Scan an Emby source's collections")]
|
||||
[EndpointDescription(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ErsatzTV.Application.FFmpegProfiles;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
@@ -14,7 +14,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiController]
|
||||
public class FFmpegProfileController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/ffmpeg/profiles", Name = "GetFFmpegProfiles")]
|
||||
[HttpGet("/api/v1/ffmpeg/profiles", Name = "GetFFmpegProfiles")]
|
||||
[Tags("FFmpeg Profiles")]
|
||||
[EndpointSummary("Get all FFmpeg profiles")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -22,7 +22,7 @@ public class FFmpegProfileController(IMediator mediator) : ControllerBase
|
||||
public async Task<List<FFmpegFullProfileResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllFFmpegProfilesForApi(), cancellationToken);
|
||||
|
||||
[HttpGet("/api/ffmpeg/hardware-acceleration-kinds", Name = "GetSupportedHardwareAccelerationKinds")]
|
||||
[HttpGet("/api/v1/ffmpeg/hardware-acceleration-kinds", Name = "GetSupportedHardwareAccelerationKinds")]
|
||||
[Tags("FFmpeg Profiles")]
|
||||
[EndpointSummary("Get supported hardware acceleration kinds")]
|
||||
[EndpointDescription(
|
||||
@@ -34,7 +34,7 @@ public class FFmpegProfileController(IMediator mediator) : ControllerBase
|
||||
// returns the enum values directly; the API serializes enums as their string names
|
||||
await mediator.Send(new GetSupportedHardwareAccelerationKinds(), cancellationToken);
|
||||
|
||||
[HttpGet("/api/ffmpeg/profiles/{id:int}", Name = "GetFFmpegProfileById")]
|
||||
[HttpGet("/api/v1/ffmpeg/profiles/{id:int}", Name = "GetFFmpegProfileById")]
|
||||
[Tags("FFmpeg Profiles")]
|
||||
[EndpointSummary("Get an FFmpeg profile by id")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -47,7 +47,7 @@ public class FFmpegProfileController(IMediator mediator) : ControllerBase
|
||||
return result.ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/ffmpeg/profiles", Name = "CreateFFmpegProfile")]
|
||||
[HttpPost("/api/v1/ffmpeg/profiles", Name = "CreateFFmpegProfile")]
|
||||
[Tags("FFmpeg Profiles")]
|
||||
[EndpointSummary("Create an FFmpeg profile")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -68,12 +68,12 @@ public class FFmpegProfileController(IMediator mediator) : ControllerBase
|
||||
Option<FFmpegFullProfileResponseModel> profile =
|
||||
await mediator.Send(new GetFFmpegFullProfileByIdForApi(created.FFmpegProfileId), cancellationToken);
|
||||
return profile.Match(
|
||||
Some: vm => (IActionResult)new CreatedResult($"/api/ffmpeg/profiles/{vm.Id}", vm),
|
||||
Some: vm => (IActionResult)new CreatedResult($"/api/v1/ffmpeg/profiles/{vm.Id}", vm),
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("/api/ffmpeg/profiles/{id:int}", Name = "UpdateFFmpegProfile")]
|
||||
[HttpPut("/api/v1/ffmpeg/profiles/{id:int}", Name = "UpdateFFmpegProfile")]
|
||||
[Tags("FFmpeg Profiles")]
|
||||
[EndpointSummary("Update an FFmpeg profile")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -101,7 +101,7 @@ public class FFmpegProfileController(IMediator mediator) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/ffmpeg/profiles/{id:int}", Name = "DeleteFFmpegProfile")]
|
||||
[HttpDelete("/api/v1/ffmpeg/profiles/{id:int}", Name = "DeleteFFmpegProfile")]
|
||||
[Tags("FFmpeg Profiles")]
|
||||
[EndpointSummary("Delete an FFmpeg profile")]
|
||||
[EndpointGroupName("general")]
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiController]
|
||||
public class FillerPresetController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/filler-presets", Name = "GetFillerPresets")]
|
||||
[HttpGet("/api/v1/filler-presets", Name = "GetFillerPresets")]
|
||||
[Tags("Filler Presets")]
|
||||
[EndpointSummary("Get all filler presets")]
|
||||
[EndpointDescription("Optionally filter to a single filler kind via the fillerKind query parameter.")]
|
||||
@@ -25,7 +25,7 @@ public class FillerPresetController(IMediator mediator) : ControllerBase
|
||||
CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllFillerPresetsForApi(fillerKind), cancellationToken);
|
||||
|
||||
[HttpGet("/api/filler-presets/{id:int}", Name = "GetFillerPresetById")]
|
||||
[HttpGet("/api/v1/filler-presets/{id:int}", Name = "GetFillerPresetById")]
|
||||
[Tags("Filler Presets")]
|
||||
[EndpointSummary("Get a filler preset by id")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -38,7 +38,7 @@ public class FillerPresetController(IMediator mediator) : ControllerBase
|
||||
return result.ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/filler-presets", Name = "CreateFillerPreset")]
|
||||
[HttpPost("/api/v1/filler-presets", Name = "CreateFillerPreset")]
|
||||
[Tags("Filler Presets")]
|
||||
[EndpointSummary("Create a filler preset")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -47,7 +47,7 @@ public class FillerPresetController(IMediator mediator) : ControllerBase
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Create(
|
||||
[Required] [FromBody] CreateFillerPresetRequest request,
|
||||
[Required][FromBody] CreateFillerPresetRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, CreateFillerPresetResult> result =
|
||||
@@ -59,12 +59,12 @@ public class FillerPresetController(IMediator mediator) : ControllerBase
|
||||
Option<FillerPresetFullResponseModel> fillerPreset =
|
||||
await mediator.Send(new GetFillerPresetByIdForApi(created.FillerPresetId), cancellationToken);
|
||||
return fillerPreset.Match(
|
||||
Some: vm => (IActionResult)new CreatedResult($"/api/filler-presets/{vm.Id}", vm),
|
||||
Some: vm => (IActionResult)new CreatedResult($"/api/v1/filler-presets/{vm.Id}", vm),
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("/api/filler-presets/{id:int}", Name = "UpdateFillerPreset")]
|
||||
[HttpPut("/api/v1/filler-presets/{id:int}", Name = "UpdateFillerPreset")]
|
||||
[Tags("Filler Presets")]
|
||||
[EndpointSummary("Update a filler preset")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -74,7 +74,7 @@ public class FillerPresetController(IMediator mediator) : ControllerBase
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Update(
|
||||
int id,
|
||||
[Required] [FromBody] UpdateFillerPresetRequest request,
|
||||
[Required][FromBody] UpdateFillerPresetRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
@@ -90,7 +90,7 @@ public class FillerPresetController(IMediator mediator) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/filler-presets/{id:int}", Name = "DeleteFillerPreset")]
|
||||
[HttpDelete("/api/v1/filler-presets/{id:int}", Name = "DeleteFillerPreset")]
|
||||
[Tags("Filler Presets")]
|
||||
[EndpointSummary("Delete a filler preset")]
|
||||
[EndpointGroupName("general")]
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiController]
|
||||
public class GraphicsElementController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/graphics-elements", Name = "GetGraphicsElements")]
|
||||
[HttpGet("/api/v1/graphics-elements", Name = "GetGraphicsElements")]
|
||||
[Tags("Graphics Elements")]
|
||||
[EndpointSummary("Get all graphics elements")]
|
||||
[EndpointDescription("Returns all graphics elements.")]
|
||||
@@ -18,7 +18,7 @@ public class GraphicsElementController(IMediator mediator) : ControllerBase
|
||||
public async Task<List<GraphicsElementResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllGraphicsElementsForApi(), cancellationToken);
|
||||
|
||||
[HttpPost("/api/graphics-elements/refresh", Name = "RefreshGraphicsElements")]
|
||||
[HttpPost("/api/v1/graphics-elements/refresh", Name = "RefreshGraphicsElements")]
|
||||
[Tags("Graphics Elements")]
|
||||
[EndpointSummary("Re-sync graphics elements from disk")]
|
||||
[EndpointDescription(
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiController]
|
||||
public class HealthController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/health", Name = "GetHealthChecks")]
|
||||
[HttpGet("/api/v1/health", Name = "GetHealthChecks")]
|
||||
[Tags("Health")]
|
||||
[EndpointSummary("Get health check results")]
|
||||
[EndpointGroupName("general")]
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiController]
|
||||
public class ImagesController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/images/folders", Name = "GetImageFolders")]
|
||||
[HttpGet("/api/v1/images/folders", Name = "GetImageFolders")]
|
||||
[Tags("Images")]
|
||||
[EndpointSummary("List image library folders")]
|
||||
[EndpointDescription("Omit parentId for the top-level folders; pass a folder id to list that folder's children.")]
|
||||
@@ -31,7 +31,7 @@ public class ImagesController(IMediator mediator) : ControllerBase
|
||||
return folders.Map(Project).ToList();
|
||||
}
|
||||
|
||||
[HttpPut("/api/images/folders/{id:int}/duration", Name = "UpdateImageFolderDuration")]
|
||||
[HttpPut("/api/v1/images/folders/{id:int}/duration", Name = "UpdateImageFolderDuration")]
|
||||
[Tags("Images")]
|
||||
[EndpointSummary("Set or clear an image folder's playout duration")]
|
||||
[EndpointDescription(
|
||||
@@ -43,7 +43,7 @@ public class ImagesController(IMediator mediator) : ControllerBase
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpdateDuration(
|
||||
int id,
|
||||
[Required] [FromBody] UpdateImageFolderDurationRequest request,
|
||||
[Required][FromBody] UpdateImageFolderDurationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.DurationSeconds is <= 0)
|
||||
|
||||
@@ -22,7 +22,7 @@ public class JellyfinMediaSourcesController(
|
||||
IEntityLocker entityLocker,
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/media-sources/jellyfin", Name = "GetJellyfinState")]
|
||||
[HttpGet("/api/v1/media-sources/jellyfin", Name = "GetJellyfinState")]
|
||||
[Tags("Jellyfin")]
|
||||
[EndpointSummary("Get Jellyfin connection state and discovered servers")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -42,7 +42,7 @@ public class JellyfinMediaSourcesController(
|
||||
sources.Map(s => new RemoteMediaSourceItemResponseModel(s.Id, s.Name, s.Address)).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/media-sources/jellyfin/connection", Name = "GetJellyfinConnection")]
|
||||
[HttpGet("/api/v1/media-sources/jellyfin/connection", Name = "GetJellyfinConnection")]
|
||||
[Tags("Jellyfin")]
|
||||
[EndpointSummary("Get the Jellyfin connection address")]
|
||||
[EndpointDescription(
|
||||
@@ -58,7 +58,7 @@ public class JellyfinMediaSourcesController(
|
||||
!string.IsNullOrWhiteSpace(secrets.ApiKey));
|
||||
}
|
||||
|
||||
[HttpPut("/api/media-sources/jellyfin/connection", Name = "SaveJellyfinConnection")]
|
||||
[HttpPut("/api/v1/media-sources/jellyfin/connection", Name = "SaveJellyfinConnection")]
|
||||
[Tags("Jellyfin")]
|
||||
[EndpointSummary("Connect, reconnect, or edit the Jellyfin connection")]
|
||||
[EndpointDescription(
|
||||
@@ -69,7 +69,7 @@ public class JellyfinMediaSourcesController(
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> SaveConnection(
|
||||
[Required] [FromBody] SaveRemoteConnectionRequest request,
|
||||
[Required][FromBody] SaveRemoteConnectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (entityLocker.IsRemoteMediaSourceLocked<JellyfinMediaSource>())
|
||||
@@ -107,7 +107,7 @@ public class JellyfinMediaSourcesController(
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/api/media-sources/jellyfin/disconnect", Name = "DisconnectJellyfin")]
|
||||
[HttpPost("/api/v1/media-sources/jellyfin/disconnect", Name = "DisconnectJellyfin")]
|
||||
[Tags("Jellyfin")]
|
||||
[EndpointSummary("Disconnect Jellyfin")]
|
||||
[EndpointDescription("Purges the Jellyfin connection, discovered servers, and all synced Jellyfin content.")]
|
||||
@@ -129,7 +129,7 @@ public class JellyfinMediaSourcesController(
|
||||
Right: _ => (IActionResult)new NoContentResult());
|
||||
}
|
||||
|
||||
[HttpGet("/api/media-sources/jellyfin/{id:int}/libraries", Name = "GetJellyfinLibraries")]
|
||||
[HttpGet("/api/v1/media-sources/jellyfin/{id:int}/libraries", Name = "GetJellyfinLibraries")]
|
||||
[Tags("Jellyfin")]
|
||||
[EndpointSummary("Get a Jellyfin source's libraries")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -149,7 +149,7 @@ public class JellyfinMediaSourcesController(
|
||||
return new OkObjectResult(libraries.Map(ProjectToResponseModel).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("/api/media-sources/jellyfin/{id:int}/libraries", Name = "ReplaceJellyfinLibraryPreferences")]
|
||||
[HttpPut("/api/v1/media-sources/jellyfin/{id:int}/libraries", Name = "ReplaceJellyfinLibraryPreferences")]
|
||||
[Tags("Jellyfin")]
|
||||
[EndpointSummary("Replace a Jellyfin source's library sync preferences")]
|
||||
[EndpointDescription(
|
||||
@@ -162,7 +162,7 @@ public class JellyfinMediaSourcesController(
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ReplaceLibraryPreferences(
|
||||
int id,
|
||||
[Required] [FromBody] ReplaceRemoteLibraryPreferencesRequest request,
|
||||
[Required][FromBody] ReplaceRemoteLibraryPreferencesRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<JellyfinMediaSourceViewModel> maybeSource =
|
||||
@@ -199,7 +199,7 @@ public class JellyfinMediaSourcesController(
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/api/media-sources/jellyfin/{id:int}/path-replacements", Name = "GetJellyfinPathReplacements")]
|
||||
[HttpGet("/api/v1/media-sources/jellyfin/{id:int}/path-replacements", Name = "GetJellyfinPathReplacements")]
|
||||
[Tags("Jellyfin")]
|
||||
[EndpointSummary("Get a Jellyfin source's path replacements")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -219,7 +219,7 @@ public class JellyfinMediaSourcesController(
|
||||
return new OkObjectResult(replacements.Map(ProjectToResponseModel).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("/api/media-sources/jellyfin/{id:int}/path-replacements", Name = "ReplaceJellyfinPathReplacements")]
|
||||
[HttpPut("/api/v1/media-sources/jellyfin/{id:int}/path-replacements", Name = "ReplaceJellyfinPathReplacements")]
|
||||
[Tags("Jellyfin")]
|
||||
[EndpointSummary("Replace a Jellyfin source's path replacements")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -228,7 +228,7 @@ public class JellyfinMediaSourcesController(
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ReplacePathReplacements(
|
||||
int id,
|
||||
[Required] [FromBody] ReplacePathReplacementsRequest request,
|
||||
[Required][FromBody] ReplacePathReplacementsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<JellyfinMediaSourceViewModel> maybeSource =
|
||||
@@ -251,7 +251,7 @@ public class JellyfinMediaSourcesController(
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/api/media-sources/jellyfin/{id:int}/refresh-libraries", Name = "RefreshJellyfinLibraries")]
|
||||
[HttpPost("/api/v1/media-sources/jellyfin/{id:int}/refresh-libraries", Name = "RefreshJellyfinLibraries")]
|
||||
[Tags("Jellyfin")]
|
||||
[EndpointSummary("Refresh a Jellyfin source's libraries")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -278,7 +278,7 @@ public class JellyfinMediaSourcesController(
|
||||
return new AcceptedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/media-sources/jellyfin/{id:int}/scan-collections", Name = "ScanJellyfinCollections")]
|
||||
[HttpPost("/api/v1/media-sources/jellyfin/{id:int}/scan-collections", Name = "ScanJellyfinCollections")]
|
||||
[Tags("Jellyfin")]
|
||||
[EndpointSummary("Scan a Jellyfin source's collections")]
|
||||
[EndpointDescription(
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiController]
|
||||
public class LanguagesController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/languages", Name = "GetLanguages")]
|
||||
[HttpGet("/api/v1/languages", Name = "GetLanguages")]
|
||||
[Tags("Languages")]
|
||||
[EndpointSummary("Get all available language codes")]
|
||||
[EndpointGroupName("general")]
|
||||
|
||||
@@ -13,14 +13,14 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[EndpointGroupName("general")]
|
||||
public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/libraries/scan-status", Name = "GetLibraryScanStatus")]
|
||||
[HttpGet("/api/v1/libraries/scan-status", Name = "GetLibraryScanStatus")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Get active library scan status")]
|
||||
[ProducesResponseType(typeof(List<LibraryScanStatusResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<LibraryScanStatusResponseModel>> GetScanStatus(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetLibraryScanStatus(), cancellationToken);
|
||||
|
||||
[HttpPost("/api/libraries/{id:int}/scan")]
|
||||
[HttpPost("/api/v1/libraries/{id:int}/scan")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Scan library")]
|
||||
[EndpointDescription("Queues a scan of the whole library. Pass ?deep=true for a deep (metadata-refresh) scan.")]
|
||||
@@ -52,7 +52,7 @@ public class LibrariesController(ITelevisionRepository televisionRepository, IMe
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("/api/libraries/{id:int}/scan-show")]
|
||||
[HttpPost("/api/v1/libraries/{id:int}/scan-show")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Scan show")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
|
||||
@@ -12,7 +12,7 @@ public class LibraryBrowseController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
private const int MaxPageSize = 100;
|
||||
|
||||
[HttpGet("/api/library/browse", Name = "BrowseLibrary")]
|
||||
[HttpGet("/api/v1/library/browse", Name = "BrowseLibrary")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Browse and search library items")]
|
||||
[EndpointGroupName("general")]
|
||||
|
||||
@@ -27,7 +27,7 @@ public class LocalLibrariesController(
|
||||
IFileSystem fileSystem,
|
||||
ILibraryRepository libraryRepository) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/libraries/local", Name = "GetLocalLibraries")]
|
||||
[HttpGet("/api/v1/libraries/local", Name = "GetLocalLibraries")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Get all local libraries")]
|
||||
[ProducesResponseType(typeof(List<LocalLibraryResponseModel>), StatusCodes.Status200OK)]
|
||||
@@ -37,7 +37,7 @@ public class LocalLibrariesController(
|
||||
return libraries.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
|
||||
[HttpGet("/api/libraries/local/{id:int}", Name = "GetLocalLibrary")]
|
||||
[HttpGet("/api/v1/libraries/local/{id:int}", Name = "GetLocalLibrary")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Get a local library by id")]
|
||||
[ProducesResponseType(typeof(LocalLibraryDetailResponseModel), StatusCodes.Status200OK)]
|
||||
@@ -50,22 +50,22 @@ public class LocalLibrariesController(
|
||||
None: () => Task.FromResult(ApiResults.NotFoundProblem($"Local library {id} does not exist.")));
|
||||
}
|
||||
|
||||
[HttpPost("/api/libraries/local")]
|
||||
[HttpPost("/api/v1/libraries/local")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Create a local library")]
|
||||
[ProducesResponseType(typeof(LocalLibraryResponseModel), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Create(
|
||||
[Required] [FromBody] CreateLocalLibraryRequest request,
|
||||
[Required][FromBody] CreateLocalLibraryRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, LocalLibraryViewModel> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(
|
||||
vm => $"/api/libraries/local/{vm.Id}",
|
||||
vm => $"/api/v1/libraries/local/{vm.Id}",
|
||||
ProjectToResponseModel);
|
||||
}
|
||||
|
||||
[HttpPut("/api/libraries/local/{id:int}")]
|
||||
[HttpPut("/api/v1/libraries/local/{id:int}")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Update a local library")]
|
||||
[EndpointDescription(
|
||||
@@ -78,7 +78,7 @@ public class LocalLibrariesController(
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Update(
|
||||
int id,
|
||||
[Required] [FromBody] UpdateLocalLibraryRequest request,
|
||||
[Required][FromBody] UpdateLocalLibraryRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<LocalLibraryViewModel> existing = await mediator.Send(new GetLocalLibraryById(id), cancellationToken);
|
||||
@@ -112,7 +112,7 @@ public class LocalLibrariesController(
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/libraries/local/{id:int}")]
|
||||
[HttpDelete("/api/v1/libraries/local/{id:int}")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Delete a local library")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
@@ -137,7 +137,7 @@ public class LocalLibrariesController(
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/libraries/local/paths/{pathId:int}/move")]
|
||||
[HttpPost("/api/v1/libraries/local/paths/{pathId:int}/move")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Move a local library path to another local library")]
|
||||
[EndpointDescription(
|
||||
@@ -150,7 +150,7 @@ public class LocalLibrariesController(
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> MovePath(
|
||||
int pathId,
|
||||
[Required] [FromBody] MoveLocalLibraryPathRequest request,
|
||||
[Required][FromBody] MoveLocalLibraryPathRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<int> maybeSourceLibraryId = await libraryRepository.GetLibraryIdForPath(pathId);
|
||||
@@ -173,7 +173,7 @@ public class LocalLibrariesController(
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/libraries/local/path-exists")]
|
||||
[HttpPost("/api/v1/libraries/local/path-exists")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Check whether a filesystem path exists")]
|
||||
[EndpointDescription(
|
||||
@@ -181,7 +181,7 @@ public class LocalLibrariesController(
|
||||
"This is a check-then-act convenience only — Create/Update still validate new paths at save time " +
|
||||
"(design #202 §C2).")]
|
||||
[ProducesResponseType(typeof(LocalPathCheckResponseModel), StatusCodes.Status200OK)]
|
||||
public IActionResult CheckPathExists([Required] [FromBody] LocalPathCheckRequest request)
|
||||
public IActionResult CheckPathExists([Required][FromBody] LocalPathCheckRequest request)
|
||||
{
|
||||
bool exists = !string.IsNullOrWhiteSpace(request.Path) && fileSystem.Directory.Exists(request.Path);
|
||||
return new OkObjectResult(new LocalPathCheckResponseModel(exists));
|
||||
|
||||
@@ -19,7 +19,7 @@ public class LogsController(IMediator mediator) : ControllerBase
|
||||
private static readonly System.Collections.Generic.HashSet<string> AllowedSortFields =
|
||||
new(StringComparer.OrdinalIgnoreCase) { "timestamp", "level" };
|
||||
|
||||
[HttpGet("/api/logs", Name = "GetLogs")]
|
||||
[HttpGet("/api/v1/logs", Name = "GetLogs")]
|
||||
[Tags("Logs")]
|
||||
[EndpointSummary("Get recent log entries")]
|
||||
[EndpointDescription(
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[RequiresAuthentication]
|
||||
public class MaintenanceController(IMediator mediator, ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
{
|
||||
[HttpPost("/api/maintenance/gc")]
|
||||
[HttpPost("/api/v1/maintenance/gc")]
|
||||
[Tags("Maintenance")]
|
||||
[EndpointSummary("Garbage collect")]
|
||||
public async Task<IActionResult> GarbageCollection([FromQuery] bool force = false)
|
||||
@@ -23,7 +23,7 @@ public class MaintenanceController(IMediator mediator, ChannelWriter<IBackground
|
||||
return new OkResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/maintenance/empty_trash")]
|
||||
[HttpPost("/api/v1/maintenance/empty_trash")]
|
||||
[Tags("Maintenance")]
|
||||
[EndpointSummary("Empty trash")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
@@ -39,7 +39,7 @@ public class MaintenanceController(IMediator mediator, ChannelWriter<IBackground
|
||||
return new OkResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/maintenance/clean_artwork")]
|
||||
[HttpPost("/api/v1/maintenance/clean_artwork")]
|
||||
[Tags("Maintenance")]
|
||||
[EndpointSummary("Clean artwork cache")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
|
||||
@@ -14,14 +14,14 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiController]
|
||||
public class MediaItemsController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpDelete("/api/media-items", Name = "DeleteMediaItems")]
|
||||
[HttpDelete("/api/v1/media-items", Name = "DeleteMediaItems")]
|
||||
[Tags("Media Items")]
|
||||
[EndpointSummary("Delete media items from the database")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Delete(
|
||||
[Required] [FromBody] DeleteMediaItemsRequest request,
|
||||
[Required][FromBody] DeleteMediaItemsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Ids is null || request.Ids.Count == 0)
|
||||
@@ -33,7 +33,7 @@ public class MediaItemsController(IMediator mediator) : ControllerBase
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpGet("/api/media-items/{id:int}/info", Name = "GetMediaItemInfo")]
|
||||
[HttpGet("/api/v1/media-items/{id:int}/info", Name = "GetMediaItemInfo")]
|
||||
[Tags("Media Items")]
|
||||
[EndpointSummary("Get technical media info for a media item")]
|
||||
[EndpointGroupName("general")]
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiController]
|
||||
public class MediaSourcesController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/media-sources", Name = "GetMediaSources")]
|
||||
[HttpGet("/api/v1/media-sources", Name = "GetMediaSources")]
|
||||
[Tags("Media Sources")]
|
||||
[EndpointSummary("Get all media sources with their libraries")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -17,7 +17,7 @@ public class MediaSourcesController(IMediator mediator) : ControllerBase
|
||||
public async Task<List<MediaSourceResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllMediaSourcesForApi(), cancellationToken);
|
||||
|
||||
[HttpGet("/api/media-sources/collections-scan-status", Name = "GetCollectionsScanStatus")]
|
||||
[HttpGet("/api/v1/media-sources/collections-scan-status", Name = "GetCollectionsScanStatus")]
|
||||
[Tags("Media Sources")]
|
||||
[EndpointSummary("Get active external-collections scan status")]
|
||||
[EndpointDescription(
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiController]
|
||||
public class MoviesController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/movies/{id:int}", Name = "GetMovieById")]
|
||||
[HttpGet("/api/v1/movies/{id:int}", Name = "GetMovieById")]
|
||||
[Tags("Movies")]
|
||||
[EndpointSummary("Get a movie by id")]
|
||||
[EndpointGroupName("general")]
|
||||
|
||||
@@ -15,7 +15,7 @@ public class MultiCollectionController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
private const int MaxPageSize = 100;
|
||||
|
||||
[HttpGet("/api/multi-collections", Name = "GetMultiCollections")]
|
||||
[HttpGet("/api/v1/multi-collections", Name = "GetMultiCollections")]
|
||||
[Tags("Multi Collections")]
|
||||
[EndpointSummary("Get all multi collections (paged)")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -38,7 +38,7 @@ public class MultiCollectionController(IMediator mediator) : ControllerBase
|
||||
result.Page.Map(ProjectToResponseModel).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/multi-collections/{id:int}", Name = "GetMultiCollectionById")]
|
||||
[HttpGet("/api/v1/multi-collections/{id:int}", Name = "GetMultiCollectionById")]
|
||||
[Tags("Multi Collections")]
|
||||
[EndpointSummary("Get a multi collection by id")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -58,7 +58,7 @@ public class MultiCollectionController(IMediator mediator) : ControllerBase
|
||||
return result.Map(ProjectToResponseModel).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/multi-collections")]
|
||||
[HttpPost("/api/v1/multi-collections")]
|
||||
[Tags("Multi Collections")]
|
||||
[EndpointSummary("Create a multi collection")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -72,11 +72,11 @@ public class MultiCollectionController(IMediator mediator) : ControllerBase
|
||||
Either<BaseError, MultiCollectionViewModel> result =
|
||||
await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(
|
||||
vm => $"/api/multi-collections/{vm.Id}",
|
||||
vm => $"/api/v1/multi-collections/{vm.Id}",
|
||||
ProjectToResponseModel);
|
||||
}
|
||||
|
||||
[HttpPut("/api/multi-collections/{id:int}")]
|
||||
[HttpPut("/api/v1/multi-collections/{id:int}")]
|
||||
[Tags("Multi Collections")]
|
||||
[EndpointSummary("Update a multi collection")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -115,7 +115,7 @@ public class MultiCollectionController(IMediator mediator) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/multi-collections/{id:int}")]
|
||||
[HttpDelete("/api/v1/multi-collections/{id:int}")]
|
||||
[Tags("Multi Collections")]
|
||||
[EndpointSummary("Delete a multi collection")]
|
||||
[EndpointGroupName("general")]
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiController]
|
||||
public class PlaylistController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/playlists/groups", Name = "GetPlaylistGroups")]
|
||||
[HttpGet("/api/v1/playlists/groups", Name = "GetPlaylistGroups")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Get all playlist groups")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -26,7 +26,7 @@ public class PlaylistController(IMediator mediator) : ControllerBase
|
||||
return groups.Map(ProjectToGroupResponse).ToList();
|
||||
}
|
||||
|
||||
[HttpPost("/api/playlists/groups", Name = "CreatePlaylistGroup")]
|
||||
[HttpPost("/api/v1/playlists/groups", Name = "CreatePlaylistGroup")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Create a playlist group")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -39,11 +39,11 @@ public class PlaylistController(IMediator mediator) : ControllerBase
|
||||
Either<BaseError, PlaylistGroupViewModel> result =
|
||||
await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(
|
||||
g => $"/api/playlists/groups/{g.Id}",
|
||||
g => $"/api/v1/playlists/groups/{g.Id}",
|
||||
ProjectToGroupResponse);
|
||||
}
|
||||
|
||||
[HttpPut("/api/playlists/groups/{id:int}", Name = "UpdatePlaylistGroup")]
|
||||
[HttpPut("/api/v1/playlists/groups/{id:int}", Name = "UpdatePlaylistGroup")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Rename a playlist group")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -83,7 +83,7 @@ public class PlaylistController(IMediator mediator) : ControllerBase
|
||||
Right: vm => (IActionResult)new OkObjectResult(ProjectToGroupResponse(vm)));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/playlists/groups/{id:int}", Name = "DeletePlaylistGroup")]
|
||||
[HttpDelete("/api/v1/playlists/groups/{id:int}", Name = "DeletePlaylistGroup")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Delete a playlist group")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -104,7 +104,7 @@ public class PlaylistController(IMediator mediator) : ControllerBase
|
||||
None: () => new NoContentResult());
|
||||
}
|
||||
|
||||
[HttpGet("/api/playlists", Name = "GetPlaylists")]
|
||||
[HttpGet("/api/v1/playlists", Name = "GetPlaylists")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Get playlists in a playlist group")]
|
||||
[EndpointDescription("Returns the playlists in the given playlist group.")]
|
||||
@@ -119,7 +119,7 @@ public class PlaylistController(IMediator mediator) : ControllerBase
|
||||
return playlists.Map(ProjectToPlaylistResponse).ToList();
|
||||
}
|
||||
|
||||
[HttpGet("/api/playlists/{id:int}", Name = "GetPlaylistById")]
|
||||
[HttpGet("/api/v1/playlists/{id:int}", Name = "GetPlaylistById")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Get a playlist by id")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -131,7 +131,7 @@ public class PlaylistController(IMediator mediator) : ControllerBase
|
||||
return result.Map(ProjectToPlaylistResponse).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpGet("/api/playlists/{id:int}/items", Name = "GetPlaylistItems")]
|
||||
[HttpGet("/api/v1/playlists/{id:int}/items", Name = "GetPlaylistItems")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Get the items in a playlist")]
|
||||
[EndpointDescription(
|
||||
@@ -155,7 +155,7 @@ public class PlaylistController(IMediator mediator) : ControllerBase
|
||||
return new OkObjectResult(items.Map(ProjectToItemResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("/api/playlists", Name = "CreatePlaylistInGroup")]
|
||||
[HttpPost("/api/v1/playlists", Name = "CreatePlaylistInGroup")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Create a playlist")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -167,11 +167,11 @@ public class PlaylistController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
Either<BaseError, PlaylistViewModel> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(
|
||||
p => $"/api/playlists/{p.Id}",
|
||||
p => $"/api/v1/playlists/{p.Id}",
|
||||
ProjectToPlaylistResponse);
|
||||
}
|
||||
|
||||
[HttpPut("/api/playlists/{id:int}", Name = "UpdatePlaylist")]
|
||||
[HttpPut("/api/v1/playlists/{id:int}", Name = "UpdatePlaylist")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Update a playlist (rename and replace its items)")]
|
||||
[EndpointDescription(
|
||||
@@ -240,7 +240,7 @@ public class PlaylistController(IMediator mediator) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/playlists/{id:int}", Name = "DeletePlaylist")]
|
||||
[HttpDelete("/api/v1/playlists/{id:int}", Name = "DeletePlaylist")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Delete a playlist")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -261,7 +261,7 @@ public class PlaylistController(IMediator mediator) : ControllerBase
|
||||
None: () => new NoContentResult());
|
||||
}
|
||||
|
||||
[HttpPost("/api/playlists/{id:int}/items", Name = "AddItemsToPlaylist")]
|
||||
[HttpPost("/api/v1/playlists/{id:int}/items", Name = "AddItemsToPlaylist")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Add items to a playlist")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -286,7 +286,7 @@ public class PlaylistController(IMediator mediator) : ControllerBase
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/playlists/preview", Name = "PreviewPlaylist")]
|
||||
[HttpPost("/api/v1/playlists/preview", Name = "PreviewPlaylist")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Preview the playout of a draft playlist")]
|
||||
[EndpointDescription("Builds a preview playout from the posted draft playlist items (no persistence).")]
|
||||
|
||||
@@ -34,7 +34,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
private static IActionResult PlayoutLockedProblem() =>
|
||||
ApiResults.ConflictProblem(BuildInProgressTitle, BuildInProgressDetail);
|
||||
|
||||
[HttpGet("/api/playouts", Name = "GetPlayouts")]
|
||||
[HttpGet("/api/v1/playouts", Name = "GetPlayouts")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("List playouts")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -54,7 +54,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
result.Page.Map(vm => ToListItemResponse(vm, entityLocker.IsPlayoutLocked(vm.PlayoutId))).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/playouts/warnings/count", Name = "GetPlayoutWarningsCount")]
|
||||
[HttpGet("/api/v1/playouts/warnings/count", Name = "GetPlayoutWarningsCount")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Count playouts with a failed build")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -62,7 +62,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
public async Task<int> GetWarningsCount(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetPlayoutWarningsCount(), cancellationToken);
|
||||
|
||||
[HttpGet("/api/playouts/{id:int}", Name = "GetPlayoutById")]
|
||||
[HttpGet("/api/v1/playouts/{id:int}", Name = "GetPlayoutById")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Get a playout by id")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -74,7 +74,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
return result.Map(vm => ToResponse(vm, entityLocker.IsPlayoutLocked(id))).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpGet("/api/playouts/{id:int}/items", Name = "GetPlayoutItems")]
|
||||
[HttpGet("/api/v1/playouts/{id:int}/items", Name = "GetPlayoutItems")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Get upcoming items (and unscheduled gaps) for a playout")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -104,7 +104,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
result.Page.Map(ToItemResponse).ToList()));
|
||||
}
|
||||
|
||||
[HttpPost("/api/playouts")]
|
||||
[HttpPost("/api/v1/playouts")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Create a playout")]
|
||||
[EndpointDescription(
|
||||
@@ -133,14 +133,14 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
await mediator.Send(new GetPlayoutById(created.PlayoutId), cancellationToken);
|
||||
return playout.Match(
|
||||
Some: vm => (IActionResult)new CreatedResult(
|
||||
$"/api/playouts/{vm.PlayoutId}",
|
||||
$"/api/v1/playouts/{vm.PlayoutId}",
|
||||
ToResponse(vm, entityLocker.IsPlayoutLocked(vm.PlayoutId))),
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("/api/playouts/{id:int}")]
|
||||
[HttpPut("/api/v1/playouts/{id:int}")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Update playout scheduling details")]
|
||||
[EndpointDescription(
|
||||
@@ -222,7 +222,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
_ => BaseError.New("[ScheduleFile] is only valid for Sequential, Scripted, or ExternalJson playouts")
|
||||
};
|
||||
|
||||
[HttpPut("/api/playouts/{id:int}/deco")]
|
||||
[HttpPut("/api/v1/playouts/{id:int}/deco")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Set (or clear) a playout's default deco")]
|
||||
[EndpointDescription("Assigns the default deco for a block playout. Send a null decoId to clear it.")]
|
||||
@@ -271,7 +271,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
}
|
||||
|
||||
[HttpGet("/api/playouts/{id:int}/alternate-schedules", Name = "GetPlayoutAlternateSchedules")]
|
||||
[HttpGet("/api/v1/playouts/{id:int}/alternate-schedules", Name = "GetPlayoutAlternateSchedules")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Get a classic playout's alternate schedules")]
|
||||
[EndpointDescription(
|
||||
@@ -307,7 +307,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
return new OkObjectResult(items.OrderBy(i => i.Index).Select(ToResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("/api/playouts/{id:int}/alternate-schedules")]
|
||||
[HttpPut("/api/v1/playouts/{id:int}/alternate-schedules")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Replace a classic playout's alternate schedules")]
|
||||
[EndpointDescription(
|
||||
@@ -390,7 +390,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/api/playouts/{id:int}/templates", Name = "GetPlayoutTemplates")]
|
||||
[HttpGet("/api/v1/playouts/{id:int}/templates", Name = "GetPlayoutTemplates")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Get a block playout's templates")]
|
||||
[EndpointDescription(
|
||||
@@ -423,7 +423,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
return new OkObjectResult(items.OrderBy(i => i.Index).Select(ToResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("/api/playouts/{id:int}/templates")]
|
||||
[HttpPut("/api/v1/playouts/{id:int}/templates")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Replace a block playout's templates")]
|
||||
[EndpointDescription(
|
||||
@@ -510,7 +510,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
return new OkObjectResult(refreshed.OrderBy(i => i.Index).Select(ToResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/playouts/{id:int}/blocks", Name = "GetPlayoutBlocks")]
|
||||
[HttpGet("/api/v1/playouts/{id:int}/blocks", Name = "GetPlayoutBlocks")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Get the blocks scheduled by a block playout")]
|
||||
[EndpointDescription(
|
||||
@@ -531,12 +531,12 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
return new OkObjectResult(blocks.Map(ToBlockResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/playouts/{id:int}/blocks/{blockId:int}/history", Name = "GetPlayoutBlockHistory")]
|
||||
[HttpGet("/api/v1/playouts/{id:int}/blocks/{blockId:int}/history", Name = "GetPlayoutBlockHistory")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Get a block's playout history")]
|
||||
[EndpointDescription(
|
||||
"Returns the paged scheduling history for a single block within a block playout, oldest first. Each row's " +
|
||||
"Key and Details carry raw JSON; decode a row via GET /api/playouts/history/{id}.")]
|
||||
"Key and Details carry raw JSON; decode a row via GET /api/v1/playouts/history/{id}.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PagedPlayoutHistoryResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
@@ -566,7 +566,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
result.Page.Map(ToHistoryResponse).ToList()));
|
||||
}
|
||||
|
||||
[HttpGet("/api/playouts/history/{id:int}", Name = "GetPlayoutHistoryDetails")]
|
||||
[HttpGet("/api/v1/playouts/history/{id:int}", Name = "GetPlayoutHistoryDetails")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Decode a playout history row")]
|
||||
[EndpointDescription(
|
||||
@@ -586,7 +586,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
Right: vm => (IActionResult)new OkObjectResult(ToDetailsResponse(vm)));
|
||||
}
|
||||
|
||||
[HttpPost("/api/playouts/reset-all", Name = "ResetAllPlayouts")]
|
||||
[HttpPost("/api/v1/playouts/reset-all", Name = "ResetAllPlayouts")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Reset all playouts")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -605,7 +605,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
return new AcceptedResult((string)null, body);
|
||||
}
|
||||
|
||||
[HttpPost("/api/playouts/{id:int}/erase-items", Name = "ErasePlayoutItems")]
|
||||
[HttpPost("/api/v1/playouts/{id:int}/erase-items", Name = "ErasePlayoutItems")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Erase a playout's items")]
|
||||
[EndpointDescription(
|
||||
@@ -644,7 +644,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("/api/playouts/{id:int}/erase-items-and-history", Name = "ErasePlayoutItemsAndHistory")]
|
||||
[HttpPost("/api/v1/playouts/{id:int}/erase-items-and-history", Name = "ErasePlayoutItemsAndHistory")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Erase a playout's items and history")]
|
||||
[EndpointDescription(
|
||||
@@ -683,7 +683,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("/api/playouts/items/{id:int}/scheduling-context", Name = "GetPlayoutItemSchedulingContext")]
|
||||
[HttpGet("/api/v1/playouts/items/{id:int}/scheduling-context", Name = "GetPlayoutItemSchedulingContext")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Decode a playout item's scheduling context")]
|
||||
[EndpointDescription(
|
||||
@@ -698,7 +698,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
return result.Map(context => new PlayoutItemSchedulingContextResponseModel(context)).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpDelete("/api/playouts/{id:int}")]
|
||||
[HttpDelete("/api/v1/playouts/{id:int}")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Delete a playout")]
|
||||
[EndpointGroupName("general")]
|
||||
|
||||
@@ -29,7 +29,7 @@ public class PlexMediaSourcesController(
|
||||
private static IActionResult PlexLockedProblem() =>
|
||||
ApiResults.ConflictProblem(PlexBusyTitle, PlexBusyDetail);
|
||||
|
||||
[HttpGet("/api/media-sources/plex", Name = "GetPlexState")]
|
||||
[HttpGet("/api/v1/media-sources/plex", Name = "GetPlexState")]
|
||||
[Tags("Plex")]
|
||||
[EndpointSummary("Get Plex connection state")]
|
||||
[EndpointDescription(
|
||||
@@ -48,12 +48,12 @@ public class PlexMediaSourcesController(
|
||||
servers.Map(ToItemResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("/api/media-sources/plex/pin-flow", Name = "StartPlexPinFlow")]
|
||||
[HttpPost("/api/v1/media-sources/plex/pin-flow", Name = "StartPlexPinFlow")]
|
||||
[Tags("Plex")]
|
||||
[EndpointSummary("Start the Plex sign-in pin flow")]
|
||||
[EndpointDescription(
|
||||
"Acquires the Plex lock and starts the OAuth pin flow, returning the plex.tv authorization URL to " +
|
||||
"open in a new tab. The lock stays held for the background flow; poll GET /api/media-sources/plex " +
|
||||
"open in a new tab. The lock stays held for the background flow; poll GET /api/v1/media-sources/plex " +
|
||||
"until authorized && !locked. Also used to fix credentials for an existing but unauthorized server.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PlexPinFlowResponseModel), StatusCodes.Status200OK)]
|
||||
@@ -88,7 +88,7 @@ public class PlexMediaSourcesController(
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("/api/media-sources/plex/sign-out", Name = "SignOutOfPlex")]
|
||||
[HttpPost("/api/v1/media-sources/plex/sign-out", Name = "SignOutOfPlex")]
|
||||
[Tags("Plex")]
|
||||
[EndpointSummary("Sign out of Plex")]
|
||||
[EndpointDescription(
|
||||
@@ -110,7 +110,7 @@ public class PlexMediaSourcesController(
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpGet("/api/media-sources/plex/{id:int}/libraries", Name = "GetPlexLibraries")]
|
||||
[HttpGet("/api/v1/media-sources/plex/{id:int}/libraries", Name = "GetPlexLibraries")]
|
||||
[Tags("Plex")]
|
||||
[EndpointSummary("Get a Plex server's libraries")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -128,7 +128,7 @@ public class PlexMediaSourcesController(
|
||||
return new OkObjectResult(libraries.Map(ToLibraryResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("/api/media-sources/plex/{id:int}/libraries", Name = "ReplacePlexLibraryPreferences")]
|
||||
[HttpPut("/api/v1/media-sources/plex/{id:int}/libraries", Name = "ReplacePlexLibraryPreferences")]
|
||||
[Tags("Plex")]
|
||||
[EndpointSummary("Replace a Plex server's library sync preferences")]
|
||||
[EndpointDescription(
|
||||
@@ -142,7 +142,7 @@ public class PlexMediaSourcesController(
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ReplaceLibraryPreferences(
|
||||
int id,
|
||||
[Required] [FromBody] ReplaceRemoteLibraryPreferencesRequest request,
|
||||
[Required][FromBody] ReplaceRemoteLibraryPreferencesRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await PlexSourceExists(id, cancellationToken))
|
||||
@@ -196,7 +196,7 @@ public class PlexMediaSourcesController(
|
||||
return new OkObjectResult(reloaded.Map(ToLibraryResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/media-sources/plex/{id:int}/path-replacements", Name = "GetPlexPathReplacements")]
|
||||
[HttpGet("/api/v1/media-sources/plex/{id:int}/path-replacements", Name = "GetPlexPathReplacements")]
|
||||
[Tags("Plex")]
|
||||
[EndpointSummary("Get a Plex server's path replacements")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -214,7 +214,7 @@ public class PlexMediaSourcesController(
|
||||
return new OkObjectResult(replacements.Map(ToPathReplacementResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("/api/media-sources/plex/{id:int}/path-replacements", Name = "ReplacePlexPathReplacements")]
|
||||
[HttpPut("/api/v1/media-sources/plex/{id:int}/path-replacements", Name = "ReplacePlexPathReplacements")]
|
||||
[Tags("Plex")]
|
||||
[EndpointSummary("Replace a Plex server's path replacements")]
|
||||
[EndpointDescription(
|
||||
@@ -227,7 +227,7 @@ public class PlexMediaSourcesController(
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ReplacePathReplacements(
|
||||
int id,
|
||||
[Required] [FromBody] ReplacePathReplacementsRequest request,
|
||||
[Required][FromBody] ReplacePathReplacementsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await PlexSourceExists(id, cancellationToken))
|
||||
@@ -251,7 +251,7 @@ public class PlexMediaSourcesController(
|
||||
return new OkObjectResult(reloaded.Map(ToPathReplacementResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("/api/media-sources/plex/{id:int}/refresh-libraries", Name = "RefreshPlexLibraries")]
|
||||
[HttpPost("/api/v1/media-sources/plex/{id:int}/refresh-libraries", Name = "RefreshPlexLibraries")]
|
||||
[Tags("Plex")]
|
||||
[EndpointSummary("Refresh a Plex server's libraries")]
|
||||
[EndpointDescription(
|
||||
@@ -277,7 +277,7 @@ public class PlexMediaSourcesController(
|
||||
return new AcceptedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/media-sources/plex/{id:int}/scan-collections", Name = "ScanPlexCollections")]
|
||||
[HttpPost("/api/v1/media-sources/plex/{id:int}/scan-collections", Name = "ScanPlexCollections")]
|
||||
[Tags("Plex")]
|
||||
[EndpointSummary("Scan a Plex server's collections")]
|
||||
[EndpointDescription(
|
||||
|
||||
@@ -5,7 +5,7 @@ using ErsatzTV.Core.Scheduling;
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
/// <param name="Id">
|
||||
/// Server-assigned identity of an existing schedule item, as returned by GET /api/schedules/{id}/items.
|
||||
/// Server-assigned identity of an existing schedule item, as returned by GET /api/v1/schedules/{id}/items.
|
||||
/// Omit (or send null / 0) for a new item. On a replace, the item carrying this id keeps its persisted
|
||||
/// fill-group/shuffle progression even when moved to a different position. Unknown or duplicated ids are
|
||||
/// rejected with 422 — never fabricate an id.
|
||||
|
||||
@@ -15,7 +15,7 @@ public class RerunCollectionController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
private const int MaxPageSize = 100;
|
||||
|
||||
[HttpGet("/api/rerun-collections", Name = "GetRerunCollections")]
|
||||
[HttpGet("/api/v1/rerun-collections", Name = "GetRerunCollections")]
|
||||
[Tags("Rerun Collections")]
|
||||
[EndpointSummary("Get all rerun collections (paged)")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -38,7 +38,7 @@ public class RerunCollectionController(IMediator mediator) : ControllerBase
|
||||
result.Page.Map(ProjectToResponseModel).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/rerun-collections/{id:int}", Name = "GetRerunCollectionById")]
|
||||
[HttpGet("/api/v1/rerun-collections/{id:int}", Name = "GetRerunCollectionById")]
|
||||
[Tags("Rerun Collections")]
|
||||
[EndpointSummary("Get a rerun collection by id")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -58,7 +58,7 @@ public class RerunCollectionController(IMediator mediator) : ControllerBase
|
||||
return result.Map(ProjectToResponseModel).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/rerun-collections")]
|
||||
[HttpPost("/api/v1/rerun-collections")]
|
||||
[Tags("Rerun Collections")]
|
||||
[EndpointSummary("Create a rerun collection")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -79,11 +79,11 @@ public class RerunCollectionController(IMediator mediator) : ControllerBase
|
||||
Either<BaseError, RerunCollectionViewModel> result =
|
||||
await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(
|
||||
vm => $"/api/rerun-collections/{vm.Id}",
|
||||
vm => $"/api/v1/rerun-collections/{vm.Id}",
|
||||
ProjectToResponseModel);
|
||||
}
|
||||
|
||||
[HttpPut("/api/rerun-collections/{id:int}")]
|
||||
[HttpPut("/api/v1/rerun-collections/{id:int}")]
|
||||
[Tags("Rerun Collections")]
|
||||
[EndpointSummary("Update a rerun collection")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -129,7 +129,7 @@ public class RerunCollectionController(IMediator mediator) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/rerun-collections/{id:int}")]
|
||||
[HttpDelete("/api/v1/rerun-collections/{id:int}")]
|
||||
[Tags("Rerun Collections")]
|
||||
[EndpointSummary("Delete a rerun collection")]
|
||||
[EndpointGroupName("general")]
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[EndpointGroupName("general")]
|
||||
public class ResolutionController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/ffmpeg/resolution/by-name/{name}", Name = "GetResolutionByName")]
|
||||
[HttpGet("/api/v1/ffmpeg/resolution/by-name/{name}", Name = "GetResolutionByName")]
|
||||
[Tags("Settings")]
|
||||
[EndpointSummary("Get a resolution by name")]
|
||||
[ProducesResponseType(typeof(ResolutionResponseModel), StatusCodes.Status200OK)]
|
||||
@@ -29,7 +29,7 @@ public class ResolutionController(IMediator mediator) : ControllerBase
|
||||
() => NotFound());
|
||||
}
|
||||
|
||||
[HttpGet("/api/settings/resolutions", Name = "GetResolutions")]
|
||||
[HttpGet("/api/v1/settings/resolutions", Name = "GetResolutions")]
|
||||
[Tags("Settings")]
|
||||
[EndpointSummary("Get all resolutions, including custom resolutions")]
|
||||
[ProducesResponseType(typeof(List<ResolutionResponseModel>), StatusCodes.Status200OK)]
|
||||
@@ -39,7 +39,7 @@ public class ResolutionController(IMediator mediator) : ControllerBase
|
||||
return resolutions.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
|
||||
[HttpPost("/api/settings/resolutions", Name = "CreateResolution")]
|
||||
[HttpPost("/api/v1/settings/resolutions", Name = "CreateResolution")]
|
||||
[Tags("Settings")]
|
||||
[EndpointSummary("Create a custom resolution")]
|
||||
[ProducesResponseType(typeof(ResolutionResponseModel), StatusCodes.Status201Created)]
|
||||
@@ -60,13 +60,13 @@ public class ResolutionController(IMediator mediator) : ControllerBase
|
||||
await mediator.Send(new GetResolutionByName(name), cancellationToken);
|
||||
return resolution.Match(
|
||||
Some: vm => (IActionResult)new CreatedResult(
|
||||
$"/api/settings/resolutions/{vm.Id}",
|
||||
$"/api/v1/settings/resolutions/{vm.Id}",
|
||||
ProjectToResponseModel(vm)),
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/settings/resolutions/{id:int}", Name = "DeleteResolution")]
|
||||
[HttpDelete("/api/v1/settings/resolutions/{id:int}", Name = "DeleteResolution")]
|
||||
[Tags("Settings")]
|
||||
[EndpointSummary("Delete a custom resolution")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
[SkipApiAuthorization]
|
||||
[LocalhostOnly]
|
||||
[Route("api/scan/{scanId:guid}")]
|
||||
[Route("/api/v1/scan/{scanId:guid}")]
|
||||
public class ScannerController(
|
||||
IScannerProxyService scannerProxyService,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channelWriter)
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiController]
|
||||
public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/schedules")]
|
||||
[HttpGet("/api/v1/schedules")]
|
||||
[Tags("Schedules")]
|
||||
[EndpointSummary("Get all schedules")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -25,7 +25,7 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
return schedules.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
|
||||
[HttpGet("/api/schedules/{id:int}", Name = "GetScheduleById")]
|
||||
[HttpGet("/api/v1/schedules/{id:int}", Name = "GetScheduleById")]
|
||||
[Tags("Schedules")]
|
||||
[EndpointSummary("Get a schedule by id")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -37,7 +37,7 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
return result.Map(ProjectToResponseModel).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/schedules")]
|
||||
[HttpPost("/api/v1/schedules")]
|
||||
[Tags("Schedules")]
|
||||
[EndpointSummary("Create a schedule")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -58,13 +58,13 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
await mediator.Send(new GetProgramScheduleById(created.ProgramScheduleId), cancellationToken);
|
||||
return schedule.Match(
|
||||
Some: vm => (IActionResult)new CreatedResult(
|
||||
$"/api/schedules/{vm.Id}",
|
||||
$"/api/v1/schedules/{vm.Id}",
|
||||
ProjectToResponseModel(vm)),
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("/api/schedules/{id:int}")]
|
||||
[HttpPut("/api/v1/schedules/{id:int}")]
|
||||
[Tags("Schedules")]
|
||||
[EndpointSummary("Update a schedule")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -90,7 +90,7 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/schedules/{id:int}")]
|
||||
[HttpDelete("/api/v1/schedules/{id:int}")]
|
||||
[Tags("Schedules")]
|
||||
[EndpointSummary("Delete a schedule")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -103,7 +103,7 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpGet("/api/schedules/{id:int}/items")]
|
||||
[HttpGet("/api/v1/schedules/{id:int}/items")]
|
||||
[Tags("Schedules")]
|
||||
[EndpointSummary("Get schedule items")]
|
||||
[EndpointDescription(
|
||||
@@ -131,7 +131,7 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
return new OkObjectResult(ScheduleItemResponseMapper.ProjectToResponseModel(items));
|
||||
}
|
||||
|
||||
[HttpPost("/api/schedules/{id:int}/items")]
|
||||
[HttpPost("/api/v1/schedules/{id:int}/items")]
|
||||
[Tags("Schedules")]
|
||||
[EndpointSummary("Add a schedule item")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -146,11 +146,11 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
Either<BaseError, ProgramScheduleItemViewModel> result =
|
||||
await mediator.Send(request.ToAddCommand(id), cancellationToken);
|
||||
return result.ToCreatedResult(
|
||||
item => $"/api/schedules/{id}/items/{item.Id}",
|
||||
item => $"/api/v1/schedules/{id}/items/{item.Id}",
|
||||
item => ScheduleItemResponseMapper.ProjectToResponseModel(item));
|
||||
}
|
||||
|
||||
[HttpPut("/api/schedules/{id:int}/items")]
|
||||
[HttpPut("/api/v1/schedules/{id:int}/items")]
|
||||
[Tags("Schedules")]
|
||||
[EndpointSummary("Replace schedule items")]
|
||||
[EndpointDescription(
|
||||
@@ -206,7 +206,7 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/schedules/{id:int}/items/{itemId:int}")]
|
||||
[HttpDelete("/api/v1/schedules/{id:int}/items/{itemId:int}")]
|
||||
[Tags("Schedules")]
|
||||
[EndpointSummary("Delete a schedule item")]
|
||||
[EndpointGroupName("general")]
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
[EndpointGroupName("scripted-schedule")]
|
||||
[Route("api/scripted/playout/build/{buildId:guid}")]
|
||||
[Route("/api/v1/scripted/playout/build/{buildId:guid}")]
|
||||
public class ScriptedScheduleController(IScriptedPlayoutBuilderService scriptedPlayoutBuilderService) : ControllerBase
|
||||
{
|
||||
[HttpGet("context", Name = "GetContext")]
|
||||
@@ -124,7 +124,7 @@ public class ScriptedScheduleController(IScriptedPlayoutBuilderService scriptedP
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpPost("create_playlist", Name="CreatePlaylist")]
|
||||
[HttpPost("create_playlist", Name = "CreatePlaylist")]
|
||||
[Tags("Scripted Content")]
|
||||
[EndpointSummary("Create a playlist")]
|
||||
public async Task<IActionResult> CreatePlaylist(
|
||||
@@ -410,7 +410,7 @@ public class ScriptedScheduleController(IScriptedPlayoutBuilderService scriptedP
|
||||
return GetContextInternal(engine);
|
||||
}
|
||||
|
||||
[HttpGet("peek_next/{content}", Name="PeekNext")]
|
||||
[HttpGet("peek_next/{content}", Name = "PeekNext")]
|
||||
[Tags("Scripted Scheduling")]
|
||||
[EndpointSummary("Peek the next content item")]
|
||||
public ActionResult<PeekItemDuration> PeekNext(Guid buildId, string content)
|
||||
|
||||
@@ -16,7 +16,7 @@ public class SearchController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
private const int MaxPageSize = 100;
|
||||
|
||||
[HttpGet("/api/search", Name = "Search")]
|
||||
[HttpGet("/api/v1/search", Name = "Search")]
|
||||
[Tags("Search")]
|
||||
[EndpointSummary("Search library items across all media kinds")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -41,7 +41,7 @@ public class SearchController(IMediator mediator) : ControllerBase
|
||||
return new OkObjectResult(result);
|
||||
}
|
||||
|
||||
[HttpGet("/api/search/all-items", Name = "SearchAllItems")]
|
||||
[HttpGet("/api/v1/search/all-items", Name = "SearchAllItems")]
|
||||
[Tags("Search")]
|
||||
[EndpointSummary("Search library items across all media kinds and return raw id lists")]
|
||||
[EndpointDescription(
|
||||
@@ -65,7 +65,7 @@ public class SearchController(IMediator mediator) : ControllerBase
|
||||
return new OkObjectResult(Project(result));
|
||||
}
|
||||
|
||||
[HttpGet("/api/search/collections", Name = "SearchCollections")]
|
||||
[HttpGet("/api/v1/search/collections", Name = "SearchCollections")]
|
||||
[Tags("Search")]
|
||||
[EndpointSummary("Search collections by name")]
|
||||
[EndpointDescription("Returns matching collections as {id, name} options for scheduling editors.")]
|
||||
@@ -81,7 +81,7 @@ public class SearchController(IMediator mediator) : ControllerBase
|
||||
return results.Map(c => new SchedulingPickerOptionResponseModel(c.Id, c.Name)).ToList();
|
||||
}
|
||||
|
||||
[HttpGet("/api/search/television-shows", Name = "SearchTelevisionShows")]
|
||||
[HttpGet("/api/v1/search/television-shows", Name = "SearchTelevisionShows")]
|
||||
[Tags("Search")]
|
||||
[EndpointSummary("Search television shows by name")]
|
||||
[EndpointDescription("Returns matching television shows as {id, name} options for scheduling editors.")]
|
||||
@@ -97,7 +97,7 @@ public class SearchController(IMediator mediator) : ControllerBase
|
||||
return results.Map(s => new SchedulingPickerOptionResponseModel(s.MediaItemId, s.Name)).ToList();
|
||||
}
|
||||
|
||||
[HttpGet("/api/search/television-seasons", Name = "SearchTelevisionSeasons")]
|
||||
[HttpGet("/api/v1/search/television-seasons", Name = "SearchTelevisionSeasons")]
|
||||
[Tags("Search")]
|
||||
[EndpointSummary("Search television seasons by name")]
|
||||
[EndpointDescription("Returns matching television seasons as {id, name} options for scheduling editors.")]
|
||||
@@ -113,7 +113,7 @@ public class SearchController(IMediator mediator) : ControllerBase
|
||||
return results.Map(s => new SchedulingPickerOptionResponseModel(s.MediaItemId, s.Name)).ToList();
|
||||
}
|
||||
|
||||
[HttpGet("/api/search/smart-collections", Name = "SearchSmartCollections")]
|
||||
[HttpGet("/api/v1/search/smart-collections", Name = "SearchSmartCollections")]
|
||||
[Tags("Search")]
|
||||
[EndpointSummary("Search smart collections by name")]
|
||||
[EndpointDescription("Returns matching smart collections as {id, name} options for scheduling editors.")]
|
||||
@@ -129,7 +129,7 @@ public class SearchController(IMediator mediator) : ControllerBase
|
||||
return results.Map(c => new SchedulingPickerOptionResponseModel(c.Id, c.Name)).ToList();
|
||||
}
|
||||
|
||||
[HttpGet("/api/search/artists", Name = "SearchArtists")]
|
||||
[HttpGet("/api/v1/search/artists", Name = "SearchArtists")]
|
||||
[Tags("Search")]
|
||||
[EndpointSummary("Search artists by name")]
|
||||
[EndpointDescription("Returns matching artists as {id, name} options for scheduling editors.")]
|
||||
@@ -145,7 +145,7 @@ public class SearchController(IMediator mediator) : ControllerBase
|
||||
return results.Map(a => new SchedulingPickerOptionResponseModel(a.MediaItemId, a.Name)).ToList();
|
||||
}
|
||||
|
||||
[HttpGet("/api/search/multi-collections", Name = "SearchMultiCollections")]
|
||||
[HttpGet("/api/v1/search/multi-collections", Name = "SearchMultiCollections")]
|
||||
[Tags("Search")]
|
||||
[EndpointSummary("Search multi collections by name")]
|
||||
[EndpointDescription("Returns matching multi collections as {id, name} options for scheduling editors.")]
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiController]
|
||||
public class SeasonsController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/seasons/{id:int}", Name = "GetSeasonById")]
|
||||
[HttpGet("/api/v1/seasons/{id:int}", Name = "GetSeasonById")]
|
||||
[Tags("Television")]
|
||||
[EndpointSummary("Get a television season by id")]
|
||||
[EndpointGroupName("general")]
|
||||
|
||||
@@ -8,12 +8,12 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[EndpointGroupName("general")]
|
||||
public class SessionController(IFFmpegSegmenterService ffmpegSegmenterService)
|
||||
{
|
||||
[HttpGet("api/sessions")]
|
||||
[HttpGet("/api/v1/sessions")]
|
||||
[Tags("Sessions")]
|
||||
[EndpointSummary("Get sessions")]
|
||||
public List<HlsSessionModel> GetSessions() => ffmpegSegmenterService.Workers.Map(w => w.GetModel()).ToList();
|
||||
|
||||
[HttpDelete("api/session/{channelNumber}")]
|
||||
[HttpDelete("/api/v1/session/{channelNumber}")]
|
||||
[Tags("Sessions")]
|
||||
[EndpointSummary("Stop session")]
|
||||
public async Task<IActionResult> StopSession(string channelNumber, CancellationToken cancellationToken)
|
||||
|
||||
@@ -24,7 +24,7 @@ public class SettingsController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
// FFmpeg settings
|
||||
|
||||
[HttpGet("/api/settings/ffmpeg", Name = "GetFfmpegSettings")]
|
||||
[HttpGet("/api/v1/settings/ffmpeg", Name = "GetFfmpegSettings")]
|
||||
[Tags("Settings")]
|
||||
[EndpointSummary("Get global FFmpeg settings")]
|
||||
[ProducesResponseType(typeof(FFmpegSettingsResponseModel), StatusCodes.Status200OK)]
|
||||
@@ -34,7 +34,7 @@ public class SettingsController(IMediator mediator) : ControllerBase
|
||||
return ProjectToResponseModel(settings);
|
||||
}
|
||||
|
||||
[HttpPut("/api/settings/ffmpeg", Name = "UpdateFfmpegSettings")]
|
||||
[HttpPut("/api/v1/settings/ffmpeg", Name = "UpdateFfmpegSettings")]
|
||||
[Tags("Settings")]
|
||||
[EndpointSummary("Update global FFmpeg settings")]
|
||||
[ProducesResponseType(typeof(FFmpegSettingsResponseModel), StatusCodes.Status200OK)]
|
||||
@@ -57,7 +57,7 @@ public class SettingsController(IMediator mediator) : ControllerBase
|
||||
|
||||
// Playout settings
|
||||
|
||||
[HttpGet("/api/settings/playout", Name = "GetPlayoutSettings")]
|
||||
[HttpGet("/api/v1/settings/playout", Name = "GetPlayoutSettings")]
|
||||
[Tags("Settings")]
|
||||
[EndpointSummary("Get global playout settings")]
|
||||
[ProducesResponseType(typeof(PlayoutSettingsResponseModel), StatusCodes.Status200OK)]
|
||||
@@ -67,7 +67,7 @@ public class SettingsController(IMediator mediator) : ControllerBase
|
||||
return ProjectToResponseModel(settings);
|
||||
}
|
||||
|
||||
[HttpPut("/api/settings/playout", Name = "UpdatePlayoutSettings")]
|
||||
[HttpPut("/api/v1/settings/playout", Name = "UpdatePlayoutSettings")]
|
||||
[Tags("Settings")]
|
||||
[EndpointSummary("Update global playout settings")]
|
||||
[ProducesResponseType(typeof(PlayoutSettingsResponseModel), StatusCodes.Status200OK)]
|
||||
@@ -90,7 +90,7 @@ public class SettingsController(IMediator mediator) : ControllerBase
|
||||
|
||||
// XMLTV settings
|
||||
|
||||
[HttpGet("/api/settings/xmltv", Name = "GetXmltvSettings")]
|
||||
[HttpGet("/api/v1/settings/xmltv", Name = "GetXmltvSettings")]
|
||||
[Tags("Settings")]
|
||||
[EndpointSummary("Get global XMLTV settings")]
|
||||
[ProducesResponseType(typeof(XmltvSettingsResponseModel), StatusCodes.Status200OK)]
|
||||
@@ -100,7 +100,7 @@ public class SettingsController(IMediator mediator) : ControllerBase
|
||||
return ProjectToResponseModel(settings);
|
||||
}
|
||||
|
||||
[HttpPut("/api/settings/xmltv", Name = "UpdateXmltvSettings")]
|
||||
[HttpPut("/api/v1/settings/xmltv", Name = "UpdateXmltvSettings")]
|
||||
[Tags("Settings")]
|
||||
[EndpointSummary("Update global XMLTV settings")]
|
||||
[ProducesResponseType(typeof(XmltvSettingsResponseModel), StatusCodes.Status200OK)]
|
||||
@@ -123,7 +123,7 @@ public class SettingsController(IMediator mediator) : ControllerBase
|
||||
|
||||
// Scanner settings
|
||||
|
||||
[HttpGet("/api/settings/scanner", Name = "GetScannerSettings")]
|
||||
[HttpGet("/api/v1/settings/scanner", Name = "GetScannerSettings")]
|
||||
[Tags("Settings")]
|
||||
[EndpointSummary("Get library scan cadence")]
|
||||
[ProducesResponseType(typeof(ScannerSettingsResponseModel), StatusCodes.Status200OK)]
|
||||
@@ -133,7 +133,7 @@ public class SettingsController(IMediator mediator) : ControllerBase
|
||||
return new ScannerSettingsResponseModel(libraryRefreshInterval);
|
||||
}
|
||||
|
||||
[HttpPut("/api/settings/scanner", Name = "UpdateScannerSettings")]
|
||||
[HttpPut("/api/v1/settings/scanner", Name = "UpdateScannerSettings")]
|
||||
[Tags("Settings")]
|
||||
[EndpointSummary("Update library scan cadence")]
|
||||
[ProducesResponseType(typeof(ScannerSettingsResponseModel), StatusCodes.Status200OK)]
|
||||
@@ -156,7 +156,7 @@ public class SettingsController(IMediator mediator) : ControllerBase
|
||||
|
||||
// Logging settings
|
||||
|
||||
[HttpGet("/api/settings/logging", Name = "GetLoggingSettings")]
|
||||
[HttpGet("/api/v1/settings/logging", Name = "GetLoggingSettings")]
|
||||
[Tags("Settings")]
|
||||
[EndpointSummary("Get per-area minimum log levels")]
|
||||
[ProducesResponseType(typeof(LoggingSettingsResponseModel), StatusCodes.Status200OK)]
|
||||
@@ -166,7 +166,7 @@ public class SettingsController(IMediator mediator) : ControllerBase
|
||||
return ProjectToResponseModel(settings);
|
||||
}
|
||||
|
||||
[HttpPut("/api/settings/logging", Name = "UpdateLoggingSettings")]
|
||||
[HttpPut("/api/v1/settings/logging", Name = "UpdateLoggingSettings")]
|
||||
[Tags("Settings")]
|
||||
[EndpointSummary("Update per-area minimum log levels")]
|
||||
[ProducesResponseType(typeof(LoggingSettingsResponseModel), StatusCodes.Status200OK)]
|
||||
@@ -189,7 +189,7 @@ public class SettingsController(IMediator mediator) : ControllerBase
|
||||
|
||||
// UI settings
|
||||
|
||||
[HttpGet("/api/settings/ui", Name = "GetUiSettings")]
|
||||
[HttpGet("/api/v1/settings/ui", Name = "GetUiSettings")]
|
||||
[Tags("Settings")]
|
||||
[EndpointSummary("Get UI preferences")]
|
||||
[ProducesResponseType(typeof(UiSettingsResponseModel), StatusCodes.Status200OK)]
|
||||
@@ -199,7 +199,7 @@ public class SettingsController(IMediator mediator) : ControllerBase
|
||||
return ProjectToResponseModel(settings);
|
||||
}
|
||||
|
||||
[HttpPut("/api/settings/ui", Name = "UpdateUiSettings")]
|
||||
[HttpPut("/api/v1/settings/ui", Name = "UpdateUiSettings")]
|
||||
[Tags("Settings")]
|
||||
[EndpointSummary("Update UI preferences")]
|
||||
[ProducesResponseType(typeof(UiSettingsResponseModel), StatusCodes.Status200OK)]
|
||||
@@ -222,14 +222,14 @@ public class SettingsController(IMediator mediator) : ControllerBase
|
||||
|
||||
// HDHR settings
|
||||
|
||||
[HttpGet("/api/settings/hdhr", Name = "GetHdhrSettings")]
|
||||
[HttpGet("/api/v1/settings/hdhr", Name = "GetHdhrSettings")]
|
||||
[Tags("Settings")]
|
||||
[EndpointSummary("Get HDHomeRun emulation settings")]
|
||||
[ProducesResponseType(typeof(HdhrSettingsResponseModel), StatusCodes.Status200OK)]
|
||||
public async Task<HdhrSettingsResponseModel> GetHdhr(CancellationToken cancellationToken) =>
|
||||
await LoadHdhrSettings(cancellationToken);
|
||||
|
||||
[HttpPut("/api/settings/hdhr", Name = "UpdateHdhrSettings")]
|
||||
[HttpPut("/api/v1/settings/hdhr", Name = "UpdateHdhrSettings")]
|
||||
[Tags("Settings")]
|
||||
[EndpointSummary("Update HDHomeRun emulation settings")]
|
||||
[ProducesResponseType(typeof(HdhrSettingsResponseModel), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiController]
|
||||
public class ShowsController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/shows/{id:int}", Name = "GetShowById")]
|
||||
[HttpGet("/api/v1/shows/{id:int}", Name = "GetShowById")]
|
||||
[Tags("Television")]
|
||||
[EndpointSummary("Get a television show by id")]
|
||||
[EndpointGroupName("general")]
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiController]
|
||||
public class SmartCollectionController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/smart-collections")]
|
||||
[HttpGet("/api/v1/smart-collections")]
|
||||
[Tags("Smart Collections")]
|
||||
[EndpointSummary("Get all smart collections")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -21,7 +21,7 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase
|
||||
public async Task<List<SmartCollectionResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllSmartCollectionsForApi(), cancellationToken);
|
||||
|
||||
[HttpGet("/api/smart-collections/{id:int}", Name = "GetSmartCollectionById")]
|
||||
[HttpGet("/api/v1/smart-collections/{id:int}", Name = "GetSmartCollectionById")]
|
||||
[Tags("Smart Collections")]
|
||||
[EndpointSummary("Get a smart collection by id")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -34,7 +34,7 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase
|
||||
return result.Map(ProjectToResponseModel).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/smart-collections")]
|
||||
[HttpPost("/api/v1/smart-collections")]
|
||||
[Tags("Smart Collections")]
|
||||
[EndpointSummary("Create a smart collection")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -42,15 +42,15 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Create(
|
||||
[Required] [FromBody] CreateSmartCollectionRequest request,
|
||||
[Required][FromBody] CreateSmartCollectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, SmartCollectionViewModel> result =
|
||||
await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(vm => $"/api/smart-collections/{vm.Id}", ProjectToResponseModel);
|
||||
return result.ToCreatedResult(vm => $"/api/v1/smart-collections/{vm.Id}", ProjectToResponseModel);
|
||||
}
|
||||
|
||||
[HttpPut("/api/smart-collections/{id:int}")]
|
||||
[HttpPut("/api/v1/smart-collections/{id:int}")]
|
||||
[Tags("Smart Collections")]
|
||||
[EndpointSummary("Update a smart collection")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -59,7 +59,7 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Update(
|
||||
int id,
|
||||
[Required] [FromBody] UpdateSmartCollectionRequest request,
|
||||
[Required][FromBody] UpdateSmartCollectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, UpdateSmartCollectionResult> result =
|
||||
@@ -76,7 +76,7 @@ public class SmartCollectionController(IMediator mediator) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/smart-collections/{id:int}")]
|
||||
[HttpDelete("/api/v1/smart-collections/{id:int}")]
|
||||
[Tags("Smart Collections")]
|
||||
[EndpointSummary("Delete a smart collection")]
|
||||
[EndpointGroupName("general")]
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiController]
|
||||
public class TemplateController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/templates/groups", Name = "GetTemplateGroups")]
|
||||
[HttpGet("/api/v1/templates/groups", Name = "GetTemplateGroups")]
|
||||
[Tags("Templates")]
|
||||
[EndpointSummary("Get all template groups")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -24,7 +24,7 @@ public class TemplateController(IMediator mediator) : ControllerBase
|
||||
return groups.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
|
||||
[HttpPost("/api/templates/groups")]
|
||||
[HttpPost("/api/v1/templates/groups")]
|
||||
[Tags("Templates")]
|
||||
[EndpointSummary("Create a template group")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -38,11 +38,11 @@ public class TemplateController(IMediator mediator) : ControllerBase
|
||||
Either<BaseError, TemplateGroupViewModel> result =
|
||||
await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(
|
||||
vm => $"/api/templates/groups/{vm.Id}",
|
||||
vm => $"/api/v1/templates/groups/{vm.Id}",
|
||||
vm => ProjectToResponseModel(vm));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/templates/groups/{id:int}")]
|
||||
[HttpDelete("/api/v1/templates/groups/{id:int}")]
|
||||
[Tags("Templates")]
|
||||
[EndpointSummary("Delete a template group")]
|
||||
[EndpointDescription(
|
||||
@@ -64,7 +64,7 @@ public class TemplateController(IMediator mediator) : ControllerBase
|
||||
None: () => new NoContentResult());
|
||||
}
|
||||
|
||||
[HttpGet("/api/templates")]
|
||||
[HttpGet("/api/v1/templates")]
|
||||
[Tags("Templates")]
|
||||
[EndpointSummary("Get all templates")]
|
||||
[EndpointDescription(
|
||||
@@ -81,7 +81,7 @@ public class TemplateController(IMediator mediator) : ControllerBase
|
||||
return templates.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
|
||||
[HttpGet("/api/templates/{id:int}", Name = "GetTemplateById")]
|
||||
[HttpGet("/api/v1/templates/{id:int}", Name = "GetTemplateById")]
|
||||
[Tags("Templates")]
|
||||
[EndpointSummary("Get a template by id")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -93,7 +93,7 @@ public class TemplateController(IMediator mediator) : ControllerBase
|
||||
return result.Map(ProjectToResponseModel).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/templates")]
|
||||
[HttpPost("/api/v1/templates")]
|
||||
[Tags("Templates")]
|
||||
[EndpointSummary("Create a template")]
|
||||
[EndpointDescription("Creates an empty template in the given template group.")]
|
||||
@@ -107,11 +107,11 @@ public class TemplateController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
Either<BaseError, TemplateViewModel> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(
|
||||
vm => $"/api/templates/{vm.Id}",
|
||||
vm => $"/api/v1/templates/{vm.Id}",
|
||||
vm => ProjectToResponseModel(vm));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/templates/{id:int}")]
|
||||
[HttpDelete("/api/v1/templates/{id:int}")]
|
||||
[Tags("Templates")]
|
||||
[EndpointSummary("Delete a template")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -131,7 +131,7 @@ public class TemplateController(IMediator mediator) : ControllerBase
|
||||
None: () => new NoContentResult());
|
||||
}
|
||||
|
||||
[HttpGet("/api/templates/{id:int}/items")]
|
||||
[HttpGet("/api/v1/templates/{id:int}/items")]
|
||||
[Tags("Templates")]
|
||||
[EndpointSummary("Get template items")]
|
||||
[EndpointDescription(
|
||||
@@ -156,7 +156,7 @@ public class TemplateController(IMediator mediator) : ControllerBase
|
||||
items.OrderBy(i => i.StartTime.TimeOfDay).Map(ProjectToResponseModel).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("/api/templates/{id:int}")]
|
||||
[HttpPut("/api/v1/templates/{id:int}")]
|
||||
[Tags("Templates")]
|
||||
[EndpointSummary("Replace a template and its items")]
|
||||
[EndpointDescription(
|
||||
@@ -219,7 +219,7 @@ public class TemplateController(IMediator mediator) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/api/templates/{id:int}/copy")]
|
||||
[HttpPost("/api/v1/templates/{id:int}/copy")]
|
||||
[Tags("Templates")]
|
||||
[EndpointSummary("Copy a template")]
|
||||
[EndpointDescription("Copies the template and its items into another (or the same) template group under a new name.")]
|
||||
@@ -241,7 +241,7 @@ public class TemplateController(IMediator mediator) : ControllerBase
|
||||
Either<BaseError, TemplateViewModel> result =
|
||||
await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return result.ToCreatedResult(
|
||||
vm => $"/api/templates/{vm.Id}",
|
||||
vm => $"/api/v1/templates/{vm.Id}",
|
||||
vm => ProjectToResponseModel(vm));
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ public partial class TraktController(
|
||||
{
|
||||
private const int MaxPageSize = 100;
|
||||
|
||||
[HttpGet("/api/trakt/lists", Name = "GetTraktLists")]
|
||||
[HttpGet("/api/v1/trakt/lists", Name = "GetTraktLists")]
|
||||
[Tags("Trakt")]
|
||||
[EndpointSummary("Get paged Trakt lists")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -44,7 +44,7 @@ public partial class TraktController(
|
||||
result.Page.Map(ProjectToResponseModel).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/trakt/lists/{id:int}", Name = "GetTraktListById")]
|
||||
[HttpGet("/api/v1/trakt/lists/{id:int}", Name = "GetTraktListById")]
|
||||
[Tags("Trakt")]
|
||||
[EndpointSummary("Get a Trakt list by id")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -56,18 +56,18 @@ public partial class TraktController(
|
||||
return result.Map(ProjectToResponseModel).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/trakt/lists")]
|
||||
[HttpPost("/api/v1/trakt/lists")]
|
||||
[Tags("Trakt")]
|
||||
[EndpointSummary("Add a Trakt list by URL")]
|
||||
[EndpointDescription(
|
||||
"Dispatches to the same background worker channel used by the classic UI's \"Add Trakt List\" dialog; " +
|
||||
"the list is fetched, saved, and matched asynchronously. Poll GET /api/trakt/status while busy.")]
|
||||
"the list is fetched, saved, and matched asynchronously. Poll GET /api/v1/trakt/status while busy.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Add(
|
||||
[Required] [FromBody] AddTraktListRequest request,
|
||||
[Required][FromBody] AddTraktListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsValidTraktListUrl(request.Url))
|
||||
@@ -79,7 +79,7 @@ public partial class TraktController(
|
||||
return await EnqueueWithTraktLock(AddTraktList.FromUrl(request.Url), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("/api/trakt/lists/{id:int}/match")]
|
||||
[HttpPost("/api/v1/trakt/lists/{id:int}/match")]
|
||||
[Tags("Trakt")]
|
||||
[EndpointSummary("Match a Trakt list's items")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -97,7 +97,7 @@ public partial class TraktController(
|
||||
return await EnqueueWithTraktLock(new MatchTraktListItems(id), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpDelete("/api/trakt/lists/{id:int}")]
|
||||
[HttpDelete("/api/v1/trakt/lists/{id:int}")]
|
||||
[Tags("Trakt")]
|
||||
[EndpointSummary("Delete a Trakt list")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -115,7 +115,7 @@ public partial class TraktController(
|
||||
return await EnqueueWithTraktLock(new DeleteTraktList(id), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPut("/api/trakt/lists/{id:int}")]
|
||||
[HttpPut("/api/v1/trakt/lists/{id:int}")]
|
||||
[Tags("Trakt")]
|
||||
[EndpointSummary("Update a Trakt list's settings")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -124,7 +124,7 @@ public partial class TraktController(
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Update(
|
||||
int id,
|
||||
[Required] [FromBody] UpdateTraktListRequest request,
|
||||
[Required][FromBody] UpdateTraktListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<TraktListViewModel> existing = await mediator.Send(new GetTraktListById(id), cancellationToken);
|
||||
@@ -146,7 +146,7 @@ public partial class TraktController(
|
||||
return updated.Map(ProjectToResponseModel).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpGet("/api/trakt/status", Name = "GetTraktStatus")]
|
||||
[HttpGet("/api/v1/trakt/status", Name = "GetTraktStatus")]
|
||||
[Tags("Trakt")]
|
||||
[EndpointSummary("Get Trakt background operation status")]
|
||||
[EndpointDescription(
|
||||
|
||||
@@ -44,7 +44,7 @@ public class TroubleshootController(
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
[HttpGet("api/troubleshoot/info", Name = "GetTroubleshootingInfo")]
|
||||
[HttpGet("/api/v1/troubleshoot/info", Name = "GetTroubleshootingInfo")]
|
||||
[Tags("Troubleshooting")]
|
||||
[EndpointSummary("Get troubleshooting diagnostic info")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -81,7 +81,7 @@ public class TroubleshootController(
|
||||
info.VideoToolboxCapabilities);
|
||||
}
|
||||
|
||||
[HttpPost("api/troubleshoot/validate-schedule", Name = "ValidateSequentialSchedule")]
|
||||
[HttpPost("/api/v1/troubleshoot/validate-schedule", Name = "ValidateSequentialSchedule")]
|
||||
[Tags("Troubleshooting")]
|
||||
[EndpointSummary("Validate a sequential schedule YAML document")]
|
||||
[EndpointDescription(
|
||||
@@ -112,7 +112,7 @@ public class TroubleshootController(
|
||||
new ValidateSequentialScheduleResponseModel(result.IsValid, result.Messages, result.Json));
|
||||
}
|
||||
|
||||
[HttpPost("api/troubleshoot/playback/start", Name = "StartTroubleshootingPlayback")]
|
||||
[HttpPost("/api/v1/troubleshoot/playback/start", Name = "StartTroubleshootingPlayback")]
|
||||
[Tags("Troubleshooting")]
|
||||
[EndpointSummary("Start a troubleshooting playback session")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -287,7 +287,7 @@ public class TroubleshootController(
|
||||
"Troubleshooting playback did not produce any output. It may have failed to start or been cancelled.");
|
||||
}
|
||||
|
||||
[HttpPost("api/troubleshoot/playback/archive", Name = "DownloadTroubleshootingArchive")]
|
||||
[HttpPost("/api/v1/troubleshoot/playback/archive", Name = "DownloadTroubleshootingArchive")]
|
||||
[Tags("Troubleshooting")]
|
||||
[EndpointSummary("Download the last troubleshooting playback session archive")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -312,7 +312,7 @@ public class TroubleshootController(
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
[HttpPost("api/troubleshoot/playback/sample/{mediaItemId:int}", Name = "DownloadTroubleshootingMediaSample")]
|
||||
[HttpPost("/api/v1/troubleshoot/playback/sample/{mediaItemId:int}", Name = "DownloadTroubleshootingMediaSample")]
|
||||
[Tags("Troubleshooting")]
|
||||
[EndpointSummary("Download a media sample archive for troubleshooting")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -337,7 +337,7 @@ public class TroubleshootController(
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("api/troubleshoot/playback/stream-selectors", Name = "GetTroubleshootingStreamSelectors")]
|
||||
[HttpGet("/api/v1/troubleshoot/playback/stream-selectors", Name = "GetTroubleshootingStreamSelectors")]
|
||||
[Tags("Troubleshooting")]
|
||||
[EndpointSummary("List available channel stream selectors")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -345,12 +345,12 @@ public class TroubleshootController(
|
||||
public async Task<List<string>> GetStreamSelectors(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetChannelStreamSelectors(), cancellationToken);
|
||||
|
||||
[HttpGet("api/troubleshoot/playback/subtitles/{mediaItemId:int}", Name = "GetTroubleshootingSubtitles")]
|
||||
[HttpGet("/api/v1/troubleshoot/playback/subtitles/{mediaItemId:int}", Name = "GetTroubleshootingSubtitles")]
|
||||
[Tags("Troubleshooting")]
|
||||
[EndpointSummary("List selectable subtitle streams for a media item")]
|
||||
[EndpointDescription(
|
||||
"Returns the subtitle streams that can be burned in for a troubleshooting playback. Each item's id is the " +
|
||||
"value to pass back as the POST /api/troubleshoot/playback/start request body's subtitleId field.")]
|
||||
"value to pass back as the POST /api/v1/troubleshoot/playback/start request body's subtitleId field.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<TroubleshootingSubtitleResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
@@ -372,7 +372,7 @@ public class TroubleshootController(
|
||||
.ToList());
|
||||
}
|
||||
|
||||
[HttpGet("api/troubleshoot/playback/status", Name = "GetTroubleshootingPlaybackStatus")]
|
||||
[HttpGet("/api/v1/troubleshoot/playback/status", Name = "GetTroubleshootingPlaybackStatus")]
|
||||
[Tags("Troubleshooting")]
|
||||
[EndpointSummary("Get the status of the current or last troubleshooting playback session")]
|
||||
[EndpointDescription(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Reflection;
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
@@ -16,7 +16,7 @@ public class VersionController
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||
.InformationalVersion ?? "unknown");
|
||||
|
||||
[HttpGet("/api/version", Name="GetVersion")]
|
||||
[HttpGet("/api/v1/version", Name = "GetVersion")]
|
||||
[Tags("Version")]
|
||||
[EndpointSummary("Get version")]
|
||||
public CombinedVersion GetVersion() => Version;
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiController]
|
||||
public class WatermarkController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/watermarks", Name = "GetWatermarks")]
|
||||
[HttpGet("/api/v1/watermarks", Name = "GetWatermarks")]
|
||||
[Tags("Watermarks")]
|
||||
[EndpointSummary("Get all watermarks")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -21,7 +21,7 @@ public class WatermarkController(IMediator mediator) : ControllerBase
|
||||
public async Task<List<WatermarkResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllWatermarksForApi(), cancellationToken);
|
||||
|
||||
[HttpGet("/api/watermarks/{id:int}", Name = "GetWatermarkById")]
|
||||
[HttpGet("/api/v1/watermarks/{id:int}", Name = "GetWatermarkById")]
|
||||
[Tags("Watermarks")]
|
||||
[EndpointSummary("Get a watermark by id")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -34,7 +34,7 @@ public class WatermarkController(IMediator mediator) : ControllerBase
|
||||
return result.ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/watermarks", Name = "CreateWatermark")]
|
||||
[HttpPost("/api/v1/watermarks", Name = "CreateWatermark")]
|
||||
[Tags("Watermarks")]
|
||||
[EndpointSummary("Create a watermark")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -43,7 +43,7 @@ public class WatermarkController(IMediator mediator) : ControllerBase
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Create(
|
||||
[Required] [FromBody] CreateWatermarkRequest request,
|
||||
[Required][FromBody] CreateWatermarkRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, CreateWatermarkResult> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
@@ -54,12 +54,12 @@ public class WatermarkController(IMediator mediator) : ControllerBase
|
||||
Option<WatermarkFullResponseModel> watermark =
|
||||
await mediator.Send(new GetWatermarkByIdForApi(created.WatermarkId), cancellationToken);
|
||||
return watermark.Match(
|
||||
Some: vm => (IActionResult)new CreatedResult($"/api/watermarks/{vm.Id}", vm),
|
||||
Some: vm => (IActionResult)new CreatedResult($"/api/v1/watermarks/{vm.Id}", vm),
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("/api/watermarks/{id:int}", Name = "UpdateWatermark")]
|
||||
[HttpPut("/api/v1/watermarks/{id:int}", Name = "UpdateWatermark")]
|
||||
[Tags("Watermarks")]
|
||||
[EndpointSummary("Update a watermark")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -69,7 +69,7 @@ public class WatermarkController(IMediator mediator) : ControllerBase
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Update(
|
||||
int id,
|
||||
[Required] [FromBody] UpdateWatermarkRequest request,
|
||||
[Required][FromBody] UpdateWatermarkRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, UpdateWatermarkResult> result =
|
||||
@@ -86,7 +86,7 @@ public class WatermarkController(IMediator mediator) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/watermarks/{id:int}", Name = "DeleteWatermark")]
|
||||
[HttpDelete("/api/v1/watermarks/{id:int}", Name = "DeleteWatermark")]
|
||||
[Tags("Watermarks")]
|
||||
[EndpointSummary("Delete a watermark")]
|
||||
[EndpointGroupName("general")]
|
||||
|
||||
@@ -116,7 +116,7 @@ public class ApiAuthorizationFilter(IApiKeyProvider apiKeyProvider) : IAuthoriza
|
||||
return false;
|
||||
}
|
||||
|
||||
// Explicit opt-out for internal, separately-guarded endpoints (scanner callback, /api/auth/*).
|
||||
// Explicit opt-out for internal, separately-guarded endpoints (scanner callback, /api/v1/auth/*).
|
||||
if (endpointMetadata.OfType<SkipApiAuthorizationAttribute>().Any())
|
||||
{
|
||||
return false;
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ErsatzTV.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Restricts an endpoint to loopback callers (127.0.0.0/8, ::1). Used for the in-process scanner
|
||||
/// callback surface (<c>/api/scan/*</c>), which is always reached over
|
||||
/// callback surface (<c>/api/v1/scan/*</c>), which is always reached over
|
||||
/// <c>http://localhost:{UiPort}</c> from the co-located scanner child process. Replaces relying
|
||||
/// on a guessable scan-id GUID as the sole gate (issue #285). This is only spoof-resistant when
|
||||
/// <c>ForwardedHeaders</c> trust is restricted (KnownProxies/KnownNetworks configured), since the
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace ErsatzTV.Filters;
|
||||
/// Marks an internal API endpoint as exempt from the global <see cref="ApiAuthorizationFilter" />
|
||||
/// (neither a machine key nor a session is required). Used for endpoints that are guarded by a
|
||||
/// different mechanism (e.g. the scanner callback's localhost-only check) and for the
|
||||
/// <c>/api/auth/*</c> surface itself, which must be reachable before a caller is authenticated.
|
||||
/// <c>/api/v1/auth/*</c> surface itself, which must be reachable before a caller is authenticated.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public sealed class SkipApiAuthorizationAttribute : Attribute, IFilterMetadata;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
namespace ErsatzTV.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites an unversioned legacy <c>/api/*</c> request to the current default version
|
||||
/// (<c>/api/v1/*</c>) in-pipeline. This is a <b>rewrite, not a redirect</b>: the method, body,
|
||||
/// query string and auth headers all survive, so a legacy client (curl, the MCP server, a
|
||||
/// bookmarked URL) keeps working with no extra round-trip. The deprecation is advertised via the
|
||||
/// RFC 8594 <c>Deprecation</c> and (optionally) <c>Sunset</c> response headers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Sequenced <b>before</b> <c>UseRouting</c> so the rewritten path matches the versioned controller
|
||||
/// routes. Introduced by ersatztv#286 when the whole <c>/api</c> surface was versioned to
|
||||
/// <c>/api/v1</c>; the compat shim is scheduled for removal roughly two releases out (see
|
||||
/// <c>docs/decisions.md</c>). Once <c>/api/v2</c> exists this middleware deliberately does <b>not</b>
|
||||
/// force an unversioned call onto v2 — an already-versioned path is passed through untouched.
|
||||
/// </remarks>
|
||||
public sealed class ApiVersionRewriteMiddleware
|
||||
{
|
||||
/// <summary>The default API version an unversioned legacy path is rewritten onto.</summary>
|
||||
public const string DefaultVersionSegment = "v1";
|
||||
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly string _sunset;
|
||||
|
||||
public ApiVersionRewriteMiddleware(RequestDelegate next, IConfiguration configuration)
|
||||
{
|
||||
_next = next;
|
||||
|
||||
// Optional operator-set removal date advertised in the Sunset header (RFC 8594). Advisory only —
|
||||
// the actual removal of the compat shim is a future release (ersatztv#286 Phase-3 follow-up).
|
||||
_sunset = configuration["Api:LegacyRoutesSunset"];
|
||||
}
|
||||
|
||||
public Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (TryRewriteLegacyApiPath(context.Request.Path, out PathString rewritten))
|
||||
{
|
||||
context.Request.Path = rewritten;
|
||||
context.Response.Headers["Deprecation"] = "true";
|
||||
context.Response.Headers["Link"] = "</docs>; rel=\"deprecation\"";
|
||||
if (!string.IsNullOrWhiteSpace(_sunset))
|
||||
{
|
||||
context.Response.Headers["Sunset"] = _sunset;
|
||||
}
|
||||
}
|
||||
|
||||
return _next(context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure decision: an <c>/api/*</c> path whose first segment after <c>/api</c> is <b>not</b> already
|
||||
/// a version token (<c>v<digits></c>) is a legacy unversioned call and is rewritten under the
|
||||
/// default version. Returns <c>false</c> (no rewrite) for an already-versioned path or any non-<c>/api</c>
|
||||
/// path. The <c>/api</c> and version segments are matched case-insensitively.
|
||||
/// </summary>
|
||||
public static bool TryRewriteLegacyApiPath(PathString path, out PathString rewritten)
|
||||
{
|
||||
rewritten = path;
|
||||
if (!path.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string value = path.Value;
|
||||
if (!value.StartsWith("/api/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const int firstSegmentStart = 5; // "/api/".Length
|
||||
int firstSegmentEnd = value.IndexOf('/', firstSegmentStart);
|
||||
string firstSegment = firstSegmentEnd < 0
|
||||
? value[firstSegmentStart..]
|
||||
: value[firstSegmentStart..firstSegmentEnd];
|
||||
|
||||
if (IsVersionSegment(firstSegment))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Keep everything from "/api" onward, splice the version segment in after it.
|
||||
rewritten = new PathString("/api/" + DefaultVersionSegment + value[4..]);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsVersionSegment(string segment)
|
||||
{
|
||||
if (segment.Length < 2 || (segment[0] != 'v' && segment[0] != 'V'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 1; i < segment.Length; i++)
|
||||
{
|
||||
if (!char.IsDigit(segment[i]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -758,7 +758,7 @@ public class Startup
|
||||
"/api",
|
||||
StringComparison.OrdinalIgnoreCase) &&
|
||||
!httpContext.Request.Path.ToUriComponent().StartsWith(
|
||||
"/api/scan",
|
||||
"/api/v1/scan",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return LogEventLevel.Debug;
|
||||
@@ -898,6 +898,12 @@ public class Startup
|
||||
await next(context);
|
||||
});
|
||||
|
||||
// ersatztv#286: version the API surface at /api/v1. Legacy unversioned /api/* callers
|
||||
// (curl, the MCP server, bookmarks) are rewritten in-pipeline to /api/v1/* — a rewrite,
|
||||
// not a redirect, so method/body/auth survive — carrying RFC 8594 Deprecation/Sunset
|
||||
// headers. Must run before UseRouting so the rewritten path matches the versioned routes.
|
||||
legacy.UseMiddleware<ApiVersionRewriteMiddleware>();
|
||||
|
||||
legacy.UseRouting();
|
||||
|
||||
// Browser SPA / API authentication (#295). This branch hosts /api, the OIDC /callback, and
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/scripted/playout/build/{buildId}/context": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/context": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Scripted Metadata"
|
||||
@@ -47,7 +47,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/add_collection": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/add_collection": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scripted Content"
|
||||
@@ -97,7 +97,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/add_marathon": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/add_marathon": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scripted Content"
|
||||
@@ -147,7 +147,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/add_multi_collection": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/add_multi_collection": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scripted Content"
|
||||
@@ -197,7 +197,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/add_playlist": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/add_playlist": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scripted Content"
|
||||
@@ -247,7 +247,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/create_playlist": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/create_playlist": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scripted Content"
|
||||
@@ -297,7 +297,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/add_search": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/add_search": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scripted Content"
|
||||
@@ -347,7 +347,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/add_smart_collection": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/add_smart_collection": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scripted Content"
|
||||
@@ -397,7 +397,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/add_show": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/add_show": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scripted Content"
|
||||
@@ -447,7 +447,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/add_all": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/add_all": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scripted Scheduling"
|
||||
@@ -514,7 +514,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/add_count": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/add_count": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scripted Scheduling"
|
||||
@@ -581,7 +581,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/add_duration": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/add_duration": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scripted Scheduling"
|
||||
@@ -648,7 +648,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/pad_to_next": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/pad_to_next": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scripted Scheduling"
|
||||
@@ -715,7 +715,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/pad_until": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/pad_until": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scripted Scheduling"
|
||||
@@ -782,7 +782,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/pad_until_exact": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/pad_until_exact": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scripted Scheduling"
|
||||
@@ -849,7 +849,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/peek_next/{content}": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/peek_next/{content}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Scripted Scheduling"
|
||||
@@ -899,7 +899,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/start_epg_group": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/start_epg_group": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scripted Control"
|
||||
@@ -949,7 +949,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/stop_epg_group": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/stop_epg_group": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scripted Control"
|
||||
@@ -974,7 +974,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/graphics_on": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/graphics_on": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scripted Control"
|
||||
@@ -1024,7 +1024,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/graphics_off": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/graphics_off": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scripted Control"
|
||||
@@ -1074,7 +1074,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/watermark_on": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/watermark_on": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scripted Control"
|
||||
@@ -1124,7 +1124,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/watermark_off": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/watermark_off": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scripted Control"
|
||||
@@ -1174,7 +1174,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/pre_roll_on": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/pre_roll_on": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scripted Control"
|
||||
@@ -1224,7 +1224,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/pre_roll_off": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/pre_roll_off": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scripted Control"
|
||||
@@ -1249,7 +1249,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/skip_items": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/skip_items": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scripted Control"
|
||||
@@ -1299,7 +1299,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/skip_to_item": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/skip_to_item": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scripted Control"
|
||||
@@ -1349,7 +1349,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/wait_until_exact": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/wait_until_exact": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scripted Control"
|
||||
@@ -1416,7 +1416,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/wait_until": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/wait_until": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scripted Control"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/scripted/playout/build/{buildId}/context": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/context": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -47,7 +47,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/add_collection": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/add_collection": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -97,7 +97,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/add_marathon": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/add_marathon": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -147,7 +147,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/add_multi_collection": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/add_multi_collection": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -197,7 +197,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/add_playlist": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/add_playlist": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -247,7 +247,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/create_playlist": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/create_playlist": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -297,7 +297,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/add_search": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/add_search": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -347,7 +347,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/add_smart_collection": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/add_smart_collection": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -397,7 +397,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/add_show": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/add_show": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -447,7 +447,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/add_all": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/add_all": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -514,7 +514,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/add_count": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/add_count": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -581,7 +581,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/add_duration": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/add_duration": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -648,7 +648,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/pad_to_next": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/pad_to_next": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -715,7 +715,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/pad_until": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/pad_until": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -782,7 +782,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/pad_until_exact": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/pad_until_exact": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -849,7 +849,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/peek_next/{content}": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/peek_next/{content}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -899,7 +899,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/start_epg_group": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/start_epg_group": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -949,7 +949,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/stop_epg_group": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/stop_epg_group": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -974,7 +974,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/graphics_on": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/graphics_on": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -1024,7 +1024,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/graphics_off": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/graphics_off": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -1074,7 +1074,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/watermark_on": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/watermark_on": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -1124,7 +1124,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/watermark_off": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/watermark_off": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -1174,7 +1174,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/pre_roll_on": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/pre_roll_on": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -1224,7 +1224,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/pre_roll_off": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/pre_roll_off": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -1249,7 +1249,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/skip_items": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/skip_items": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -1299,7 +1299,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/skip_to_item": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/skip_to_item": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -1349,7 +1349,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/wait_until_exact": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/wait_until_exact": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
@@ -1416,7 +1416,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/scripted/playout/build/{buildId}/wait_until": {
|
||||
"/api/v1/scripted/playout/build/{buildId}/wait_until": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"ScriptedSchedule"
|
||||
|
||||
+164
-164
File diff suppressed because it is too large
Load Diff
+35
-23
@@ -13,8 +13,18 @@ Controllers live in `ErsatzTV/Controllers/Api/*.cs`, one per domain (e.g. `Templ
|
||||
`BlockController.cs`, `LogsController.cs`). Every action needs this attribute set:
|
||||
|
||||
- `[ApiController]` on the class.
|
||||
- `[HttpGet/Post/Put/Delete("/api/...")]` on the action, with `Name = "..."` on at least the
|
||||
- `[HttpGet/Post/Put/Delete("/api/v1/...")]` on the action, with `Name = "..."` on at least the
|
||||
primary GET (used by the SPA's OpenAPI-generated client and by route-assertion tests).
|
||||
**The route is versioned and absolute** (`/api/v1/...`, leading slash, full path on the method attribute —
|
||||
no class-level `[Route]`). This is enforced: `ApiRouteVersioningTests` (sibling of `ApiControllerSecurityTests`)
|
||||
reflects over every `[ApiController]` action in `Controllers.Api` and fails if an effective route doesn't match
|
||||
`^/api/v\d+/`. The **only** controllers with a class-level `[Route]` are the two whose ~all actions share a
|
||||
parametrized prefix — `ScannerController` (`[Route("/api/v1/scan/{scanId:guid}")]`) and
|
||||
`ScriptedScheduleController` (`[Route("/api/v1/scripted/playout/build/{buildId:guid}")]`) — and there the
|
||||
method segments are relative (`[HttpPost("progress")]`). A browser-nav endpoint deliberately outside `/api`
|
||||
(`AuthController`'s `GET /auth/oidc/login`) is out of scope for the versioning rule. See `docs/decisions.md`
|
||||
2026-07-13 (#286) for the versioning contract (additive-only after freeze; the legacy `/api/*`→`/api/v1/*`
|
||||
in-pipeline rewrite in `ApiVersionRewriteMiddleware`).
|
||||
- `[Tags("Domain")]` — groups the endpoint in Swagger UI / the SPA's generated client namespace.
|
||||
- `[EndpointSummary("...")]` — one-line description; optionally `[EndpointDescription("...")]` for
|
||||
more detail.
|
||||
@@ -84,7 +94,7 @@ Exemplars:
|
||||
- **Optional enum filter via query param**: to filter a list endpoint by an enum, add a nullable
|
||||
enum parameter to the query record (default `null`) and bind it with `[FromQuery] TEnum? name` on
|
||||
the action; filter server-side only when it has a value. Exemplar: `?fillerKind=` on
|
||||
`GET /api/filler-presets` (`GetAllFillerPresetsForApi(FillerKind? FillerKind = null)`). An invalid
|
||||
`GET /api/v1/filler-presets` (`GetAllFillerPresetsForApi(FillerKind? FillerKind = null)`). An invalid
|
||||
enum value is rejected by model binding (400) — no handler-side guard needed.
|
||||
|
||||
## 3. Error mapping
|
||||
@@ -121,7 +131,7 @@ Established by issue #215 (`PlayoutController` + `ChannelController.ResetPlayout
|
||||
`IsPlayoutLocked(id)` → `ConflictProblem("Playout build in progress", ...)`; add
|
||||
`[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]` to each guarded
|
||||
action. Precedent for the 409 shape: `TraktController` (its private `ConflictProblem()`). Two nuances:
|
||||
- **Fire-and-forget bulk operations don't 409** — `POST /api/playouts/reset-all` stays 202; its
|
||||
- **Fire-and-forget bulk operations don't 409** — `POST /api/v1/playouts/reset-all` stays 202; its
|
||||
handler (`ResetAllPlayoutsHandler`) *skips* locked playouts, matching Blazor + the handler
|
||||
semantics. Only per-id mutations 409. As of #235 the handler returns a `ResetAllPlayoutsResult`
|
||||
(`QueuedPlayoutIds` / `SkippedLocked` / `SkippedUnsupported`) and the controller returns the 202
|
||||
@@ -135,7 +145,7 @@ action. Precedent for the 409 shape: `TraktController` (its private `ConflictPro
|
||||
maps it) so a client polling one playout has the same flag. The SPA reads it and, on a 409,
|
||||
refreshes to pick up the flag.
|
||||
- **Async-op success is 202, not 200** — an endpoint whose success path only *queues* a background
|
||||
rebuild returns **202 Accepted**, not 200 (#235: `POST /api/channels/{id:int}/playout/reset`
|
||||
rebuild returns **202 Accepted**, not 200 (#235: `POST /api/v1/channels/{id:int}/playout/reset`
|
||||
resolves the channel's playout by the immutable channel **`Id`** (`GetPlayoutIdByChannelId`), guards
|
||||
on `IsPlayoutLocked` → 409, queues a `BuildPlayout` → `AcceptedResult`, and 404s when the channel has
|
||||
no playout). It is keyed on `{id:int}`, **not** `{channelNumber}` — the single-item Channel admin
|
||||
@@ -169,19 +179,19 @@ operation itself, not a mutation racing it). Add `[ProducesResponseType]` for 20
|
||||
after a successful `Lock*`, `Unlock*` in a `catch` and rethrow — one lock ⇄ exactly one release.
|
||||
|
||||
A second exemplar (issue #235 slice B), where the lock lives on the **controller** rather than in a
|
||||
handler: `POST /api/media-sources/{plex|jellyfin|emby}/{id}/scan-collections?deep=` acquires the
|
||||
handler: `POST /api/v1/media-sources/{plex|jellyfin|emby}/{id}/scan-collections?deep=` acquires the
|
||||
per-source collections lock (`entityLocker.LockPlexCollections()` etc.) — the lock IS the running
|
||||
collections scan, so a `false` = 409 — then `WriteAsync`es `Synchronize{X}Collections(id, ForceScan:
|
||||
true, deep)` to the scanner channel and returns **202**. `ScannerService` releases that lock in a
|
||||
`finally` when it processes the message; the controller compensating-unlocks in a `catch` if the
|
||||
enqueue throws. `POST /api/libraries/{id}/scan?deep=` similarly threads an optional `[FromQuery] bool
|
||||
enqueue throws. `POST /api/v1/libraries/{id}/scan?deep=` similarly threads an optional `[FromQuery] bool
|
||||
deep` into `QueueLibraryScanByLibraryId(id, DeepScan)`.
|
||||
|
||||
**Status counterpart for a lock-backed async op.** A queue-triggering endpoint whose "is it running?"
|
||||
state lives in a lock/registry should expose a **GET status surface** the SPA can poll to reconcile its
|
||||
optimistic pending flag, rather than relying on a client-side timeout. Two exemplars:
|
||||
`GET /api/libraries/scan-status` reads `IScannerProxyService.GetActiveScans()` (per-library, with
|
||||
percent); `GET /api/media-sources/collections-scan-status` (#271) reads
|
||||
`GET /api/v1/libraries/scan-status` reads `IScannerProxyService.GetActiveScans()` (per-library, with
|
||||
percent); `GET /api/v1/media-sources/collections-scan-status` (#271) reads
|
||||
`IEntityLocker.Are{X}CollectionsLocked()` and returns one `{family}` entry per **family-global**
|
||||
collections lock that's held (no id, no percent — the lock granularity dictates the DTO shape). Return
|
||||
only the *active* entries (empty list = nothing running), mirroring the queue op's own lock.
|
||||
@@ -205,7 +215,7 @@ handler's validation when a lookup fails, so the controller-side mapping falls o
|
||||
|
||||
### 3c. A durable-save PUT that also triggers a background sync as a side effect
|
||||
|
||||
Some PUT-replace endpoints (issue #202: `PUT /api/media-sources/{plex|jellyfin|emby}/{id}/libraries`)
|
||||
Some PUT-replace endpoints (issue #202: `PUT /api/v1/media-sources/{plex|jellyfin|emby}/{id}/libraries`)
|
||||
have a synchronous durable write as their primary purpose — the response must reflect that write —
|
||||
but Blazor's editor also fired off a background sync per newly-enabled library after the save. This
|
||||
is a **different shape from §3b**: §3b is for an endpoint whose entire job *is* starting a
|
||||
@@ -254,7 +264,7 @@ the domain/VM directly and root the path yourself, following the PR #181 pattern
|
||||
The uploaded-artwork surfaces (channel logo, watermark) must never trust a client-declared content
|
||||
type — doing so was a stored-XSS chain (upload `<script>` as `image/png`, serve it back as
|
||||
`text/html`). The contract:
|
||||
- **Upload**: `POST /api/artwork/uploads` derives the content type from the actual bytes via
|
||||
- **Upload**: `POST /api/v1/artwork/uploads` derives the content type from the actual bytes via
|
||||
`ErsatzTV.Core/Images/ImageContentTypes.DetectContentType` (SkiaSharp header sniff — no full
|
||||
decode), rejecting non-images 422. That helper (`Accepted` set + `IsAccepted`) is the **single
|
||||
source of truth** for which image types are allowed — reuse it, don't re-list content types.
|
||||
@@ -376,8 +386,8 @@ GET handlers that feed an ordered list must also `.OrderBy(i => i.Index)` — id
|
||||
The replace-all aggregate PUTs (blocks, templates, schedules items, playlists, collections, playouts,
|
||||
etc.) carry an **optimistic-concurrency contract** so a stale second tab can't silently overwrite a
|
||||
fresher edit (issue #253). The Block endpoints are the reference implementation; **PR2 fanned the same
|
||||
recipe onto Template, DecoTemplate, Playlist, and schedule-items** (`PUT /api/templates/{id}`,
|
||||
`/api/deco-templates/{id}`, `/api/playlists/{id}`, `/api/schedules/{id}/items`); PR3 covers the
|
||||
recipe onto Template, DecoTemplate, Playlist, and schedule-items** (`PUT /api/v1/templates/{id}`,
|
||||
`/api/v1/deco-templates/{id}`, `/api/v1/playlists/{id}`, `/api/v1/schedules/{id}/items`); PR3 covers the
|
||||
Diff/Scalar aggregates (Collection, Playout ×2, MultiCollection, RerunCollection) and PR4 is the Phase-2
|
||||
428 flip.
|
||||
|
||||
@@ -397,7 +407,7 @@ from the ETag response header (`client.ts` `requestWithMeta`). Every replace PUT
|
||||
(`AddAggregateVersions`) adds the column (`nullable: false, defaultValue: 0`). Do **not** overload the
|
||||
existing `DateUpdated` — a plain `int` is portable across SQLite/MySQL and decoupled from UI cosmetics.
|
||||
|
||||
**Transport.** The aggregate's GET (the one the editor loads from — e.g. `GET /api/blocks/{id}/items`)
|
||||
**Transport.** The aggregate's GET (the one the editor loads from — e.g. `GET /api/v1/blocks/{id}/items`)
|
||||
emits a strong `ETag: "3"` of `Version`; the PUT sends it back as `If-Match: "3"`. Mismatch → **412
|
||||
Precondition Failed** (distinct from the §3a **409** "build in progress" lock guard). A successful PUT
|
||||
returns the **new** ETag (post-increment) so a same-tab second save doesn't 412 against its own write.
|
||||
@@ -544,7 +554,7 @@ That is correct only when a child row is pure config: reordering merely re-numbe
|
||||
rows. **Schedule items are the exception** (issue #259): a schedule item anchors persisted runtime state —
|
||||
`PlayoutScheduleItemFillGroupIndex` (fill-group / shuffle enumerator progression) FKs the item row with
|
||||
`OnDelete(Cascade)`. Reconciling those by position makes a **moved** item inherit the state of whatever item
|
||||
previously occupied its new slot. So `PUT /api/schedules/{id}/items` carries a stable child identity:
|
||||
previously occupied its new slot. So `PUT /api/v1/schedules/{id}/items` carries a stable child identity:
|
||||
|
||||
- `ScheduleItemRequest.Id` (`int?`) round-trips each existing item's server id (as returned by the items GET).
|
||||
**null / absent / 0 ⇒ a new item** (the controller normalizes `0`→null so the handler contract is
|
||||
@@ -571,7 +581,7 @@ later without breaking anything (the field stays optional).
|
||||
|
||||
## 8. Known API warts (don't "fix" without discussion — they're deliberate synthesized rows)
|
||||
|
||||
`GET /api/blocks` and `GET /api/templates` (via `GetAllBlocksHandler` / `GetAllTemplatesHandler` in
|
||||
`GET /api/v1/blocks` and `GET /api/v1/templates` (via `GetAllBlocksHandler` / `GetAllTemplatesHandler` in
|
||||
`ErsatzTV.Application/Scheduling/Queries/`) synthesize a fake **negative-id "(none)" group row**
|
||||
for items that have no group, so the SPA can render an "ungrouped" bucket
|
||||
(`Id = unusedGroup.Id * -1`, `Name = "(none)"`). See issue #172. If you add a similar "ungrouped"
|
||||
@@ -607,12 +617,12 @@ OpenAPI generation, so the spec can't drift). When you add an endpoint:
|
||||
(renamed from `[RequiresApiKey]`) so they stay gated even if an operator sets `Api:RequireKeyForReads=false`.
|
||||
A valid session satisfies this tier just as the key does. Current tier: `Troubleshoot`/`Logs`/`Settings`/
|
||||
`Maintenance`. `ApiControllerSecurityTests` asserts this reflectively.
|
||||
- **Internal loopback callbacks** (the scanner's `/api/scan/*`) and the **`/api/auth/*` surface itself** use
|
||||
- **Internal loopback callbacks** (the scanner's `/api/v1/scan/*`) and the **`/api/v1/auth/*` surface itself** use
|
||||
`[SkipApiAuthorization]` (renamed from `[SkipApiKeyAuthorization]`). The scanner adds `[LocalhostOnly]`; the
|
||||
auth surface must be reachable before a caller is authenticated, and its one sensitive action
|
||||
(`POST /api/auth/password`) self-checks the principal. `ApiControllerSecurityTests` asserts these two are the
|
||||
(`POST /api/v1/auth/password`) self-checks the principal. `ApiControllerSecurityTests` asserts these two are the
|
||||
**only** auth-exempt controllers.
|
||||
- **The `/api/auth/*` surface** (`AuthController`, `[ApiExplorerSettings(IgnoreApi = true)]` → excluded from the
|
||||
- **The `/api/v1/auth/*` surface** (`AuthController`, `[ApiExplorerSettings(IgnoreApi = true)]` → excluded from the
|
||||
OpenAPI doc, whose audience is machine clients): `GET config` (what auth options exist + `setupRequired`),
|
||||
`GET session`, `POST setup` (first-run claim), `POST login`, `POST logout`, `POST password`, `GET machine-key`
|
||||
(returns the server machine key to an authenticated session — the SPA's machine-key-management screen). The browser-nav
|
||||
@@ -638,16 +648,16 @@ OpenAPI generation, so the spec can't drift). When you add an endpoint:
|
||||
`Secure` behind TLS.
|
||||
- **Never add a side-effecting GET/HEAD under `/api`.** The filter's CSRF check only covers mutating verbs, so
|
||||
a side-effecting GET is a CSRF vector the moment a session cookie is a normal credential (a `SameSite=Lax`
|
||||
cookie rides a cross-site top-level GET navigation). The former `GET /api/troubleshoot/playback.m3u8` (started
|
||||
cookie rides a cross-site top-level GET navigation). The former `GET /api/v1/troubleshoot/playback.m3u8` (started
|
||||
an FFmpeg workload) and the archive/sample GETs were **POST-ified for #301** (PR2): `POST
|
||||
/api/troubleshoot/playback/start` (returns `{ url }` pointing at the open `/iptv` manifest), `POST
|
||||
/api/troubleshoot/playback/archive`, `POST /api/troubleshoot/playback/sample/{mediaItemId}` — so the standard
|
||||
/api/v1/troubleshoot/playback/start` (returns `{ url }` pointing at the open `/iptv` manifest), `POST
|
||||
/api/v1/troubleshoot/playback/archive`, `POST /api/v1/troubleshoot/playback/sample/{mediaItemId}` — so the standard
|
||||
session-mutation CSRF gate covers them with no new filter machinery (removing HEAD also fixed a latent
|
||||
`DeleteOnClose`-on-HEAD artifact-destruction bug). Any new endpoint that *does* something must be a mutating
|
||||
verb; a GET must be a pure read.
|
||||
- **The SPA cutover shipped in PR2 (#295 + #301).** The browser now authenticates with the session cookie only —
|
||||
it no longer sends `X-Api-Key`; the machine key is external/MCP-only, surfaced read-only by the machine-key
|
||||
screen (`GET /api/auth/machine-key`). See `spa-conventions.md §5e` for the SPA seams (boot gate, central
|
||||
screen (`GET /api/v1/auth/machine-key`). See `spa-conventions.md §5e` for the SPA seams (boot gate, central
|
||||
`X-Csrf` on mutations, fetch-blob downloads).
|
||||
|
||||
**The OpenAPI "v1" document declares the machine posture by construction (#287).** An `ApiKey` security
|
||||
@@ -667,4 +677,6 @@ a generated client method (a base id unique across the document stays unsuffixed
|
||||
never touched). `ValidationProblemOperationTransformer` documents the
|
||||
`400 ValidationProblemDetails` a model-binding/FluentValidation failure actually returns for any
|
||||
body/param-binding operation. All four are registered in `Startup.cs` on "v1" only, mirroring
|
||||
`NewtonsoftSchemaNamingTransformer`. `/api/v1` route versioning remains #286.
|
||||
`NewtonsoftSchemaNamingTransformer`. **Route versioning shipped in #286**: the whole surface is mounted at
|
||||
`/api/v1` (see §1 and `docs/decisions.md` 2026-07-13) — the OpenAPI document's `paths` are all `/api/v1/…`, and a
|
||||
legacy unversioned `/api/*` caller is rewritten in-pipeline by `ApiVersionRewriteMiddleware`.
|
||||
|
||||
+12
-12
@@ -196,7 +196,7 @@ MultiCollection, SmartCollection, and the media-item types
|
||||
TelevisionShow/TelevisionSeason/Artist/Movie/Episode/MusicVideo/OtherVideo/Song/Image/RemoteStream);
|
||||
Playlist is intentionally excluded, matching `RerunCollectionRequestMapping.IsSupportedSelectionType`.
|
||||
|
||||
**#153**: the playlist CRUD REST API (`/api/playlists/*` — groups, playlists, item-list replace, and
|
||||
**#153**: the playlist CRUD REST API (`/api/v1/playlists/*` — groups, playlists, item-list replace, and
|
||||
draft playout preview) plus the `/app/playlists` SPA screen (`PlaylistsScreen`) now mirror
|
||||
`Playlists.razor` (group tree, add/rename/delete groups, add/delete playlists) and
|
||||
`PlaylistEditor.razor` (per-item Collection Type over the 12 playlist item types — Collection,
|
||||
@@ -204,10 +204,10 @@ TelevisionShow, TelevisionSeason, Artist, MultiCollection, SmartCollection, Movi
|
||||
OtherVideo, Song, Image; type-conditional playback order; count; Play All; Show In EPG; reorder/copy/
|
||||
remove; playout preview). `IsSystem` groups and playlists are read-only in the SPA, matching Blazor.
|
||||
|
||||
**#155 RESOLVED** (collection-items enumeration): `GET /api/collections/{id}/items` (paged) now returns a
|
||||
**#155 RESOLVED** (collection-items enumeration): `GET /api/v1/collections/{id}/items` (paged) now returns a
|
||||
manual collection's full contents across all media kinds (reusing `LibraryBrowseItemResponseModel`), so the
|
||||
SPA `/app/collections` items view lists real members instead of the old lossy Lucene `collection:"name"`
|
||||
search preview. The `POST /api/collections/{id}/items` bogus-id case already returns 422 (guarded by
|
||||
search preview. The `POST /api/v1/collections/{id}/items` bogus-id case already returns 422 (guarded by
|
||||
`AddItemsToCollectionHandler.ValidateMediaItems`), not 500.
|
||||
|
||||
### Playback troubleshooting — #145 DONE
|
||||
@@ -224,8 +224,8 @@ path replacements, and library enable/disable had **no SPA UI and no write REST
|
||||
install could not connect any library without Blazor.
|
||||
|
||||
**Resolved by #202**: new write controllers (`LocalLibrariesController` under
|
||||
`/api/libraries/local/*`; `PlexMediaSourcesController`/`JellyfinMediaSourcesController`/
|
||||
`EmbyMediaSourcesController` under `/api/media-sources/{plex|jellyfin|emby}/*`) wrap the existing
|
||||
`/api/v1/libraries/local/*`; `PlexMediaSourcesController`/`JellyfinMediaSourcesController`/
|
||||
`EmbyMediaSourcesController` under `/api/v1/media-sources/{plex|jellyfin|emby}/*`) wrap the existing
|
||||
MediatR commands (no new commands, no DB migration), plus new SPA screens
|
||||
(`LocalLibraryEditScreen`, `PlexSourceScreen`, `RemoteSourceScreen`, `RemoteConnectionEditScreen`,
|
||||
`RemoteLibrariesEditScreen`, `PathReplacementsEditScreen`) under the now-`allowSubPaths`
|
||||
@@ -264,10 +264,10 @@ CLOSED 2026-07-09: **#210** (playout delete/reset/erase/scheduling-context + pre
|
||||
and **#211** (collection custom order + all-kind add picker); the block/watermark copy, trash
|
||||
select-all, and Trakt-note items of #213 landed in the same PR.
|
||||
CLOSED 2026-07-10: **#208** (search mutations: card drill-in, per-card + multi-select add-to,
|
||||
query-wide Add All via `GET /api/search/all-items`, Save As Smart Collection) and **#209**
|
||||
query-wide Add All via `GET /api/v1/search/all-items`, Save As Smart Collection) and **#209**
|
||||
(media browse/detail: shared Add-to layer `web/src/media/addTo/` on tiles + all four detail
|
||||
pages + child grids, per-show Quick/Deep scan gated to Plex/Jellyfin/Emby, per-episode Media
|
||||
Info + Troubleshoot entries; `POST /api/playlists/{id}/items` added). Known accepted deviations
|
||||
Info + Troubleshoot entries; `POST /api/v1/playlists/{id}/items` added). Known accepted deviations
|
||||
(select-mode toggle, per-card target superset) recorded in `docs/decisions.md`.
|
||||
CLOSED 2026-07-10: **#221** (adversarial-reviewer#18 follow-up to #208/#209) — those PRs added
|
||||
mutation actions to two screens whose fetch model keeps the previous result set rendered during a
|
||||
@@ -275,7 +275,7 @@ refetch. On Search and Media browse the per-card Add-to menu, Select/select-mode
|
||||
bar, Add-all, and Save-as-smart-collection are now **gated while a refetch is in flight** (query on
|
||||
Search; kind/query/page on Media browse), with a visible "Refreshing…" cue and dimmed grid; card
|
||||
navigation stays live. `SearchScreen.addAll` also binds its completion to the requesting query so a
|
||||
late `GET /api/search/all-items` can no longer open a bulk-add dialog scoped to the previous query.
|
||||
late `GET /api/v1/search/all-items` can no longer open a bulk-add dialog scoped to the previous query.
|
||||
See `docs/spa-conventions.md` §3a for the pattern.
|
||||
|
||||
CLOSED 2026-07-10: **#215** (adversarial-reviewer#18 removal gate) — Blazor's `EntityLocker`
|
||||
@@ -285,10 +285,10 @@ id-keyed `PlayoutController` mutation + `ChannelController.ResetPlayout` returns
|
||||
DTO (disables the buttons + shows a "Building…" cue). The safety invariant no longer depends on
|
||||
Blazor, so its removal can't silently drop it. See `docs/api-conventions.md` §3a + `decisions.md`.
|
||||
|
||||
2026-07-11 (#213, remaining scope): logs sort (`GET /api/logs` `sortField`/`sortDirection`,
|
||||
2026-07-11 (#213, remaining scope): logs sort (`GET /api/v1/logs` `sortField`/`sortDirection`,
|
||||
clickable column headers) and page-size persistence (client-local `localStorage`, not a server
|
||||
`ConfigElement`) landed; trash "see all" now pages past the 100/kind cap via
|
||||
`GET /api/library/browse` (no new API surface — see `docs/decisions.md`). The sibling branch landed the rest
|
||||
`GET /api/v1/library/browse` (no new API surface — see `docs/decisions.md`). The sibling branch landed the rest
|
||||
(block-history page-size/gating, blocks/templates list filters) — #213 fully closed.
|
||||
|
||||
## Section 4 — Blazor home / escape hatch — REMOVED
|
||||
@@ -314,7 +314,7 @@ Blazor wiring is gone and the catch-all is present; new `LegacyUiRedirects` test
|
||||
|
||||
The removal PR was **gated** — it started only after these cleared: ~~#202 (media-source write API + SPA)~~
|
||||
**DONE 2026-07-11**, ~~#235 F9 (deep-scan + external-collections-scan API)~~ **API DONE (#235 slice B)**:
|
||||
`POST /api/libraries/{id}/scan?deep=` now threads deep-scan, and `POST /api/media-sources/{plex|jellyfin|emby}/{id}/scan-collections?deep=`
|
||||
`POST /api/v1/libraries/{id}/scan?deep=` now threads deep-scan, and `POST /api/v1/media-sources/{plex|jellyfin|emby}/{id}/scan-collections?deep=`
|
||||
covers external-collections scan (the two `Libraries.razor` parity gaps) — the SPA `Libraries.razor` port can now
|
||||
proceed, and the **mandatory cold adversarial pass** (#91 comment 2026-07-09). ~~**Remaining SPA affordance for
|
||||
the removal PR**: `LibrariesScreen` exposes only quick-scan; add the **deep-scan** and **external-collections-scan**
|
||||
@@ -325,7 +325,7 @@ so the deletion diff stays pure): `LibrariesScreen` now wires the shipped `scanL
|
||||
The External Collections rows derive client-side from `getMediaSources()` (no new endpoint): the media-sources API
|
||||
handler already filters each source's `libraries` to sync-enabled entries, so a remote source with a non-empty
|
||||
`libraries` list is exactly `GetExternalCollections`'s `Libraries.Any(ShouldSyncItems)` filter. Collections scans
|
||||
now reconcile against `GET /api/media-sources/collections-scan-status` (family-global lock state), like library
|
||||
now reconcile against `GET /api/v1/media-sources/collections-scan-status` (family-global lock state), like library
|
||||
scans do — #271 replaced the original optimistic timeout with authoritative polling. (#204's id-carrying
|
||||
pattern redirects landed 2026-07-11; its catch-all fallback that replaces `MapFallbackToPage("/_Host")` is folded
|
||||
into Step 2 below, since it can only ship when `_Host` is deleted.) With the SPA parity done and the
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user