diff --git a/ErsatzTV.Application/Libraries/Commands/CallLibraryScannerHandler.cs b/ErsatzTV.Application/Libraries/Commands/CallLibraryScannerHandler.cs index 1b1ead77d..14d91819a 100644 --- a/ErsatzTV.Application/Libraries/Commands/CallLibraryScannerHandler.cs +++ b/ErsatzTV.Application/Libraries/Commands/CallLibraryScannerHandler.cs @@ -22,7 +22,7 @@ public abstract class CallLibraryScannerHandler( 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> PerformScan( ScanParameters parameters, diff --git a/ErsatzTV.Application/Search/Queries/GetSearchResultsHandler.cs b/ErsatzTV.Application/Search/Queries/GetSearchResultsHandler.cs index 8ea6bb692..9558134ad 100644 --- a/ErsatzTV.Application/Search/Queries/GetSearchResultsHandler.cs +++ b/ErsatzTV.Application/Search/Queries/GetSearchResultsHandler.cs @@ -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 diff --git a/ErsatzTV.Core/Api/Channels/ChannelDetailResponseModel.cs b/ErsatzTV.Core/Api/Channels/ChannelDetailResponseModel.cs index 850c9e14a..4b4182756 100644 --- a/ErsatzTV.Core/Api/Channels/ChannelDetailResponseModel.cs +++ b/ErsatzTV.Core/Api/Channels/ChannelDetailResponseModel.cs @@ -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 diff --git a/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs b/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs index 46775e5e7..a1510a59b 100644 --- a/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs +++ b/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs @@ -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) diff --git a/ErsatzTV.Tests/Controllers/ApiRouteVersioningTests.cs b/ErsatzTV.Tests/Controllers/ApiRouteVersioningTests.cs new file mode 100644 index 000000000..4559e4066 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/ApiRouteVersioningTests.cs @@ -0,0 +1,153 @@ +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; + +/// +/// Enforces the #286 route convention: every API-controller action's effective route is +/// versioned and absolute (^/api/v{n}/). 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. +/// +[TestFixture] +public class ApiRouteVersioningTests +{ + private static readonly Regex VersionedAbsolute = new(@"^/api/v\d+/", RegexOptions.Compiled); + + // Routes on an API controller that deliberately live OUTSIDE /api because they are browser-navigation + // endpoints, not part of the JSON API surface (api-conventions §9). This is an explicit allowlist, NOT + // a blanket skip: an accidental non-/api absolute route (e.g. a stray [HttpPost("/channels")]) must fail + // here, because it would also escape ApiAuthorizationFilter's /api-scoped gate → an unauthenticated + // mutation. The only intentional entry today is AuthController's OIDC challenge. + private static readonly string[] KnownNonApiRoutes = ["/auth/oidc/login"]; + + [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(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(inherit: true) + .Select(r => r.Template) + .FirstOrDefault(); + + foreach (MethodInfo action in controllerType + .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + { + // IRouteTemplateProvider covers BOTH [HttpGet("...")] (HttpMethodAttribute) and a bare + // action-level [Route("...")] — so a template-less [HttpGet] paired with [Route("...")] + // can't slip an unversioned route past this net (cold-review nit, #326). + foreach (IRouteTemplateProvider routeProvider in action + .GetCustomAttributes(inherit: true) + .OfType()) + { + string? effective = CombineRoute(controllerTemplate, routeProvider.Template); + if (effective is null) + { + // No route on the controller or the action → not a routable API endpoint; skip. + continue; + } + + // A route outside /api must be a KNOWN, intentional browser-nav endpoint — never a + // silent skip (see KnownNonApiRoutes above for why: it would also escape the /api-scoped + // auth filter). A new one fails here until it's explicitly allowlisted or versioned. + if (!effective.StartsWith("/api/", StringComparison.OrdinalIgnoreCase) && + !effective.Equals("/api", StringComparison.OrdinalIgnoreCase)) + { + KnownNonApiRoutes.ShouldContain( + effective, + $"{controllerType.Name}.{action.Name} route '{effective}' is neither versioned " + + "(/api/v1/…) nor a known non-/api browser-nav endpoint — version it, or add it to " + + "KnownNonApiRoutes if it is intentionally outside the JSON API surface."); + 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); + } +} diff --git a/ErsatzTV.Tests/Controllers/ArtworkUploadControllerTests.cs b/ErsatzTV.Tests/Controllers/ArtworkUploadControllerTests.cs index 0c507ddba..4f7f84867 100644 --- a/ErsatzTV.Tests/Controllers/ArtworkUploadControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ArtworkUploadControllerTests.cs @@ -36,7 +36,7 @@ public class ArtworkUploadControllerTests HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); attribute.HttpMethods.ShouldContain("POST"); - attribute.Template.ShouldBe("/api/artwork/uploads"); + attribute.Template.ShouldBe("/api/v1/artwork/uploads"); attribute.Name.ShouldBe("UploadArtwork"); } diff --git a/ErsatzTV.Tests/Controllers/BlockControllerTests.cs b/ErsatzTV.Tests/Controllers/BlockControllerTests.cs index d304e9ca7..229add27c 100644 --- a/ErsatzTV.Tests/Controllers/BlockControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/BlockControllerTests.cs @@ -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(); - created.Location.ShouldBe("/api/blocks/groups/5"); + created.Location.ShouldBe("/api/v1/blocks/groups/5"); created.Value.ShouldBeOfType().Name.ShouldBe("Prime Time"); await _mediator.Received(1).Send( Arg.Is(c => c.Name == "Prime Time"), @@ -138,7 +138,7 @@ public class BlockControllerTests CancellationToken.None); var created = result.ShouldBeOfType(); - created.Location.ShouldBe("/api/blocks/8"); + created.Location.ShouldBe("/api/v1/blocks/8"); await _mediator.Received(1).Send( Arg.Is(c => c.BlockGroupId == 2 && c.Name == "Morning"), Arg.Any()); @@ -440,7 +440,7 @@ public class BlockControllerTests CancellationToken.None); var created = result.ShouldBeOfType(); - created.Location.ShouldBe("/api/blocks/9"); + created.Location.ShouldBe("/api/v1/blocks/9"); await _mediator.Received(1).Send( Arg.Is(c => c.BlockId == 4 && c.NewBlockGroupId == 3 && c.NewBlockName == "Morning Copy"), Arg.Any()); diff --git a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs index 97971c94e..59ee2f9a1 100644 --- a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs @@ -67,11 +67,11 @@ public class ChannelControllerTests { MethodInfo mvct = typeof(ChannelController).GetMethod(nameof(ChannelController.GetMusicVideoCreditsTemplates))!; mvct.GetCustomAttributes(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(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(); 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(); 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(inherit: true).Single().Template - .ShouldBe("/api/channels/{id:int}/playout/reset"); + .ShouldBe("/api/v1/channels/{id:int}/playout/reset"); } [Test] diff --git a/ErsatzTV.Tests/Controllers/ChannelTemplateControllerTests.cs b/ErsatzTV.Tests/Controllers/ChannelTemplateControllerTests.cs index b66907bb1..1ac7be2e8 100644 --- a/ErsatzTV.Tests/Controllers/ChannelTemplateControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ChannelTemplateControllerTests.cs @@ -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(); - created.Location.ShouldBe("/api/channel-templates/7"); + created.Location.ShouldBe("/api/v1/channel-templates/7"); created.Value.ShouldBe(vm); } diff --git a/ErsatzTV.Tests/Controllers/CollectionControllerTests.cs b/ErsatzTV.Tests/Controllers/CollectionControllerTests.cs index 5a2c805b5..69ce0d470 100644 --- a/ErsatzTV.Tests/Controllers/CollectionControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/CollectionControllerTests.cs @@ -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(); 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)); } diff --git a/ErsatzTV.Tests/Controllers/DecoControllerTests.cs b/ErsatzTV.Tests/Controllers/DecoControllerTests.cs index e96eae117..4754d1413 100644 --- a/ErsatzTV.Tests/Controllers/DecoControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/DecoControllerTests.cs @@ -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(); - created.Location.ShouldBe("/api/decos/groups/5"); + created.Location.ShouldBe("/api/v1/decos/groups/5"); created.Value.ShouldBeOfType().Name.ShouldBe("Bumpers"); await _mediator.Received(1).Send( Arg.Is(c => c.Name == "Bumpers"), @@ -193,7 +193,7 @@ public class DecoControllerTests CancellationToken.None); var created = result.ShouldBeOfType(); - created.Location.ShouldBe("/api/decos/8"); + created.Location.ShouldBe("/api/v1/decos/8"); await _mediator.Received(1).Send( Arg.Is(c => c.DecoGroupId == 2 && c.Name == "Movie Night"), Arg.Any()); diff --git a/ErsatzTV.Tests/Controllers/DecoTemplateControllerTests.cs b/ErsatzTV.Tests/Controllers/DecoTemplateControllerTests.cs index ad82bd885..6cc449fee 100644 --- a/ErsatzTV.Tests/Controllers/DecoTemplateControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/DecoTemplateControllerTests.cs @@ -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(); - created.Location.ShouldBe("/api/deco-templates/groups/5"); + created.Location.ShouldBe("/api/v1/deco-templates/groups/5"); created.Value.ShouldBeOfType().Name.ShouldBe("Weekday"); await _mediator.Received(1).Send( Arg.Is(c => c.Name == "Weekday"), @@ -163,7 +163,7 @@ public class DecoTemplateControllerTests CancellationToken.None); var created = result.ShouldBeOfType(); - created.Location.ShouldBe("/api/deco-templates/8"); + created.Location.ShouldBe("/api/v1/deco-templates/8"); await _mediator.Received(1).Send( Arg.Is(c => c.DecoTemplateGroupId == 2 && c.Name == "Morning"), Arg.Any()); diff --git a/ErsatzTV.Tests/Controllers/EmbyMediaSourcesControllerTests.cs b/ErsatzTV.Tests/Controllers/EmbyMediaSourcesControllerTests.cs index cd838dcd8..ca315d021 100644 --- a/ErsatzTV.Tests/Controllers/EmbyMediaSourcesControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/EmbyMediaSourcesControllerTests.cs @@ -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] diff --git a/ErsatzTV.Tests/Controllers/FFmpegProfileControllerTests.cs b/ErsatzTV.Tests/Controllers/FFmpegProfileControllerTests.cs index 46cbd597e..5100691fc 100644 --- a/ErsatzTV.Tests/Controllers/FFmpegProfileControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/FFmpegProfileControllerTests.cs @@ -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(); created.StatusCode.ShouldBe(201); - created.Location.ShouldBe("/api/ffmpeg/profiles/7"); + created.Location.ShouldBe("/api/v1/ffmpeg/profiles/7"); created.Value.ShouldBe(vm); } diff --git a/ErsatzTV.Tests/Controllers/FillerPresetControllerTests.cs b/ErsatzTV.Tests/Controllers/FillerPresetControllerTests.cs index b49f8e6bd..5a3e169fd 100644 --- a/ErsatzTV.Tests/Controllers/FillerPresetControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/FillerPresetControllerTests.cs @@ -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(); created.StatusCode.ShouldBe(201); - created.Location.ShouldBe("/api/filler-presets/7"); + created.Location.ShouldBe("/api/v1/filler-presets/7"); created.Value.ShouldBe(vm); } diff --git a/ErsatzTV.Tests/Controllers/GraphicsElementControllerTests.cs b/ErsatzTV.Tests/Controllers/GraphicsElementControllerTests.cs index 5dfcc6297..fb2e9a150 100644 --- a/ErsatzTV.Tests/Controllers/GraphicsElementControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/GraphicsElementControllerTests.cs @@ -32,7 +32,7 @@ public class GraphicsElementControllerTests HttpMethodAttribute attribute = action.GetCustomAttributes(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(inherit: true).Single(); attribute.HttpMethods.ShouldContain("POST"); - attribute.Template.ShouldBe("/api/graphics-elements/refresh"); + attribute.Template.ShouldBe("/api/v1/graphics-elements/refresh"); } [Test] diff --git a/ErsatzTV.Tests/Controllers/HealthControllerTests.cs b/ErsatzTV.Tests/Controllers/HealthControllerTests.cs index ca8cbfc2c..153f52c82 100644 --- a/ErsatzTV.Tests/Controllers/HealthControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/HealthControllerTests.cs @@ -31,7 +31,7 @@ public class HealthControllerTests HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); attribute.HttpMethods.ShouldContain("GET"); - attribute.Template.ShouldBe("/api/health"); + attribute.Template.ShouldBe("/api/v1/health"); attribute.Name.ShouldBe("GetHealthChecks"); } diff --git a/ErsatzTV.Tests/Controllers/ImagesControllerTests.cs b/ErsatzTV.Tests/Controllers/ImagesControllerTests.cs index 5ed875e5f..43810b4e3 100644 --- a/ErsatzTV.Tests/Controllers/ImagesControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ImagesControllerTests.cs @@ -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] diff --git a/ErsatzTV.Tests/Controllers/JellyfinMediaSourcesControllerTests.cs b/ErsatzTV.Tests/Controllers/JellyfinMediaSourcesControllerTests.cs index a891570bf..7f265eece 100644 --- a/ErsatzTV.Tests/Controllers/JellyfinMediaSourcesControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/JellyfinMediaSourcesControllerTests.cs @@ -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] diff --git a/ErsatzTV.Tests/Controllers/LanguagesControllerTests.cs b/ErsatzTV.Tests/Controllers/LanguagesControllerTests.cs index 3b8536468..a47427522 100644 --- a/ErsatzTV.Tests/Controllers/LanguagesControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/LanguagesControllerTests.cs @@ -29,7 +29,7 @@ public class LanguagesControllerTests MethodInfo action = typeof(LanguagesController).GetMethod(nameof(LanguagesController.GetLanguages))!; HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); attribute.HttpMethods.ShouldContain("GET"); - attribute.Template.ShouldBe("/api/languages"); + attribute.Template.ShouldBe("/api/v1/languages"); } [Test] diff --git a/ErsatzTV.Tests/Controllers/LibrariesControllerTests.cs b/ErsatzTV.Tests/Controllers/LibrariesControllerTests.cs index 476758752..619304044 100644 --- a/ErsatzTV.Tests/Controllers/LibrariesControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/LibrariesControllerTests.cs @@ -37,7 +37,7 @@ public class LibrariesControllerTests HttpMethodAttribute attribute = action.GetCustomAttributes(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"); } diff --git a/ErsatzTV.Tests/Controllers/LibraryBrowseControllerTests.cs b/ErsatzTV.Tests/Controllers/LibraryBrowseControllerTests.cs index b7f0b829d..adfeb6bd4 100644 --- a/ErsatzTV.Tests/Controllers/LibraryBrowseControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/LibraryBrowseControllerTests.cs @@ -32,7 +32,7 @@ public class LibraryBrowseControllerTests ?? throw new AssertionException($"Missing action {nameof(LibraryBrowseController.Browse)}"); var attribute = action.GetCustomAttributes().Single(); - attribute.Template.ShouldBe("/api/library/browse"); + attribute.Template.ShouldBe("/api/v1/library/browse"); attribute.Name.ShouldBe("BrowseLibrary"); } diff --git a/ErsatzTV.Tests/Controllers/LocalLibrariesControllerTests.cs b/ErsatzTV.Tests/Controllers/LocalLibrariesControllerTests.cs index 0cdcc7948..5af941cb5 100644 --- a/ErsatzTV.Tests/Controllers/LocalLibrariesControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/LocalLibrariesControllerTests.cs @@ -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(); - created.Location.ShouldBe("/api/libraries/local/5"); + created.Location.ShouldBe("/api/v1/libraries/local/5"); created.Value.ShouldBeOfType().Name.ShouldBe("Movies"); await _mediator.Received(1).Send( Arg.Is(c => c.Name == "Movies" && c.MediaKind == LibraryMediaKind.Movies), diff --git a/ErsatzTV.Tests/Controllers/LogsControllerTests.cs b/ErsatzTV.Tests/Controllers/LogsControllerTests.cs index e3cd79d12..79a5c0223 100644 --- a/ErsatzTV.Tests/Controllers/LogsControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/LogsControllerTests.cs @@ -32,7 +32,7 @@ public class LogsControllerTests HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); attribute.HttpMethods.ShouldContain("GET"); - attribute.Template.ShouldBe("/api/logs"); + attribute.Template.ShouldBe("/api/v1/logs"); attribute.Name.ShouldBe("GetLogs"); } diff --git a/ErsatzTV.Tests/Controllers/MediaDetailControllerTests.cs b/ErsatzTV.Tests/Controllers/MediaDetailControllerTests.cs index 22e0b0def..64b8fb874 100644 --- a/ErsatzTV.Tests/Controllers/MediaDetailControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/MediaDetailControllerTests.cs @@ -35,14 +35,14 @@ public class MediaDetailControllerTests [Test] public void Controllers_Should_Expose_Idiomatic_Rest_Routes() { - ShouldHaveActionRoute(nameof(MoviesController.GetById), "GET", "/api/movies/{id:int}"); - ShouldHaveActionRoute(nameof(ShowsController.GetById), "GET", "/api/shows/{id:int}"); - ShouldHaveActionRoute(nameof(SeasonsController.GetById), "GET", "/api/seasons/{id:int}"); - ShouldHaveActionRoute(nameof(ArtistsController.GetById), "GET", "/api/artists/{id:int}"); + ShouldHaveActionRoute(nameof(MoviesController.GetById), "GET", "/api/v1/movies/{id:int}"); + ShouldHaveActionRoute(nameof(ShowsController.GetById), "GET", "/api/v1/shows/{id:int}"); + ShouldHaveActionRoute(nameof(SeasonsController.GetById), "GET", "/api/v1/seasons/{id:int}"); + ShouldHaveActionRoute(nameof(ArtistsController.GetById), "GET", "/api/v1/artists/{id:int}"); ShouldHaveActionRoute( nameof(MediaItemsController.GetInfo), "GET", - "/api/media-items/{id:int}/info"); + "/api/v1/media-items/{id:int}/info"); } [Test] diff --git a/ErsatzTV.Tests/Controllers/MediaItemsControllerTests.cs b/ErsatzTV.Tests/Controllers/MediaItemsControllerTests.cs index 89d43e0ba..b039c2a17 100644 --- a/ErsatzTV.Tests/Controllers/MediaItemsControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/MediaItemsControllerTests.cs @@ -34,7 +34,7 @@ public class MediaItemsControllerTests ?? throw new AssertionException($"Missing action {nameof(MediaItemsController.Delete)}"); var attribute = action.GetCustomAttributes().Single(); - attribute.Template.ShouldBe("/api/media-items"); + attribute.Template.ShouldBe("/api/v1/media-items"); attribute.Name.ShouldBe("DeleteMediaItems"); } diff --git a/ErsatzTV.Tests/Controllers/MediaSourcesControllerTests.cs b/ErsatzTV.Tests/Controllers/MediaSourcesControllerTests.cs index 13ddfe03a..80ac0c75d 100644 --- a/ErsatzTV.Tests/Controllers/MediaSourcesControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/MediaSourcesControllerTests.cs @@ -32,7 +32,7 @@ public class MediaSourcesControllerTests HttpMethodAttribute attribute = action.GetCustomAttributes(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(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"); } diff --git a/ErsatzTV.Tests/Controllers/MultiCollectionControllerTests.cs b/ErsatzTV.Tests/Controllers/MultiCollectionControllerTests.cs index d9e491943..a7f89f844 100644 --- a/ErsatzTV.Tests/Controllers/MultiCollectionControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/MultiCollectionControllerTests.cs @@ -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(); - created.Location.ShouldBe("/api/multi-collections/8"); + created.Location.ShouldBe("/api/v1/multi-collections/8"); created.Value.ShouldBeOfType().Name.ShouldBe("Kids"); await _mediator.Received(1).Send( Arg.Is(c => diff --git a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs index 14e03d18e..10c004cfa 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs @@ -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, diff --git a/ErsatzTV.Tests/Controllers/PlaylistControllerTests.cs b/ErsatzTV.Tests/Controllers/PlaylistControllerTests.cs index 37e88e3f2..1ae7f2607 100644 --- a/ErsatzTV.Tests/Controllers/PlaylistControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/PlaylistControllerTests.cs @@ -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(); - created.Location.ShouldBe("/api/playlists/groups/8"); + created.Location.ShouldBe("/api/v1/playlists/groups/8"); created.Value.ShouldBeOfType().Name.ShouldBe("Kids"); await _mediator.Received(1).Send( Arg.Is(c => c.Name == "Kids"), @@ -311,7 +311,7 @@ public class PlaylistControllerTests CancellationToken.None); var created = result.ShouldBeOfType(); - created.Location.ShouldBe("/api/playlists/9"); + created.Location.ShouldBe("/api/v1/playlists/9"); await _mediator.Received(1).Send( Arg.Is(c => c.PlaylistGroupId == 1 && c.Name == "Kids"), Arg.Any()); diff --git a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs index a56964a9c..c81648a2b 100644 --- a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs @@ -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(); created.StatusCode.ShouldBe(201); - created.Location.ShouldBe("/api/playouts/9"); + created.Location.ShouldBe("/api/v1/playouts/9"); created.Value.ShouldBe(ToResponse(vm)); } diff --git a/ErsatzTV.Tests/Controllers/PlexMediaSourcesControllerTests.cs b/ErsatzTV.Tests/Controllers/PlexMediaSourcesControllerTests.cs index a2a9a4ec2..dadc42d73 100644 --- a/ErsatzTV.Tests/Controllers/PlexMediaSourcesControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/PlexMediaSourcesControllerTests.cs @@ -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 ----- diff --git a/ErsatzTV.Tests/Controllers/RerunCollectionControllerTests.cs b/ErsatzTV.Tests/Controllers/RerunCollectionControllerTests.cs index 6f4cf53ab..9cae6bfc9 100644 --- a/ErsatzTV.Tests/Controllers/RerunCollectionControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/RerunCollectionControllerTests.cs @@ -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(); - created.Location.ShouldBe("/api/rerun-collections/8"); + created.Location.ShouldBe("/api/v1/rerun-collections/8"); await _mediator.Received(1).Send( Arg.Is(c => c.Name == "Nightly" && diff --git a/ErsatzTV.Tests/Controllers/ResolutionControllerTests.cs b/ErsatzTV.Tests/Controllers/ResolutionControllerTests.cs index 06c2a1225..34e2f37e4 100644 --- a/ErsatzTV.Tests/Controllers/ResolutionControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ResolutionControllerTests.cs @@ -84,7 +84,7 @@ public class ResolutionControllerTests var created = result.ShouldBeOfType(); 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(c => c.Width == 640 && c.Height == 480), diff --git a/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs b/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs index 2c8eae1d5..6dd63d3bd 100644 --- a/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs @@ -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(); 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(); - created.Location.ShouldBe("/api/schedules/4/items/12"); + created.Location.ShouldBe("/api/v1/schedules/4/items/12"); created.Value.ShouldBeOfType().Id.ShouldBe(12); await _mediator.Received(1).Send( Arg.Is(c => c.ProgramScheduleId == 4 && c.PlayoutMode == PlayoutMode.One), diff --git a/ErsatzTV.Tests/Controllers/SearchControllerTests.cs b/ErsatzTV.Tests/Controllers/SearchControllerTests.cs index 418b22b00..d2b393bf5 100644 --- a/ErsatzTV.Tests/Controllers/SearchControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/SearchControllerTests.cs @@ -35,7 +35,7 @@ public class SearchControllerTests ?? throw new AssertionException($"Missing action {nameof(SearchController.Search)}"); var attribute = action.GetCustomAttributes().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().Single(); - attribute.Template.ShouldBe("/api/search/all-items"); + attribute.Template.ShouldBe("/api/v1/search/all-items"); attribute.Name.ShouldBe("SearchAllItems"); } diff --git a/ErsatzTV.Tests/Controllers/SmartCollectionControllerTests.cs b/ErsatzTV.Tests/Controllers/SmartCollectionControllerTests.cs index 45e7bd251..ddb2f6a30 100644 --- a/ErsatzTV.Tests/Controllers/SmartCollectionControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/SmartCollectionControllerTests.cs @@ -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(); 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")); } diff --git a/ErsatzTV.Tests/Controllers/TemplateControllerTests.cs b/ErsatzTV.Tests/Controllers/TemplateControllerTests.cs index 1d509cc3a..81f1f413e 100644 --- a/ErsatzTV.Tests/Controllers/TemplateControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/TemplateControllerTests.cs @@ -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(); - created.Location.ShouldBe("/api/templates/groups/5"); + created.Location.ShouldBe("/api/v1/templates/groups/5"); created.Value.ShouldBeOfType().Name.ShouldBe("Prime Time"); await _mediator.Received(1).Send( Arg.Is(c => c.Name == "Prime Time"), @@ -150,7 +150,7 @@ public class TemplateControllerTests CancellationToken.None); var created = result.ShouldBeOfType(); - created.Location.ShouldBe("/api/templates/8"); + created.Location.ShouldBe("/api/v1/templates/8"); await _mediator.Received(1).Send( Arg.Is(c => c.TemplateGroupId == 2 && c.Name == "Morning"), Arg.Any()); @@ -416,7 +416,7 @@ public class TemplateControllerTests CancellationToken.None); var created = result.ShouldBeOfType(); - created.Location.ShouldBe("/api/templates/9"); + created.Location.ShouldBe("/api/v1/templates/9"); await _mediator.Received(1).Send( Arg.Is(c => c.TemplateId == 4 && c.NewTemplateGroupId == 3 && c.NewTemplateName == "Morning Copy"), Arg.Any()); diff --git a/ErsatzTV.Tests/Controllers/TroubleshootControllerTests.cs b/ErsatzTV.Tests/Controllers/TroubleshootControllerTests.cs index 4f9436ba8..e1314e7c6 100644 --- a/ErsatzTV.Tests/Controllers/TroubleshootControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/TroubleshootControllerTests.cs @@ -79,7 +79,7 @@ public class TroubleshootControllerTests ?? throw new AssertionException("Missing action GetInfo"); var attribute = action.GetCustomAttributes().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().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().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().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().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().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().Single(); - attribute.Template.ShouldBe("api/troubleshoot/playback/archive"); + attribute.Template.ShouldBe("/api/v1/troubleshoot/playback/archive"); attribute.Name.ShouldBe("DownloadTroubleshootingArchive"); action.GetCustomAttributes().ShouldBeEmpty(); action.GetCustomAttributes().ShouldBeEmpty(); @@ -295,7 +295,7 @@ public class TroubleshootControllerTests ?? throw new AssertionException("Missing action TroubleshootPlaybackSample"); var attribute = action.GetCustomAttributes().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().ShouldBeEmpty(); action.GetCustomAttributes().ShouldBeEmpty(); diff --git a/ErsatzTV.Tests/Controllers/WatermarkControllerTests.cs b/ErsatzTV.Tests/Controllers/WatermarkControllerTests.cs index 9ae781f6b..8994de194 100644 --- a/ErsatzTV.Tests/Controllers/WatermarkControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/WatermarkControllerTests.cs @@ -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(); created.StatusCode.ShouldBe(201); - created.Location.ShouldBe("/api/watermarks/7"); + created.Location.ShouldBe("/api/v1/watermarks/7"); created.Value.ShouldBe(vm); } diff --git a/ErsatzTV.Tests/Extensions/ApiResultsTests.cs b/ErsatzTV.Tests/Extensions/ApiResultsTests.cs index fc8916dad..63096638c 100644 --- a/ErsatzTV.Tests/Extensions/ApiResultsTests.cs +++ b/ErsatzTV.Tests/Extensions/ApiResultsTests.cs @@ -80,11 +80,11 @@ public class ApiResultsTests { Either either = Right(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(); 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 either = Left(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(); } diff --git a/ErsatzTV.Tests/Filters/ApiAuthorizationFilterTests.cs b/ErsatzTV.Tests/Filters/ApiAuthorizationFilterTests.cs index a700c27db..4e508fdc6 100644 --- a/ErsatzTV.Tests/Filters/ApiAuthorizationFilterTests.cs +++ b/ErsatzTV.Tests/Filters/ApiAuthorizationFilterTests.cs @@ -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); diff --git a/ErsatzTV.Tests/Filters/ApiKeyEndpointRequiresKeyTests.cs b/ErsatzTV.Tests/Filters/ApiKeyEndpointRequiresKeyTests.cs index fbcc5b190..f665950bf 100644 --- a/ErsatzTV.Tests/Filters/ApiKeyEndpointRequiresKeyTests.cs +++ b/ErsatzTV.Tests/Filters/ApiKeyEndpointRequiresKeyTests.cs @@ -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; diff --git a/ErsatzTV.Tests/Middleware/SecurityHeadersMiddlewareTests.cs b/ErsatzTV.Tests/Middleware/SecurityHeadersMiddlewareTests.cs index 70d8ff6f6..540ba8c12 100644 --- a/ErsatzTV.Tests/Middleware/SecurityHeadersMiddlewareTests.cs +++ b/ErsatzTV.Tests/Middleware/SecurityHeadersMiddlewareTests.cs @@ -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(); diff --git a/ErsatzTV/Controllers/Api/ArtistsController.cs b/ErsatzTV/Controllers/Api/ArtistsController.cs index 06fef7283..3a7027405 100644 --- a/ErsatzTV/Controllers/Api/ArtistsController.cs +++ b/ErsatzTV/Controllers/Api/ArtistsController.cs @@ -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")] diff --git a/ErsatzTV/Controllers/Api/ArtworkUploadController.cs b/ErsatzTV/Controllers/Api/ArtworkUploadController.cs index aead9f0f1..e99175a99 100644 --- a/ErsatzTV/Controllers/Api/ArtworkUploadController.cs +++ b/ErsatzTV/Controllers/Api/ArtworkUploadController.cs @@ -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 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) diff --git a/ErsatzTV/Controllers/Api/AuthController.cs b/ErsatzTV/Controllers/Api/AuthController.cs index 732e6d462..7279585ae 100644 --- a/ErsatzTV/Controllers/Api/AuthController.cs +++ b/ErsatzTV/Controllers/Api/AuthController.cs @@ -31,7 +31,7 @@ public class AuthController(IMediator mediator, IConfiguration configuration, IA private bool EnvSeedConfigured => !string.IsNullOrWhiteSpace(configuration["Auth:LocalAdmin:Password"]); /// Public: what auth options exist + whether first-run setup is still needed (drives the SPA boot gate). - [HttpGet("/api/auth/config")] + [HttpGet("/api/v1/auth/config")] public async Task Config(CancellationToken cancellationToken) { bool configured = await mediator.Send(new IsLocalAdminConfigured(), cancellationToken); @@ -39,7 +39,7 @@ public class AuthController(IMediator mediator, IConfiguration configuration, IA } /// The current session (anonymous is a 200 with authenticated=false, never a 401). - [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. /// - [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 } /// First-run setup-claim: create the local admin. Fails 409 if one already exists. - [HttpPost("/api/auth/setup")] + [HttpPost("/api/v1/auth/setup")] [EnableRateLimiting("auth")] public async Task Setup([FromBody] SetupRequest request, CancellationToken cancellationToken) { @@ -114,7 +114,7 @@ public class AuthController(IMediator mediator, IConfiguration configuration, IA } /// Local username/password login. A generic 401 on any failure (no username enumeration). - [HttpPost("/api/auth/login")] + [HttpPost("/api/v1/auth/login")] [EnableRateLimiting("auth")] public async Task 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. /// - [HttpPost("/api/auth/logout")] + [HttpPost("/api/v1/auth/logout")] public async Task 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 } /// Change the local admin password (requires a local-login session); rotates the stamp, revoking other sessions. - [HttpPost("/api/auth/password")] + [HttpPost("/api/v1/auth/password")] [EnableRateLimiting("auth")] public async Task ChangePassword([FromBody] ChangePasswordRequest request, CancellationToken cancellationToken) { diff --git a/ErsatzTV/Controllers/Api/BlockController.cs b/ErsatzTV/Controllers/Api/BlockController.cs index 7e26d93b5..ea241c26c 100644 --- a/ErsatzTV/Controllers/Api/BlockController.cs +++ b/ErsatzTV/Controllers/Api/BlockController.cs @@ -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 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 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 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)); } diff --git a/ErsatzTV/Controllers/Api/ChannelController.cs b/ErsatzTV/Controllers/Api/ChannelController.cs index 283ff9a59..c29ff6278 100644 --- a/ErsatzTV/Controllers/Api/ChannelController.cs +++ b/ErsatzTV/Controllers/Api/ChannelController.cs @@ -23,18 +23,18 @@ public class ChannelController( IMediator mediator, IEntityLocker entityLocker) { - [HttpGet("/api/channels")] + [HttpGet("/api/v1/channels")] [EndpointGroupName("general")] public async Task> 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> 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> 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> 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 Create( - [Required] [FromBody] CreateChannelRequest request, + [Required][FromBody] CreateChannelRequest request, CancellationToken cancellationToken) { Either result = await mediator.Send(request.ToCommand(), cancellationToken); @@ -95,12 +95,12 @@ public class ChannelController( Option 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 CreateFromLineup( - [Required] [FromBody] CreateChannelFromLineupRequest request, + [Required][FromBody] CreateChannelFromLineupRequest request, CancellationToken cancellationToken) { Either 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 Update( int id, - [Required] [FromBody] UpdateChannelRequest request, + [Required][FromBody] UpdateChannelRequest request, CancellationToken cancellationToken) { Either 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 BulkRenumber( - [Required] [FromBody] BulkRenumberChannelsRequest request, + [Required][FromBody] BulkRenumberChannelsRequest request, CancellationToken cancellationToken) { Option 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 BulkMoveToGroup( - [Required] [FromBody] BulkMoveChannelsToGroupRequest request, + [Required][FromBody] BulkMoveChannelsToGroupRequest request, CancellationToken cancellationToken) { Either 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 BulkDelete( - [Required] [FromBody] BulkDeleteChannelsRequest request, + [Required][FromBody] BulkDeleteChannelsRequest request, CancellationToken cancellationToken) { Either 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( diff --git a/ErsatzTV/Controllers/Api/ChannelTemplateController.cs b/ErsatzTV/Controllers/Api/ChannelTemplateController.cs index bf0215fbb..87d8c4b66 100644 --- a/ErsatzTV/Controllers/Api/ChannelTemplateController.cs +++ b/ErsatzTV/Controllers/Api/ChannelTemplateController.cs @@ -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> 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 Create( - [Required] [FromBody] CreateChannelTemplateRequest request, + [Required][FromBody] CreateChannelTemplateRequest request, CancellationToken cancellationToken) { Either 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 Update( int id, - [Required] [FromBody] UpdateChannelTemplateRequest request, + [Required][FromBody] UpdateChannelTemplateRequest request, CancellationToken cancellationToken) { Either 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")] diff --git a/ErsatzTV/Controllers/Api/CollectionController.cs b/ErsatzTV/Controllers/Api/CollectionController.cs index d63bf78b1..2a7e81e5a 100644 --- a/ErsatzTV/Controllers/Api/CollectionController.cs +++ b/ErsatzTV/Controllers/Api/CollectionController.cs @@ -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 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")] diff --git a/ErsatzTV/Controllers/Api/DecoController.cs b/ErsatzTV/Controllers/Api/DecoController.cs index f2bbd716b..40a5ebc54 100644 --- a/ErsatzTV/Controllers/Api/DecoController.cs +++ b/ErsatzTV/Controllers/Api/DecoController.cs @@ -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 CreateGroup( - [Required] [FromBody] CreateDecoGroupRequest request, + [Required][FromBody] CreateDecoGroupRequest request, CancellationToken cancellationToken) { Either 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 Create( - [Required] [FromBody] CreateDecoRequest request, + [Required][FromBody] CreateDecoRequest request, CancellationToken cancellationToken) { Either 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 Replace( int id, - [Required] [FromBody] ReplaceDecoRequest request, + [Required][FromBody] ReplaceDecoRequest request, CancellationToken cancellationToken) { Option maybeDeco = await mediator.Send(new GetDecoById(id), cancellationToken); diff --git a/ErsatzTV/Controllers/Api/DecoTemplateController.cs b/ErsatzTV/Controllers/Api/DecoTemplateController.cs index 92e4e010b..62dd15e67 100644 --- a/ErsatzTV/Controllers/Api/DecoTemplateController.cs +++ b/ErsatzTV/Controllers/Api/DecoTemplateController.cs @@ -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 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 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( diff --git a/ErsatzTV/Controllers/Api/EmbyMediaSourcesController.cs b/ErsatzTV/Controllers/Api/EmbyMediaSourcesController.cs index a63450fda..2fc409746 100644 --- a/ErsatzTV/Controllers/Api/EmbyMediaSourcesController.cs +++ b/ErsatzTV/Controllers/Api/EmbyMediaSourcesController.cs @@ -22,7 +22,7 @@ public class EmbyMediaSourcesController( IEntityLocker entityLocker, ChannelWriter 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 SaveConnection( - [Required] [FromBody] SaveRemoteConnectionRequest request, + [Required][FromBody] SaveRemoteConnectionRequest request, CancellationToken cancellationToken) { if (entityLocker.IsRemoteMediaSourceLocked()) @@ -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 ReplaceLibraryPreferences( int id, - [Required] [FromBody] ReplaceRemoteLibraryPreferencesRequest request, + [Required][FromBody] ReplaceRemoteLibraryPreferencesRequest request, CancellationToken cancellationToken) { Option 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 ReplacePathReplacements( int id, - [Required] [FromBody] ReplacePathReplacementsRequest request, + [Required][FromBody] ReplacePathReplacementsRequest request, CancellationToken cancellationToken) { Option 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( diff --git a/ErsatzTV/Controllers/Api/FFmpegProfileController.cs b/ErsatzTV/Controllers/Api/FFmpegProfileController.cs index 14e52dc75..b8e34deba 100644 --- a/ErsatzTV/Controllers/Api/FFmpegProfileController.cs +++ b/ErsatzTV/Controllers/Api/FFmpegProfileController.cs @@ -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> 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 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")] diff --git a/ErsatzTV/Controllers/Api/FillerPresetController.cs b/ErsatzTV/Controllers/Api/FillerPresetController.cs index 8fa51aabb..5ab533901 100644 --- a/ErsatzTV/Controllers/Api/FillerPresetController.cs +++ b/ErsatzTV/Controllers/Api/FillerPresetController.cs @@ -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 Create( - [Required] [FromBody] CreateFillerPresetRequest request, + [Required][FromBody] CreateFillerPresetRequest request, CancellationToken cancellationToken) { Either result = @@ -59,12 +59,12 @@ public class FillerPresetController(IMediator mediator) : ControllerBase Option 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 Update( int id, - [Required] [FromBody] UpdateFillerPresetRequest request, + [Required][FromBody] UpdateFillerPresetRequest request, CancellationToken cancellationToken) { Either 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")] diff --git a/ErsatzTV/Controllers/Api/GraphicsElementController.cs b/ErsatzTV/Controllers/Api/GraphicsElementController.cs index ba63df156..887e5875e 100644 --- a/ErsatzTV/Controllers/Api/GraphicsElementController.cs +++ b/ErsatzTV/Controllers/Api/GraphicsElementController.cs @@ -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> 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( diff --git a/ErsatzTV/Controllers/Api/HealthController.cs b/ErsatzTV/Controllers/Api/HealthController.cs index 9398c1e03..bb70d1e16 100644 --- a/ErsatzTV/Controllers/Api/HealthController.cs +++ b/ErsatzTV/Controllers/Api/HealthController.cs @@ -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")] diff --git a/ErsatzTV/Controllers/Api/ImagesController.cs b/ErsatzTV/Controllers/Api/ImagesController.cs index ac687eab8..e6d5c3808 100644 --- a/ErsatzTV/Controllers/Api/ImagesController.cs +++ b/ErsatzTV/Controllers/Api/ImagesController.cs @@ -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 UpdateDuration( int id, - [Required] [FromBody] UpdateImageFolderDurationRequest request, + [Required][FromBody] UpdateImageFolderDurationRequest request, CancellationToken cancellationToken) { if (request.DurationSeconds is <= 0) diff --git a/ErsatzTV/Controllers/Api/JellyfinMediaSourcesController.cs b/ErsatzTV/Controllers/Api/JellyfinMediaSourcesController.cs index d3a96d296..a90898629 100644 --- a/ErsatzTV/Controllers/Api/JellyfinMediaSourcesController.cs +++ b/ErsatzTV/Controllers/Api/JellyfinMediaSourcesController.cs @@ -22,7 +22,7 @@ public class JellyfinMediaSourcesController( IEntityLocker entityLocker, ChannelWriter 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 SaveConnection( - [Required] [FromBody] SaveRemoteConnectionRequest request, + [Required][FromBody] SaveRemoteConnectionRequest request, CancellationToken cancellationToken) { if (entityLocker.IsRemoteMediaSourceLocked()) @@ -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 ReplaceLibraryPreferences( int id, - [Required] [FromBody] ReplaceRemoteLibraryPreferencesRequest request, + [Required][FromBody] ReplaceRemoteLibraryPreferencesRequest request, CancellationToken cancellationToken) { Option 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 ReplacePathReplacements( int id, - [Required] [FromBody] ReplacePathReplacementsRequest request, + [Required][FromBody] ReplacePathReplacementsRequest request, CancellationToken cancellationToken) { Option 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( diff --git a/ErsatzTV/Controllers/Api/LanguagesController.cs b/ErsatzTV/Controllers/Api/LanguagesController.cs index 069caddb0..f9561b7b9 100644 --- a/ErsatzTV/Controllers/Api/LanguagesController.cs +++ b/ErsatzTV/Controllers/Api/LanguagesController.cs @@ -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")] diff --git a/ErsatzTV/Controllers/Api/LibrariesController.cs b/ErsatzTV/Controllers/Api/LibrariesController.cs index bd37c0bca..ee5d28433 100644 --- a/ErsatzTV/Controllers/Api/LibrariesController.cs +++ b/ErsatzTV/Controllers/Api/LibrariesController.cs @@ -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), StatusCodes.Status200OK)] public async Task> 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)] diff --git a/ErsatzTV/Controllers/Api/LibraryBrowseController.cs b/ErsatzTV/Controllers/Api/LibraryBrowseController.cs index cba075e64..0a5997550 100644 --- a/ErsatzTV/Controllers/Api/LibraryBrowseController.cs +++ b/ErsatzTV/Controllers/Api/LibraryBrowseController.cs @@ -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")] diff --git a/ErsatzTV/Controllers/Api/LocalLibrariesController.cs b/ErsatzTV/Controllers/Api/LocalLibrariesController.cs index af4b3cb38..a7ae9944a 100644 --- a/ErsatzTV/Controllers/Api/LocalLibrariesController.cs +++ b/ErsatzTV/Controllers/Api/LocalLibrariesController.cs @@ -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), 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 Create( - [Required] [FromBody] CreateLocalLibraryRequest request, + [Required][FromBody] CreateLocalLibraryRequest request, CancellationToken cancellationToken) { Either 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 Update( int id, - [Required] [FromBody] UpdateLocalLibraryRequest request, + [Required][FromBody] UpdateLocalLibraryRequest request, CancellationToken cancellationToken) { Option 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 MovePath( int pathId, - [Required] [FromBody] MoveLocalLibraryPathRequest request, + [Required][FromBody] MoveLocalLibraryPathRequest request, CancellationToken cancellationToken) { Option 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)); diff --git a/ErsatzTV/Controllers/Api/LogsController.cs b/ErsatzTV/Controllers/Api/LogsController.cs index 55746db02..a079129ca 100644 --- a/ErsatzTV/Controllers/Api/LogsController.cs +++ b/ErsatzTV/Controllers/Api/LogsController.cs @@ -19,7 +19,7 @@ public class LogsController(IMediator mediator) : ControllerBase private static readonly System.Collections.Generic.HashSet 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( diff --git a/ErsatzTV/Controllers/Api/MaintenanceController.cs b/ErsatzTV/Controllers/Api/MaintenanceController.cs index e8d66527a..cad6707d5 100644 --- a/ErsatzTV/Controllers/Api/MaintenanceController.cs +++ b/ErsatzTV/Controllers/Api/MaintenanceController.cs @@ -14,7 +14,7 @@ namespace ErsatzTV.Controllers.Api; [RequiresAuthentication] public class MaintenanceController(IMediator mediator, ChannelWriter workerChannel) { - [HttpPost("/api/maintenance/gc")] + [HttpPost("/api/v1/maintenance/gc")] [Tags("Maintenance")] [EndpointSummary("Garbage collect")] public async Task GarbageCollection([FromQuery] bool force = false) @@ -23,7 +23,7 @@ public class MaintenanceController(IMediator mediator, ChannelWriter 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")] diff --git a/ErsatzTV/Controllers/Api/MediaSourcesController.cs b/ErsatzTV/Controllers/Api/MediaSourcesController.cs index a4eac9794..8239bad6b 100644 --- a/ErsatzTV/Controllers/Api/MediaSourcesController.cs +++ b/ErsatzTV/Controllers/Api/MediaSourcesController.cs @@ -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> 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( diff --git a/ErsatzTV/Controllers/Api/MoviesController.cs b/ErsatzTV/Controllers/Api/MoviesController.cs index e1feee55f..dd3b3a5a6 100644 --- a/ErsatzTV/Controllers/Api/MoviesController.cs +++ b/ErsatzTV/Controllers/Api/MoviesController.cs @@ -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")] diff --git a/ErsatzTV/Controllers/Api/MultiCollectionController.cs b/ErsatzTV/Controllers/Api/MultiCollectionController.cs index 08c4ca520..2422334c5 100644 --- a/ErsatzTV/Controllers/Api/MultiCollectionController.cs +++ b/ErsatzTV/Controllers/Api/MultiCollectionController.cs @@ -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 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")] diff --git a/ErsatzTV/Controllers/Api/PlaylistController.cs b/ErsatzTV/Controllers/Api/PlaylistController.cs index 5f01602b0..d2ed36249 100644 --- a/ErsatzTV/Controllers/Api/PlaylistController.cs +++ b/ErsatzTV/Controllers/Api/PlaylistController.cs @@ -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 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 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).")] diff --git a/ErsatzTV/Controllers/Api/PlayoutController.cs b/ErsatzTV/Controllers/Api/PlayoutController.cs index 1a74ac50c..7218fdc6d 100644 --- a/ErsatzTV/Controllers/Api/PlayoutController.cs +++ b/ErsatzTV/Controllers/Api/PlayoutController.cs @@ -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 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")] diff --git a/ErsatzTV/Controllers/Api/PlexMediaSourcesController.cs b/ErsatzTV/Controllers/Api/PlexMediaSourcesController.cs index ff6a48098..a1318a157 100644 --- a/ErsatzTV/Controllers/Api/PlexMediaSourcesController.cs +++ b/ErsatzTV/Controllers/Api/PlexMediaSourcesController.cs @@ -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 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 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( diff --git a/ErsatzTV/Controllers/Api/Requests/ScheduleItemRequest.cs b/ErsatzTV/Controllers/Api/Requests/ScheduleItemRequest.cs index c1e22c54f..6d67b125d 100644 --- a/ErsatzTV/Controllers/Api/Requests/ScheduleItemRequest.cs +++ b/ErsatzTV/Controllers/Api/Requests/ScheduleItemRequest.cs @@ -5,7 +5,7 @@ using ErsatzTV.Core.Scheduling; namespace ErsatzTV.Controllers.Api.Requests; /// -/// 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. diff --git a/ErsatzTV/Controllers/Api/RerunCollectionController.cs b/ErsatzTV/Controllers/Api/RerunCollectionController.cs index b9ad3f993..8262afe24 100644 --- a/ErsatzTV/Controllers/Api/RerunCollectionController.cs +++ b/ErsatzTV/Controllers/Api/RerunCollectionController.cs @@ -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 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")] diff --git a/ErsatzTV/Controllers/Api/ResolutionController.cs b/ErsatzTV/Controllers/Api/ResolutionController.cs index b19cfc7b5..f1943ccd1 100644 --- a/ErsatzTV/Controllers/Api/ResolutionController.cs +++ b/ErsatzTV/Controllers/Api/ResolutionController.cs @@ -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), 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)] diff --git a/ErsatzTV/Controllers/Api/ScannerController.cs b/ErsatzTV/Controllers/Api/ScannerController.cs index cc428b669..b7731a58e 100644 --- a/ErsatzTV/Controllers/Api/ScannerController.cs +++ b/ErsatzTV/Controllers/Api/ScannerController.cs @@ -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 channelWriter) diff --git a/ErsatzTV/Controllers/Api/ScheduleController.cs b/ErsatzTV/Controllers/Api/ScheduleController.cs index dee707344..3d6d7ae1c 100644 --- a/ErsatzTV/Controllers/Api/ScheduleController.cs +++ b/ErsatzTV/Controllers/Api/ScheduleController.cs @@ -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 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")] diff --git a/ErsatzTV/Controllers/Api/ScriptedScheduleController.cs b/ErsatzTV/Controllers/Api/ScriptedScheduleController.cs index a7d5a2a8b..342dc04a6 100644 --- a/ErsatzTV/Controllers/Api/ScriptedScheduleController.cs +++ b/ErsatzTV/Controllers/Api/ScriptedScheduleController.cs @@ -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 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 PeekNext(Guid buildId, string content) diff --git a/ErsatzTV/Controllers/Api/SearchController.cs b/ErsatzTV/Controllers/Api/SearchController.cs index 5e6d17c0a..5a6c2f7ce 100644 --- a/ErsatzTV/Controllers/Api/SearchController.cs +++ b/ErsatzTV/Controllers/Api/SearchController.cs @@ -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.")] diff --git a/ErsatzTV/Controllers/Api/SeasonsController.cs b/ErsatzTV/Controllers/Api/SeasonsController.cs index 495cc3977..03348d2cd 100644 --- a/ErsatzTV/Controllers/Api/SeasonsController.cs +++ b/ErsatzTV/Controllers/Api/SeasonsController.cs @@ -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")] diff --git a/ErsatzTV/Controllers/Api/SessionController.cs b/ErsatzTV/Controllers/Api/SessionController.cs index 29508bacc..522170c43 100644 --- a/ErsatzTV/Controllers/Api/SessionController.cs +++ b/ErsatzTV/Controllers/Api/SessionController.cs @@ -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 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 StopSession(string channelNumber, CancellationToken cancellationToken) diff --git a/ErsatzTV/Controllers/Api/SettingsController.cs b/ErsatzTV/Controllers/Api/SettingsController.cs index 5d16159b7..cf3f6af66 100644 --- a/ErsatzTV/Controllers/Api/SettingsController.cs +++ b/ErsatzTV/Controllers/Api/SettingsController.cs @@ -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 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)] diff --git a/ErsatzTV/Controllers/Api/ShowsController.cs b/ErsatzTV/Controllers/Api/ShowsController.cs index 2966f2698..8d61bd990 100644 --- a/ErsatzTV/Controllers/Api/ShowsController.cs +++ b/ErsatzTV/Controllers/Api/ShowsController.cs @@ -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")] diff --git a/ErsatzTV/Controllers/Api/SmartCollectionController.cs b/ErsatzTV/Controllers/Api/SmartCollectionController.cs index 272bfa5fa..87ac4d6f4 100644 --- a/ErsatzTV/Controllers/Api/SmartCollectionController.cs +++ b/ErsatzTV/Controllers/Api/SmartCollectionController.cs @@ -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> 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 Create( - [Required] [FromBody] CreateSmartCollectionRequest request, + [Required][FromBody] CreateSmartCollectionRequest request, CancellationToken cancellationToken) { Either 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 Update( int id, - [Required] [FromBody] UpdateSmartCollectionRequest request, + [Required][FromBody] UpdateSmartCollectionRequest request, CancellationToken cancellationToken) { Either 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")] diff --git a/ErsatzTV/Controllers/Api/TemplateController.cs b/ErsatzTV/Controllers/Api/TemplateController.cs index ac9364b22..9171dfa49 100644 --- a/ErsatzTV/Controllers/Api/TemplateController.cs +++ b/ErsatzTV/Controllers/Api/TemplateController.cs @@ -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 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 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 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)); } diff --git a/ErsatzTV/Controllers/Api/TraktController.cs b/ErsatzTV/Controllers/Api/TraktController.cs index ae919a4d1..838004695 100644 --- a/ErsatzTV/Controllers/Api/TraktController.cs +++ b/ErsatzTV/Controllers/Api/TraktController.cs @@ -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 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 Update( int id, - [Required] [FromBody] UpdateTraktListRequest request, + [Required][FromBody] UpdateTraktListRequest request, CancellationToken cancellationToken) { Option 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( diff --git a/ErsatzTV/Controllers/Api/TroubleshootController.cs b/ErsatzTV/Controllers/Api/TroubleshootController.cs index 3fadd189f..7fa49fb2c 100644 --- a/ErsatzTV/Controllers/Api/TroubleshootController.cs +++ b/ErsatzTV/Controllers/Api/TroubleshootController.cs @@ -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> 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), 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( diff --git a/ErsatzTV/Controllers/Api/VersionController.cs b/ErsatzTV/Controllers/Api/VersionController.cs index 6c144c312..23b63d34c 100644 --- a/ErsatzTV/Controllers/Api/VersionController.cs +++ b/ErsatzTV/Controllers/Api/VersionController.cs @@ -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()? .InformationalVersion ?? "unknown"); - [HttpGet("/api/version", Name="GetVersion")] + [HttpGet("/api/v1/version", Name = "GetVersion")] [Tags("Version")] [EndpointSummary("Get version")] public CombinedVersion GetVersion() => Version; diff --git a/ErsatzTV/Controllers/Api/WatermarkController.cs b/ErsatzTV/Controllers/Api/WatermarkController.cs index 8c8d4aa1d..a04a7314b 100644 --- a/ErsatzTV/Controllers/Api/WatermarkController.cs +++ b/ErsatzTV/Controllers/Api/WatermarkController.cs @@ -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> 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 Create( - [Required] [FromBody] CreateWatermarkRequest request, + [Required][FromBody] CreateWatermarkRequest request, CancellationToken cancellationToken) { Either result = await mediator.Send(request.ToCommand(), cancellationToken); @@ -54,12 +54,12 @@ public class WatermarkController(IMediator mediator) : ControllerBase Option 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 Update( int id, - [Required] [FromBody] UpdateWatermarkRequest request, + [Required][FromBody] UpdateWatermarkRequest request, CancellationToken cancellationToken) { Either 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")] diff --git a/ErsatzTV/Filters/ApiAuthorizationFilter.cs b/ErsatzTV/Filters/ApiAuthorizationFilter.cs index 3c0b87bb1..4c2800be0 100644 --- a/ErsatzTV/Filters/ApiAuthorizationFilter.cs +++ b/ErsatzTV/Filters/ApiAuthorizationFilter.cs @@ -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().Any()) { return false; diff --git a/ErsatzTV/Filters/LocalhostOnlyAttribute.cs b/ErsatzTV/Filters/LocalhostOnlyAttribute.cs index a5cdc7414..854118cf1 100644 --- a/ErsatzTV/Filters/LocalhostOnlyAttribute.cs +++ b/ErsatzTV/Filters/LocalhostOnlyAttribute.cs @@ -7,7 +7,7 @@ namespace ErsatzTV.Filters; /// /// Restricts an endpoint to loopback callers (127.0.0.0/8, ::1). Used for the in-process scanner -/// callback surface (/api/scan/*), which is always reached over +/// callback surface (/api/v1/scan/*), which is always reached over /// http://localhost:{UiPort} 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 /// ForwardedHeaders trust is restricted (KnownProxies/KnownNetworks configured), since the diff --git a/ErsatzTV/Filters/SkipApiAuthorizationAttribute.cs b/ErsatzTV/Filters/SkipApiAuthorizationAttribute.cs index a9d428855..7c9c85fb0 100644 --- a/ErsatzTV/Filters/SkipApiAuthorizationAttribute.cs +++ b/ErsatzTV/Filters/SkipApiAuthorizationAttribute.cs @@ -6,7 +6,7 @@ namespace ErsatzTV.Filters; /// Marks an internal API endpoint as exempt from the global /// (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 -/// /api/auth/* surface itself, which must be reachable before a caller is authenticated. +/// /api/v1/auth/* surface itself, which must be reachable before a caller is authenticated. /// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public sealed class SkipApiAuthorizationAttribute : Attribute, IFilterMetadata; diff --git a/ErsatzTV/Middleware/ApiVersionRewriteMiddleware.cs b/ErsatzTV/Middleware/ApiVersionRewriteMiddleware.cs new file mode 100644 index 000000000..d24d4c7c2 --- /dev/null +++ b/ErsatzTV/Middleware/ApiVersionRewriteMiddleware.cs @@ -0,0 +1,105 @@ +namespace ErsatzTV.Middleware; + +/// +/// Rewrites an unversioned legacy /api/* request to the current default version +/// (/api/v1/*) in-pipeline. This is a rewrite, not a redirect: 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 Deprecation and (optionally) Sunset response headers. +/// +/// +/// Sequenced before UseRouting so the rewritten path matches the versioned controller +/// routes. Introduced by ersatztv#286 when the whole /api surface was versioned to +/// /api/v1; the compat shim is scheduled for removal roughly two releases out (see +/// docs/decisions.md). Once /api/v2 exists this middleware deliberately does not +/// force an unversioned call onto v2 — an already-versioned path is passed through untouched. +/// +public sealed class ApiVersionRewriteMiddleware +{ + /// The default API version an unversioned legacy path is rewritten onto. + 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"; + // Root the docs link at the request PathBase so it stays correct under a reverse-proxy + // base URL (ETV_BASE_URL): empty PathBase → , PathBase "/etv" → . + context.Response.Headers["Link"] = $"<{context.Request.PathBase}/docs>; rel=\"deprecation\""; + if (!string.IsNullOrWhiteSpace(_sunset)) + { + context.Response.Headers["Sunset"] = _sunset; + } + } + + return _next(context); + } + + /// + /// Pure decision: an /api/* path whose first segment after /api is not already + /// a version token (v<digits>) is a legacy unversioned call and is rewritten under the + /// default version. Returns false (no rewrite) for an already-versioned path or any non-/api + /// path. The /api and version segments are matched case-insensitively. + /// + 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; + } +} diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 6c65141d4..dd5aebf21 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -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(); + legacy.UseRouting(); // Browser SPA / API authentication (#295). This branch hosts /api, the OIDC /callback, and diff --git a/ErsatzTV/wwwroot/openapi/scripted-schedule-tagged.json b/ErsatzTV/wwwroot/openapi/scripted-schedule-tagged.json index c9c8e262e..77a22fdee 100644 --- a/ErsatzTV/wwwroot/openapi/scripted-schedule-tagged.json +++ b/ErsatzTV/wwwroot/openapi/scripted-schedule-tagged.json @@ -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" diff --git a/ErsatzTV/wwwroot/openapi/scripted-schedule.json b/ErsatzTV/wwwroot/openapi/scripted-schedule.json index d70777bc0..ec134ef5b 100644 --- a/ErsatzTV/wwwroot/openapi/scripted-schedule.json +++ b/ErsatzTV/wwwroot/openapi/scripted-schedule.json @@ -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" diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 228df4de5..828f8dfe2 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -5,7 +5,7 @@ "version": "1.0.0" }, "paths": { - "/api/artists/{id}": { + "/api/v1/artists/{id}": { "get": { "tags": [ "Artists" @@ -90,7 +90,7 @@ ] } }, - "/api/artwork/uploads": { + "/api/v1/artwork/uploads": { "post": { "tags": [ "Artwork" @@ -192,7 +192,7 @@ ] } }, - "/api/blocks/groups": { + "/api/v1/blocks/groups": { "get": { "tags": [ "Blocks" @@ -362,7 +362,7 @@ ] } }, - "/api/blocks/groups/{id}": { + "/api/v1/blocks/groups/{id}": { "delete": { "tags": [ "Blocks" @@ -431,7 +431,7 @@ ] } }, - "/api/blocks": { + "/api/v1/blocks": { "get": { "tags": [ "Blocks" @@ -602,7 +602,7 @@ ] } }, - "/api/blocks/{id}": { + "/api/v1/blocks/{id}": { "get": { "tags": [ "Blocks" @@ -912,7 +912,7 @@ ] } }, - "/api/blocks/{id}/items": { + "/api/v1/blocks/{id}/items": { "get": { "tags": [ "Blocks" @@ -1007,7 +1007,7 @@ ] } }, - "/api/blocks/{id}/preview": { + "/api/v1/blocks/{id}/preview": { "post": { "tags": [ "Blocks" @@ -1127,7 +1127,7 @@ ] } }, - "/api/blocks/{id}/copy": { + "/api/v1/blocks/{id}/copy": { "post": { "tags": [ "Blocks" @@ -1258,7 +1258,7 @@ ] } }, - "/api/channels": { + "/api/v1/channels": { "get": { "tags": [ "Channel" @@ -1427,7 +1427,7 @@ ] } }, - "/api/channels/state": { + "/api/v1/channels/state": { "get": { "tags": [ "Channels" @@ -1480,7 +1480,7 @@ ] } }, - "/api/guide": { + "/api/v1/guide": { "get": { "tags": [ "Channels" @@ -1553,7 +1553,7 @@ ] } }, - "/api/channels/music-video-credits-templates": { + "/api/v1/channels/music-video-credits-templates": { "get": { "tags": [ "Channels" @@ -1606,7 +1606,7 @@ ] } }, - "/api/channels/stream-selectors": { + "/api/v1/channels/stream-selectors": { "get": { "tags": [ "Channels" @@ -1659,7 +1659,7 @@ ] } }, - "/api/channels/{id}": { + "/api/v1/channels/{id}": { "get": { "tags": [ "Channels" @@ -1958,7 +1958,7 @@ ] } }, - "/api/channels/from-lineup": { + "/api/v1/channels/from-lineup": { "post": { "tags": [ "Channels" @@ -2078,7 +2078,7 @@ ] } }, - "/api/channels/bulk/renumber": { + "/api/v1/channels/bulk/renumber": { "post": { "tags": [ "Channels" @@ -2180,7 +2180,7 @@ ] } }, - "/api/channels/bulk/group": { + "/api/v1/channels/bulk/group": { "post": { "tags": [ "Channels" @@ -2282,7 +2282,7 @@ ] } }, - "/api/channels/bulk/delete": { + "/api/v1/channels/bulk/delete": { "post": { "tags": [ "Channels" @@ -2384,7 +2384,7 @@ ] } }, - "/api/channels/{id}/playout/reset": { + "/api/v1/channels/{id}/playout/reset": { "post": { "tags": [ "Channels" @@ -2480,7 +2480,7 @@ ] } }, - "/api/channel-templates": { + "/api/v1/channel-templates": { "get": { "tags": [ "Channel Templates" @@ -2650,7 +2650,7 @@ ] } }, - "/api/channel-templates/default": { + "/api/v1/channel-templates/default": { "get": { "tags": [ "Channel Templates" @@ -2714,7 +2714,7 @@ ] } }, - "/api/channel-templates/default/{id}": { + "/api/v1/channel-templates/default/{id}": { "put": { "tags": [ "Channel Templates" @@ -2819,7 +2819,7 @@ ] } }, - "/api/channel-templates/{id}": { + "/api/v1/channel-templates/{id}": { "get": { "tags": [ "Channel Templates" @@ -3118,7 +3118,7 @@ ] } }, - "/api/collections": { + "/api/v1/collections": { "get": { "tags": [ "Collections" @@ -3288,7 +3288,7 @@ ] } }, - "/api/collections/{id}": { + "/api/v1/collections/{id}": { "get": { "tags": [ "Collections" @@ -3587,7 +3587,7 @@ ] } }, - "/api/collections/{id}/items": { + "/api/v1/collections/{id}/items": { "get": { "tags": [ "Collections" @@ -3802,7 +3802,7 @@ ] } }, - "/api/collections/{id}/custom-order": { + "/api/v1/collections/{id}/custom-order": { "put": { "tags": [ "Collections" @@ -3946,7 +3946,7 @@ ] } }, - "/api/collections/{id}/items/{mediaItemId}": { + "/api/v1/collections/{id}/items/{mediaItemId}": { "delete": { "tags": [ "Collections" @@ -4043,7 +4043,7 @@ ] } }, - "/api/decos/groups": { + "/api/v1/decos/groups": { "get": { "tags": [ "Decos" @@ -4213,7 +4213,7 @@ ] } }, - "/api/decos/groups/{id}": { + "/api/v1/decos/groups/{id}": { "delete": { "tags": [ "Decos" @@ -4282,7 +4282,7 @@ ] } }, - "/api/decos": { + "/api/v1/decos": { "get": { "tags": [ "Decos" @@ -4454,7 +4454,7 @@ ] } }, - "/api/decos/{id}": { + "/api/v1/decos/{id}": { "get": { "tags": [ "Decos" @@ -4735,7 +4735,7 @@ ] } }, - "/api/deco-templates/groups": { + "/api/v1/deco-templates/groups": { "get": { "tags": [ "DecoTemplates" @@ -4905,7 +4905,7 @@ ] } }, - "/api/deco-templates/groups/{id}": { + "/api/v1/deco-templates/groups/{id}": { "delete": { "tags": [ "DecoTemplates" @@ -4974,7 +4974,7 @@ ] } }, - "/api/deco-templates": { + "/api/v1/deco-templates": { "get": { "tags": [ "DecoTemplates" @@ -5146,7 +5146,7 @@ ] } }, - "/api/deco-templates/{id}": { + "/api/v1/deco-templates/{id}": { "get": { "tags": [ "DecoTemplates" @@ -5456,7 +5456,7 @@ ] } }, - "/api/deco-templates/{id}/items": { + "/api/v1/deco-templates/{id}/items": { "get": { "tags": [ "DecoTemplates" @@ -5551,7 +5551,7 @@ ] } }, - "/api/media-sources/emby": { + "/api/v1/media-sources/emby": { "get": { "tags": [ "Emby" @@ -5595,7 +5595,7 @@ ] } }, - "/api/media-sources/emby/connection": { + "/api/v1/media-sources/emby/connection": { "get": { "tags": [ "Emby" @@ -5758,7 +5758,7 @@ ] } }, - "/api/media-sources/emby/disconnect": { + "/api/v1/media-sources/emby/disconnect": { "post": { "tags": [ "Emby" @@ -5806,7 +5806,7 @@ ] } }, - "/api/media-sources/emby/{id}/libraries": { + "/api/v1/media-sources/emby/{id}/libraries": { "get": { "tags": [ "Emby" @@ -6038,7 +6038,7 @@ ] } }, - "/api/media-sources/emby/{id}/path-replacements": { + "/api/v1/media-sources/emby/{id}/path-replacements": { "get": { "tags": [ "Emby" @@ -6269,7 +6269,7 @@ ] } }, - "/api/media-sources/emby/{id}/refresh-libraries": { + "/api/v1/media-sources/emby/{id}/refresh-libraries": { "post": { "tags": [ "Emby" @@ -6357,7 +6357,7 @@ ] } }, - "/api/media-sources/emby/{id}/scan-collections": { + "/api/v1/media-sources/emby/{id}/scan-collections": { "post": { "tags": [ "Emby" @@ -6454,7 +6454,7 @@ ] } }, - "/api/ffmpeg/profiles": { + "/api/v1/ffmpeg/profiles": { "get": { "tags": [ "FFmpeg Profiles" @@ -6634,7 +6634,7 @@ ] } }, - "/api/ffmpeg/hardware-acceleration-kinds": { + "/api/v1/ffmpeg/hardware-acceleration-kinds": { "get": { "tags": [ "FFmpeg Profiles" @@ -6688,7 +6688,7 @@ ] } }, - "/api/ffmpeg/profiles/{id}": { + "/api/v1/ffmpeg/profiles/{id}": { "get": { "tags": [ "FFmpeg Profiles" @@ -7007,7 +7007,7 @@ ] } }, - "/api/filler-presets": { + "/api/v1/filler-presets": { "get": { "tags": [ "Filler Presets" @@ -7207,7 +7207,7 @@ ] } }, - "/api/filler-presets/{id}": { + "/api/v1/filler-presets/{id}": { "get": { "tags": [ "Filler Presets" @@ -7526,7 +7526,7 @@ ] } }, - "/api/graphics-elements": { + "/api/v1/graphics-elements": { "get": { "tags": [ "Graphics Elements" @@ -7580,7 +7580,7 @@ ] } }, - "/api/graphics-elements/refresh": { + "/api/v1/graphics-elements/refresh": { "post": { "tags": [ "Graphics Elements" @@ -7608,7 +7608,7 @@ ] } }, - "/api/health": { + "/api/v1/health": { "get": { "tags": [ "Health" @@ -7661,7 +7661,7 @@ ] } }, - "/api/images/folders": { + "/api/v1/images/folders": { "get": { "tags": [ "Images" @@ -7736,7 +7736,7 @@ ] } }, - "/api/images/folders/{id}/duration": { + "/api/v1/images/folders/{id}/duration": { "put": { "tags": [ "Images" @@ -7857,7 +7857,7 @@ ] } }, - "/api/media-sources/jellyfin": { + "/api/v1/media-sources/jellyfin": { "get": { "tags": [ "Jellyfin" @@ -7901,7 +7901,7 @@ ] } }, - "/api/media-sources/jellyfin/connection": { + "/api/v1/media-sources/jellyfin/connection": { "get": { "tags": [ "Jellyfin" @@ -8064,7 +8064,7 @@ ] } }, - "/api/media-sources/jellyfin/disconnect": { + "/api/v1/media-sources/jellyfin/disconnect": { "post": { "tags": [ "Jellyfin" @@ -8112,7 +8112,7 @@ ] } }, - "/api/media-sources/jellyfin/{id}/libraries": { + "/api/v1/media-sources/jellyfin/{id}/libraries": { "get": { "tags": [ "Jellyfin" @@ -8344,7 +8344,7 @@ ] } }, - "/api/media-sources/jellyfin/{id}/path-replacements": { + "/api/v1/media-sources/jellyfin/{id}/path-replacements": { "get": { "tags": [ "Jellyfin" @@ -8575,7 +8575,7 @@ ] } }, - "/api/media-sources/jellyfin/{id}/refresh-libraries": { + "/api/v1/media-sources/jellyfin/{id}/refresh-libraries": { "post": { "tags": [ "Jellyfin" @@ -8663,7 +8663,7 @@ ] } }, - "/api/media-sources/jellyfin/{id}/scan-collections": { + "/api/v1/media-sources/jellyfin/{id}/scan-collections": { "post": { "tags": [ "Jellyfin" @@ -8760,7 +8760,7 @@ ] } }, - "/api/languages": { + "/api/v1/languages": { "get": { "tags": [ "Languages" @@ -8813,7 +8813,7 @@ ] } }, - "/api/libraries/scan-status": { + "/api/v1/libraries/scan-status": { "get": { "tags": [ "Libraries" @@ -8866,7 +8866,7 @@ ] } }, - "/api/libraries/{id}/scan": { + "/api/v1/libraries/{id}/scan": { "post": { "tags": [ "Libraries" @@ -8983,7 +8983,7 @@ ] } }, - "/api/libraries/{id}/scan-show": { + "/api/v1/libraries/{id}/scan-show": { "post": { "tags": [ "Libraries" @@ -9116,7 +9116,7 @@ ] } }, - "/api/library/browse": { + "/api/v1/library/browse": { "get": { "tags": [ "Libraries" @@ -9222,7 +9222,7 @@ ] } }, - "/api/libraries/local": { + "/api/v1/libraries/local": { "get": { "tags": [ "Libraries" @@ -9372,7 +9372,7 @@ ] } }, - "/api/libraries/local/{id}": { + "/api/v1/libraries/local/{id}": { "get": { "tags": [ "Libraries" @@ -9692,7 +9692,7 @@ ] } }, - "/api/libraries/local/paths/{pathId}/move": { + "/api/v1/libraries/local/paths/{pathId}/move": { "post": { "tags": [ "Libraries" @@ -9826,7 +9826,7 @@ ] } }, - "/api/libraries/local/path-exists": { + "/api/v1/libraries/local/path-exists": { "post": { "tags": [ "Libraries" @@ -9906,7 +9906,7 @@ ] } }, - "/api/logs": { + "/api/v1/logs": { "get": { "tags": [ "Logs" @@ -10005,7 +10005,7 @@ ] } }, - "/api/maintenance/gc": { + "/api/v1/maintenance/gc": { "post": { "tags": [ "Maintenance" @@ -10052,7 +10052,7 @@ ] } }, - "/api/maintenance/empty_trash": { + "/api/v1/maintenance/empty_trash": { "post": { "tags": [ "Maintenance" @@ -10099,7 +10099,7 @@ ] } }, - "/api/maintenance/clean_artwork": { + "/api/v1/maintenance/clean_artwork": { "post": { "tags": [ "Maintenance" @@ -10126,7 +10126,7 @@ ] } }, - "/api/media-items": { + "/api/v1/media-items": { "delete": { "tags": [ "Media Items" @@ -10208,7 +10208,7 @@ ] } }, - "/api/media-items/{id}/info": { + "/api/v1/media-items/{id}/info": { "get": { "tags": [ "Media Items" @@ -10313,7 +10313,7 @@ ] } }, - "/api/media-sources": { + "/api/v1/media-sources": { "get": { "tags": [ "Media Sources" @@ -10366,7 +10366,7 @@ ] } }, - "/api/media-sources/collections-scan-status": { + "/api/v1/media-sources/collections-scan-status": { "get": { "tags": [ "Media Sources" @@ -10420,7 +10420,7 @@ ] } }, - "/api/movies/{id}": { + "/api/v1/movies/{id}": { "get": { "tags": [ "Movies" @@ -10505,7 +10505,7 @@ ] } }, - "/api/multi-collections": { + "/api/v1/multi-collections": { "get": { "tags": [ "Multi Collections" @@ -10704,7 +10704,7 @@ ] } }, - "/api/multi-collections/{id}": { + "/api/v1/multi-collections/{id}": { "get": { "tags": [ "Multi Collections" @@ -11033,7 +11033,7 @@ ] } }, - "/api/playlists/groups": { + "/api/v1/playlists/groups": { "get": { "tags": [ "Playlists" @@ -11183,7 +11183,7 @@ ] } }, - "/api/playlists/groups/{id}": { + "/api/v1/playlists/groups/{id}": { "put": { "tags": [ "Playlists" @@ -11399,7 +11399,7 @@ ] } }, - "/api/playlists": { + "/api/v1/playlists": { "get": { "tags": [ "Playlists" @@ -11570,7 +11570,7 @@ ] } }, - "/api/playlists/{id}": { + "/api/v1/playlists/{id}": { "get": { "tags": [ "Playlists" @@ -11909,7 +11909,7 @@ ] } }, - "/api/playlists/{id}/items": { + "/api/v1/playlists/{id}/items": { "get": { "tags": [ "Playlists" @@ -12115,7 +12115,7 @@ ] } }, - "/api/playlists/preview": { + "/api/v1/playlists/preview": { "post": { "tags": [ "Playlists" @@ -12224,7 +12224,7 @@ ] } }, - "/api/playouts": { + "/api/v1/playouts": { "get": { "tags": [ "Playouts" @@ -12424,7 +12424,7 @@ ] } }, - "/api/playouts/warnings/count": { + "/api/v1/playouts/warnings/count": { "get": { "tags": [ "Playouts" @@ -12471,7 +12471,7 @@ ] } }, - "/api/playouts/{id}": { + "/api/v1/playouts/{id}": { "get": { "tags": [ "Playouts" @@ -12811,7 +12811,7 @@ ] } }, - "/api/playouts/{id}/items": { + "/api/v1/playouts/{id}/items": { "get": { "tags": [ "Playouts" @@ -12922,7 +12922,7 @@ ] } }, - "/api/playouts/{id}/deco": { + "/api/v1/playouts/{id}/deco": { "put": { "tags": [ "Playouts" @@ -13073,7 +13073,7 @@ ] } }, - "/api/playouts/{id}/alternate-schedules": { + "/api/v1/playouts/{id}/alternate-schedules": { "get": { "tags": [ "Playouts" @@ -13376,7 +13376,7 @@ ] } }, - "/api/playouts/{id}/templates": { + "/api/v1/playouts/{id}/templates": { "get": { "tags": [ "Playouts" @@ -13679,7 +13679,7 @@ ] } }, - "/api/playouts/{id}/blocks": { + "/api/v1/playouts/{id}/blocks": { "get": { "tags": [ "Playouts" @@ -13774,13 +13774,13 @@ ] } }, - "/api/playouts/{id}/blocks/{blockId}/history": { + "/api/v1/playouts/{id}/blocks/{blockId}/history": { "get": { "tags": [ "Playouts" ], "summary": "Get a block's playout history", - "description": "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}.", + "description": "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/v1/playouts/history/{id}.", "operationId": "GetPlayoutBlockHistory", "parameters": [ { @@ -13887,7 +13887,7 @@ ] } }, - "/api/playouts/history/{id}": { + "/api/v1/playouts/history/{id}": { "get": { "tags": [ "Playouts" @@ -13993,7 +13993,7 @@ ] } }, - "/api/playouts/reset-all": { + "/api/v1/playouts/reset-all": { "post": { "tags": [ "Playouts" @@ -14037,7 +14037,7 @@ ] } }, - "/api/playouts/{id}/erase-items": { + "/api/v1/playouts/{id}/erase-items": { "post": { "tags": [ "Playouts" @@ -14146,7 +14146,7 @@ ] } }, - "/api/playouts/{id}/erase-items-and-history": { + "/api/v1/playouts/{id}/erase-items-and-history": { "post": { "tags": [ "Playouts" @@ -14255,7 +14255,7 @@ ] } }, - "/api/playouts/items/{id}/scheduling-context": { + "/api/v1/playouts/items/{id}/scheduling-context": { "get": { "tags": [ "Playouts" @@ -14341,7 +14341,7 @@ ] } }, - "/api/media-sources/plex": { + "/api/v1/media-sources/plex": { "get": { "tags": [ "Plex" @@ -14386,13 +14386,13 @@ ] } }, - "/api/media-sources/plex/pin-flow": { + "/api/v1/media-sources/plex/pin-flow": { "post": { "tags": [ "Plex" ], "summary": "Start the Plex sign-in pin flow", - "description": "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 until authorized && !locked. Also used to fix credentials for an existing but unauthorized server.", + "description": "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/v1/media-sources/plex until authorized && !locked. Also used to fix credentials for an existing but unauthorized server.", "operationId": "StartPlexPinFlow", "responses": { "200": { @@ -14471,7 +14471,7 @@ ] } }, - "/api/media-sources/plex/sign-out": { + "/api/v1/media-sources/plex/sign-out": { "post": { "tags": [ "Plex" @@ -14519,7 +14519,7 @@ ] } }, - "/api/media-sources/plex/{id}/libraries": { + "/api/v1/media-sources/plex/{id}/libraries": { "get": { "tags": [ "Plex" @@ -14751,7 +14751,7 @@ ] } }, - "/api/media-sources/plex/{id}/path-replacements": { + "/api/v1/media-sources/plex/{id}/path-replacements": { "get": { "tags": [ "Plex" @@ -14983,7 +14983,7 @@ ] } }, - "/api/media-sources/plex/{id}/refresh-libraries": { + "/api/v1/media-sources/plex/{id}/refresh-libraries": { "post": { "tags": [ "Plex" @@ -15072,7 +15072,7 @@ ] } }, - "/api/media-sources/plex/{id}/scan-collections": { + "/api/v1/media-sources/plex/{id}/scan-collections": { "post": { "tags": [ "Plex" @@ -15169,7 +15169,7 @@ ] } }, - "/api/rerun-collections": { + "/api/v1/rerun-collections": { "get": { "tags": [ "Rerun Collections" @@ -15368,7 +15368,7 @@ ] } }, - "/api/rerun-collections/{id}": { + "/api/v1/rerun-collections/{id}": { "get": { "tags": [ "Rerun Collections" @@ -15697,7 +15697,7 @@ ] } }, - "/api/ffmpeg/resolution/by-name/{name}": { + "/api/v1/ffmpeg/resolution/by-name/{name}": { "get": { "tags": [ "Settings" @@ -15781,7 +15781,7 @@ ] } }, - "/api/settings/resolutions": { + "/api/v1/settings/resolutions": { "get": { "tags": [ "Settings" @@ -15941,7 +15941,7 @@ ] } }, - "/api/settings/resolutions/{id}": { + "/api/v1/settings/resolutions/{id}": { "delete": { "tags": [ "Settings" @@ -16039,7 +16039,7 @@ ] } }, - "/api/schedules": { + "/api/v1/schedules": { "get": { "tags": [ "Schedules" @@ -16209,7 +16209,7 @@ ] } }, - "/api/schedules/{id}": { + "/api/v1/schedules/{id}": { "get": { "tags": [ "Schedules" @@ -16508,7 +16508,7 @@ ] } }, - "/api/schedules/{id}/items": { + "/api/v1/schedules/{id}/items": { "get": { "tags": [ "Schedules" @@ -16890,7 +16890,7 @@ ] } }, - "/api/schedules/{id}/items/{itemId}": { + "/api/v1/schedules/{id}/items/{itemId}": { "delete": { "tags": [ "Schedules" @@ -16987,7 +16987,7 @@ ] } }, - "/api/search": { + "/api/v1/search": { "get": { "tags": [ "Search" @@ -17089,7 +17089,7 @@ ] } }, - "/api/search/all-items": { + "/api/v1/search/all-items": { "get": { "tags": [ "Search" @@ -17174,7 +17174,7 @@ ] } }, - "/api/search/collections": { + "/api/v1/search/collections": { "get": { "tags": [ "Search" @@ -17248,7 +17248,7 @@ ] } }, - "/api/search/television-shows": { + "/api/v1/search/television-shows": { "get": { "tags": [ "Search" @@ -17322,7 +17322,7 @@ ] } }, - "/api/search/television-seasons": { + "/api/v1/search/television-seasons": { "get": { "tags": [ "Search" @@ -17396,7 +17396,7 @@ ] } }, - "/api/search/smart-collections": { + "/api/v1/search/smart-collections": { "get": { "tags": [ "Search" @@ -17470,7 +17470,7 @@ ] } }, - "/api/search/artists": { + "/api/v1/search/artists": { "get": { "tags": [ "Search" @@ -17544,7 +17544,7 @@ ] } }, - "/api/search/multi-collections": { + "/api/v1/search/multi-collections": { "get": { "tags": [ "Search" @@ -17618,7 +17618,7 @@ ] } }, - "/api/seasons/{id}": { + "/api/v1/seasons/{id}": { "get": { "tags": [ "Television" @@ -17703,7 +17703,7 @@ ] } }, - "/api/sessions": { + "/api/v1/sessions": { "get": { "tags": [ "Sessions" @@ -17756,7 +17756,7 @@ ] } }, - "/api/session/{channelNumber}": { + "/api/v1/session/{channelNumber}": { "delete": { "tags": [ "Sessions" @@ -17803,7 +17803,7 @@ ] } }, - "/api/settings/ffmpeg": { + "/api/v1/settings/ffmpeg": { "get": { "tags": [ "Settings" @@ -17954,7 +17954,7 @@ ] } }, - "/api/settings/playout": { + "/api/v1/settings/playout": { "get": { "tags": [ "Settings" @@ -18105,7 +18105,7 @@ ] } }, - "/api/settings/xmltv": { + "/api/v1/settings/xmltv": { "get": { "tags": [ "Settings" @@ -18256,7 +18256,7 @@ ] } }, - "/api/settings/scanner": { + "/api/v1/settings/scanner": { "get": { "tags": [ "Settings" @@ -18407,7 +18407,7 @@ ] } }, - "/api/settings/logging": { + "/api/v1/settings/logging": { "get": { "tags": [ "Settings" @@ -18558,7 +18558,7 @@ ] } }, - "/api/settings/ui": { + "/api/v1/settings/ui": { "get": { "tags": [ "Settings" @@ -18709,7 +18709,7 @@ ] } }, - "/api/settings/hdhr": { + "/api/v1/settings/hdhr": { "get": { "tags": [ "Settings" @@ -18860,7 +18860,7 @@ ] } }, - "/api/shows/{id}": { + "/api/v1/shows/{id}": { "get": { "tags": [ "Television" @@ -18945,7 +18945,7 @@ ] } }, - "/api/smart-collections": { + "/api/v1/smart-collections": { "get": { "tags": [ "Smart Collections" @@ -19115,7 +19115,7 @@ ] } }, - "/api/smart-collections/{id}": { + "/api/v1/smart-collections/{id}": { "get": { "tags": [ "Smart Collections" @@ -19414,7 +19414,7 @@ ] } }, - "/api/templates/groups": { + "/api/v1/templates/groups": { "get": { "tags": [ "Templates" @@ -19584,7 +19584,7 @@ ] } }, - "/api/templates/groups/{id}": { + "/api/v1/templates/groups/{id}": { "delete": { "tags": [ "Templates" @@ -19653,7 +19653,7 @@ ] } }, - "/api/templates": { + "/api/v1/templates": { "get": { "tags": [ "Templates" @@ -19845,7 +19845,7 @@ ] } }, - "/api/templates/{id}": { + "/api/v1/templates/{id}": { "get": { "tags": [ "Templates" @@ -20155,7 +20155,7 @@ ] } }, - "/api/templates/{id}/items": { + "/api/v1/templates/{id}/items": { "get": { "tags": [ "Templates" @@ -20250,7 +20250,7 @@ ] } }, - "/api/templates/{id}/copy": { + "/api/v1/templates/{id}/copy": { "post": { "tags": [ "Templates" @@ -20381,7 +20381,7 @@ ] } }, - "/api/trakt/lists": { + "/api/v1/trakt/lists": { "get": { "tags": [ "Trakt" @@ -20459,7 +20459,7 @@ "Trakt" ], "summary": "Add a Trakt list by URL", - "description": "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.", + "description": "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/v1/trakt/status while busy.", "operationId": "TraktAdd", "requestBody": { "content": { @@ -20556,7 +20556,7 @@ ] } }, - "/api/trakt/lists/{id}": { + "/api/v1/trakt/lists/{id}": { "get": { "tags": [ "Trakt" @@ -20855,7 +20855,7 @@ ] } }, - "/api/trakt/lists/{id}/match": { + "/api/v1/trakt/lists/{id}/match": { "post": { "tags": [ "Trakt" @@ -20943,7 +20943,7 @@ ] } }, - "/api/trakt/status": { + "/api/v1/trakt/status": { "get": { "tags": [ "Trakt" @@ -20988,7 +20988,7 @@ ] } }, - "/api/troubleshoot/info": { + "/api/v1/troubleshoot/info": { "get": { "tags": [ "Troubleshooting" @@ -21032,7 +21032,7 @@ ] } }, - "/api/troubleshoot/validate-schedule": { + "/api/v1/troubleshoot/validate-schedule": { "post": { "tags": [ "Troubleshooting" @@ -21122,7 +21122,7 @@ ] } }, - "/api/troubleshoot/playback/start": { + "/api/v1/troubleshoot/playback/start": { "post": { "tags": [ "Troubleshooting" @@ -21261,7 +21261,7 @@ ] } }, - "/api/troubleshoot/playback/archive": { + "/api/v1/troubleshoot/playback/archive": { "post": { "tags": [ "Troubleshooting" @@ -21288,7 +21288,7 @@ ] } }, - "/api/troubleshoot/playback/sample/{mediaItemId}": { + "/api/v1/troubleshoot/playback/sample/{mediaItemId}": { "post": { "tags": [ "Troubleshooting" @@ -21336,7 +21336,7 @@ ] } }, - "/api/troubleshoot/playback/stream-selectors": { + "/api/v1/troubleshoot/playback/stream-selectors": { "get": { "tags": [ "Troubleshooting" @@ -21389,13 +21389,13 @@ ] } }, - "/api/troubleshoot/playback/subtitles/{mediaItemId}": { + "/api/v1/troubleshoot/playback/subtitles/{mediaItemId}": { "get": { "tags": [ "Troubleshooting" ], "summary": "List selectable subtitle streams for a media item", - "description": "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.", + "description": "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/v1/troubleshoot/playback/start request body's subtitleId field.", "operationId": "GetTroubleshootingSubtitles", "parameters": [ { @@ -21484,7 +21484,7 @@ ] } }, - "/api/troubleshoot/playback/status": { + "/api/v1/troubleshoot/playback/status": { "get": { "tags": [ "Troubleshooting" @@ -21529,7 +21529,7 @@ ] } }, - "/api/version": { + "/api/v1/version": { "get": { "tags": [ "Version" @@ -21573,7 +21573,7 @@ ] } }, - "/api/watermarks": { + "/api/v1/watermarks": { "get": { "tags": [ "Watermarks" @@ -21753,7 +21753,7 @@ ] } }, - "/api/watermarks/{id}": { + "/api/v1/watermarks/{id}": { "get": { "tags": [ "Watermarks" diff --git a/docs/api-conventions.md b/docs/api-conventions.md index b9836feb3..3b34813a5 100644 --- a/docs/api-conventions.md +++ b/docs/api-conventions.md @@ -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 `