From 199d16bf168baa0213aadcec1a1de24b3a8ef243 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 --- .../Streaming/Spotify/SpotifyMappingTest.cs | 20 ++ smoc/Configuration/SpotifyConfig.cs | 38 ++++ smoc/Configuration/StreamingService.cs | 14 +- .../Spotify/SpotifyStreamingClient.cs | 188 ++++++++++++++++++ smoc/smoc.csproj | 1 + 5 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 smoc.Tests/Streaming/Spotify/SpotifyMappingTest.cs create mode 100644 smoc/Configuration/SpotifyConfig.cs create mode 100644 smoc/Streaming/Spotify/SpotifyStreamingClient.cs 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 @@ +