diff --git a/smoc.Tests/Configuration/ConfigurationBindingTest.cs b/smoc.Tests/Configuration/ConfigurationBindingTest.cs
index 88eea1f..f886962 100644
--- a/smoc.Tests/Configuration/ConfigurationBindingTest.cs
+++ b/smoc.Tests/Configuration/ConfigurationBindingTest.cs
@@ -5,7 +5,13 @@
namespace smoc.Tests.Configuration;
+///
+/// Unit tests for configuration binding using .
+///
public class ConfigurationBindingTest {
+ ///
+ /// Tests binding of .
+ ///
[Fact]
public void Bind_SmocConfiguration_FromSection_UsingTuiConfigurationBuilder() {
var json = """
@@ -39,6 +45,9 @@ public void Bind_SmocConfiguration_FromSection_UsingTuiConfigurationBuilder() {
Assert.Equal(LoudnessNormalizationMode.Full, SmocConfiguration.Defaults.LoudnessNormalizationMode);
}
+ ///
+ /// Tests binding of provider-specific configuration POCOs.
+ ///
[Fact]
public void Bind_OtherConfigs_UsingTuiConfigurationBuilder() {
var json = """
@@ -52,6 +61,13 @@ public void Bind_OtherConfigs_UsingTuiConfigurationBuilder() {
"ClientId": "sc-id",
"AuthToken": "sc-token"
},
+ "SpotifyConfig": {
+ "ClientId": "sp-id",
+ "ClientSecret": "sp-secret",
+ "Username": "sp-user",
+ "Password": "sp-pass",
+ "CacheDirectory": "/tmp/spotify-cache"
+ },
"SubsonicConfig": {
"ServerHost": "subsonic.local",
"ServerPort": 4040,
@@ -70,6 +86,7 @@ public void Bind_OtherConfigs_UsingTuiConfigurationBuilder() {
builder.RuntimeConfig = json;
builder.BindAppSettings("ListenHistoryConfig", s => ListenHistoryConfig.Defaults = s)
.BindAppSettings("SoundCloudConfig", s => SoundCloudConfig.Defaults = s)
+ .BindAppSettings("SpotifyConfig", s => SpotifyConfig.Defaults = s)
.BindAppSettings("SubsonicConfig", s => SubsonicConfig.Defaults = s)
.BindAppSettings("YouTubeMusicConfig", s => YouTubeMusicConfig.Defaults = s);
@@ -80,6 +97,12 @@ public void Bind_OtherConfigs_UsingTuiConfigurationBuilder() {
Assert.Equal("sc-id", SoundCloudConfig.Defaults.ClientId);
Assert.Equal("sc-token", SoundCloudConfig.Defaults.AuthToken);
+ Assert.Equal("sp-id", SpotifyConfig.Defaults.ClientId);
+ Assert.Equal("sp-secret", SpotifyConfig.Defaults.ClientSecret);
+ Assert.Equal("sp-user", SpotifyConfig.Defaults.Username);
+ Assert.Equal("sp-pass", SpotifyConfig.Defaults.Password);
+ Assert.Equal("/tmp/spotify-cache", SpotifyConfig.Defaults.CacheDirectory);
+
Assert.Equal("subsonic.local", SubsonicConfig.Defaults.ServerHost);
Assert.Equal(4040, SubsonicConfig.Defaults.ServerPort);
Assert.Equal("https", SubsonicConfig.Defaults.ServerScheme);
diff --git a/smoc.Tests/Streaming/Spotify/SpotifyMappingTest.cs b/smoc.Tests/Streaming/Spotify/SpotifyMappingTest.cs
new file mode 100644
index 0000000..53dc13c
--- /dev/null
+++ b/smoc.Tests/Streaming/Spotify/SpotifyMappingTest.cs
@@ -0,0 +1,294 @@
+using Smoc.Streaming;
+using Smoc.Streaming.Spotify.Util;
+using SpotifyAPI.Web;
+using Xunit;
+
+namespace smoc.Tests.Streaming.Spotify;
+
+///
+/// Unit tests for .
+///
+public class SpotifyMappingTest {
+ ///
+ /// Verifies mapping of a valid .
+ ///
+ [Fact]
+ public void MapArtist_WithSimpleArtist_MapsCorrectly() {
+ var simpleArtist = new SimpleArtist {
+ Id = "artist-1",
+ Name = "Daft Punk"
+ };
+
+ var artist = SpotifyMapper.MapArtist(simpleArtist);
+
+ Assert.Equal("artist-1", artist.Id);
+ Assert.Equal("Daft Punk", artist.Name);
+ }
+
+ ///
+ /// Verifies mapping of a null fallback.
+ ///
+ [Fact]
+ public void MapArtist_WithNullSimpleArtist_ReturnsUnknown() {
+ var artist = SpotifyMapper.MapArtist((SimpleArtist?)null);
+
+ Assert.Equal(string.Empty, artist.Id);
+ Assert.Equal("Unknown Artist", artist.Name);
+ }
+
+ ///
+ /// Verifies mapping of a valid .
+ ///
+ [Fact]
+ public void MapArtist_WithFullArtist_MapsCorrectly() {
+ var fullArtist = new FullArtist {
+ Id = "artist-2",
+ Name = "Kraftwerk"
+ };
+
+ var artist = SpotifyMapper.MapArtist(fullArtist);
+
+ Assert.Equal("artist-2", artist.Id);
+ Assert.Equal("Kraftwerk", artist.Name);
+ }
+
+ ///
+ /// Verifies mapping of a null fallback.
+ ///
+ [Fact]
+ public void MapArtist_WithNullFullArtist_ReturnsUnknown() {
+ var artist = SpotifyMapper.MapArtist((FullArtist?)null);
+
+ Assert.Equal(string.Empty, artist.Id);
+ Assert.Equal("Unknown Artist", artist.Name);
+ }
+
+ ///
+ /// Verifies mapping of a with full metadata.
+ ///
+ [Fact]
+ public void MapSimpleAlbumToAlbum_WithFullMetadata_MapsCorrectly() {
+ var album = new SimpleAlbum {
+ Id = "album-1",
+ Name = "Discovery",
+ ReleaseDate = "2001-03-12",
+ Artists = [new SimpleArtist { Id = "art-1", Name = "Daft Punk" }],
+ Images = [
+ new Image { Url = "https://img.spotify.com/300.jpg", Width = 300, Height = 300 },
+ new Image { Url = "https://img.spotify.com/64.jpg", Width = 64, Height = 64 }
+ ]
+ };
+
+ var mapped = SpotifyMapper.MapSimpleAlbumToAlbum(album);
+
+ Assert.Equal("album-1", mapped.Id);
+ Assert.Equal("Discovery", mapped.Name);
+ Assert.Equal(2001, mapped.ReleaseYear);
+ Assert.Equal("art-1", mapped.Artist.Id);
+ Assert.Equal("Daft Punk", mapped.Artist.Name);
+ Assert.Equal(2, mapped.Covers.Count());
+ var firstCover = mapped.Covers.First();
+ Assert.Equal("https://img.spotify.com/300.jpg", firstCover.Url);
+ Assert.Equal(300, firstCover.Width);
+ Assert.Equal(300, firstCover.Height);
+ }
+
+ ///
+ /// Verifies mapping of a with null/empty collections.
+ ///
+ [Fact]
+ public void MapSimpleAlbumToAlbum_WithNullCollections_HandlesDefensively() {
+ var album = new SimpleAlbum {
+ Id = null!,
+ Name = null!,
+ ReleaseDate = null!,
+ Artists = null!,
+ Images = null!
+ };
+
+ var mapped = SpotifyMapper.MapSimpleAlbumToAlbum(album);
+
+ Assert.Equal(string.Empty, mapped.Id);
+ Assert.Equal("Unknown Album", mapped.Name);
+ Assert.Null(mapped.ReleaseYear);
+ Assert.Equal(string.Empty, mapped.Artist.Id);
+ Assert.Equal("Unknown Artist", mapped.Artist.Name);
+ Assert.Empty(mapped.Covers);
+ }
+
+ ///
+ /// Verifies mapping of a with full metadata.
+ ///
+ [Fact]
+ public void MapFullAlbumToAlbum_WithFullMetadata_MapsCorrectly() {
+ var album = new FullAlbum {
+ Id = "album-full-1",
+ Name = "Random Access Memories",
+ ReleaseDate = "2013-05-17",
+ Artists = [new SimpleArtist { Id = "art-1", Name = "Daft Punk" }],
+ Images = [new Image { Url = "https://img.spotify.com/640.jpg", Width = 640, Height = 640 }]
+ };
+
+ var mapped = SpotifyMapper.MapFullAlbumToAlbum(album);
+
+ Assert.Equal("album-full-1", mapped.Id);
+ Assert.Equal("Random Access Memories", mapped.Name);
+ Assert.Equal(2013, mapped.ReleaseYear);
+ Assert.Equal("art-1", mapped.Artist.Id);
+ Assert.Equal("Daft Punk", mapped.Artist.Name);
+ Assert.Single(mapped.Covers);
+ }
+
+ ///
+ /// Verifies mapping of a with full metadata.
+ ///
+ [Fact]
+ public void MapTrackToSong_WithFullTrack_MapsCorrectly() {
+ var track = new FullTrack {
+ Id = "track-1",
+ Name = "One More Time",
+ DurationMs = 320000,
+ TrackNumber = 1,
+ Artists = [new SimpleArtist { Id = "art-1", Name = "Daft Punk" }],
+ Album = new SimpleAlbum {
+ Id = "alb-1",
+ Name = "Discovery",
+ ReleaseDate = "2001",
+ Artists = [new SimpleArtist { Id = "art-1", Name = "Daft Punk" }],
+ Images = [new Image { Url = "https://img.spotify.com/cover.jpg", Width = 300, Height = 300 }]
+ }
+ };
+
+ var song = SpotifyMapper.MapTrackToSong(track);
+
+ Assert.Equal("track-1", song.Id);
+ Assert.Equal("One More Time", song.Title);
+ Assert.Equal(TimeSpan.FromMilliseconds(320000), song.Duration);
+ Assert.Equal(1, song.TrackNumber);
+ Assert.Equal("art-1", song.Artist.Id);
+ Assert.Equal("Daft Punk", song.Artist.Name);
+ Assert.Equal("alb-1", song.Album.Id);
+ Assert.Equal("Discovery", song.Album.Name);
+ Assert.Equal(2001, song.Album.ReleaseYear);
+ }
+
+ ///
+ /// Verifies mapping of a with null/empty artists and album.
+ ///
+ [Fact]
+ public void MapTrackToSong_WithNullProperties_HandlesDefensively() {
+ var track = new FullTrack {
+ Id = null!,
+ Name = null!,
+ DurationMs = 0,
+ TrackNumber = 0,
+ Artists = null!,
+ Album = null!
+ };
+
+ var song = SpotifyMapper.MapTrackToSong(track);
+
+ Assert.Equal(string.Empty, song.Id);
+ Assert.Equal("Unknown Title", song.Title);
+ Assert.Equal(TimeSpan.Zero, song.Duration);
+ Assert.Null(song.TrackNumber);
+ Assert.Equal(string.Empty, song.Artist.Id);
+ Assert.Equal("Unknown Artist", song.Artist.Name);
+ Assert.Equal(string.Empty, song.Album.Id);
+ Assert.Equal("Unknown Album", song.Album.Name);
+ }
+
+ ///
+ /// Verifies mapping of a where artist is fallback from album artists.
+ ///
+ [Fact]
+ public void MapTrackToSong_FallsBackToAlbumArtist_WhenTrackArtistsEmpty() {
+ var track = new FullTrack {
+ Id = "track-2",
+ Name = "Aerodynamic",
+ DurationMs = 210000,
+ Artists = [],
+ Album = new SimpleAlbum {
+ Id = "alb-1",
+ Name = "Discovery",
+ Artists = [new SimpleArtist { Id = "art-fallback", Name = "Album Artist" }]
+ }
+ };
+
+ var song = SpotifyMapper.MapTrackToSong(track);
+
+ Assert.Equal("art-fallback", song.Artist.Id);
+ Assert.Equal("Album Artist", song.Artist.Name);
+ }
+
+ ///
+ /// Verifies mapping of a .
+ ///
+ [Fact]
+ public void MapSimpleTrackToSong_MapsCorrectly() {
+ var artist = new Artist("art-1", "Daft Punk");
+ var album = new Album("alb-1", artist, "Discovery", []);
+ var simpleTrack = new SimpleTrack {
+ Id = "simple-1",
+ Name = "Digital Love",
+ DurationMs = 301000,
+ TrackNumber = 3
+ };
+
+ var song = SpotifyMapper.MapSimpleTrackToSong(simpleTrack, album);
+
+ Assert.Equal("simple-1", song.Id);
+ Assert.Equal("Digital Love", song.Title);
+ Assert.Equal(album, song.Album);
+ Assert.Equal(3, song.TrackNumber);
+ Assert.Equal(TimeSpan.FromMilliseconds(301000), song.Duration);
+ }
+
+ ///
+ /// Verifies parsing of various release date formats.
+ ///
+ /// The input date string.
+ /// The expected parsed year.
+ [Theory]
+ [InlineData("1994", 1994)]
+ [InlineData("1994-03-08", 1994)]
+ [InlineData("1994-03", 1994)]
+ [InlineData("2026-12-31", 2026)]
+ [InlineData(null, null)]
+ [InlineData("", null)]
+ [InlineData(" ", null)]
+ [InlineData("abc", null)]
+ [InlineData("invalid-date", null)]
+ [InlineData("-100", null)]
+ [InlineData("0000", null)]
+ public void ParseReleaseYear_WithVariousInputs_ReturnsExpected(string? dateString, int? expectedYear) {
+ var result = SpotifyMapper.ParseReleaseYear(dateString);
+ Assert.Equal(expectedYear, result);
+ }
+
+ ///
+ /// Verifies mapping of images to covers and defensive handling of null or empty URLs.
+ ///
+ [Fact]
+ public void MapImagesToCovers_HandlesNullAndEmptyCorrectly() {
+ Assert.Empty(SpotifyMapper.MapImagesToCovers(null));
+ Assert.Empty(SpotifyMapper.MapImagesToCovers([]));
+
+ var images = new List {
+ new Image { Url = "https://example.com/img1.jpg", Width = 300, Height = 300 },
+ new Image { Url = "", Width = 100, Height = 100 },
+ new Image { Url = null!, Width = 50, Height = 50 },
+ new Image { Url = "https://example.com/img2.jpg", Width = 600, Height = 600 }
+ };
+
+ var covers = SpotifyMapper.MapImagesToCovers(images);
+
+ Assert.Equal(2, covers.Count);
+ Assert.Equal("https://example.com/img1.jpg", covers[0].Url);
+ Assert.Equal(300, covers[0].Width);
+ Assert.Equal(300, covers[0].Height);
+ Assert.Equal("https://example.com/img2.jpg", covers[1].Url);
+ Assert.Equal(600, covers[1].Width);
+ Assert.Equal(600, covers[1].Height);
+ }
+}
diff --git a/smoc.Tests/Streaming/Spotify/SpotifyStreamingClientTest.cs b/smoc.Tests/Streaming/Spotify/SpotifyStreamingClientTest.cs
new file mode 100644
index 0000000..f3217ef
--- /dev/null
+++ b/smoc.Tests/Streaming/Spotify/SpotifyStreamingClientTest.cs
@@ -0,0 +1,361 @@
+using System.Net;
+using Moq;
+using Smoc.Configuration;
+using Smoc.Services.Caching;
+using Smoc.Streaming;
+using Smoc.Streaming.Spotify;
+using SpotifyAPI.Web;
+using Xunit;
+
+namespace smoc.Tests.Streaming.Spotify;
+
+///
+/// Unit tests for .
+///
+public class SpotifyStreamingClientTest {
+ ///
+ /// Verifies factory method creates an instance.
+ ///
+ [Fact]
+ public void Create_InitializesCorrectly() {
+ using var client = SpotifyStreamingClient.Create(new NoCachingCacheService(), new NoCachingCacheService());
+ Assert.NotNull(client);
+ }
+
+ ///
+ /// Verifies testing factory method creates an instance with injected mock.
+ ///
+ [Fact]
+ public void CreateForTesting_InitializesCorrectly() {
+ var mockSpotify = new Mock();
+ using var client = SpotifyStreamingClient.CreateForTesting(mockSpotify.Object);
+ Assert.NotNull(client);
+ }
+
+ ///
+ /// Verifies that playback stream request throws with descriptive message.
+ ///
+ [Fact]
+ public async Task GetSongStreamAsync_ThrowsNotSupportedException() {
+ var mockSpotify = new Mock();
+ using var client = SpotifyStreamingClient.CreateForTesting(mockSpotify.Object);
+
+ var ex = await Assert.ThrowsAsync(() => client.GetSongStreamAsync("song-123", TestContext.Current.CancellationToken));
+ Assert.Contains("Spotify playback is not supported yet (requires Librespot integration)", ex.Message);
+ }
+
+ ///
+ /// Verifies that GetLikedSongsAsync returns an empty list.
+ ///
+ [Fact]
+ public async Task GetLikedSongsAsync_ReturnsEmptyList() {
+ var mockSpotify = new Mock();
+ using var client = SpotifyStreamingClient.CreateForTesting(mockSpotify.Object);
+
+ var songs = await client.GetLikedSongsAsync(TestContext.Current.CancellationToken);
+ Assert.NotNull(songs);
+ Assert.Empty(songs);
+ }
+
+ ///
+ /// Verifies that AddToListenHistory completes without throwing.
+ ///
+ [Fact]
+ public async Task AddToListenHistory_CompletesSuccessfully() {
+ var mockSpotify = new Mock();
+ using var client = SpotifyStreamingClient.CreateForTesting(mockSpotify.Object);
+
+ var artist = new Artist("a1", "Artist 1");
+ var album = new Album("alb1", artist, "Album 1", []);
+ var song = new Song("s1", album, "Song 1", TimeSpan.FromMinutes(3));
+
+ await client.AddToListenHistory(song, TestContext.Current.CancellationToken);
+ }
+
+ ///
+ /// Verifies searching artists maps returned artists.
+ ///
+ [Fact]
+ public async Task SearchArtistsAsync_ReturnsMappedArtists() {
+ var mockSpotify = new Mock();
+ var mockSearch = new Mock();
+ mockSpotify.Setup(s => s.Search).Returns(mockSearch.Object);
+
+ mockSearch.Setup(s => s.Item(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new SearchResponse {
+ Artists = new Paging {
+ Items = [new FullArtist { Id = "art-1", Name = "Daft Punk" }]
+ }
+ });
+
+ using var client = SpotifyStreamingClient.CreateForTesting(mockSpotify.Object);
+ var artists = await client.SearchArtistsAsync("Daft Punk", TestContext.Current.CancellationToken);
+
+ Assert.Single(artists);
+ Assert.Equal("art-1", artists[0].Id);
+ Assert.Equal("Daft Punk", artists[0].Name);
+ }
+
+ ///
+ /// Verifies searching songs maps returned tracks.
+ ///
+ [Fact]
+ public async Task SearchSongsAsync_ReturnsMappedSongs() {
+ var mockSpotify = new Mock();
+ var mockSearch = new Mock();
+ mockSpotify.Setup(s => s.Search).Returns(mockSearch.Object);
+
+ mockSearch.Setup(s => s.Item(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new SearchResponse {
+ Tracks = new Paging {
+ Items = [
+ new FullTrack {
+ Id = "track-1",
+ Name = "Get Lucky",
+ DurationMs = 240000,
+ TrackNumber = 8,
+ Artists = [new SimpleArtist { Id = "art-1", Name = "Daft Punk" }],
+ Album = new SimpleAlbum { Id = "alb-1", Name = "RAM", ReleaseDate = "2013" }
+ }
+ ]
+ }
+ });
+
+ using var client = SpotifyStreamingClient.CreateForTesting(mockSpotify.Object);
+ var songs = await client.SearchSongsAsync("Get Lucky", TestContext.Current.CancellationToken);
+
+ Assert.Single(songs);
+ Assert.Equal("track-1", songs[0].Id);
+ Assert.Equal("Get Lucky", songs[0].Title);
+ Assert.Equal("Daft Punk", songs[0].Artist.Name);
+ }
+
+ ///
+ /// Verifies GetSongAsync retrieves and maps a track.
+ ///
+ [Fact]
+ public async Task GetSongAsync_ReturnsMappedSong() {
+ var mockSpotify = new Mock();
+ var mockTracks = new Mock();
+ mockSpotify.Setup(s => s.Tracks).Returns(mockTracks.Object);
+
+ mockTracks.Setup(t => t.Get("track-99", It.IsAny()))
+ .ReturnsAsync(new FullTrack {
+ Id = "track-99",
+ Name = "Harder, Better, Faster, Stronger",
+ DurationMs = 224000,
+ TrackNumber = 4,
+ Artists = [new SimpleArtist { Id = "art-1", Name = "Daft Punk" }],
+ Album = new SimpleAlbum { Id = "alb-1", Name = "Discovery", ReleaseDate = "2001" }
+ });
+
+ using var client = SpotifyStreamingClient.CreateForTesting(mockSpotify.Object);
+ var song = await client.GetSongAsync("track-99", TestContext.Current.CancellationToken);
+
+ Assert.Equal("track-99", song.Id);
+ Assert.Equal("Harder, Better, Faster, Stronger", song.Title);
+ Assert.Equal(4, song.TrackNumber);
+ Assert.Equal(TimeSpan.FromMilliseconds(224000), song.Duration);
+ }
+
+ ///
+ /// Verifies GetArtistAsync retrieves and maps an artist.
+ ///
+ [Fact]
+ public async Task GetArtistAsync_ReturnsMappedArtist() {
+ var mockSpotify = new Mock();
+ var mockArtists = new Mock();
+ mockSpotify.Setup(s => s.Artists).Returns(mockArtists.Object);
+
+ mockArtists.Setup(a => a.Get("art-1", It.IsAny()))
+ .ReturnsAsync(new FullArtist { Id = "art-1", Name = "Daft Punk" });
+
+ using var client = SpotifyStreamingClient.CreateForTesting(mockSpotify.Object);
+ var artist = await client.GetArtistAsync("art-1", TestContext.Current.CancellationToken);
+
+ Assert.Equal("art-1", artist.Id);
+ Assert.Equal("Daft Punk", artist.Name);
+ }
+
+ ///
+ /// Verifies GetAlbumsByArtistAsync retrieves and maps albums.
+ ///
+ [Fact]
+ public async Task GetAlbumsByArtistAsync_ReturnsMappedAlbums() {
+ var mockSpotify = new Mock();
+ var mockArtists = new Mock();
+ mockSpotify.Setup(s => s.Artists).Returns(mockArtists.Object);
+
+ mockArtists.Setup(a => a.GetAlbums("art-1", It.IsAny()))
+ .ReturnsAsync(new Paging {
+ Items = [
+ new SimpleAlbum {
+ Id = "alb-1",
+ Name = "Homework",
+ ReleaseDate = "1997",
+ Artists = [new SimpleArtist { Id = "art-1", Name = "Daft Punk" }]
+ }
+ ]
+ });
+
+ using var client = SpotifyStreamingClient.CreateForTesting(mockSpotify.Object);
+ var artist = new Artist("art-1", "Daft Punk");
+ var albums = await client.GetAlbumsByArtistAsync(artist, TestContext.Current.CancellationToken);
+
+ Assert.Single(albums);
+ Assert.Equal("alb-1", albums[0].Id);
+ Assert.Equal("Homework", albums[0].Name);
+ Assert.Equal(1997, albums[0].ReleaseYear);
+ }
+
+ ///
+ /// Verifies GetSongsByAlbumAsync retrieves and maps album tracks.
+ ///
+ [Fact]
+ public async Task GetSongsByAlbumAsync_ReturnsMappedSongs() {
+ var mockSpotify = new Mock();
+ var mockAlbums = new Mock();
+ mockSpotify.Setup(s => s.Albums).Returns(mockAlbums.Object);
+
+ mockAlbums.Setup(a => a.GetTracks("alb-1", It.IsAny()))
+ .ReturnsAsync(new Paging {
+ Items = [
+ new SimpleTrack {
+ Id = "st-1",
+ Name = "Da Funk",
+ DurationMs = 328000,
+ TrackNumber = 4
+ }
+ ]
+ });
+
+ using var client = SpotifyStreamingClient.CreateForTesting(mockSpotify.Object);
+ var artist = new Artist("art-1", "Daft Punk");
+ var album = new Album("alb-1", artist, "Homework", []);
+ var songs = await client.GetSongsByAlbumAsync(album, TestContext.Current.CancellationToken);
+
+ Assert.Single(songs);
+ Assert.Equal("st-1", songs[0].Id);
+ Assert.Equal("Da Funk", songs[0].Title);
+ Assert.Equal(4, songs[0].TrackNumber);
+ Assert.Equal(album, songs[0].Album);
+ }
+
+ ///
+ /// Verifies SearchPlaylistsAsync retrieves and maps playlists.
+ ///
+ [Fact]
+ public async Task SearchPlaylistsAsync_ReturnsMappedPlaylists() {
+ var mockSpotify = new Mock();
+ var mockSearch = new Mock();
+ mockSpotify.Setup(s => s.Search).Returns(mockSearch.Object);
+
+ mockSearch.Setup(s => s.Item(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new SearchResponse {
+ Playlists = new Paging {
+ Items = [
+ new FullPlaylist { Id = "pl-1", Name = "Best of Electronic" }
+ ]
+ }
+ });
+
+ using var client = SpotifyStreamingClient.CreateForTesting(mockSpotify.Object);
+ var playlists = await client.SearchPlaylistsAsync("Electronic", TestContext.Current.CancellationToken);
+
+ Assert.Single(playlists);
+ Assert.Equal("pl-1", playlists[0].Id);
+ Assert.Equal("Best of Electronic", playlists[0].Name);
+ }
+
+ ///
+ /// Verifies GetPlaylistSongsAsync retrieves and maps full tracks in a playlist.
+ ///
+ [Fact]
+ public async Task GetPlaylistSongsAsync_ReturnsMappedSongs() {
+ var mockSpotify = new Mock();
+ var mockPlaylists = new Mock();
+ mockSpotify.Setup(s => s.Playlists).Returns(mockPlaylists.Object);
+
+ mockPlaylists.Setup(p => p.GetPlaylistItems("pl-1", It.IsAny()))
+ .ReturnsAsync(new Paging> {
+ Items = [
+ new PlaylistTrack {
+ Track = new FullTrack {
+ Id = "track-pl-1",
+ Name = "Around the World",
+ DurationMs = 429000,
+ TrackNumber = 7,
+ Artists = [new SimpleArtist { Id = "art-1", Name = "Daft Punk" }],
+ Album = new SimpleAlbum { Id = "alb-1", Name = "Homework", ReleaseDate = "1997" }
+ }
+ }
+ ]
+ });
+
+ using var client = SpotifyStreamingClient.CreateForTesting(mockSpotify.Object);
+ var playlist = new Playlist("pl-1", "Daft Punk Best");
+ var songs = await client.GetPlaylistSongsAsync(playlist, TestContext.Current.CancellationToken);
+
+ Assert.Single(songs);
+ Assert.Equal("track-pl-1", songs[0].Id);
+ Assert.Equal("Around the World", songs[0].Title);
+ }
+
+ ///
+ /// Verifies GetPlaylistSongsFromUrlAsync handles track, playlist, and album URLs.
+ ///
+ [Fact]
+ public async Task GetPlaylistSongsFromUrlAsync_WithTrackUrl_ReturnsSong() {
+ var mockSpotify = new Mock();
+ var mockTracks = new Mock();
+ mockSpotify.Setup(s => s.Tracks).Returns(mockTracks.Object);
+
+ mockTracks.Setup(t => t.Get("track-url-1", It.IsAny()))
+ .ReturnsAsync(new FullTrack {
+ Id = "track-url-1",
+ Name = "Instant Crush",
+ DurationMs = 337000,
+ TrackNumber = 5,
+ Artists = [new SimpleArtist { Id = "art-1", Name = "Daft Punk" }],
+ Album = new SimpleAlbum { Id = "alb-ram", Name = "RAM", ReleaseDate = "2013" }
+ });
+
+ using var client = SpotifyStreamingClient.CreateForTesting(mockSpotify.Object);
+ var songs = await client.GetPlaylistSongsFromUrlAsync("https://open.spotify.com/track/track-url-1", TestContext.Current.CancellationToken);
+
+ Assert.Single(songs);
+ Assert.Equal("track-url-1", songs[0].Id);
+ Assert.Equal("Instant Crush", songs[0].Title);
+ }
+
+ ///
+ /// Verifies that calling an operation without configuring ClientId and ClientSecret throws .
+ ///
+ [Fact]
+ public async Task EnsureClient_WithoutCredentials_ThrowsInvalidOperationException() {
+ var previousId = SpotifyConfig.Defaults.ClientId;
+ var previousSecret = SpotifyConfig.Defaults.ClientSecret;
+ try {
+ SpotifyConfig.Defaults.ClientId = null;
+ SpotifyConfig.Defaults.ClientSecret = null;
+
+ using var client = SpotifyStreamingClient.Create();
+ await Assert.ThrowsAsync(() => client.SearchArtistsAsync("test", TestContext.Current.CancellationToken));
+ } finally {
+ SpotifyConfig.Defaults.ClientId = previousId;
+ SpotifyConfig.Defaults.ClientSecret = previousSecret;
+ }
+ }
+
+ ///
+ /// Verifies that Dispose can be called multiple times safely.
+ ///
+ [Fact]
+ public void Dispose_CanBeCalledMultipleTimes() {
+ var mockSpotify = new Mock();
+ var client = SpotifyStreamingClient.CreateForTesting(mockSpotify.Object);
+
+ client.Dispose();
+ client.Dispose();
+ }
+}
diff --git a/smoc.Tests/Streaming/Spotify/SpotifyUrlParserTest.cs b/smoc.Tests/Streaming/Spotify/SpotifyUrlParserTest.cs
new file mode 100644
index 0000000..507b8c4
--- /dev/null
+++ b/smoc.Tests/Streaming/Spotify/SpotifyUrlParserTest.cs
@@ -0,0 +1,116 @@
+using Smoc.Streaming;
+using Smoc.Streaming.Spotify.Util;
+using Xunit;
+
+namespace smoc.Tests.Streaming.Spotify;
+
+///
+/// Unit tests for .
+///
+public class SpotifyUrlParserTest {
+ ///
+ /// Verifies parsing of standard Spotify track URLs with or without query strings and international prefixes.
+ ///
+ /// The track URL to parse.
+ /// The expected track ID.
+ [Theory]
+ [InlineData("https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT", "4cOdK2wGLETKBW3PvgPWqT")]
+ [InlineData("https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT?si=abc12345", "4cOdK2wGLETKBW3PvgPWqT")]
+ [InlineData("http://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT", "4cOdK2wGLETKBW3PvgPWqT")]
+ [InlineData("open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT", "4cOdK2wGLETKBW3PvgPWqT")]
+ [InlineData("https://open.spotify.com/intl-de/track/4cOdK2wGLETKBW3PvgPWqT", "4cOdK2wGLETKBW3PvgPWqT")]
+ [InlineData("https://open.spotify.com/intl-ja/track/4cOdK2wGLETKBW3PvgPWqT?si=xyz", "4cOdK2wGLETKBW3PvgPWqT")]
+ public void ParseUrl_WithTrackUrl_ReturnsSongTypeAndId(string url, string expectedId) {
+ var (entityType, id) = SpotifyUrlParser.ParseUrl(url);
+
+ Assert.Equal(typeof(Song), entityType);
+ Assert.Equal(expectedId, id);
+ }
+
+ ///
+ /// Verifies parsing of standard Spotify album URLs.
+ ///
+ /// The album URL to parse.
+ /// The expected album ID.
+ [Theory]
+ [InlineData("https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy", "4aawyAB9vmqN3uQ7FjRGTy")]
+ [InlineData("https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy?si=share123", "4aawyAB9vmqN3uQ7FjRGTy")]
+ [InlineData("open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy", "4aawyAB9vmqN3uQ7FjRGTy")]
+ [InlineData("https://open.spotify.com/intl-fr/album/4aawyAB9vmqN3uQ7FjRGTy", "4aawyAB9vmqN3uQ7FjRGTy")]
+ public void ParseUrl_WithAlbumUrl_ReturnsAlbumTypeAndId(string url, string expectedId) {
+ var (entityType, id) = SpotifyUrlParser.ParseUrl(url);
+
+ Assert.Equal(typeof(Album), entityType);
+ Assert.Equal(expectedId, id);
+ }
+
+ ///
+ /// Verifies parsing of standard Spotify playlist URLs.
+ ///
+ /// The playlist URL to parse.
+ /// The expected playlist ID.
+ [Theory]
+ [InlineData("https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", "37i9dQZF1DXcBWIGoYBM5M")]
+ [InlineData("https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M?si=play123", "37i9dQZF1DXcBWIGoYBM5M")]
+ [InlineData("open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", "37i9dQZF1DXcBWIGoYBM5M")]
+ [InlineData("https://open.spotify.com/intl-es/playlist/37i9dQZF1DXcBWIGoYBM5M", "37i9dQZF1DXcBWIGoYBM5M")]
+ public void ParseUrl_WithPlaylistUrl_ReturnsPlaylistTypeAndId(string url, string expectedId) {
+ var (entityType, id) = SpotifyUrlParser.ParseUrl(url);
+
+ Assert.Equal(typeof(Playlist), entityType);
+ Assert.Equal(expectedId, id);
+ }
+
+ ///
+ /// Verifies parsing of standard Spotify artist URLs.
+ ///
+ /// The artist URL to parse.
+ /// The expected artist ID.
+ [Theory]
+ [InlineData("https://open.spotify.com/artist/0OdUWJ0sBjDrqHygGUXeCF", "0OdUWJ0sBjDrqHygGUXeCF")]
+ [InlineData("https://open.spotify.com/artist/0OdUWJ0sBjDrqHygGUXeCF?si=art123", "0OdUWJ0sBjDrqHygGUXeCF")]
+ public void ParseUrl_WithArtistUrl_ReturnsArtistTypeAndId(string url, string expectedId) {
+ var (entityType, id) = SpotifyUrlParser.ParseUrl(url);
+
+ Assert.Equal(typeof(Artist), entityType);
+ Assert.Equal(expectedId, id);
+ }
+
+ ///
+ /// Verifies parsing of spotify:... URI formats.
+ ///
+ /// The URI to parse.
+ /// The expected entity type.
+ /// The expected entity ID.
+ [Theory]
+ [InlineData("spotify:track:4cOdK2wGLETKBW3PvgPWqT", typeof(Song), "4cOdK2wGLETKBW3PvgPWqT")]
+ [InlineData("spotify:album:4aawyAB9vmqN3uQ7FjRGTy", typeof(Album), "4aawyAB9vmqN3uQ7FjRGTy")]
+ [InlineData("spotify:playlist:37i9dQZF1DXcBWIGoYBM5M", typeof(Playlist), "37i9dQZF1DXcBWIGoYBM5M")]
+ [InlineData("spotify:artist:0OdUWJ0sBjDrqHygGUXeCF", typeof(Artist), "0OdUWJ0sBjDrqHygGUXeCF")]
+ [InlineData("spotify:track:4cOdK2wGLETKBW3PvgPWqT?si=abc", typeof(Song), "4cOdK2wGLETKBW3PvgPWqT")]
+ public void ParseUrl_WithSpotifyUri_ReturnsCorrectTypeAndId(string uri, Type expectedType, string expectedId) {
+ var (entityType, id) = SpotifyUrlParser.ParseUrl(uri);
+
+ Assert.Equal(expectedType, entityType);
+ Assert.Equal(expectedId, id);
+ }
+
+ ///
+ /// Verifies that invalid, unsupported, or non-Spotify URLs and URIs throw .
+ ///
+ /// The invalid URL or URI.
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("https://music.youtube.com/watch?v=12345")]
+ [InlineData("https://soundcloud.com/artist/track")]
+ [InlineData("spotify:user:someone")]
+ [InlineData("spotify:invalid")]
+ [InlineData("spotify:")]
+ [InlineData("https://open.spotify.com/unsupported/12345")]
+ [InlineData("not-a-url")]
+ public void ParseUrl_WithInvalidOrUnsupportedUrls_ThrowsArgumentException(string? invalidUrl) {
+ Assert.Throws(() => SpotifyUrlParser.ParseUrl(invalidUrl!));
+ }
+}
diff --git a/smoc/Configuration/SpotifyConfig.cs b/smoc/Configuration/SpotifyConfig.cs
new file mode 100644
index 0000000..7dd81cc
--- /dev/null
+++ b/smoc/Configuration/SpotifyConfig.cs
@@ -0,0 +1,36 @@
+namespace Smoc.Configuration;
+
+///
+/// Configuration for Spotify.
+///
+public class SpotifyConfig {
+ ///
+ /// Gets or sets the Spotify Client ID.
+ ///
+ public string? ClientId { get; set; } = null;
+
+ ///
+ /// Gets or sets the Spotify Client Secret.
+ ///
+ public string? ClientSecret { get; set; } = null;
+
+ ///
+ /// Gets or sets the Spotify username.
+ ///
+ public string? Username { get; set; } = null;
+
+ ///
+ /// Gets or sets the Spotify password.
+ ///
+ public string? Password { get; set; } = null;
+
+ ///
+ /// Gets or sets the Spotify cache directory.
+ ///
+ public string? CacheDirectory { get; set; } = null;
+
+ ///
+ /// The static facade instance containing the current effective values.
+ ///
+ public static SpotifyConfig Defaults { get; set; } = new();
+}
diff --git a/smoc/Configuration/StreamingService.cs b/smoc/Configuration/StreamingService.cs
index 34b464a..92fc8e4 100644
--- a/smoc/Configuration/StreamingService.cs
+++ b/smoc/Configuration/StreamingService.cs
@@ -17,5 +17,10 @@ public enum StreamingService {
///
/// SoundCloud.
///
- SoundCloud
-}
\ No newline at end of file
+ SoundCloud,
+
+ ///
+ /// Spotify.
+ ///
+ Spotify
+}
diff --git a/smoc/Program.cs b/smoc/Program.cs
index dc4d78d..c4969c3 100644
--- a/smoc/Program.cs
+++ b/smoc/Program.cs
@@ -8,6 +8,7 @@
using Smoc.Streaming.Subsonic;
using Smoc.Streaming.YouTubeMusic;
using Smoc.Streaming.SoundCloud;
+using Smoc.Streaming.Spotify;
using Smoc.Ui;
using Terminal.Gui;
using Terminal.Gui.App;
@@ -50,6 +51,7 @@ public static async Task Main(string[] args) {
builder.BindAppSettings("SmocConfiguration", s => SmocConfiguration.Defaults = s)
.BindAppSettings("ListenHistoryConfig", s => ListenHistoryConfig.Defaults = s)
.BindAppSettings("SoundCloudConfig", s => SoundCloudConfig.Defaults = s)
+ .BindAppSettings("SpotifyConfig", s => SpotifyConfig.Defaults = s)
.BindAppSettings("SubsonicConfig", s => SubsonicConfig.Defaults = s)
.BindAppSettings("YouTubeMusicConfig", s => YouTubeMusicConfig.Defaults = s);
@@ -114,6 +116,10 @@ private static IStreamingClient CreateStreamingClient() {
Logging.Information("Creating Subsonic streaming client...");
return SubsonicStreamingClient.Create(songCache, artCache);
+ case StreamingService.Spotify:
+ Logging.Information("Creating Spotify streaming client...");
+ return SpotifyStreamingClient.Create(songCache, artCache);
+
case StreamingService.SoundCloud:
Logging.Information("Creating SoundCloud streaming client...");
return SoundCloudStreamingClient.Create(songCache, artCache);
diff --git a/smoc/Streaming/Spotify/SpotifyStreamingClient.cs b/smoc/Streaming/Spotify/SpotifyStreamingClient.cs
new file mode 100644
index 0000000..585b077
--- /dev/null
+++ b/smoc/Streaming/Spotify/SpotifyStreamingClient.cs
@@ -0,0 +1,226 @@
+using Terminal.Gui.App;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+using Smoc.Configuration;
+using Smoc.Services;
+using Smoc.Services.Caching;
+using Smoc.Streaming.Spotify.Util;
+using SpotifyAPI.Web;
+
+namespace Smoc.Streaming.Spotify;
+
+///
+/// A streaming client implementation for Spotify.
+///
+public sealed class SpotifyStreamingClient : IStreamingClient, IDisposable {
+ private readonly ICacheService _songCacheService;
+ private readonly ICacheService _albumArtCacheService;
+ private readonly HttpClient _httpClient;
+ private readonly bool _disposeHttpClient;
+ private readonly SemaphoreSlim _initLock = new(1, 1);
+ private ISpotifyClient? _spotifyClient;
+ private bool _isDisposed;
+
+ private SpotifyStreamingClient(
+ ICacheService? songCacheService = null,
+ ICacheService? albumArtCacheService = null,
+ ISpotifyClient? spotifyClient = null,
+ HttpClient? httpClient = null) {
+ _songCacheService = songCacheService ?? new NoCachingCacheService();
+ _albumArtCacheService = albumArtCacheService ?? new NoCachingCacheService();
+ _spotifyClient = spotifyClient;
+ if (httpClient != null) {
+ _httpClient = httpClient;
+ _disposeHttpClient = false;
+ } else {
+ _httpClient = new HttpClient();
+ _disposeHttpClient = true;
+ }
+ }
+
+ ///
+ /// Creates a new instance of .
+ ///
+ /// Optional song cache service.
+ /// Optional album art cache service.
+ /// A new instance.
+ public static SpotifyStreamingClient Create(ICacheService? songCacheService = null, ICacheService? albumArtCacheService = null) {
+ return new SpotifyStreamingClient(songCacheService, albumArtCacheService);
+ }
+
+ ///
+ /// Creates a new instance of for testing with a mock or custom Spotify client.
+ ///
+ /// The instance to use.
+ /// Optional song cache service.
+ /// Optional album art cache service.
+ /// Optional HTTP client.
+ /// A new instance.
+ public static SpotifyStreamingClient CreateForTesting(
+ ISpotifyClient spotifyClient,
+ ICacheService? songCacheService = null,
+ ICacheService? albumArtCacheService = null,
+ HttpClient? httpClient = null) {
+ return new SpotifyStreamingClient(songCacheService, albumArtCacheService, spotifyClient, httpClient);
+ }
+
+ private async Task EnsureSpotifyClientAsync(CancellationToken cancellationToken = default) {
+ if (_spotifyClient != null) {
+ return;
+ }
+
+ await _initLock.WaitAsync(cancellationToken);
+ try {
+ if (_spotifyClient != null) {
+ return;
+ }
+
+ if (string.IsNullOrEmpty(SpotifyConfig.Defaults.ClientId) || string.IsNullOrEmpty(SpotifyConfig.Defaults.ClientSecret)) {
+ Logging.Error("Spotify Client ID and Client Secret must be configured.");
+ throw new InvalidOperationException("Spotify Client ID and Client Secret must be configured.");
+ }
+
+ var config = SpotifyClientConfig.CreateDefault()
+ .WithAuthenticator(new ClientCredentialsAuthenticator(
+ SpotifyConfig.Defaults.ClientId!,
+ SpotifyConfig.Defaults.ClientSecret!));
+ _spotifyClient = new SpotifyClient(config);
+ } finally {
+ _initLock.Release();
+ }
+ }
+
+ ///
+ public async Task> SearchArtistsAsync(string query, CancellationToken cancellationToken = default) {
+ await EnsureSpotifyClientAsync(cancellationToken);
+ var searchRequest = new SearchRequest(SearchRequest.Types.Artist, query);
+ var searchResponse = await _spotifyClient!.Search.Item(searchRequest, cancellationToken);
+ return searchResponse.Artists?.Items?.Select(SpotifyMapper.MapArtist).ToList() ?? [];
+ }
+
+ ///
+ public async Task> SearchSongsAsync(string query, CancellationToken cancellationToken = default) {
+ await EnsureSpotifyClientAsync(cancellationToken);
+ var searchRequest = new SearchRequest(SearchRequest.Types.Track, query);
+ var searchResponse = await _spotifyClient!.Search.Item(searchRequest, cancellationToken);
+ return searchResponse.Tracks?.Items?.Select(SpotifyMapper.MapTrackToSong).ToList() ?? [];
+ }
+
+ ///
+ public async Task GetSongAsync(string songId, CancellationToken cancellationToken = default) {
+ await EnsureSpotifyClientAsync(cancellationToken);
+ var track = await _spotifyClient!.Tracks.Get(songId, cancellationToken);
+ return SpotifyMapper.MapTrackToSong(track);
+ }
+
+ ///
+ public async Task GetArtistAsync(string artistId, CancellationToken cancellationToken = default) {
+ await EnsureSpotifyClientAsync(cancellationToken);
+ var artist = await _spotifyClient!.Artists.Get(artistId, cancellationToken);
+ return SpotifyMapper.MapArtist(artist);
+ }
+
+ ///
+ public async Task> GetAlbumsByArtistAsync(Artist artist, CancellationToken cancellationToken = default) {
+ await EnsureSpotifyClientAsync(cancellationToken);
+ var albums = await _spotifyClient!.Artists.GetAlbums(artist.Id, cancellationToken);
+ return albums.Items?.Select(a => SpotifyMapper.MapSimpleAlbumToAlbum(a, artist)).ToList() ?? [];
+ }
+
+ ///
+ public async Task> GetSongsByAlbumAsync(Album album, CancellationToken cancellationToken = default) {
+ await EnsureSpotifyClientAsync(cancellationToken);
+ var albumTracks = await _spotifyClient!.Albums.GetTracks(album.Id, cancellationToken);
+ return albumTracks.Items?.Select(t => SpotifyMapper.MapSimpleTrackToSong(t, album)).ToList() ?? [];
+ }
+
+ ///
+ public Task GetSongStreamAsync(string songId, CancellationToken cancellationToken = default) {
+ throw new NotSupportedException("Spotify playback is not supported yet (requires Librespot integration). Metadata and search only.");
+ }
+
+ ///
+ public Task> GetLikedSongsAsync(CancellationToken cancellationToken = default) {
+ // Requires User Token
+ return Task.FromResult>([]);
+ }
+
+ ///
+ public async Task> SearchPlaylistsAsync(string query, CancellationToken cancellationToken = default) {
+ await EnsureSpotifyClientAsync(cancellationToken);
+ var searchRequest = new SearchRequest(SearchRequest.Types.Playlist, query);
+ var searchResponse = await _spotifyClient!.Search.Item(searchRequest, cancellationToken);
+ return searchResponse.Playlists?.Items?.Select(p => new Playlist(p.Id ?? string.Empty, p.Name ?? string.Empty)).ToList() ?? [];
+ }
+
+ ///
+ public async Task> GetPlaylistSongsAsync(Playlist playlist, CancellationToken cancellationToken = default) {
+ await EnsureSpotifyClientAsync(cancellationToken);
+ var playlistTracks = await _spotifyClient!.Playlists.GetPlaylistItems(playlist.Id, cancellationToken);
+ return playlistTracks.Items?
+ .Where(i => i?.Track is FullTrack)
+ .Select(i => SpotifyMapper.MapTrackToSong((FullTrack)i.Track!))
+ .ToList() ?? [];
+ }
+
+ ///
+ public async Task> GetPlaylistSongsFromUrlAsync(string url, CancellationToken cancellationToken = default) {
+ var (entityType, id) = SpotifyUrlParser.ParseUrl(url);
+
+ if (entityType == typeof(Song)) {
+ return [await GetSongAsync(id, cancellationToken)];
+ }
+
+ if (entityType == typeof(Playlist)) {
+ return await GetPlaylistSongsAsync(new Playlist(id, string.Empty), cancellationToken);
+ }
+
+ if (entityType == typeof(Album)) {
+ await EnsureSpotifyClientAsync(cancellationToken);
+ var fullAlbum = await _spotifyClient!.Albums.Get(id, cancellationToken);
+ var artist = fullAlbum.Artists?.Count > 0 ? SpotifyMapper.MapArtist(fullAlbum.Artists[0]) : new Artist(string.Empty, "Unknown Artist");
+ var album = SpotifyMapper.MapFullAlbumToAlbum(fullAlbum, artist);
+ var albumTracks = await _spotifyClient!.Albums.GetTracks(id, cancellationToken);
+ return albumTracks.Items?.Select(t => SpotifyMapper.MapSimpleTrackToSong(t, album)).ToList() ?? [];
+ }
+
+ return [];
+ }
+
+ ///
+ public Task AddToListenHistory(Song song, CancellationToken cancellationToken = default) {
+ return Task.CompletedTask;
+ }
+
+ ///
+ public async Task> GetAlbumArtAsync(Album album, Func, AlbumCover>? coverSelector = null, CancellationToken cancellationToken = default) {
+ if (!album.Covers.Any()) {
+ throw new ArgumentException("Album has no covers.", nameof(album));
+ }
+
+ var cover = coverSelector?.Invoke(album.Covers) ?? album.Covers.First();
+
+ using var albumArt = await _albumArtCacheService.GetOrAddAsync(
+ string.Concat(album.Id, "-", cover.Width, "x", cover.Height),
+ async ct => {
+ var albumResponse = await _httpClient.GetAsync(cover.Url, ct);
+ return await albumResponse.Content.ReadAsStreamAsync(ct);
+ },
+ cancellationToken);
+ return await SixLabors.ImageSharp.Image.LoadAsync(albumArt, cancellationToken);
+ }
+
+ ///
+ public void Dispose() {
+ if (_isDisposed) {
+ return;
+ }
+
+ _initLock.Dispose();
+ if (_disposeHttpClient) {
+ _httpClient.Dispose();
+ }
+
+ _isDisposed = true;
+ }
+}
diff --git a/smoc/Streaming/Spotify/Util/SpotifyMapper.cs b/smoc/Streaming/Spotify/Util/SpotifyMapper.cs
new file mode 100644
index 0000000..697c191
--- /dev/null
+++ b/smoc/Streaming/Spotify/Util/SpotifyMapper.cs
@@ -0,0 +1,147 @@
+using Smoc.Streaming;
+using SpotifyAPI.Web;
+
+namespace Smoc.Streaming.Spotify.Util;
+
+///
+/// Provides utility methods for mapping Spotify models to SMoC streaming models.
+///
+public static class SpotifyMapper {
+ ///
+ /// Maps a Spotify to an .
+ ///
+ /// The Spotify simple artist to map.
+ /// The mapped SMoC .
+ public static Artist MapArtist(SimpleArtist? artist) {
+ if (artist == null) {
+ return new Artist(string.Empty, "Unknown Artist");
+ }
+ return new Artist(artist.Id ?? string.Empty, string.IsNullOrWhiteSpace(artist.Name) ? "Unknown Artist" : artist.Name);
+ }
+
+ ///
+ /// Maps a Spotify to an .
+ ///
+ /// The Spotify full artist to map.
+ /// The mapped SMoC .
+ public static Artist MapArtist(FullArtist? artist) {
+ if (artist == null) {
+ return new Artist(string.Empty, "Unknown Artist");
+ }
+ return new Artist(artist.Id ?? string.Empty, string.IsNullOrWhiteSpace(artist.Name) ? "Unknown Artist" : artist.Name);
+ }
+
+ ///
+ /// Maps a Spotify to an .
+ ///
+ /// The Spotify simple album to map.
+ /// Optional pre-mapped artist. If not provided, the first artist in the album is used.
+ /// The mapped SMoC .
+ public static Album MapSimpleAlbumToAlbum(SimpleAlbum album, Artist? artist = null) {
+ var albumArtist = artist ?? (album.Artists?.Count > 0 ? MapArtist(album.Artists[0]) : new Artist(string.Empty, "Unknown Artist"));
+ var covers = MapImagesToCovers(album.Images);
+ var releaseYear = ParseReleaseYear(album.ReleaseDate);
+
+ return new Album(
+ album.Id ?? string.Empty,
+ albumArtist,
+ string.IsNullOrWhiteSpace(album.Name) ? "Unknown Album" : album.Name,
+ covers,
+ releaseYear
+ );
+ }
+
+ ///
+ /// Maps a Spotify to an .
+ ///
+ /// The Spotify full album to map.
+ /// Optional pre-mapped artist. If not provided, the first artist in the album is used.
+ /// The mapped SMoC .
+ public static Album MapFullAlbumToAlbum(FullAlbum album, Artist? artist = null) {
+ var albumArtist = artist ?? (album.Artists?.Count > 0 ? MapArtist(album.Artists[0]) : new Artist(string.Empty, "Unknown Artist"));
+ var covers = MapImagesToCovers(album.Images);
+ var releaseYear = ParseReleaseYear(album.ReleaseDate);
+
+ return new Album(
+ album.Id ?? string.Empty,
+ albumArtist,
+ string.IsNullOrWhiteSpace(album.Name) ? "Unknown Album" : album.Name,
+ covers,
+ releaseYear
+ );
+ }
+
+ ///
+ /// Maps a Spotify to a .
+ ///
+ /// The Spotify full track to map.
+ /// The mapped SMoC .
+ public static Song MapTrackToSong(FullTrack track) {
+ var artist = track.Artists?.Count > 0
+ ? MapArtist(track.Artists[0])
+ : (track.Album?.Artists?.Count > 0
+ ? MapArtist(track.Album.Artists[0])
+ : new Artist(string.Empty, "Unknown Artist"));
+
+ var album = track.Album != null
+ ? MapSimpleAlbumToAlbum(track.Album, artist)
+ : new Album(string.Empty, artist, "Unknown Album", []);
+
+ return new Song(
+ track.Id ?? string.Empty,
+ album,
+ string.IsNullOrWhiteSpace(track.Name) ? "Unknown Title" : track.Name,
+ TimeSpan.FromMilliseconds(track.DurationMs),
+ track.TrackNumber > 0 ? track.TrackNumber : null
+ );
+ }
+
+ ///
+ /// Maps a Spotify to a with a pre-mapped album.
+ ///
+ /// The Spotify simple track to map.
+ /// The album the track belongs to.
+ /// The mapped SMoC .
+ public static Song MapSimpleTrackToSong(SimpleTrack track, Album album) {
+ return new Song(
+ track.Id ?? string.Empty,
+ album,
+ string.IsNullOrWhiteSpace(track.Name) ? "Unknown Title" : track.Name,
+ TimeSpan.FromMilliseconds(track.DurationMs),
+ track.TrackNumber > 0 ? track.TrackNumber : null
+ );
+ }
+
+ ///
+ /// Parses the release year from a Spotify date string (e.g. "1981", "1981-12", "1981-12-15").
+ ///
+ /// The release date string.
+ /// The parsed year, or null if parsing fails.
+ public static int? ParseReleaseYear(string? releaseDate) {
+ if (string.IsNullOrWhiteSpace(releaseDate) || releaseDate.Length < 4) {
+ return null;
+ }
+
+ if (int.TryParse(releaseDate.AsSpan(0, 4), out var year) && year > 0) {
+ return year;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Maps a collection of Spotify objects to objects.
+ ///
+ /// The Spotify image collection.
+ /// A list of mapped objects.
+ public static List MapImagesToCovers(IEnumerable? images) {
+ if (images == null) {
+ return [];
+ }
+
+ return images
+ .Where(i => !string.IsNullOrEmpty(i.Url))
+ .Select(i => new AlbumCover(i.Url, i.Width, i.Height))
+ .ToList();
+ }
+}
diff --git a/smoc/Streaming/Spotify/Util/SpotifyUrlParser.cs b/smoc/Streaming/Spotify/Util/SpotifyUrlParser.cs
new file mode 100644
index 0000000..97d501c
--- /dev/null
+++ b/smoc/Streaming/Spotify/Util/SpotifyUrlParser.cs
@@ -0,0 +1,84 @@
+using System.Web;
+
+namespace Smoc.Streaming.Spotify.Util;
+
+///
+/// Parses Spotify URLs and URIs into entity types and identifiers.
+///
+public static class SpotifyUrlParser {
+ ///
+ /// Parses the specified Spotify URL or URI and returns the entity type and ID.
+ ///
+ ///
+ /// Examples:
+ /// https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT
+ /// https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy
+ /// https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M
+ /// spotify:track:4cOdK2wGLETKBW3PvgPWqT
+ /// spotify:album:4aawyAB9vmqN3uQ7FjRGTy
+ /// spotify:playlist:37i9dQZF1DXcBWIGoYBM5M
+ ///
+ /// The URL or URI to parse.
+ /// A tuple containing the entity type and the entity ID.
+ /// Thrown when the URL/URI is invalid or unsupported.
+ public static (Type EntityType, string Id) ParseUrl(string url) {
+ if (string.IsNullOrWhiteSpace(url)) {
+ throw new ArgumentException("URL cannot be null or empty.", nameof(url));
+ }
+
+ var trimmed = url.Trim();
+
+ // Handle spotify:type:id URIs
+ if (trimmed.StartsWith("spotify:", StringComparison.OrdinalIgnoreCase)) {
+ var parts = trimmed.Split(':');
+ if (parts.Length >= 3) {
+ var uriType = parts[1].ToLowerInvariant();
+ var uriId = parts[2].Split('?')[0].Trim();
+ if (!string.IsNullOrEmpty(uriId)) {
+ return uriType switch {
+ "track" => (typeof(Song), uriId),
+ "album" => (typeof(Album), uriId),
+ "playlist" => (typeof(Playlist), uriId),
+ "artist" => (typeof(Artist), uriId),
+ _ => throw new ArgumentException($"Unsupported Spotify URI entity type: {uriType}", nameof(url))
+ };
+ }
+ }
+ throw new ArgumentException($"Invalid Spotify URI format: {url}", nameof(url));
+ }
+
+ // Ensure scheme for HTTP/HTTPS URLs
+ if (!trimmed.Contains("://")) {
+ trimmed = "https://" + trimmed;
+ }
+
+ if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)) {
+ throw new ArgumentException($"Invalid URL format: {url}", nameof(url));
+ }
+
+ if (!uri.Host.Equals("open.spotify.com", StringComparison.OrdinalIgnoreCase) &&
+ !uri.Host.Equals("spotify.com", StringComparison.OrdinalIgnoreCase) &&
+ !uri.Host.EndsWith(".spotify.com", StringComparison.OrdinalIgnoreCase)) {
+ throw new ArgumentException($"Invalid URL. Must be a Spotify URL: {url}", nameof(url));
+ }
+
+ var segments = uri.AbsolutePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries);
+ for (var i = 0; i < segments.Length - 1; i++) {
+ var segment = segments[i].ToLowerInvariant();
+ if (segment is "track" or "album" or "playlist" or "artist") {
+ var id = segments[i + 1].Split('?')[0].Trim();
+ if (!string.IsNullOrEmpty(id)) {
+ return segment switch {
+ "track" => (typeof(Song), id),
+ "album" => (typeof(Album), id),
+ "playlist" => (typeof(Playlist), id),
+ "artist" => (typeof(Artist), id),
+ _ => throw new ArgumentException($"Unsupported Spotify entity type: {segment}", nameof(url))
+ };
+ }
+ }
+ }
+
+ throw new ArgumentException($"Invalid or unsupported Spotify URL: {url}", nameof(url));
+ }
+}
diff --git a/smoc/smoc.csproj b/smoc/smoc.csproj
index ad24d3f..00b440b 100644
--- a/smoc/smoc.csproj
+++ b/smoc/smoc.csproj
@@ -28,6 +28,7 @@
+