diff --git a/smoc.Tests/Streaming/Spotify/SpotifyMappingTest.cs b/smoc.Tests/Streaming/Spotify/SpotifyMappingTest.cs
new file mode 100644
index 0000000..3f1f0ed
--- /dev/null
+++ b/smoc.Tests/Streaming/Spotify/SpotifyMappingTest.cs
@@ -0,0 +1,20 @@
+using Moq;
+using Smoc.Streaming;
+using Smoc.Streaming.Spotify;
+using Smoc.Configuration;
+using SpotifyAPI.Web;
+using Smoc.Services.Caching;
+
+namespace smoc.Tests.Streaming.Spotify;
+
+public class SpotifyMappingTest {
+ // We can't easily mock SpotifyClient because it doesn't use interfaces for everything
+ // But we can test the mapping logic if we expose it or use reflections.
+ // For now, let's just test that the client can be created.
+
+ [Fact]
+ public void Create_InitializesCorrectly() {
+ var client = SpotifyStreamingClient.Create(new NoCachingCacheService(), new NoCachingCacheService());
+ Assert.NotNull(client);
+ }
+}
\ No newline at end of file
diff --git a/smoc/Configuration/SpotifyConfig.cs b/smoc/Configuration/SpotifyConfig.cs
new file mode 100644
index 0000000..819aa69
--- /dev/null
+++ b/smoc/Configuration/SpotifyConfig.cs
@@ -0,0 +1,38 @@
+using Terminal.Gui.Configuration;
+
+namespace Smoc.Configuration;
+
+///
+/// Configuration for Spotify.
+///
+public static class SpotifyConfig {
+ ///
+ /// Gets or sets the Spotify username.
+ ///
+ [ConfigurationProperty(Scope = typeof(SettingsScope))]
+ public static string? Username { get; set; } = null;
+
+ ///
+ /// Gets or sets the Spotify password.
+ ///
+ [ConfigurationProperty(Scope = typeof(SettingsScope))]
+ public static string? Password { get; set; } = null;
+
+ ///
+ /// Gets or sets the Spotify Client ID.
+ ///
+ [ConfigurationProperty(Scope = typeof(SettingsScope))]
+ public static string? ClientId { get; set; } = null;
+
+ ///
+ /// Gets or sets the Spotify Client Secret.
+ ///
+ [ConfigurationProperty(Scope = typeof(SettingsScope))]
+ public static string? ClientSecret { get; set; } = null;
+
+ ///
+ /// Gets or sets the Spotify cache directory.
+ ///
+ [ConfigurationProperty(Scope = typeof(SettingsScope))]
+ public static string? CacheDirectory { get; set; } = null;
+}
\ No newline at end of file
diff --git a/smoc/Configuration/StreamingService.cs b/smoc/Configuration/StreamingService.cs
index a427ce2..b3bdfc0 100644
--- a/smoc/Configuration/StreamingService.cs
+++ b/smoc/Configuration/StreamingService.cs
@@ -12,5 +12,15 @@ public enum StreamingService {
///
/// Subsonic compatible API.
///
- Subsonic
-}
+ Subsonic,
+
+ ///
+ /// SoundCloud.
+ ///
+ SoundCloud,
+
+ ///
+ /// Spotify.
+ ///
+ Spotify
+}
\ No newline at end of file
diff --git a/smoc/Streaming/Spotify/SpotifyStreamingClient.cs b/smoc/Streaming/Spotify/SpotifyStreamingClient.cs
new file mode 100644
index 0000000..ea2fd10
--- /dev/null
+++ b/smoc/Streaming/Spotify/SpotifyStreamingClient.cs
@@ -0,0 +1,188 @@
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+using Smoc.Configuration;
+using Smoc.Services;
+using Smoc.Services.Caching;
+using SpotifyAPI.Web;
+using Terminal.Gui.App;
+
+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 SpotifyClient? _spotifyClient;
+ private bool _isDisposed;
+
+ private SpotifyStreamingClient(ICacheService? songCacheService = null, ICacheService? albumArtCacheService = null) {
+ _songCacheService = songCacheService ?? new NoCachingCacheService();
+ _albumArtCacheService = albumArtCacheService ?? new NoCachingCacheService();
+ _httpClient = new HttpClient();
+ }
+
+ public static SpotifyStreamingClient Create(ICacheService? songCacheService = null, ICacheService? albumArtCacheService = null) {
+ return new SpotifyStreamingClient(songCacheService, albumArtCacheService);
+ }
+
+ private async Task EnsureSpotifyClientAsync() {
+ if (_spotifyClient != null) return;
+
+ if (string.IsNullOrEmpty(SpotifyConfig.ClientId) || string.IsNullOrEmpty(SpotifyConfig.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();
+ var request = new ClientCredentialsRequest(SpotifyConfig.ClientId, SpotifyConfig.ClientSecret);
+ var oauthClient = new OAuthClient(config);
+ var response = await oauthClient.RequestToken(request);
+
+ _spotifyClient = new SpotifyClient(config.WithToken(response.AccessToken));
+ }
+
+ ///
+ public async Task> SearchArtistsAsync(string query, CancellationToken cancellationToken = default) {
+ await EnsureSpotifyClientAsync();
+ var searchRequest = new SearchRequest(SearchRequest.Types.Artist, query);
+ var searchResponse = await _spotifyClient!.Search.Item(searchRequest, cancellationToken);
+ return searchResponse.Artists.Items?.Select(a => new Artist(a.Id, a.Name)).ToList() ?? [];
+ }
+
+ ///
+ public async Task> SearchSongsAsync(string query, CancellationToken cancellationToken = default) {
+ await EnsureSpotifyClientAsync();
+ var searchRequest = new SearchRequest(SearchRequest.Types.Track, query);
+ var searchResponse = await _spotifyClient!.Search.Item(searchRequest, cancellationToken);
+ return searchResponse.Tracks.Items?.Select(MapTrackToSong).ToList() ?? [];
+ }
+
+ ///
+ public async Task GetSongAsync(string songId, CancellationToken cancellationToken = default) {
+ await EnsureSpotifyClientAsync();
+ var track = await _spotifyClient!.Tracks.Get(songId, cancellationToken);
+ return MapTrackToSong(track);
+ }
+
+ ///
+ public async Task GetArtistAsync(string artistId, CancellationToken cancellationToken = default) {
+ await EnsureSpotifyClientAsync();
+ var artist = await _spotifyClient!.Artists.Get(artistId, cancellationToken);
+ return new Artist(artist.Id, artist.Name);
+ }
+
+ ///
+ public async Task> GetAlbumsByArtistAsync(Artist artist, CancellationToken cancellationToken = default) {
+ await EnsureSpotifyClientAsync();
+ var albums = await _spotifyClient!.Artists.GetAlbums(artist.Id);
+ return albums.Items?.Select(a => MapSimpleAlbumToAlbum(a, artist)).ToList() ?? [];
+ }
+
+ ///
+ public async Task> GetSongsByAlbumAsync(Album album, CancellationToken cancellationToken = default) {
+ await EnsureSpotifyClientAsync();
+ var albumTracks = await _spotifyClient!.Albums.GetTracks(album.Id);
+ return albumTracks.Items?.Select(t => MapSimpleTrackToSong(t, album)).ToList() ?? [];
+ }
+
+ ///
+ public async Task GetSongStreamAsync(string songId, CancellationToken cancellationToken = default) {
+ // TODO: Implement Librespot playback logic once the dependency is resolved.
+ // For now, we throw a descriptive exception.
+ throw new NotImplementedException("Spotify playback requires Librespot-DotNet which is currently being integrated.");
+ }
+
+ ///
+ public async Task> GetLikedSongsAsync(CancellationToken cancellationToken = default) {
+ // Requires User Token
+ return [];
+ }
+
+ ///
+ public async Task> SearchPlaylistsAsync(string query, CancellationToken cancellationToken = default) {
+ await EnsureSpotifyClientAsync();
+ 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();
+ var playlistTracks = await _spotifyClient!.Playlists.GetPlaylistItems(playlist.Id);
+ return playlistTracks.Items?
+ .Where(i => i.Track is FullTrack)
+ .Select(i => MapTrackToSong((FullTrack)i.Track))
+ .ToList() ?? [];
+ }
+
+ ///
+ public async Task> GetPlaylistSongsFromUrlAsync(string url, CancellationToken cancellationToken = default) {
+ // Simple URL parsing logic
+ if (url.Contains("track/")) {
+ var id = url.Split("track/").Last().Split('?').First();
+ return [await GetSongAsync(id, cancellationToken)];
+ }
+ if (url.Contains("playlist/")) {
+ var id = url.Split("playlist/").Last().Split('?').First();
+ return await GetPlaylistSongsAsync(new Playlist(id, ""), cancellationToken);
+ }
+ return [];
+ }
+
+ ///
+ public async Task AddToListenHistory(Song song, CancellationToken cancellationToken = default) {
+ await 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, cancellationToken);
+ return await albumResponse.Content.ReadAsStreamAsync(cancellationToken);
+ },
+ cancellationToken);
+ return await SixLabors.ImageSharp.Image.LoadAsync(albumArt, cancellationToken);
+ }
+
+ private Song MapTrackToSong(FullTrack track) {
+ var artist = new Artist(track.Artists.First().Id, track.Artists.First().Name);
+ var album = MapSimpleAlbumToAlbum(track.Album, artist);
+ return new Song(track.Id, album, track.Name, TimeSpan.FromMilliseconds(track.DurationMs), track.TrackNumber);
+ }
+
+ private Song MapSimpleTrackToSong(SimpleTrack track, Album album) {
+ return new Song(track.Id, album, track.Name, TimeSpan.FromMilliseconds(track.DurationMs), track.TrackNumber);
+ }
+
+ private Album MapSimpleAlbumToAlbum(SimpleAlbum album, Artist artist) {
+ int? releaseYear = null;
+ if (!string.IsNullOrEmpty(album.ReleaseDate) && album.ReleaseDate.Length >= 4 && int.TryParse(album.ReleaseDate.Substring(0, 4), out var year)) {
+ releaseYear = year;
+ }
+
+ return new Album(
+ album.Id,
+ artist,
+ album.Name,
+ album.Images.Select(i => new AlbumCover(i.Url, i.Width, i.Height)),
+ releaseYear);
+ }
+
+ public void Dispose() {
+ if (_isDisposed) return;
+ _httpClient.Dispose();
+ _isDisposed = true;
+ }
+}
\ No newline at end of file
diff --git a/smoc/smoc.csproj b/smoc/smoc.csproj
index 7f9b300..4db3bfc 100644
--- a/smoc/smoc.csproj
+++ b/smoc/smoc.csproj
@@ -32,6 +32,7 @@
+