Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions smoc.Tests/Streaming/Spotify/SpotifyMappingTest.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
38 changes: 38 additions & 0 deletions smoc/Configuration/SpotifyConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Terminal.Gui.Configuration;

namespace Smoc.Configuration;

/// <summary>
/// Configuration for Spotify.
/// </summary>
public static class SpotifyConfig {
/// <summary>
/// Gets or sets the Spotify username.
/// </summary>
[ConfigurationProperty(Scope = typeof(SettingsScope))]
public static string? Username { get; set; } = null;

/// <summary>
/// Gets or sets the Spotify password.
/// </summary>
[ConfigurationProperty(Scope = typeof(SettingsScope))]
public static string? Password { get; set; } = null;

/// <summary>
/// Gets or sets the Spotify Client ID.
/// </summary>
[ConfigurationProperty(Scope = typeof(SettingsScope))]
public static string? ClientId { get; set; } = null;

/// <summary>
/// Gets or sets the Spotify Client Secret.
/// </summary>
[ConfigurationProperty(Scope = typeof(SettingsScope))]
public static string? ClientSecret { get; set; } = null;

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

/// <summary>
/// SoundCloud.
/// </summary>
SoundCloud,

/// <summary>
/// Spotify.
/// </summary>
Spotify
}
188 changes: 188 additions & 0 deletions smoc/Streaming/Spotify/SpotifyStreamingClient.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// A streaming client implementation for Spotify.
/// </summary>
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));
}

/// <inheritdoc/>
public async Task<List<Artist>> 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() ?? [];
}

/// <inheritdoc/>
public async Task<List<Song>> 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() ?? [];
}

/// <inheritdoc/>
public async Task<Song> GetSongAsync(string songId, CancellationToken cancellationToken = default) {
await EnsureSpotifyClientAsync();
var track = await _spotifyClient!.Tracks.Get(songId, cancellationToken);
return MapTrackToSong(track);
}

/// <inheritdoc/>
public async Task<Artist> GetArtistAsync(string artistId, CancellationToken cancellationToken = default) {
await EnsureSpotifyClientAsync();
var artist = await _spotifyClient!.Artists.Get(artistId, cancellationToken);
return new Artist(artist.Id, artist.Name);
}

/// <inheritdoc/>
public async Task<List<Album>> 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() ?? [];
}

/// <inheritdoc/>
public async Task<List<Song>> 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() ?? [];
}

/// <inheritdoc/>
public async Task<SongStream> 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.");
}

/// <inheritdoc/>
public async Task<List<Song>> GetLikedSongsAsync(CancellationToken cancellationToken = default) {
// Requires User Token
return [];
}

/// <inheritdoc/>
public async Task<List<Playlist>> 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() ?? [];
}

/// <inheritdoc/>
/// <inheritdoc/>
public async Task<List<Song>> 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() ?? [];
}

/// <inheritdoc/>
public async Task<List<Song>> 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 [];
}

/// <inheritdoc/>
public async Task AddToListenHistory(Song song, CancellationToken cancellationToken = default) {
await Task.CompletedTask;
}

/// <inheritdoc/>
public async Task<Image<Rgba32>> GetAlbumArtAsync(Album album, Func<IEnumerable<AlbumCover>, 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<Rgba32>(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;
}
}
1 change: 1 addition & 0 deletions smoc/smoc.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="SoundFlow" Version="1.4.0" />
<PackageReference Include="SoundFlow.Codecs.FFMpeg" Version="1.4.0" />
<PackageReference Include="SpotifyAPI.Web" Version="7.4.2" />
<PackageReference Include="System.CommandLine" Version="2.0.1" />
<PackageReference Include="YouTubeMusicAPI" Version="3.0.7" />
<PackageReference Include="YouTubeSessionGenerator" Version="1.0.3" />
Expand Down
Loading