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
40 changes: 40 additions & 0 deletions smoc.Tests/Streaming/SoundCloud/SoundCloudDiscoveryTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using Smoc.Streaming.SoundCloud.Util;

namespace smoc.Tests.Streaming.SoundCloud;

/// <summary>
/// Tests for the <see cref="SoundCloudDiscovery"/> class.
/// </summary>
public class SoundCloudDiscoveryTest {
/// <summary>
/// Verifies that script URLs can be extracted from HTML.
/// </summary>
[Fact]
public void ExtractScriptUrls_FindsScripts() {
var html = "<html><body><script src=\"https://a-v2.sndcdn.com/assets/1.js\"></script><script src=\"https://a-v2.sndcdn.com/assets/2.js\"></script></body></html>";
var urls = SoundCloudDiscovery.ExtractScriptUrls(html).ToList();
Assert.Equal(2, urls.Count);
Assert.Equal("https://a-v2.sndcdn.com/assets/1.js", urls[0]);
Assert.Equal("https://a-v2.sndcdn.com/assets/2.js", urls[1]);
}

/// <summary>
/// Verifies that the client ID can be extracted from script content.
/// </summary>
[Fact]
public void ExtractClientId_FindsId() {
var content = "window.Snd={}; Snd.config={client_id:\"0123456789abcdef0123456789abcdef\", ...}";
var id = SoundCloudDiscovery.ExtractClientId(content);
Assert.Equal("0123456789abcdef0123456789abcdef", id);
}

/// <summary>
/// Verifies that null is returned if the client ID is not found.
/// </summary>
[Fact]
public void ExtractClientId_NotFound_ReturnsNull() {
var content = "console.log('hello');";
var id = SoundCloudDiscovery.ExtractClientId(content);
Assert.Null(id);
}
}
43 changes: 43 additions & 0 deletions smoc.Tests/Streaming/SoundCloud/SoundCloudMappingTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using Smoc.Streaming.SoundCloud.Models;
using Smoc.Streaming.SoundCloud.Util;

namespace smoc.Tests.Streaming.SoundCloud;

/// <summary>
/// Tests for the <see cref="SoundCloudMapper"/> class.
/// </summary>
public class SoundCloudMappingTest {
/// <summary>
/// Verifies that a SoundCloud track is correctly mapped to a SMoC song.
/// </summary>
[Fact]
public void MapTrackToSong_ReturnsCorrectSong() {
var user = new SoundCloudUser(123, "Test Artist", "http://avatar");
var track = new SoundCloudTrack(456, "Test Track", 180000, "http://artwork-large.jpg", user, new SoundCloudMedia([]));

var song = SoundCloudMapper.MapTrackToSong(track);

Assert.Equal("456", song.Id);
Assert.Equal("Test Track", song.Title);
Assert.Equal(TimeSpan.FromSeconds(180), song.Duration);
Assert.Equal("Test Artist", song.Artist.Name);
Assert.Equal("123", song.Artist.Id);
Assert.Equal("SoundCloud Uploads", song.Album.Name);
Assert.Equal("sc-uploads-123", song.Album.Id);
Assert.Single(song.Album.Covers);
Assert.Equal("http://artwork-t500x500.jpg", song.Album.Covers.First().Url);
}

/// <summary>
/// Verifies that a SoundCloud track with no artwork is correctly mapped.
/// </summary>
[Fact]
public void MapTrackToSong_NoArtwork_ReturnsEmptyCovers() {
var user = new SoundCloudUser(123, "Test Artist", "http://avatar");
var track = new SoundCloudTrack(456, "Test Track", 180000, null, user, new SoundCloudMedia([]));

var song = SoundCloudMapper.MapTrackToSong(track);

Assert.Empty(song.Album.Covers);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using System.Net;
using System.Text.Json;
using Smoc.Streaming.SoundCloud;
using Smoc.Streaming.SoundCloud.Models;

namespace smoc.Tests.Streaming.SoundCloud;

/// <summary>
/// Tests for the <see cref="SoundCloudStreamingClient"/> class.
/// </summary>
public class SoundCloudStreamingClientServiceTest {
private class MockHandler : HttpMessageHandler {
public Func<HttpRequestMessage, Task<HttpResponseMessage>> Handler { get; set; } = req => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => Handler(request);
}

/// <summary>
/// Verifies that songs can be searched.
/// </summary>
[Fact]
public async Task SearchSongsAsync_ReturnsSongs() {
var response = new SoundCloudSearchResponse<SoundCloudTrack>(
[
new SoundCloudTrack(1, "Track 1", 1000, null, new SoundCloudUser(10, "Artist 1", "http://avatar"), new SoundCloudMedia([]))
],
null
);
var json = JsonSerializer.Serialize(response);

var handler = new MockHandler {
Handler = req => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) {
Content = new StringContent(json)
})
};

var httpClient = new HttpClient(handler);
var client = SoundCloudStreamingClient.CreateForTesting(httpClient);

var results = await client.SearchSongsAsync("query", TestContext.Current.CancellationToken);

Assert.Single(results);
Assert.Equal("Track 1", results[0].Title);
Assert.Equal("1", results[0].Id);
}

/// <summary>
/// Verifies that a song stream can be retrieved.
/// </summary>
[Fact]
public async Task GetSongStreamAsync_ReturnsStream() {
var track = new SoundCloudTrack(1, "Track 1", 1000, null, new SoundCloudUser(10, "Artist 1", "http://avatar"), new SoundCloudMedia(
[
new SoundCloudTranscoding("http://stream-meta/v1", "mp3", new SoundCloudFormat("progressive", "audio/mpeg"))
]
));
var streamInfo = new SoundCloudStreamResponse("http://actual-stream-url");

var handler = new MockHandler {
Handler = req => {
var url = req.RequestUri!.ToString();
if (url.Contains("/tracks/1")) {
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) {
Content = new StringContent(JsonSerializer.Serialize(track))
});
}
if (url.Contains("stream-meta")) {
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) {
Content = new StringContent(JsonSerializer.Serialize(streamInfo))
});
}
if (url.Contains("actual-stream-url")) {
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) {
Content = new ByteArrayContent([1, 2, 3, 4])
});
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
}
};

var httpClient = new HttpClient(handler);
var client = SoundCloudStreamingClient.CreateForTesting(httpClient);

var songStream = await client.GetSongStreamAsync("1", TestContext.Current.CancellationToken);

Assert.Equal("1", songStream.Id);
Assert.Equal("mp3", songStream.Codec);

var buffer = new byte[4];
var read = await songStream.Stream.ReadAsync(buffer, TestContext.Current.CancellationToken);
Assert.Equal(4, read);
Assert.Equal([1, 2, 3, 4], buffer);
}
}
20 changes: 20 additions & 0 deletions smoc/Configuration/SoundCloudConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Terminal.Gui.Configuration;

namespace Smoc.Configuration;

/// <summary>
/// Configuration for SoundCloud.
/// </summary>
public static class SoundCloudConfig {
/// <summary>
/// Gets or sets the SoundCloud client ID.
/// </summary>
[ConfigurationProperty(Scope = typeof(SettingsScope))]
public static string? ClientId { get; set; } = null;

/// <summary>
/// Gets or sets the SoundCloud authentication token.
/// </summary>
[ConfigurationProperty(Scope = typeof(SettingsScope))]
public static string? AuthToken { get; set; } = null;
}
9 changes: 7 additions & 2 deletions smoc/Configuration/StreamingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,10 @@ public enum StreamingService {
/// <summary>
/// Subsonic compatible API.
/// </summary>
Subsonic
}
Subsonic,

/// <summary>
/// SoundCloud.
/// </summary>
SoundCloud
}
5 changes: 5 additions & 0 deletions smoc/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Smoc.Streaming;
using Smoc.Streaming.Subsonic;
using Smoc.Streaming.YouTubeMusic;
using Smoc.Streaming.SoundCloud;
using Smoc.Ui;
using Terminal.Gui;
using Terminal.Gui.App;
Expand Down Expand Up @@ -104,6 +105,10 @@ private static IStreamingClient CreateStreamingClient() {
Logging.Information("Creating Subsonic streaming client...");
return SubsonicStreamingClient.Create(songCache, artCache);

case StreamingService.SoundCloud:
Logging.Information("Creating SoundCloud streaming client...");
return SoundCloudStreamingClient.Create(songCache, artCache);

case StreamingService.YouTubeMusic:
Logging.Information("Creating YouTube Music streaming client...");
if (!File.Exists(_cookiesPath) || !File.Exists(_tokensPath)) {
Expand Down
4 changes: 2 additions & 2 deletions smoc/Services/Audio/SoundFlow/SoundFlowAudioService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ public sealed class SoundFlowAudioService : IAudioService {

/// <inheritdoc/>
public float Volume {
get => this._playbackDevice.MasterMixer.Volume / 2.0f;
set => this._playbackDevice.MasterMixer.Volume = value * 2.0f;
get => _playbackDevice.MasterMixer.Volume / 2.0f;
set => _playbackDevice.MasterMixer.Volume = value * 2.0f;
}

/// <summary>
Expand Down
15 changes: 15 additions & 0 deletions smoc/Streaming/SoundCloud/Models/SoundCloudPlaylist.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System.Text.Json.Serialization;

namespace Smoc.Streaming.SoundCloud.Models;

/// <summary>
/// Represents a SoundCloud playlist.
/// </summary>
/// <param name="Id">The playlist ID.</param>
/// <param name="Title">The playlist title.</param>
/// <param name="Tracks">The tracks in the playlist.</param>
public record SoundCloudPlaylist(
[property: JsonPropertyName("id")] long Id,
[property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("tracks")] List<SoundCloudTrack> Tracks
);
14 changes: 14 additions & 0 deletions smoc/Streaming/SoundCloud/Models/SoundCloudSearchResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Text.Json.Serialization;

namespace Smoc.Streaming.SoundCloud.Models;

/// <summary>
/// Represents a SoundCloud search response.
/// </summary>
/// <typeparam name="T">The type of the collection items.</typeparam>
/// <param name="Collection">The collection of items.</param>
/// <param name="NextHref">The URL for the next page of results.</param>
public record SoundCloudSearchResponse<T>(
[property: JsonPropertyName("collection")] List<T> Collection,
[property: JsonPropertyName("next_href")] string? NextHref
);
11 changes: 11 additions & 0 deletions smoc/Streaming/SoundCloud/Models/SoundCloudStreamResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;

namespace Smoc.Streaming.SoundCloud.Models;

/// <summary>
/// Represents a SoundCloud stream response.
/// </summary>
/// <param name="Url">The stream URL.</param>
public record SoundCloudStreamResponse(
[property: JsonPropertyName("url")] string Url
);
51 changes: 51 additions & 0 deletions smoc/Streaming/SoundCloud/Models/SoundCloudTrack.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using System.Text.Json.Serialization;

namespace Smoc.Streaming.SoundCloud.Models;

/// <summary>
/// Represents a SoundCloud track.
/// </summary>
/// <param name="Id">The track ID.</param>
/// <param name="Title">The track title.</param>
/// <param name="Duration">The track duration in milliseconds.</param>
/// <param name="ArtworkUrl">The track artwork URL.</param>
/// <param name="User">The user who uploaded the track.</param>
/// <param name="Media">The track media info.</param>
public record SoundCloudTrack(
[property: JsonPropertyName("id")] long Id,
[property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("duration")] long Duration,
[property: JsonPropertyName("artwork_url")] string? ArtworkUrl,
[property: JsonPropertyName("user")] SoundCloudUser User,
[property: JsonPropertyName("media")] SoundCloudMedia Media
);

/// <summary>
/// Represents SoundCloud media info.
/// </summary>
/// <param name="Transcodings">The list of transcodings.</param>
public record SoundCloudMedia(
[property: JsonPropertyName("transcodings")] List<SoundCloudTranscoding> Transcodings
);

/// <summary>
/// Represents a SoundCloud transcoding.
/// </summary>
/// <param name="Url">The transcoding URL.</param>
/// <param name="Preset">The transcoding preset.</param>
/// <param name="Format">The transcoding format.</param>
public record SoundCloudTranscoding(
[property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("preset")] string Preset,
[property: JsonPropertyName("format")] SoundCloudFormat Format
);

/// <summary>
/// Represents a SoundCloud format.
/// </summary>
/// <param name="Protocol">The format protocol.</param>
/// <param name="MimeType">The format MIME type.</param>
public record SoundCloudFormat(
[property: JsonPropertyName("protocol")] string Protocol,
[property: JsonPropertyName("mime_type")] string MimeType
);
15 changes: 15 additions & 0 deletions smoc/Streaming/SoundCloud/Models/SoundCloudUser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System.Text.Json.Serialization;

namespace Smoc.Streaming.SoundCloud.Models;

/// <summary>
/// Represents a SoundCloud user.
/// </summary>
/// <param name="Id">The user ID.</param>
/// <param name="Username">The username.</param>
/// <param name="AvatarUrl">The user avatar URL.</param>
public record SoundCloudUser(
[property: JsonPropertyName("id")] long Id,
[property: JsonPropertyName("username")] string Username,
[property: JsonPropertyName("avatar_url")] string AvatarUrl
);
Loading
Loading