Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions SonosControl.DAL/Repos/SonosConnectorRepo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ public async Task SetSpeakerVolume(string ip, int volume, CancellationToken canc

public virtual async Task SetTuneInStationAsync(string ip, string stationUri, CancellationToken cancellationToken = default)
{
await ClearQueue(ip, cancellationToken);

stationUri = stationUri
.Replace("https://", "", StringComparison.OrdinalIgnoreCase)
.Replace("http://", "", StringComparison.OrdinalIgnoreCase);
Expand Down Expand Up @@ -588,6 +590,10 @@ public async Task PlaySpotifyTrackAsync(string ip, string spotifyUrl, string? fa
{
await SetTuneInStationAsync(ip, fallbackStationUri, cancellationToken);
}
else
{
await ClearQueue(ip, cancellationToken);
}

string? rinconId = await GetRinconIdAsync(ip, cancellationToken);
if (rinconId == null)
Expand Down Expand Up @@ -693,6 +699,10 @@ public async Task PlayYouTubeMusicTrackAsync(string ip, string youtubeMusicUrl,
{
await SetTuneInStationAsync(ip, fallbackStationUri, cancellationToken);
}
else
{
await ClearQueue(ip, cancellationToken);
}

string trimmedUrl = youtubeMusicUrl.Trim();
string contentType = "track";
Expand Down
38 changes: 38 additions & 0 deletions SonosControl.Tests/DirectUrlMediaResolverTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using SonosControl.Web.Services;
using Xunit;

namespace SonosControl.Tests;

public class DirectUrlMediaResolverTests
{
[Theory]
[InlineData("https://open.spotify.com/track/123", "spotify")]
[InlineData("spotify:track:123", "spotify")]
[InlineData("https://www.youtube.com/watch?v=abc123", "youtube")]
[InlineData("https://youtu.be/abc123", "youtube")]
[InlineData("https://music.youtube.com/watch?v=abc123", "youtubemusic")]
[InlineData("https://stream.example/live", "station")]
[InlineData("stream.example/live", "station")]
public void Resolve_ClassifiesSupportedUrls(string rawUrl, string expectedMediaType)
{
var result = DirectUrlMediaResolver.Resolve(rawUrl);

Assert.True(result.IsValid);
Assert.Equal(expectedMediaType, result.MediaType);
Assert.Equal(rawUrl.Trim(), result.NormalizedUrl);
Assert.Null(result.ErrorMessage);
}

[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("not a url")]
[InlineData("ftp://example.com/audio.mp3")]
public void Resolve_RejectsInvalidInput(string rawUrl)
{
var result = DirectUrlMediaResolver.Resolve(rawUrl);

Assert.False(result.IsValid);
Assert.False(string.IsNullOrWhiteSpace(result.ErrorMessage));
}
}
164 changes: 163 additions & 1 deletion SonosControl.Tests/IndexPageUXTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public void IndexPage_RendersEmptyStates_WhenListsAreEmpty()
{
Assert.Single(cut.FindAll("[data-qa='home-operations-dashboard']"));
Assert.Contains("Library", cut.Markup);
Assert.Contains("Direct URL Play", cut.Markup);
Assert.DoesNotContain("Quick Sources", cut.Markup);
Assert.Contains("No saved stations.", cut.Markup);
Assert.Empty(cut.FindAll(".spotify-library"));
Expand Down Expand Up @@ -260,6 +261,9 @@ public void IndexPage_HasAccessibleLibraryTabs_AndAddButtons()
Assert.Equal("tab", cut.Find("#home-library-tab-youtube").GetAttribute("role"));
Assert.Equal("tab", cut.Find("#home-library-tab-youtube-music").GetAttribute("role"));
Assert.NotNull(cut.Find("input[aria-label='Search active library tab']"));
Assert.NotNull(cut.Find("input[aria-label='Direct media URL']"));
Assert.NotNull(cut.Find("button[aria-label='Play direct URL']"));
Assert.NotNull(cut.Find("button[aria-label='Save direct URL']"));
Assert.NotNull(cut.Find("button[aria-label='Add Station']"));
Assert.NotNull(cut.Find("button[aria-label='Add Spotify Track']"));
Assert.NotNull(cut.Find("button[aria-label='Add YouTube Video']"));
Expand Down Expand Up @@ -380,6 +384,38 @@ public void IndexPage_RendersDashboardOnlyHome_WithLibraryAutomationHealthAndAct
});
}

[Fact]
public void IndexPage_UpNext_ShowsOnlyNextTwoQueueItems()
{
using var ctx = new TestContext();
using var resources = ConfigureServices(ctx, new List<TuneInStation>(), new List<SpotifyObject>(), new List<YouTubeMusicObject>());

resources.ConnectorRepo
.Setup(r => r.GetQueue(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new SonosQueuePage(
new[]
{
new SonosQueueItem(0, "Track 1", "Artist 1", null, null),
new SonosQueueItem(1, "Track 2", "Artist 2", null, null),
new SonosQueueItem(2, "Track 3", "Artist 3", null, null),
new SonosQueueItem(3, "Track 4", "Artist 4", null, null)
},
0,
4,
4));

var cut = ctx.RenderComponent<IndexPage>();

cut.WaitForAssertion(() =>
{
Assert.Contains("Up Next", cut.Markup);
Assert.Contains("Track 1", cut.Markup);
Assert.Contains("Track 2", cut.Markup);
Assert.DoesNotContain("Track 3", cut.Markup);
Assert.DoesNotContain("Track 4", cut.Markup);
});
}

[Fact]
public void IndexPage_RendersRecentActivityDetailsWithoutTruncation()
{
Expand Down Expand Up @@ -500,7 +536,6 @@ public void IndexPage_YouTubeToolbarModeControl_IsVisibleAndUpdatesSelectedEntry
new() { IpAddress = "1.2.3.4", Name = "Kitchen" }
}
};

using var resources = ConfigureServices(
ctx,
new List<TuneInStation>(),
Expand Down Expand Up @@ -535,6 +570,127 @@ public void IndexPage_YouTubeToolbarModeControl_IsVisibleAndUpdatesSelectedEntry
It.IsAny<CancellationToken>()), Times.Never);
}

[Fact]
public void IndexPage_DirectUrlPlay_ResolvesMediaTypes_AndUsesSelectedSpeaker()
{
using var ctx = new TestContext();

var settings = new SonosSettings
{
IP_Adress = "1.2.3.4",
Speakers = new List<SonosSpeaker>
{
new() { IpAddress = "1.2.3.4", Name = "Kitchen" },
new() { IpAddress = "1.2.3.5", Name = "Living Room" }
}
};

using var resources = ConfigureServices(ctx, new List<TuneInStation>(), new List<SpotifyObject>(), new List<YouTubeMusicObject>(), settings);
var cut = ctx.RenderComponent<IndexPage>();

var selectedSpeakerField = typeof(IndexPage).GetField("_selectedSpeakerIp", BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(selectedSpeakerField);
selectedSpeakerField!.SetValue(cut.Instance, "1.2.3.5");

var directUrlInput = cut.Find("input[aria-label='Direct media URL']");
directUrlInput.Input("https://open.spotify.com/track/123");
cut.Find("button[aria-label='Play direct URL']").Click();
resources.ConnectorRepo.Verify(repo => repo.PlaySpotifyTrackAsync("1.2.3.5", "https://open.spotify.com/track/123", It.IsAny<string?>(), It.IsAny<CancellationToken>()), Times.Once);

directUrlInput.Input("https://www.youtube.com/watch?v=5EsEJRXlrGk");
cut.Find("button[aria-label='Play direct URL']").Click();
resources.ConnectorRepo.Verify(repo => repo.PlayYouTubeAudioAsync("1.2.3.5", "https://www.youtube.com/watch?v=5EsEJRXlrGk", It.IsAny<YouTubePlaybackMode?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()), Times.Once);

directUrlInput.Input("https://youtu.be/5EsEJRXlrGk");
cut.Find("button[aria-label='Play direct URL']").Click();
resources.ConnectorRepo.Verify(repo => repo.PlayYouTubeAudioAsync("1.2.3.5", "https://youtu.be/5EsEJRXlrGk", It.IsAny<YouTubePlaybackMode?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()), Times.Once);

directUrlInput.Input("https://music.youtube.com/watch?v=5EsEJRXlrGk");
cut.Find("button[aria-label='Play direct URL']").Click();
resources.ConnectorRepo.Verify(repo => repo.PlayYouTubeMusicTrackAsync("1.2.3.5", "https://music.youtube.com/watch?v=5EsEJRXlrGk", settings.AutoPlayStationUrl, It.IsAny<CancellationToken>()), Times.Once);

directUrlInput.Input("https://stream.example/live");
cut.Find("button[aria-label='Play direct URL']").Click();
resources.ConnectorRepo.Verify(repo => repo.SetTuneInStationAsync("1.2.3.5", "https://stream.example/live", It.IsAny<CancellationToken>()), Times.Once);
resources.ConnectorRepo.Verify(repo => repo.StartPlaying("1.2.3.5"), Times.Exactly(5));
}

[Fact]
public void IndexPage_DirectUrlPlay_RejectsEmptyUrl()
{
using var ctx = new TestContext();
using var resources = ConfigureServices(ctx, new List<TuneInStation>(), new List<SpotifyObject>(), new List<YouTubeMusicObject>());

var cut = ctx.RenderComponent<IndexPage>();

cut.Find("input[aria-label='Direct media URL']").Input(string.Empty);
var playButton = cut.Find("button[aria-label='Play direct URL']");
Assert.True(playButton.HasAttribute("disabled"));

resources.ConnectorRepo.Verify(repo => repo.SetTuneInStationAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
resources.ConnectorRepo.Verify(repo => repo.PlaySpotifyTrackAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()), Times.Never);
resources.ConnectorRepo.Verify(repo => repo.PlayYouTubeAudioAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<YouTubePlaybackMode?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()), Times.Never);
resources.ConnectorRepo.Verify(repo => repo.PlayYouTubeMusicTrackAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()), Times.Never);
}

[Fact]
public void IndexPage_DirectUrlSave_PrefillsModal_AndAddsItemToLibrary()
{
using var ctx = new TestContext();
using var resources = ConfigureServices(ctx, new List<TuneInStation>(), new List<SpotifyObject>(), new List<YouTubeMusicObject>());

var cut = ctx.RenderComponent<IndexPage>();

cut.Find("input[aria-label='Direct media URL']").Input("https://www.youtube.com/watch?v=5EsEJRXlrGk");
cut.Find("button[aria-label='Save direct URL']").Click();

cut.WaitForAssertion(() =>
{
Assert.Contains("Add New YouTube Video", cut.Markup);
Assert.Equal("https://www.youtube.com/watch?v=5EsEJRXlrGk", cut.Find("#mediaUrl").GetAttribute("value"));
});

cut.Find("#mediaName").Input("Friday Mix");
cut.Find(".modal-content button.btn.btn-primary").Click();

cut.Find("#home-library-tab-youtube").Click();
cut.WaitForAssertion(() =>
{
Assert.Contains("Friday Mix", cut.Markup);
Assert.NotEmpty(cut.FindAll("button[aria-label='Play Friday Mix']"));
});
}

[Fact]
public void IndexPage_DirectUrlSave_ShowsDuplicateValidationError()
{
using var ctx = new TestContext();
var videos = new List<YouTubeObject>
{
new() { Name = "Friday Mix", Url = "https://www.youtube.com/watch?v=5EsEJRXlrGk" }
};

using var resources = ConfigureServices(
ctx,
new List<TuneInStation>(),
new List<SpotifyObject>(),
new List<YouTubeMusicObject>(),
youTubeVideos: videos);

var cut = ctx.RenderComponent<IndexPage>();

cut.Find("input[aria-label='Direct media URL']").Input("https://www.youtube.com/watch?v=5EsEJRXlrGk");
cut.Find("button[aria-label='Save direct URL']").Click();

cut.Find("#mediaName").Input("Friday Mix");
cut.Find(".modal-content button.btn.btn-primary").Click();

cut.WaitForAssertion(() =>
{
Assert.Contains("YouTube video with this name or URL already exists.", cut.Markup);
});
}

private sealed class TestResources : IDisposable
{
public ApplicationDbContext DbContext { get; }
Expand Down Expand Up @@ -565,6 +721,7 @@ private static TestResources ConfigureServices(
var auth = ctx.AddTestAuthorization();
auth.SetAuthorized("tester");
auth.SetRoles("admin");
ctx.JSInterop.SetupVoid("window.sonosUi.setBodyScrollLock", _ => true);

var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
Expand Down Expand Up @@ -598,6 +755,11 @@ private static TestResources ConfigureServices(
connectorRepo.Setup(r => r.GetTrackProgressAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((TimeSpan.Zero, TimeSpan.Zero));
connectorRepo.Setup(r => r.SetVolume(It.IsAny<string>(), It.IsAny<int>())).Returns(Task.CompletedTask);
connectorRepo.Setup(r => r.SetTuneInStationAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
connectorRepo.Setup(r => r.PlaySpotifyTrackAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
connectorRepo.Setup(r => r.PlayYouTubeAudioAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<YouTubePlaybackMode?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
connectorRepo.Setup(r => r.PlayYouTubeMusicTrackAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
connectorRepo.Setup(r => r.StartPlaying(It.IsAny<string>())).Returns(Task.CompletedTask);

var unitOfWork = new Mock<IUnitOfWork>();
unitOfWork.SetupGet(u => u.ISettingsRepo).Returns(settingsRepo.Object);
Expand Down
29 changes: 20 additions & 9 deletions SonosControl.Tests/SonosConnectorRepoTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -446,13 +446,16 @@ public async Task SetTuneInStationAsync_Success_PostsSoapRequestAndStartsPlaybac
{
var handler = new QueueHttpMessageHandler();
handler.Enqueue(new HttpResponseMessage(HttpStatusCode.OK));
handler.Enqueue(new HttpResponseMessage(HttpStatusCode.OK));
var client = new HttpClient(handler);
var settingsRepo = new Mock<ISettingsRepo>().Object;
var repo = new TestableSonosConnectorRepo(new TestHttpClientFactory(client), settingsRepo);

await repo.SetTuneInStationAsync("1.2.3.4", "example.com/stream");

var request = Assert.Single(handler.Requests);
Assert.Equal(2, handler.Requests.Count);
Assert.Equal("\"urn:schemas-upnp-org:service:AVTransport:1#RemoveAllTracksFromQueue\"", handler.Requests[0].SoapAction);
var request = handler.Requests[1];
Assert.Equal("http://1.2.3.4:1400/MediaRenderer/AVTransport/Control", request.Uri!.ToString());
Assert.NotNull(request.Body);
Assert.Contains("x-rincon-mp3radio://example.com/stream", request.Body);
Expand Down Expand Up @@ -539,14 +542,17 @@ public async Task PlayYouTubeAudioAsync_PopulatesQueueAndStartsPlayback()
public async Task SetTuneInStationAsync_Failure_DoesNotStartPlayback()
{
var handler = new QueueHttpMessageHandler();
handler.Enqueue(new HttpResponseMessage(HttpStatusCode.OK));
handler.Enqueue(new HttpResponseMessage(HttpStatusCode.InternalServerError));
var client = new HttpClient(handler);
var settingsRepo = new Mock<ISettingsRepo>().Object;
var repo = new TestableSonosConnectorRepo(new TestHttpClientFactory(client), settingsRepo);

await repo.SetTuneInStationAsync("1.2.3.4", "example.com/stream");

var request = Assert.Single(handler.Requests);
Assert.Equal(2, handler.Requests.Count);
Assert.Equal("\"urn:schemas-upnp-org:service:AVTransport:1#RemoveAllTracksFromQueue\"", handler.Requests[0].SoapAction);
var request = handler.Requests[1];
Assert.Equal("\"urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI\"", request.SoapAction);
Assert.Equal(0, repo.StartPlayingCallCount);
}
Expand All @@ -556,6 +562,7 @@ public async Task PlaySpotifyTrackAsync_Success_UsesFallbackAndStartsPlayback()
{
var handler = new QueueHttpMessageHandler();
handler.Enqueue(new HttpResponseMessage(HttpStatusCode.OK));
handler.Enqueue(new HttpResponseMessage(HttpStatusCode.OK));
handler.Enqueue(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("<root><UDN>uuid:RINCON_ABC123</UDN></root>")
Expand All @@ -567,18 +574,20 @@ public async Task PlaySpotifyTrackAsync_Success_UsesFallbackAndStartsPlayback()

await repo.PlaySpotifyTrackAsync("1.2.3.4", "https://open.spotify.com/track/12345", "fallback-station");

Assert.Equal(3, handler.Requests.Count);
Assert.Equal(4, handler.Requests.Count);

Assert.Equal("\"urn:schemas-upnp-org:service:AVTransport:1#RemoveAllTracksFromQueue\"", handler.Requests[0].SoapAction);

var fallbackRequest = handler.Requests[0];
var fallbackRequest = handler.Requests[1];
Assert.Equal(HttpMethod.Post, fallbackRequest.Method);
Assert.NotNull(fallbackRequest.Body);
Assert.Contains("fallback-station", fallbackRequest.Body);

var deviceDescriptionRequest = handler.Requests[1];
var deviceDescriptionRequest = handler.Requests[2];
Assert.Equal(HttpMethod.Get, deviceDescriptionRequest.Method);
Assert.Equal("http://1.2.3.4:1400/xml/device_description.xml", deviceDescriptionRequest.Uri!.ToString());

var spotifyRequest = handler.Requests[2];
var spotifyRequest = handler.Requests[3];
Assert.Equal("\"urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI\"", spotifyRequest.SoapAction);
Assert.Equal(2, repo.StartPlayingCallCount);
}
Expand All @@ -587,6 +596,7 @@ public async Task PlaySpotifyTrackAsync_Success_UsesFallbackAndStartsPlayback()
public async Task PlaySpotifyTrackAsync_Failure_DoesNotStartPlayback()
{
var handler = new QueueHttpMessageHandler();
handler.Enqueue(new HttpResponseMessage(HttpStatusCode.OK));
handler.Enqueue(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("<root><UDN>uuid:RINCON_ABC123</UDN></root>")
Expand All @@ -598,9 +608,10 @@ public async Task PlaySpotifyTrackAsync_Failure_DoesNotStartPlayback()

await repo.PlaySpotifyTrackAsync("1.2.3.4", "https://open.spotify.com/track/12345");

Assert.Equal(2, handler.Requests.Count);
Assert.Equal(HttpMethod.Get, handler.Requests[0].Method);
Assert.Equal(HttpMethod.Post, handler.Requests[1].Method);
Assert.Equal(3, handler.Requests.Count);
Assert.Equal("\"urn:schemas-upnp-org:service:AVTransport:1#RemoveAllTracksFromQueue\"", handler.Requests[0].SoapAction);
Assert.Equal(HttpMethod.Get, handler.Requests[1].Method);
Assert.Equal(HttpMethod.Post, handler.Requests[2].Method);
Assert.Equal(0, repo.StartPlayingCallCount);
}

Expand Down
1 change: 0 additions & 1 deletion SonosControl.Tests/YouTubePlaybackServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,6 @@ public Task<ResolvedYouTubeQueue> ResolveQueueAsync(string sourceUrl, YouTubePla
ResolveCallCount++;
return Task.FromResult(_resolver(sourceUrl, playbackMode, preferredQueueLength));
}

public async Task<string> MaterializeAudioAsync(ResolvedYouTubeSourceItem source, string artifactDirectory, CancellationToken cancellationToken)
{
Directory.CreateDirectory(artifactDirectory);
Expand Down
Loading