From 988719d5804e560d8972f220127d38c4ee3cc097 Mon Sep 17 00:00:00 2001 From: oca-agent <277152249+oca-agent@users.noreply.github.com> Date: Tue, 5 May 2026 23:18:39 -0400 Subject: [PATCH 1/2] feat: Tidal integration implementation --- .../Tidal/TidalStreamingClientServiceTest.cs | 108 ++++++++ smoc/Configuration/StreamingService.cs | 2 +- smoc/Configuration/TidalConfig.cs | 44 ++++ smoc/Streaming/Tidal/Models/TidalAuth.cs | 19 ++ smoc/Streaming/Tidal/Models/TidalCommon.cs | 33 +++ smoc/Streaming/Tidal/Models/TidalMetadata.cs | 44 ++++ smoc/Streaming/Tidal/Models/TidalPlayback.cs | 18 ++ smoc/Streaming/Tidal/Models/TidalSearch.cs | 15 ++ smoc/Streaming/Tidal/TidalStreamingClient.cs | 240 ++++++++++++++++++ 9 files changed, 522 insertions(+), 1 deletion(-) create mode 100644 smoc.Tests/Streaming/Tidal/TidalStreamingClientServiceTest.cs create mode 100644 smoc/Configuration/TidalConfig.cs create mode 100644 smoc/Streaming/Tidal/Models/TidalAuth.cs create mode 100644 smoc/Streaming/Tidal/Models/TidalCommon.cs create mode 100644 smoc/Streaming/Tidal/Models/TidalMetadata.cs create mode 100644 smoc/Streaming/Tidal/Models/TidalPlayback.cs create mode 100644 smoc/Streaming/Tidal/Models/TidalSearch.cs create mode 100644 smoc/Streaming/Tidal/TidalStreamingClient.cs diff --git a/smoc.Tests/Streaming/Tidal/TidalStreamingClientServiceTest.cs b/smoc.Tests/Streaming/Tidal/TidalStreamingClientServiceTest.cs new file mode 100644 index 0000000..392616f --- /dev/null +++ b/smoc.Tests/Streaming/Tidal/TidalStreamingClientServiceTest.cs @@ -0,0 +1,108 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Moq; +using Moq.Protected; +using Smoc.Configuration; +using Smoc.Streaming; +using Smoc.Streaming.Tidal; +using Smoc.Streaming.Tidal.Models; +using Smoc.Services.Caching; + +namespace smoc.Tests.Streaming.Tidal; + +public class TidalStreamingClientServiceTest { + private static (TidalStreamingClient, Mock) CreateClientWithMockResponse(T responseObj, HttpStatusCode statusCode = HttpStatusCode.OK) { + var handlerMock = new Mock(MockBehavior.Strict); + handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage { + StatusCode = statusCode, + Content = JsonContent.Create(responseObj), + }); + + var httpClient = new HttpClient(handlerMock.Object); + var client = TidalStreamingClient.CreateForTesting(httpClient); + return (client, handlerMock); + } + + [Fact] + public async Task SearchSongsAsync_ReturnsSongs() { + var artist = new TidalArtist(10, "Artist 1", null); + var album = new TidalAlbum(100, "Album 1", "cover-id", "2023", artist); + var track = new TidalTrack(1, "Track 1", 180, 1, album, artist, [artist]); + var response = new TidalSearchContainer { + Tracks = new TidalSearchResponse { Items = [track], TotalNumberOfItems = 1 } + }; + + var (client, _) = CreateClientWithMockResponse(response); + + var results = await client.SearchSongsAsync("query", TestContext.Current.CancellationToken); + + Assert.Single(results); + Assert.Equal("Track 1", results[0].Title); + Assert.Equal("1", results[0].Id); + Assert.Equal("Artist 1", results[0].Artist.Name); + Assert.Equal("https://resources.tidal.com/images/cover/id/640x640.jpg", results[0].Album.Covers.First().Url); + } + + [Fact] + public async Task GetSongStreamAsync_ParsesManifestAndReturnsStream() { + var manifest = new TidalManifest { + MimeType = "audio/flac", + Codecs = "flac", + EncryptionType = "none", + Urls = ["http://actual-stream-url"] + }; + var manifestJson = JsonSerializer.Serialize(manifest); + var manifestBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(manifestJson)); + + var playbackInfo = new TidalPlaybackInfo(1, "FULL", "LOSSLESS", "application/vnd.tidal.bt", manifestBase64); + + var handlerMock = new Mock(MockBehavior.Strict); + + // First call: GET /tracks/1/playbackinfo + handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => req.RequestUri!.ToString().Contains("/tracks/1/playbackinfo")), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage { + StatusCode = HttpStatusCode.OK, + Content = JsonContent.Create(playbackInfo), + }); + + // Second call: GET http://actual-stream-url + handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => req.RequestUri!.ToString() == "http://actual-stream-url"), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage { + StatusCode = HttpStatusCode.OK, + Content = new ByteArrayContent([1, 2, 3, 4]), + }); + + var httpClient = new HttpClient(handlerMock.Object); + var client = TidalStreamingClient.CreateForTesting(httpClient); + + var songStream = await client.GetSongStreamAsync("1", TestContext.Current.CancellationToken); + + Assert.Equal("1", songStream.Id); + Assert.Equal("flac", songStream.Codec); + + var buffer = new byte[4]; + await songStream.Stream.ReadAsync(buffer, TestContext.Current.CancellationToken); + Assert.Equal([1, 2, 3, 4], buffer); + } +} \ No newline at end of file diff --git a/smoc/Configuration/StreamingService.cs b/smoc/Configuration/StreamingService.cs index a427ce2..e905287 100644 --- a/smoc/Configuration/StreamingService.cs +++ b/smoc/Configuration/StreamingService.cs @@ -13,4 +13,4 @@ public enum StreamingService { /// Subsonic compatible API. /// Subsonic -} +} \ No newline at end of file diff --git a/smoc/Configuration/TidalConfig.cs b/smoc/Configuration/TidalConfig.cs new file mode 100644 index 0000000..698f8fb --- /dev/null +++ b/smoc/Configuration/TidalConfig.cs @@ -0,0 +1,44 @@ +using Terminal.Gui.Configuration; + +namespace Smoc.Configuration; + +/// +/// Configuration for Tidal. +/// +public static class TidalConfig { + /// + /// Gets or sets the Tidal Client ID. + /// + [ConfigurationProperty(Scope = typeof(SettingsScope))] + public static string? ClientId { get; set; } = null; + + /// + /// Gets or sets the Tidal Client Secret. + /// + [ConfigurationProperty(Scope = typeof(SettingsScope))] + public static string? ClientSecret { get; set; } = null; + + /// + /// Gets or sets the Tidal access token. + /// + [ConfigurationProperty(Scope = typeof(SettingsScope))] + public static string? AccessToken { get; set; } = null; + + /// + /// Gets or sets the Tidal refresh token. + /// + [ConfigurationProperty(Scope = typeof(SettingsScope))] + public static string? RefreshToken { get; set; } = null; + + /// + /// Gets or sets the Tidal country code. + /// + [ConfigurationProperty(Scope = typeof(SettingsScope))] + public static string CountryCode { get; set; } = "US"; + + /// + /// Gets or sets the Tidal audio quality. + /// + [ConfigurationProperty(Scope = typeof(SettingsScope))] + public static string AudioQuality { get; set; } = "LOSSLESS"; +} \ No newline at end of file diff --git a/smoc/Streaming/Tidal/Models/TidalAuth.cs b/smoc/Streaming/Tidal/Models/TidalAuth.cs new file mode 100644 index 0000000..c290669 --- /dev/null +++ b/smoc/Streaming/Tidal/Models/TidalAuth.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace Smoc.Streaming.Tidal.Models; + +public record TidalDeviceAuthResponse( + [property: JsonPropertyName("deviceCode")] string DeviceCode, + [property: JsonPropertyName("userCode")] string UserCode, + [property: JsonPropertyName("verificationUri")] string VerificationUri, + [property: JsonPropertyName("verificationUriComplete")] string VerificationUriComplete, + [property: JsonPropertyName("expiresIn")] int ExpiresIn, + [property: JsonPropertyName("interval")] int Interval +); + +public record TidalTokenResponse( + [property: JsonPropertyName("access_token")] string AccessToken, + [property: JsonPropertyName("refresh_token")] string RefreshToken, + [property: JsonPropertyName("expires_in")] int ExpiresIn, + [property: JsonPropertyName("token_type")] string TokenType +); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/Models/TidalCommon.cs b/smoc/Streaming/Tidal/Models/TidalCommon.cs new file mode 100644 index 0000000..a0567a1 --- /dev/null +++ b/smoc/Streaming/Tidal/Models/TidalCommon.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace Smoc.Streaming.Tidal.Models; + +public record TidalArtist( + [property: JsonPropertyName("id")] long Id, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("picture")] string? Picture +); + +public record TidalAlbum( + [property: JsonPropertyName("id")] long Id, + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("cover")] string? Cover, + [property: JsonPropertyName("releaseDate")] string? ReleaseDate, + [property: JsonPropertyName("artist")] TidalArtist? Artist +); + +public record TidalTrack( + [property: JsonPropertyName("id")] long Id, + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("duration")] int Duration, + [property: JsonPropertyName("trackNumber")] int TrackNumber, + [property: JsonPropertyName("album")] TidalAlbum Album, + [property: JsonPropertyName("artist")] TidalArtist Artist, + [property: JsonPropertyName("artists")] List Artists +); + +public record TidalPlaylist( + [property: JsonPropertyName("uuid")] string Uuid, + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("description")] string? Description +); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/Models/TidalMetadata.cs b/smoc/Streaming/Tidal/Models/TidalMetadata.cs new file mode 100644 index 0000000..0b59172 --- /dev/null +++ b/smoc/Streaming/Tidal/Models/TidalMetadata.cs @@ -0,0 +1,44 @@ +using System.Text.Json.Serialization; + +namespace Smoc.Streaming.Tidal.Models; + +public record TidalArtist( + [property: JsonPropertyName("id")] long Id, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("picture")] string? Picture +); + +public record TidalAlbum( + [property: JsonPropertyName("id")] long Id, + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("cover")] string? Cover, + [property: JsonPropertyName("releaseDate")] string? ReleaseDate, + [property: JsonPropertyName("artist")] TidalArtist? Artist +); + +public record TidalTrack( + [property: JsonPropertyName("id")] long Id, + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("duration")] int Duration, + [property: JsonPropertyName("trackNumber")] int TrackNumber, + [property: JsonPropertyName("album")] TidalAlbum Album, + [property: JsonPropertyName("artist")] TidalArtist Artist, + [property: JsonPropertyName("artists")] List Artists +); + +public record TidalPlaylist( + [property: JsonPropertyName("uuid")] string Uuid, + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("description")] string? Description +); + +public record TidalSearchResponse( + [property: JsonPropertyName("artists")] TidalCollection? Artists, + [property: JsonPropertyName("albums")] TidalCollection? Albums, + [property: JsonPropertyName("tracks")] TidalCollection? Tracks +); + +public record TidalCollection( + [property: JsonPropertyName("items")] List Items, + [property: JsonPropertyName("totalNumberOfItems")] int Total +); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/Models/TidalPlayback.cs b/smoc/Streaming/Tidal/Models/TidalPlayback.cs new file mode 100644 index 0000000..1135210 --- /dev/null +++ b/smoc/Streaming/Tidal/Models/TidalPlayback.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Smoc.Streaming.Tidal.Models; + +public record TidalPlaybackInfo( + [property: JsonPropertyName("trackId")] long TrackId, + [property: JsonPropertyName("assetPresentation")] string AssetPresentation, + [property: JsonPropertyName("audioQuality")] string AudioQuality, + [property: JsonPropertyName("manifestMimeType")] string ManifestMimeType, + [property: JsonPropertyName("manifest")] string Manifest +); + +public record TidalManifest( + [property: JsonPropertyName("mimeType")] string MimeType, + [property: JsonPropertyName("codecs")] string Codecs, + [property: JsonPropertyName("encryptionType")] string EncryptionType, + [property: JsonPropertyName("urls")] List Urls +); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/Models/TidalSearch.cs b/smoc/Streaming/Tidal/Models/TidalSearch.cs new file mode 100644 index 0000000..e08aaef --- /dev/null +++ b/smoc/Streaming/Tidal/Models/TidalSearch.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Smoc.Streaming.Tidal.Models; + +public record TidalSearchResponse( + [property: JsonPropertyName("items")] List Items, + [property: JsonPropertyName("totalNumberOfItems")] int TotalNumberOfItems +); + +public record TidalSearchContainer( + [property: JsonPropertyName("artists")] TidalSearchResponse? Artists, + [property: JsonPropertyName("albums")] TidalSearchResponse? Albums, + [property: JsonPropertyName("tracks")] TidalSearchResponse? Tracks, + [property: JsonPropertyName("playlists")] TidalSearchResponse? Playlists +); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/TidalStreamingClient.cs b/smoc/Streaming/Tidal/TidalStreamingClient.cs new file mode 100644 index 0000000..cf76b28 --- /dev/null +++ b/smoc/Streaming/Tidal/TidalStreamingClient.cs @@ -0,0 +1,240 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using Smoc.Configuration; +using Smoc.Services; +using Smoc.Services.Caching; +using Smoc.Streaming.Tidal.Models; +using Terminal.Gui.App; + +namespace Smoc.Streaming.Tidal; + +public sealed class TidalStreamingClient : IStreamingClient, IDisposable { + private static readonly string AuthUrl = "https://auth.tidal.com/v1/oauth2"; + private static readonly string ApiUrl = "https://api.tidal.com/v1"; + private readonly HttpClient _httpClient; + private readonly ICacheService _songCacheService; + private readonly ICacheService _albumArtCacheService; + private bool _isDisposed; + + private TidalStreamingClient(ICacheService? songCacheService = null, ICacheService? albumArtCacheService = null) { + _httpClient = new HttpClient(); + _songCacheService = songCacheService ?? new NoCachingCacheService(); + _albumArtCacheService = albumArtCacheService ?? new NoCachingCacheService(); + } + + public static TidalStreamingClient CreateForTesting(HttpClient httpClient, ICacheService? songCacheService = null, ICacheService? albumArtCacheService = null) { + var client = new TidalStreamingClient(songCacheService, albumArtCacheService); + var httpClientField = typeof(TidalStreamingClient).GetField("_httpClient", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + httpClientField?.SetValue(client, httpClient); + TidalConfig.AccessToken = "test-token"; + return client; + } + + public static TidalStreamingClient Create(ICacheService? songCacheService = null, ICacheService? albumArtCacheService = null) { + return new TidalStreamingClient(songCacheService, albumArtCacheService); + } + + private async Task EnsureAuthenticatedAsync(CancellationToken cancellationToken = default) { + if (string.IsNullOrEmpty(TidalConfig.AccessToken)) { + await AuthorizeDeviceAsync(cancellationToken); + } + // TODO: Implement token refresh logic if expired + } + + private async Task AuthorizeDeviceAsync(CancellationToken cancellationToken = default) { + if (string.IsNullOrEmpty(TidalConfig.ClientId)) { + throw new InvalidOperationException("Tidal Client ID not configured."); + } + + Logging.Information("Starting Tidal Device Authorization flow..."); + var authRequest = new Dictionary { + { "client_id", TidalConfig.ClientId }, + { "scope", "user" } + }; + + var response = await _httpClient.PostAsync($"{AuthUrl}/device/authorization", new FormUrlEncodedContent(authRequest), cancellationToken); + var authData = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + + if (authData == null) throw new InvalidOperationException("Failed to get device authorization data."); + + Logging.Information($"Please visit {authData.VerificationUriComplete} or go to {authData.VerificationUri} and enter code: {authData.UserCode}"); + // In a real CLI, we would display this to the user. For now, we'll log it. + + // Polling logic + var tokenRequest = new Dictionary { + { "client_id", TidalConfig.ClientId }, + { "device_code", authData.DeviceCode }, + { "grant_type", "urn:ietf:params:oauth:grant-type:device_code" } + }; + + while (!cancellationToken.IsCancellationRequested) { + await Task.Delay(authData.Interval * 1000, cancellationToken); + var tokenResponse = await _httpClient.PostAsync($"{AuthUrl}/token", new FormUrlEncodedContent(tokenRequest), cancellationToken); + + if (tokenResponse.IsSuccessStatusCode) { + var tokenData = await tokenResponse.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + if (tokenData != null) { + TidalConfig.AccessToken = tokenData.AccessToken; + TidalConfig.RefreshToken = tokenData.RefreshToken; + Logging.Information("Tidal authentication successful."); + return; + } + } + + var errorContent = await tokenResponse.Content.ReadAsStringAsync(cancellationToken); + if (!errorContent.Contains("authorization_pending")) { + throw new InvalidOperationException($"Tidal auth failed: {errorContent}"); + } + } + } + + private async Task GetAsync(string endpoint, Dictionary? parameters = null, CancellationToken cancellationToken = default) { + await EnsureAuthenticatedAsync(cancellationToken); + + var url = $"{ApiUrl}/{endpoint.TrimStart('/')}"; + var queryParams = parameters ?? new Dictionary(); + queryParams["countryCode"] = TidalConfig.CountryCode; + + var queryString = string.Join("&", queryParams.Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}")); + var fullUrl = $"{url}?{queryString}"; + + var request = new HttpRequestMessage(HttpMethod.Get, fullUrl); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", TidalConfig.AccessToken); + + var response = await _httpClient.SendAsync(request, cancellationToken); + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) { + // Refresh token and retry once + // TODO: Implement refresh logic + } + + return await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken) ?? throw new InvalidOperationException("API returned null."); + } + + public async Task> SearchArtistsAsync(string query, CancellationToken cancellationToken = default) { + var response = await GetAsync("search", new Dictionary { { "query", query }, { "types", "ARTISTS" }, { "limit", "20" } }, cancellationToken); + return response.Artists?.Items.Select(a => new Artist(a.Id.ToString(), a.Name)).ToList() ?? []; + } + + public async Task> SearchSongsAsync(string query, CancellationToken cancellationToken = default) { + var response = await GetAsync("search", new Dictionary { { "query", query }, { "types", "TRACKS" }, { "limit", "20" } }, cancellationToken); + return response.Tracks?.Items.Select(MapTrackToSong).ToList() ?? []; + } + + public async Task GetSongAsync(string songId, CancellationToken cancellationToken = default) { + var track = await GetAsync($"/tracks/{songId}", null, cancellationToken); + return MapTrackToSong(track); + } + + public async Task GetArtistAsync(string artistId, CancellationToken cancellationToken = default) { + var artist = await GetAsync($"/artists/{artistId}", null, cancellationToken); + return new Artist(artist.Id.ToString(), artist.Name); + } + + public async Task> GetAlbumsByArtistAsync(Artist artist, CancellationToken cancellationToken = default) { + var response = await GetAsync>($"/artists/{artist.Id}/albums", new Dictionary { { "limit", "50" } }, cancellationToken); + return response.Items.Select(a => MapAlbumToAlbum(a, artist)).ToList(); + } + + public async Task> GetSongsByAlbumAsync(Album album, CancellationToken cancellationToken = default) { + var response = await GetAsync>($"/albums/{album.Id}/tracks", new Dictionary { { "limit", "50" } }, cancellationToken); + return response.Items.Select(MapTrackToSong).ToList(); + } + + public async Task GetSongStreamAsync(string songId, CancellationToken cancellationToken = default) { + var playbackInfo = await GetAsync($"/tracks/{songId}/playbackinfo", new Dictionary { + { "audioquality", TidalConfig.AudioQuality }, + { "playbackmode", "STREAM" }, + { "assetpresentation", "FULL" } + }, cancellationToken); + + var manifestBytes = Convert.FromBase64String(playbackInfo.Manifest); + var manifestJson = Encoding.UTF8.GetString(manifestBytes); + var manifest = JsonSerializer.Deserialize(manifestJson); + + if (manifest == null || manifest.Urls.Count == 0) throw new InvalidOperationException("No stream URLs found in manifest."); + + var streamUrl = manifest.Urls[0]; + var stream = await _songCacheService.GetOrAddAsync( + $"{songId}-{playbackInfo.AudioQuality}", + async ct => await _httpClient.GetStreamAsync(streamUrl, ct), + cancellationToken); + + return new SongStream(songId, manifest.MimeType.Contains("flac") ? "flac" : "aac", stream); + } + + public async Task> GetLikedSongsAsync(CancellationToken cancellationToken = default) { + // Requires user authentication and specific scopes + return []; + } + + public async Task> SearchPlaylistsAsync(string query, CancellationToken cancellationToken = default) { + var response = await GetAsync("search", new Dictionary { { "query", query }, { "types", "PLAYLISTS" }, { "limit", "20" } }, cancellationToken); + return response.Playlists?.Items.Select(p => new Playlist(p.Uuid, p.Title)).ToList() ?? []; + } + + public async Task> GetPlaylistSongsAsync(Playlist playlist, CancellationToken cancellationToken = default) { + var response = await GetAsync>($"/playlists/{playlist.Id}/tracks", new Dictionary { { "limit", "50" } }, cancellationToken); + return response.Items.Select(MapTrackToSong).ToList(); + } + + public async Task> GetPlaylistSongsFromUrlAsync(string url, CancellationToken cancellationToken = default) { + // Basic URL parsing for Tidal + 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 Image.LoadAsync(albumArt, cancellationToken); + } + + private Song MapTrackToSong(TidalTrack track) { + var artist = new Artist(track.Artist.Id.ToString(), track.Artist.Name); + var album = MapAlbumToAlbum(track.Album, artist); + return new Song(track.Id.ToString(), album, track.Title, TimeSpan.FromSeconds(track.Duration), track.TrackNumber); + } + + private Album MapAlbumToAlbum(TidalAlbum album, Artist artist) { + var covers = new List(); + if (!string.IsNullOrEmpty(album.Cover)) { + // Tidal covers follow a specific format: https://resources.tidal.com/images/{uuid}/640x640.jpg + var uuid = album.Cover.Replace("-", "/"); + covers.Add(new AlbumCover($"https://resources.tidal.com/images/{uuid}/640x640.jpg", 640, 640)); + covers.Add(new AlbumCover($"https://resources.tidal.com/images/{uuid}/320x320.jpg", 320, 320)); + } + return new Album(album.Id.ToString(), artist, album.Title, covers, album.ReleaseDate); + } + + public void Dispose() { + if (_isDisposed) return; + _httpClient.Dispose(); + _isDisposed = true; + } +} \ No newline at end of file From 45c19d00ffa9e48037fa8dc3b3a81fa58718be25 Mon Sep 17 00:00:00 2001 From: oca-agent <277152249+oca-agent@users.noreply.github.com> Date: Tue, 5 May 2026 23:24:57 -0400 Subject: [PATCH 2/2] feat: fix Tidal build/test errors and refactor models --- .../Tidal/TidalStreamingClientServiceTest.cs | 13 ++---- smoc/Configuration/TidalConfig.cs | 6 +++ smoc/Streaming/Tidal/Models/TidalAlbum.cs | 11 +++++ smoc/Streaming/Tidal/Models/TidalArtist.cs | 9 ++++ smoc/Streaming/Tidal/Models/TidalCommon.cs | 33 -------------- ...idalAuth.cs => TidalDeviceAuthResponse.cs} | 7 --- smoc/Streaming/Tidal/Models/TidalManifest.cs | 10 +++++ smoc/Streaming/Tidal/Models/TidalMetadata.cs | 44 ------------------- ...{TidalPlayback.cs => TidalPlaybackInfo.cs} | 7 --- smoc/Streaming/Tidal/Models/TidalPlaylist.cs | 9 ++++ ...TidalSearch.cs => TidalSearchContainer.cs} | 13 ++---- .../Tidal/Models/TidalSearchResponse.cs | 8 ++++ .../Tidal/Models/TidalTokenResponse.cs | 10 +++++ smoc/Streaming/Tidal/Models/TidalTrack.cs | 13 ++++++ smoc/Streaming/Tidal/TidalStreamingClient.cs | 42 +++++++++++++++++- 15 files changed, 123 insertions(+), 112 deletions(-) create mode 100644 smoc/Streaming/Tidal/Models/TidalAlbum.cs create mode 100644 smoc/Streaming/Tidal/Models/TidalArtist.cs delete mode 100644 smoc/Streaming/Tidal/Models/TidalCommon.cs rename smoc/Streaming/Tidal/Models/{TidalAuth.cs => TidalDeviceAuthResponse.cs} (63%) create mode 100644 smoc/Streaming/Tidal/Models/TidalManifest.cs delete mode 100644 smoc/Streaming/Tidal/Models/TidalMetadata.cs rename smoc/Streaming/Tidal/Models/{TidalPlayback.cs => TidalPlaybackInfo.cs} (61%) create mode 100644 smoc/Streaming/Tidal/Models/TidalPlaylist.cs rename smoc/Streaming/Tidal/Models/{TidalSearch.cs => TidalSearchContainer.cs} (58%) create mode 100644 smoc/Streaming/Tidal/Models/TidalSearchResponse.cs create mode 100644 smoc/Streaming/Tidal/Models/TidalTokenResponse.cs create mode 100644 smoc/Streaming/Tidal/Models/TidalTrack.cs diff --git a/smoc.Tests/Streaming/Tidal/TidalStreamingClientServiceTest.cs b/smoc.Tests/Streaming/Tidal/TidalStreamingClientServiceTest.cs index 392616f..a095461 100644 --- a/smoc.Tests/Streaming/Tidal/TidalStreamingClientServiceTest.cs +++ b/smoc.Tests/Streaming/Tidal/TidalStreamingClientServiceTest.cs @@ -37,9 +37,7 @@ public async Task SearchSongsAsync_ReturnsSongs() { var artist = new TidalArtist(10, "Artist 1", null); var album = new TidalAlbum(100, "Album 1", "cover-id", "2023", artist); var track = new TidalTrack(1, "Track 1", 180, 1, album, artist, [artist]); - var response = new TidalSearchContainer { - Tracks = new TidalSearchResponse { Items = [track], TotalNumberOfItems = 1 } - }; + var response = new TidalSearchContainer(Tracks: new TidalSearchResponse([track], 1)); var (client, _) = CreateClientWithMockResponse(response); @@ -54,12 +52,7 @@ public async Task SearchSongsAsync_ReturnsSongs() { [Fact] public async Task GetSongStreamAsync_ParsesManifestAndReturnsStream() { - var manifest = new TidalManifest { - MimeType = "audio/flac", - Codecs = "flac", - EncryptionType = "none", - Urls = ["http://actual-stream-url"] - }; + var manifest = new TidalManifest("audio/flac", "flac", "none", ["http://actual-stream-url"]); var manifestJson = JsonSerializer.Serialize(manifest); var manifestBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(manifestJson)); @@ -85,7 +78,7 @@ public async Task GetSongStreamAsync_ParsesManifestAndReturnsStream() { .Protected() .Setup>( "SendAsync", - ItExpr.Is(req => req.RequestUri!.ToString() == "http://actual-stream-url"), + ItExpr.Is(req => req.RequestUri!.ToString().Contains("actual-stream-url")), ItExpr.IsAny() ) .ReturnsAsync(new HttpResponseMessage { diff --git a/smoc/Configuration/TidalConfig.cs b/smoc/Configuration/TidalConfig.cs index 698f8fb..cece5d9 100644 --- a/smoc/Configuration/TidalConfig.cs +++ b/smoc/Configuration/TidalConfig.cs @@ -30,6 +30,12 @@ public static class TidalConfig { [ConfigurationProperty(Scope = typeof(SettingsScope))] public static string? RefreshToken { get; set; } = null; + /// + /// Gets or sets the Tidal token expiry time. + /// + [ConfigurationProperty(Scope = typeof(SettingsScope))] + public static DateTime? TokenExpiry { get; set; } = null; + /// /// Gets or sets the Tidal country code. /// diff --git a/smoc/Streaming/Tidal/Models/TidalAlbum.cs b/smoc/Streaming/Tidal/Models/TidalAlbum.cs new file mode 100644 index 0000000..c33591f --- /dev/null +++ b/smoc/Streaming/Tidal/Models/TidalAlbum.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace Smoc.Streaming.Tidal.Models; + +public record TidalAlbum( + [property: JsonPropertyName("id")] long Id, + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("cover")] string? Cover = null, + [property: JsonPropertyName("releaseDate")] string? ReleaseDate = null, + [property: JsonPropertyName("artist")] TidalArtist? Artist = null +); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/Models/TidalArtist.cs b/smoc/Streaming/Tidal/Models/TidalArtist.cs new file mode 100644 index 0000000..b90d5a8 --- /dev/null +++ b/smoc/Streaming/Tidal/Models/TidalArtist.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Smoc.Streaming.Tidal.Models; + +public record TidalArtist( + [property: JsonPropertyName("id")] long Id, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("picture")] string? Picture = null +); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/Models/TidalCommon.cs b/smoc/Streaming/Tidal/Models/TidalCommon.cs deleted file mode 100644 index a0567a1..0000000 --- a/smoc/Streaming/Tidal/Models/TidalCommon.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Smoc.Streaming.Tidal.Models; - -public record TidalArtist( - [property: JsonPropertyName("id")] long Id, - [property: JsonPropertyName("name")] string Name, - [property: JsonPropertyName("picture")] string? Picture -); - -public record TidalAlbum( - [property: JsonPropertyName("id")] long Id, - [property: JsonPropertyName("title")] string Title, - [property: JsonPropertyName("cover")] string? Cover, - [property: JsonPropertyName("releaseDate")] string? ReleaseDate, - [property: JsonPropertyName("artist")] TidalArtist? Artist -); - -public record TidalTrack( - [property: JsonPropertyName("id")] long Id, - [property: JsonPropertyName("title")] string Title, - [property: JsonPropertyName("duration")] int Duration, - [property: JsonPropertyName("trackNumber")] int TrackNumber, - [property: JsonPropertyName("album")] TidalAlbum Album, - [property: JsonPropertyName("artist")] TidalArtist Artist, - [property: JsonPropertyName("artists")] List Artists -); - -public record TidalPlaylist( - [property: JsonPropertyName("uuid")] string Uuid, - [property: JsonPropertyName("title")] string Title, - [property: JsonPropertyName("description")] string? Description -); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/Models/TidalAuth.cs b/smoc/Streaming/Tidal/Models/TidalDeviceAuthResponse.cs similarity index 63% rename from smoc/Streaming/Tidal/Models/TidalAuth.cs rename to smoc/Streaming/Tidal/Models/TidalDeviceAuthResponse.cs index c290669..5b664de 100644 --- a/smoc/Streaming/Tidal/Models/TidalAuth.cs +++ b/smoc/Streaming/Tidal/Models/TidalDeviceAuthResponse.cs @@ -9,11 +9,4 @@ public record TidalDeviceAuthResponse( [property: JsonPropertyName("verificationUriComplete")] string VerificationUriComplete, [property: JsonPropertyName("expiresIn")] int ExpiresIn, [property: JsonPropertyName("interval")] int Interval -); - -public record TidalTokenResponse( - [property: JsonPropertyName("access_token")] string AccessToken, - [property: JsonPropertyName("refresh_token")] string RefreshToken, - [property: JsonPropertyName("expires_in")] int ExpiresIn, - [property: JsonPropertyName("token_type")] string TokenType ); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/Models/TidalManifest.cs b/smoc/Streaming/Tidal/Models/TidalManifest.cs new file mode 100644 index 0000000..e2c32f2 --- /dev/null +++ b/smoc/Streaming/Tidal/Models/TidalManifest.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace Smoc.Streaming.Tidal.Models; + +public record TidalManifest( + [property: JsonPropertyName("mimeType")] string MimeType, + [property: JsonPropertyName("codecs")] string Codecs, + [property: JsonPropertyName("encryptionType")] string EncryptionType, + [property: JsonPropertyName("urls")] List Urls +); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/Models/TidalMetadata.cs b/smoc/Streaming/Tidal/Models/TidalMetadata.cs deleted file mode 100644 index 0b59172..0000000 --- a/smoc/Streaming/Tidal/Models/TidalMetadata.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Smoc.Streaming.Tidal.Models; - -public record TidalArtist( - [property: JsonPropertyName("id")] long Id, - [property: JsonPropertyName("name")] string Name, - [property: JsonPropertyName("picture")] string? Picture -); - -public record TidalAlbum( - [property: JsonPropertyName("id")] long Id, - [property: JsonPropertyName("title")] string Title, - [property: JsonPropertyName("cover")] string? Cover, - [property: JsonPropertyName("releaseDate")] string? ReleaseDate, - [property: JsonPropertyName("artist")] TidalArtist? Artist -); - -public record TidalTrack( - [property: JsonPropertyName("id")] long Id, - [property: JsonPropertyName("title")] string Title, - [property: JsonPropertyName("duration")] int Duration, - [property: JsonPropertyName("trackNumber")] int TrackNumber, - [property: JsonPropertyName("album")] TidalAlbum Album, - [property: JsonPropertyName("artist")] TidalArtist Artist, - [property: JsonPropertyName("artists")] List Artists -); - -public record TidalPlaylist( - [property: JsonPropertyName("uuid")] string Uuid, - [property: JsonPropertyName("title")] string Title, - [property: JsonPropertyName("description")] string? Description -); - -public record TidalSearchResponse( - [property: JsonPropertyName("artists")] TidalCollection? Artists, - [property: JsonPropertyName("albums")] TidalCollection? Albums, - [property: JsonPropertyName("tracks")] TidalCollection? Tracks -); - -public record TidalCollection( - [property: JsonPropertyName("items")] List Items, - [property: JsonPropertyName("totalNumberOfItems")] int Total -); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/Models/TidalPlayback.cs b/smoc/Streaming/Tidal/Models/TidalPlaybackInfo.cs similarity index 61% rename from smoc/Streaming/Tidal/Models/TidalPlayback.cs rename to smoc/Streaming/Tidal/Models/TidalPlaybackInfo.cs index 1135210..ffe263b 100644 --- a/smoc/Streaming/Tidal/Models/TidalPlayback.cs +++ b/smoc/Streaming/Tidal/Models/TidalPlaybackInfo.cs @@ -8,11 +8,4 @@ public record TidalPlaybackInfo( [property: JsonPropertyName("audioQuality")] string AudioQuality, [property: JsonPropertyName("manifestMimeType")] string ManifestMimeType, [property: JsonPropertyName("manifest")] string Manifest -); - -public record TidalManifest( - [property: JsonPropertyName("mimeType")] string MimeType, - [property: JsonPropertyName("codecs")] string Codecs, - [property: JsonPropertyName("encryptionType")] string EncryptionType, - [property: JsonPropertyName("urls")] List Urls ); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/Models/TidalPlaylist.cs b/smoc/Streaming/Tidal/Models/TidalPlaylist.cs new file mode 100644 index 0000000..367b4fd --- /dev/null +++ b/smoc/Streaming/Tidal/Models/TidalPlaylist.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Smoc.Streaming.Tidal.Models; + +public record TidalPlaylist( + [property: JsonPropertyName("uuid")] string Uuid, + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("description")] string? Description = null +); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/Models/TidalSearch.cs b/smoc/Streaming/Tidal/Models/TidalSearchContainer.cs similarity index 58% rename from smoc/Streaming/Tidal/Models/TidalSearch.cs rename to smoc/Streaming/Tidal/Models/TidalSearchContainer.cs index e08aaef..8b9fc2b 100644 --- a/smoc/Streaming/Tidal/Models/TidalSearch.cs +++ b/smoc/Streaming/Tidal/Models/TidalSearchContainer.cs @@ -2,14 +2,9 @@ namespace Smoc.Streaming.Tidal.Models; -public record TidalSearchResponse( - [property: JsonPropertyName("items")] List Items, - [property: JsonPropertyName("totalNumberOfItems")] int TotalNumberOfItems -); - public record TidalSearchContainer( - [property: JsonPropertyName("artists")] TidalSearchResponse? Artists, - [property: JsonPropertyName("albums")] TidalSearchResponse? Albums, - [property: JsonPropertyName("tracks")] TidalSearchResponse? Tracks, - [property: JsonPropertyName("playlists")] TidalSearchResponse? Playlists + [property: JsonPropertyName("artists")] TidalSearchResponse? Artists = null, + [property: JsonPropertyName("albums")] TidalSearchResponse? Albums = null, + [property: JsonPropertyName("tracks")] TidalSearchResponse? Tracks = null, + [property: JsonPropertyName("playlists")] TidalSearchResponse? Playlists = null ); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/Models/TidalSearchResponse.cs b/smoc/Streaming/Tidal/Models/TidalSearchResponse.cs new file mode 100644 index 0000000..cf19cf0 --- /dev/null +++ b/smoc/Streaming/Tidal/Models/TidalSearchResponse.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; + +namespace Smoc.Streaming.Tidal.Models; + +public record TidalSearchResponse( + [property: JsonPropertyName("items")] List Items, + [property: JsonPropertyName("totalNumberOfItems")] int TotalNumberOfItems = 0 +); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/Models/TidalTokenResponse.cs b/smoc/Streaming/Tidal/Models/TidalTokenResponse.cs new file mode 100644 index 0000000..29fad06 --- /dev/null +++ b/smoc/Streaming/Tidal/Models/TidalTokenResponse.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace Smoc.Streaming.Tidal.Models; + +public record TidalTokenResponse( + [property: JsonPropertyName("access_token")] string AccessToken, + [property: JsonPropertyName("refresh_token")] string RefreshToken, + [property: JsonPropertyName("expires_in")] int ExpiresIn, + [property: JsonPropertyName("token_type")] string TokenType +); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/Models/TidalTrack.cs b/smoc/Streaming/Tidal/Models/TidalTrack.cs new file mode 100644 index 0000000..e5d7192 --- /dev/null +++ b/smoc/Streaming/Tidal/Models/TidalTrack.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace Smoc.Streaming.Tidal.Models; + +public record TidalTrack( + [property: JsonPropertyName("id")] long Id, + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("duration")] int Duration, + [property: JsonPropertyName("trackNumber")] int TrackNumber, + [property: JsonPropertyName("album")] TidalAlbum Album, + [property: JsonPropertyName("artist")] TidalArtist Artist, + [property: JsonPropertyName("artists")] List Artists +); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/TidalStreamingClient.cs b/smoc/Streaming/Tidal/TidalStreamingClient.cs index cf76b28..d8bd560 100644 --- a/smoc/Streaming/Tidal/TidalStreamingClient.cs +++ b/smoc/Streaming/Tidal/TidalStreamingClient.cs @@ -42,8 +42,41 @@ public static TidalStreamingClient Create(ICacheService? songCacheService = null private async Task EnsureAuthenticatedAsync(CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(TidalConfig.AccessToken)) { await AuthorizeDeviceAsync(cancellationToken); + } else if (TidalConfig.TokenExpiry.HasValue && TidalConfig.TokenExpiry.Value < DateTime.UtcNow.AddMinutes(5)) { + await RefreshTokenAsync(cancellationToken); } - // TODO: Implement token refresh logic if expired + } + + private async Task RefreshTokenAsync(CancellationToken cancellationToken = default) { + if (string.IsNullOrEmpty(TidalConfig.RefreshToken) || string.IsNullOrEmpty(TidalConfig.ClientId)) { + await AuthorizeDeviceAsync(cancellationToken); + return; + } + + Logging.Information("Refreshing Tidal token..."); + var refreshRequest = new Dictionary { + { "client_id", TidalConfig.ClientId }, + { "refresh_token", TidalConfig.RefreshToken }, + { "grant_type", "refresh_token" } + }; + + var response = await _httpClient.PostAsync($"{AuthUrl}/token", new FormUrlEncodedContent(refreshRequest), cancellationToken); + if (response.IsSuccessStatusCode) { + var tokenData = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + if (tokenData != null) { + TidalConfig.AccessToken = tokenData.AccessToken; + TidalConfig.TokenExpiry = DateTime.UtcNow.AddSeconds(tokenData.ExpiresIn); + // Refresh token might also be updated + if (!string.IsNullOrEmpty(tokenData.RefreshToken)) { + TidalConfig.RefreshToken = tokenData.RefreshToken; + } + Logging.Information("Tidal token refreshed."); + return; + } + } + + Logging.Warning("Tidal token refresh failed. Re-authorizing device..."); + await AuthorizeDeviceAsync(cancellationToken); } private async Task AuthorizeDeviceAsync(CancellationToken cancellationToken = default) { @@ -81,6 +114,7 @@ private async Task AuthorizeDeviceAsync(CancellationToken cancellationToken = de if (tokenData != null) { TidalConfig.AccessToken = tokenData.AccessToken; TidalConfig.RefreshToken = tokenData.RefreshToken; + TidalConfig.TokenExpiry = DateTime.UtcNow.AddSeconds(tokenData.ExpiresIn); Logging.Information("Tidal authentication successful."); return; } @@ -229,7 +263,11 @@ private Album MapAlbumToAlbum(TidalAlbum album, Artist artist) { covers.Add(new AlbumCover($"https://resources.tidal.com/images/{uuid}/640x640.jpg", 640, 640)); covers.Add(new AlbumCover($"https://resources.tidal.com/images/{uuid}/320x320.jpg", 320, 320)); } - return new Album(album.Id.ToString(), artist, album.Title, covers, album.ReleaseDate); + int? releaseYear = null; + if (DateTime.TryParse(album.ReleaseDate, out var date)) { + releaseYear = date.Year; + } + return new Album(album.Id.ToString(), artist, album.Title, covers, releaseYear); } public void Dispose() {