@if (!string.IsNullOrEmpty(release.ImagePath))
{
diff --git a/DeepDrftPublic.Client/Pages/ArchiveView.razor.cs b/DeepDrftPublic.Client/Pages/ArchiveView.razor.cs
index a7843ba..2a3b6ae 100644
--- a/DeepDrftPublic.Client/Pages/ArchiveView.razor.cs
+++ b/DeepDrftPublic.Client/Pages/ArchiveView.razor.cs
@@ -115,16 +115,6 @@ public partial class ArchiveView : ComponentBase, IDisposable
await LoadReleases();
}
- // Per-medium detail target. Session/Mix open their own detail page; a Cut has no single-release
- // detail page, so it opens the track gallery filtered to its release title — the same destination
- // AlbumsView's Cut cards use, preserving the established navigation.
- private static string DetailHref(ReleaseDto release) => release.Medium switch
- {
- ReleaseMedium.Session => $"/sessions/{release.Id}",
- ReleaseMedium.Mix => $"/mixes/{release.Id}",
- _ => $"/tracks?album={Uri.EscapeDataString(release.Title)}",
- };
-
// Display label for a medium filter chip. Centralised so a new medium's label is one entry, not a
// markup change. "DJ Mix" matches the CMS Type-chip wording (§8.D).
private static string MediumLabel(ReleaseMedium medium) => medium switch
diff --git a/DeepDrftPublic.Client/Pages/TrackRedirect.razor b/DeepDrftPublic.Client/Pages/TrackRedirect.razor
new file mode 100644
index 0000000..845f0ea
--- /dev/null
+++ b/DeepDrftPublic.Client/Pages/TrackRedirect.razor
@@ -0,0 +1,26 @@
+@page "/tracks/{Id:long}"
+@using DeepDrftPublic.Client.Services
+@inject IReleaseDataService ReleaseData
+@inject NavigationManager Navigation
+
+@* Addressable deep-link fallback for a bare release id (Phase 11 §2, shape iii). Unlike the player
+ bar / Archive / AlbumsView call sites, an external /tracks/{id} link carries only the id, so this
+ page fetches the release to discover its medium, then forwards through the same ReleaseRoutes
+ resolver — one medium→route table, no second source. replace:true keeps the router out of history
+ so Back skips this hop. Capture Id before the await per the InteractiveAuto route-param convention. *@
+
+@code {
+ [Parameter] public long Id { get; set; }
+
+ protected override async Task OnParametersSetAsync()
+ {
+ var id = Id;
+ var result = await ReleaseData.GetById(id);
+
+ var target = result is { Success: true, Value: { } release }
+ ? ReleaseRoutes.DetailHref(release)
+ : "/cuts"; // Unknown id: fall back to the Cuts gallery rather than 404.
+
+ Navigation.NavigateTo(target, forceLoad: false, replace: true);
+ }
+}
diff --git a/DeepDrftTests/ReleaseRoutesTests.cs b/DeepDrftTests/ReleaseRoutesTests.cs
new file mode 100644
index 0000000..f94957b
--- /dev/null
+++ b/DeepDrftTests/ReleaseRoutesTests.cs
@@ -0,0 +1,51 @@
+using DeepDrftModels.DTOs;
+using DeepDrftModels.Enums;
+using DeepDrftPublic.Client.Common;
+
+namespace DeepDrftTests;
+
+///
+/// The medium→detail-route table is the single source of truth for release navigation (Phase 11
+/// §2): Archive cards, AlbumsView cards, the player-bar title, and the /tracks/{id} redirect page
+/// all resolve through . These tests pin
+/// each medium to its dedicated route and confirm the DTO overload (the call shape used everywhere
+/// but the redirect page) agrees with the primitive overload (the shape the redirect page uses
+/// after fetching the release by id).
+///
+[TestFixture]
+public class ReleaseRoutesTests
+{
+ [TestCase(ReleaseMedium.Cut, "/cuts/42")]
+ [TestCase(ReleaseMedium.Session, "/sessions/42")]
+ [TestCase(ReleaseMedium.Mix, "/mixes/42")]
+ public void DetailHref_ResolvesEachMediumToItsDedicatedRoute(ReleaseMedium medium, string expected)
+ {
+ Assert.That(ReleaseRoutes.DetailHref(42, medium), Is.EqualTo(expected));
+ }
+
+ [TestCase(ReleaseMedium.Cut, "/cuts/7")]
+ [TestCase(ReleaseMedium.Session, "/sessions/7")]
+ [TestCase(ReleaseMedium.Mix, "/mixes/7")]
+ public void DetailHref_DtoOverload_AgreesWithPrimitiveOverload(ReleaseMedium medium, string expected)
+ {
+ // The redirect page resolves a fetched ReleaseDto through this overload; every other call
+ // site does too. It must produce the same route as the (id, medium) primitive.
+ var release = new ReleaseDto { Id = 7, Medium = medium };
+
+ Assert.That(ReleaseRoutes.DetailHref(release), Is.EqualTo(expected));
+ }
+
+ [Test]
+ public void DetailHref_NonCutMediaNeverResolveToTheCutDefaultArm()
+ {
+ // The switch routes Cut via the default arm, so a new medium added without its own arm would
+ // silently fall through to /cuts. This pins every non-Cut medium to a distinct, non-/cuts
+ // route — a fourth medium lacking a route arm fails here rather than mis-routing to /cuts.
+ foreach (var medium in Enum.GetValues().Where(m => m != ReleaseMedium.Cut))
+ {
+ var href = ReleaseRoutes.DetailHref(1, medium);
+ Assert.That(href, Does.Not.StartWith("/cuts/"),
+ $"Medium {medium} fell through to the Cut default arm ('{href}') — it needs its own route.");
+ }
+ }
+}