From e42e9f799d941e412fb791f05ea02ea21c313ceb Mon Sep 17 00:00:00 2001 From: oca-agent <277152249+oca-agent@users.noreply.github.com> Date: Tue, 5 May 2026 23:08:26 -0400 Subject: [PATCH] feat: initial spotify integration with metadata support - Rebase on origin/main (Terminal.Gui 2.5.0) and resolve csproj/StreamingService conflicts - Migrate SpotifyConfig to modern MEC POCO pattern with Defaults static facade - Register SpotifyConfig and SpotifyStreamingClient factory in Program.cs - Use ClientCredentialsAuthenticator from SpotifyAPI.Web for automatic background token renewals - Synchronize SpotifyClient initialization using SemaphoreSlim - Prevent playback crash in GetSongStreamAsync with descriptive NotSupportedException - Extract SpotifyMapper and SpotifyUrlParser into Smoc.Streaming.Spotify.Util - Add comprehensive defensive null handling for Spotify API responses - Pass CancellationToken to all Spotify API calls and cache delegates - Provide CreateForTesting with ISpotifyClient dependency injection - Add unit tests for SpotifyMapper, SpotifyUrlParser, SpotifyStreamingClient, and SpotifyConfig binding --- .../Configuration/ConfigurationBindingTest.cs | 23 ++ .../Streaming/Spotify/SpotifyMappingTest.cs | 294 ++++++++++++++ .../Spotify/SpotifyStreamingClientTest.cs | 361 ++++++++++++++++++ .../Streaming/Spotify/SpotifyUrlParserTest.cs | 116 ++++++ smoc/Configuration/SpotifyConfig.cs | 36 ++ smoc/Configuration/StreamingService.cs | 9 +- smoc/Program.cs | 6 + .../Spotify/SpotifyStreamingClient.cs | 226 +++++++++++ smoc/Streaming/Spotify/Util/SpotifyMapper.cs | 147 +++++++ .../Spotify/Util/SpotifyUrlParser.cs | 84 ++++ smoc/smoc.csproj | 1 + 11 files changed, 1301 insertions(+), 2 deletions(-) create mode 100644 smoc.Tests/Streaming/Spotify/SpotifyMappingTest.cs create mode 100644 smoc.Tests/Streaming/Spotify/SpotifyStreamingClientTest.cs create mode 100644 smoc.Tests/Streaming/Spotify/SpotifyUrlParserTest.cs create mode 100644 smoc/Configuration/SpotifyConfig.cs create mode 100644 smoc/Streaming/Spotify/SpotifyStreamingClient.cs create mode 100644 smoc/Streaming/Spotify/Util/SpotifyMapper.cs create mode 100644 smoc/Streaming/Spotify/Util/SpotifyUrlParser.cs 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 @@ +