From 75b9b57207b6ea01adfd6fd4d9cdfce154f5938f 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/3] feat: Tidal integration implementation --- .../Tidal/TidalStreamingClientServiceTest.cs | 108 ++++++++ smoc/Configuration/StreamingService.cs | 9 +- 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, 528 insertions(+), 2 deletions(-) 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 34b464a..f06546a 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, + + /// + /// Tidal. + /// + Tidal +} 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 05fc161dc1b98b42bb619f87c44e780ea30eab60 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/3] 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() { From 8801c474fc8307a5b5fcc3e6588464c54a7ece0e Mon Sep 17 00:00:00 2001 From: oca-agent <277152249+oca-agent@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:02:55 -0400 Subject: [PATCH 3/3] feat(tidal): improve Tidal integration robustness, configuration and tests - Rebase feat/tidal-integration onto origin/main and resolve StreamingService conflict - Migrate TidalConfig to modern MEC POCO pattern with Defaults facade - Bind TidalConfig section in Program.cs and add StreamingService.Tidal client creation - Add --auth-tidal interactive CLI option to authenticate via OAuth device flow and save to ~/.config/smoc/tidal_tokens.json - Protect auth state and token refresh with SemaphoreSlim in TidalStreamingClient - Implement automatic token refresh on HTTP 401 Unauthorized with single retry - Support robust URL parsing via regex for track, album, and playlist URLs - Add null-safety handling to MapTrackToSong and MapAlbumToAlbum - Add manifest MIME type validation and stream exception safety - Call EnsureSuccessStatusCode and dispose HttpResponseMessage in GetAlbumArtAsync - Replace reflection in tests with internal constructor - Fix compiler warning CA2022 with Stream.ReadExactlyAsync - Add comprehensive unit tests for URL parser, 401 refresh, mapping null-safety, and config binding - Add XML doc comments and enforce 2-space indentation --- .../Configuration/ConfigurationBindingTest.cs | 171 +++++--- .../Streaming/Tidal/TidalMappingTest.cs | 79 ++++ .../Tidal/TidalStreamingClientServiceTest.cs | 236 ++++++++++- .../Streaming/Tidal/TidalUrlParserTest.cs | 38 ++ smoc/Configuration/TidalConfig.cs | 38 +- smoc/Program.cs | 55 ++- smoc/Streaming/Tidal/Models/TidalAlbum.cs | 20 +- smoc/Streaming/Tidal/Models/TidalArtist.cs | 14 +- .../Tidal/Models/TidalDeviceAuthResponse.cs | 23 +- smoc/Streaming/Tidal/Models/TidalManifest.cs | 17 +- .../Tidal/Models/TidalPlaybackInfo.cs | 20 +- smoc/Streaming/Tidal/Models/TidalPlaylist.cs | 14 +- .../Tidal/Models/TidalSearchContainer.cs | 17 +- .../Tidal/Models/TidalSearchResponse.cs | 12 +- .../Tidal/Models/TidalTokenResponse.cs | 17 +- smoc/Streaming/Tidal/Models/TidalTokens.cs | 17 + smoc/Streaming/Tidal/Models/TidalTrack.cs | 26 +- smoc/Streaming/Tidal/TidalStreamingClient.cs | 394 +++++++++++++----- smoc/Streaming/Tidal/TidalUrlParser.cs | 35 ++ 19 files changed, 978 insertions(+), 265 deletions(-) create mode 100644 smoc.Tests/Streaming/Tidal/TidalMappingTest.cs create mode 100644 smoc.Tests/Streaming/Tidal/TidalUrlParserTest.cs create mode 100644 smoc/Streaming/Tidal/Models/TidalTokens.cs create mode 100644 smoc/Streaming/Tidal/TidalUrlParser.cs diff --git a/smoc.Tests/Configuration/ConfigurationBindingTest.cs b/smoc.Tests/Configuration/ConfigurationBindingTest.cs index 88eea1f..a87efa2 100644 --- a/smoc.Tests/Configuration/ConfigurationBindingTest.cs +++ b/smoc.Tests/Configuration/ConfigurationBindingTest.cs @@ -8,85 +8,122 @@ namespace smoc.Tests.Configuration; public class ConfigurationBindingTest { [Fact] public void Bind_SmocConfiguration_FromSection_UsingTuiConfigurationBuilder() { - var json = """ - { - "SmocConfiguration": { - "LogLevel": "Trace", - "ActiveService": "SoundCloud", - "SongCacheSizeBytes": 5000000, - "SongCacheMaxElements": 20, - "AlbumCoverCacheSizeBytes": 3000000, - "AlbumCoverCacheMaxElements": 40, - "VisualizerFps": 30, - "EnableLoudnessNormalization": true, - "LoudnessNormalizationMode": "Full" + var originalConfig = SmocConfiguration.Defaults; + try { + var json = """ + { + "SmocConfiguration": { + "LogLevel": "Trace", + "ActiveService": "SoundCloud", + "SongCacheSizeBytes": 5000000, + "SongCacheMaxElements": 20, + "AlbumCoverCacheSizeBytes": 3000000, + "AlbumCoverCacheMaxElements": 40, + "VisualizerFps": 30, + "EnableLoudnessNormalization": true, + "LoudnessNormalizationMode": "Full" + } } - } - """; + """; - var builder = new TuiConfigurationBuilder("SMoC"); - builder.RuntimeConfig = json; - builder.BindAppSettings("SmocConfiguration", s => SmocConfiguration.Defaults = s); + var builder = new TuiConfigurationBuilder("SMoC"); + builder.RuntimeConfig = json; + builder.BindAppSettings("SmocConfiguration", s => SmocConfiguration.Defaults = s); - Assert.Equal(LogLevel.Trace, SmocConfiguration.Defaults.LogLevel); - Assert.Equal(StreamingService.SoundCloud, SmocConfiguration.Defaults.ActiveService); - Assert.Equal(5000000, SmocConfiguration.Defaults.SongCacheSizeBytes); - Assert.Equal(20, SmocConfiguration.Defaults.SongCacheMaxElements); - Assert.Equal(3000000, SmocConfiguration.Defaults.AlbumCoverCacheSizeBytes); - Assert.Equal(40, SmocConfiguration.Defaults.AlbumCoverCacheMaxElements); - Assert.Equal(30, SmocConfiguration.Defaults.VisualizerFps); - Assert.True(SmocConfiguration.Defaults.EnableLoudnessNormalization); - Assert.Equal(LoudnessNormalizationMode.Full, SmocConfiguration.Defaults.LoudnessNormalizationMode); + Assert.Equal(LogLevel.Trace, SmocConfiguration.Defaults.LogLevel); + Assert.Equal(StreamingService.SoundCloud, SmocConfiguration.Defaults.ActiveService); + Assert.Equal(5000000, SmocConfiguration.Defaults.SongCacheSizeBytes); + Assert.Equal(20, SmocConfiguration.Defaults.SongCacheMaxElements); + Assert.Equal(3000000, SmocConfiguration.Defaults.AlbumCoverCacheSizeBytes); + Assert.Equal(40, SmocConfiguration.Defaults.AlbumCoverCacheMaxElements); + Assert.Equal(30, SmocConfiguration.Defaults.VisualizerFps); + Assert.True(SmocConfiguration.Defaults.EnableLoudnessNormalization); + Assert.Equal(LoudnessNormalizationMode.Full, SmocConfiguration.Defaults.LoudnessNormalizationMode); + } finally { + SmocConfiguration.Defaults = originalConfig; + } } [Fact] public void Bind_OtherConfigs_UsingTuiConfigurationBuilder() { - var json = """ - { - "ListenHistoryConfig": { - "Enabled": false, - "MinimumPositionSeconds": 45, - "MinimumFraction": 0.75 - }, - "SoundCloudConfig": { - "ClientId": "sc-id", - "AuthToken": "sc-token" - }, - "SubsonicConfig": { - "ServerHost": "subsonic.local", - "ServerPort": 4040, - "ServerScheme": "https", - "Username": "admin", - "Password": "secretpassword", - "UseToken": false - }, - "YouTubeMusicConfig": { - "PlayerId": "player123" + var origListen = ListenHistoryConfig.Defaults; + var origSoundCloud = SoundCloudConfig.Defaults; + var origSubsonic = SubsonicConfig.Defaults; + var origTidal = TidalConfig.Defaults; + var origYtm = YouTubeMusicConfig.Defaults; + + try { + var json = """ + { + "ListenHistoryConfig": { + "Enabled": false, + "MinimumPositionSeconds": 45, + "MinimumFraction": 0.75 + }, + "SoundCloudConfig": { + "ClientId": "sc-id", + "AuthToken": "sc-token" + }, + "SubsonicConfig": { + "ServerHost": "subsonic.local", + "ServerPort": 4040, + "ServerScheme": "https", + "Username": "admin", + "Password": "secretpassword", + "UseToken": false + }, + "TidalConfig": { + "ClientId": "tidal-client-id", + "ClientSecret": "tidal-client-secret", + "AccessToken": "tidal-access-token", + "RefreshToken": "tidal-refresh-token", + "TokenExpiry": 1700000000, + "Quality": "LOSSLESS", + "CountryCode": "GB" + }, + "YouTubeMusicConfig": { + "PlayerId": "player123" + } } - } - """; + """; + + var builder = new TuiConfigurationBuilder("SMoC"); + builder.RuntimeConfig = json; + builder.BindAppSettings("ListenHistoryConfig", s => ListenHistoryConfig.Defaults = s) + .BindAppSettings("SoundCloudConfig", s => SoundCloudConfig.Defaults = s) + .BindAppSettings("SubsonicConfig", s => SubsonicConfig.Defaults = s) + .BindAppSettings("TidalConfig", s => TidalConfig.Defaults = s) + .BindAppSettings("YouTubeMusicConfig", s => YouTubeMusicConfig.Defaults = s); - var builder = new TuiConfigurationBuilder("SMoC"); - builder.RuntimeConfig = json; - builder.BindAppSettings("ListenHistoryConfig", s => ListenHistoryConfig.Defaults = s) - .BindAppSettings("SoundCloudConfig", s => SoundCloudConfig.Defaults = s) - .BindAppSettings("SubsonicConfig", s => SubsonicConfig.Defaults = s) - .BindAppSettings("YouTubeMusicConfig", s => YouTubeMusicConfig.Defaults = s); + Assert.False(ListenHistoryConfig.Defaults.Enabled); + Assert.Equal(45, ListenHistoryConfig.Defaults.MinimumPositionSeconds); + Assert.Equal(0.75, ListenHistoryConfig.Defaults.MinimumFraction); - Assert.False(ListenHistoryConfig.Defaults.Enabled); - Assert.Equal(45, ListenHistoryConfig.Defaults.MinimumPositionSeconds); - Assert.Equal(0.75, ListenHistoryConfig.Defaults.MinimumFraction); + Assert.Equal("sc-id", SoundCloudConfig.Defaults.ClientId); + Assert.Equal("sc-token", SoundCloudConfig.Defaults.AuthToken); - Assert.Equal("sc-id", SoundCloudConfig.Defaults.ClientId); - Assert.Equal("sc-token", SoundCloudConfig.Defaults.AuthToken); + Assert.Equal("subsonic.local", SubsonicConfig.Defaults.ServerHost); + Assert.Equal(4040, SubsonicConfig.Defaults.ServerPort); + Assert.Equal("https", SubsonicConfig.Defaults.ServerScheme); + Assert.Equal("admin", SubsonicConfig.Defaults.Username); + Assert.Equal("secretpassword", SubsonicConfig.Defaults.Password); + Assert.False(SubsonicConfig.Defaults.UseToken); - Assert.Equal("subsonic.local", SubsonicConfig.Defaults.ServerHost); - Assert.Equal(4040, SubsonicConfig.Defaults.ServerPort); - Assert.Equal("https", SubsonicConfig.Defaults.ServerScheme); - Assert.Equal("admin", SubsonicConfig.Defaults.Username); - Assert.Equal("secretpassword", SubsonicConfig.Defaults.Password); - Assert.False(SubsonicConfig.Defaults.UseToken); + Assert.Equal("tidal-client-id", TidalConfig.Defaults.ClientId); + Assert.Equal("tidal-client-secret", TidalConfig.Defaults.ClientSecret); + Assert.Equal("tidal-access-token", TidalConfig.Defaults.AccessToken); + Assert.Equal("tidal-refresh-token", TidalConfig.Defaults.RefreshToken); + Assert.Equal(1700000000L, TidalConfig.Defaults.TokenExpiry); + Assert.Equal("LOSSLESS", TidalConfig.Defaults.Quality); + Assert.Equal("GB", TidalConfig.Defaults.CountryCode); - Assert.Equal("player123", YouTubeMusicConfig.Defaults.PlayerId); + Assert.Equal("player123", YouTubeMusicConfig.Defaults.PlayerId); + } finally { + ListenHistoryConfig.Defaults = origListen; + SoundCloudConfig.Defaults = origSoundCloud; + SubsonicConfig.Defaults = origSubsonic; + TidalConfig.Defaults = origTidal; + YouTubeMusicConfig.Defaults = origYtm; + } } } diff --git a/smoc.Tests/Streaming/Tidal/TidalMappingTest.cs b/smoc.Tests/Streaming/Tidal/TidalMappingTest.cs new file mode 100644 index 0000000..37ae6d6 --- /dev/null +++ b/smoc.Tests/Streaming/Tidal/TidalMappingTest.cs @@ -0,0 +1,79 @@ +using Smoc.Streaming.Tidal; +using Smoc.Streaming.Tidal.Models; +using Xunit; + +namespace smoc.Tests.Streaming.Tidal; + +public class TidalMappingTest { + [Fact] + public void MapTrackToSong_FullMetadata_MapsCorrectly() { + var artist = new TidalArtist(42, "Test Artist", "pic-id"); + var album = new TidalAlbum(101, "Test Album", "cover-uuid-1234", "2024-05-15", artist); + var track = new TidalTrack(999, "Test Track", 215, 3, album, artist, [artist]); + + var song = TidalStreamingClient.MapTrackToSong(track); + + Assert.Equal("999", song.Id); + Assert.Equal("Test Track", song.Title); + Assert.Equal(TimeSpan.FromSeconds(215), song.Duration); + Assert.Equal(3, song.TrackNumber); + Assert.Equal("Test Artist", song.Artist.Name); + Assert.Equal("42", song.Artist.Id); + Assert.Equal("101", song.Album.Id); + Assert.Equal("Test Album", song.Album.Name); + Assert.Equal(2024, song.Album.ReleaseYear); + Assert.Equal(2, song.Album.Covers.Count()); + } + + [Fact] + public void MapTrackToSong_NullArtist_FallsBackToAlbumArtist() { + var albumArtist = new TidalArtist(55, "Album Artist", null); + var album = new TidalAlbum(202, "Album Name", null, "2020-01-01", albumArtist); + var track = new TidalTrack(888, "Track Name", 180, 1, album, null, null); + + var song = TidalStreamingClient.MapTrackToSong(track); + + Assert.Equal("Album Artist", song.Artist.Name); + Assert.Equal("55", song.Artist.Id); + Assert.Empty(song.Album.Covers); + Assert.Equal(2020, song.Album.ReleaseYear); + } + + [Fact] + public void MapTrackToSong_NullArtistAndAlbum_DoesNotThrow() { + var track = new TidalTrack(777, "Standalone Track", 120, 1, null, null, null); + + var song = TidalStreamingClient.MapTrackToSong(track); + + Assert.Equal("777", song.Id); + Assert.Equal("Standalone Track", song.Title); + Assert.Equal("Unknown Artist", song.Artist.Name); + Assert.Equal("Unknown Album", song.Album.Name); + Assert.Empty(song.Album.Covers); + Assert.Null(song.Album.ReleaseYear); + } + + [Fact] + public void MapAlbumToAlbum_NullCoverAndInvalidReleaseDate_HandlesGracefully() { + var tidalAlbum = new TidalAlbum(303, "Album Without Cover", null, "invalid-date", null); + + var album = TidalStreamingClient.MapAlbumToAlbum(tidalAlbum); + + Assert.Equal("303", album.Id); + Assert.Equal("Album Without Cover", album.Name); + Assert.Empty(album.Covers); + Assert.Null(album.ReleaseYear); + Assert.Equal("Unknown Artist", album.Artist.Name); + } + + [Fact] + public void MapAlbumToAlbum_CoverFormattedWithSlashes() { + var tidalAlbum = new TidalAlbum(404, "Cover Album", "ab-cd-ef", "1999-12-31", null); + + var album = TidalStreamingClient.MapAlbumToAlbum(tidalAlbum); + + Assert.NotEmpty(album.Covers); + Assert.Contains("ab/cd/ef", album.Covers.First().Url); + Assert.Equal(1999, album.ReleaseYear); + } +} diff --git a/smoc.Tests/Streaming/Tidal/TidalStreamingClientServiceTest.cs b/smoc.Tests/Streaming/Tidal/TidalStreamingClientServiceTest.cs index a095461..26d3a29 100644 --- a/smoc.Tests/Streaming/Tidal/TidalStreamingClientServiceTest.cs +++ b/smoc.Tests/Streaming/Tidal/TidalStreamingClientServiceTest.cs @@ -4,16 +4,18 @@ using System.Text.Json; using Moq; using Moq.Protected; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; using Smoc.Configuration; using Smoc.Streaming; using Smoc.Streaming.Tidal; using Smoc.Streaming.Tidal.Models; -using Smoc.Services.Caching; +using Xunit; namespace smoc.Tests.Streaming.Tidal; public class TidalStreamingClientServiceTest { - private static (TidalStreamingClient, Mock) CreateClientWithMockResponse(T responseObj, HttpStatusCode statusCode = HttpStatusCode.OK) { + private static (TidalStreamingClient Client, Mock Handler) CreateClientWithMockResponse(T responseObj, HttpStatusCode statusCode = HttpStatusCode.OK) { var handlerMock = new Mock(MockBehavior.Strict); handlerMock .Protected() @@ -27,8 +29,13 @@ private static (TidalStreamingClient, Mock) CreateClientWith Content = JsonContent.Create(responseObj), }); + TidalConfig.Defaults.AccessToken = "test-token"; + TidalConfig.Defaults.TokenExpiry = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds(); + TidalConfig.Defaults.ClientId = "test-client"; + TidalConfig.Defaults.RefreshToken = "test-refresh"; + var httpClient = new HttpClient(handlerMock.Object); - var client = TidalStreamingClient.CreateForTesting(httpClient); + var client = new TidalStreamingClient(httpClient); return (client, handlerMock); } @@ -38,7 +45,7 @@ public async Task SearchSongsAsync_ReturnsSongs() { 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([track], 1)); - + var (client, _) = CreateClientWithMockResponse(response); var results = await client.SearchSongsAsync("query", TestContext.Current.CancellationToken); @@ -55,11 +62,11 @@ public async Task GetSongStreamAsync_ParsesManifestAndReturnsStream() { 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)); - - var playbackInfo = new TidalPlaybackInfo(1, "FULL", "LOSSLESS", "application/vnd.tidal.bt", manifestBase64); + + var playbackInfo = new TidalPlaybackInfo(1, "FULL", "LOSSLESS", "application/vnd.tidal.bts", manifestBase64); var handlerMock = new Mock(MockBehavior.Strict); - + // First call: GET /tracks/1/playbackinfo handlerMock .Protected() @@ -86,16 +93,221 @@ public async Task GetSongStreamAsync_ParsesManifestAndReturnsStream() { Content = new ByteArrayContent([1, 2, 3, 4]), }); + TidalConfig.Defaults.AccessToken = "test-token"; + TidalConfig.Defaults.TokenExpiry = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds(); + var httpClient = new HttpClient(handlerMock.Object); - var client = TidalStreamingClient.CreateForTesting(httpClient); - + var client = new TidalStreamingClient(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); + await songStream.Stream.ReadExactlyAsync(buffer, TestContext.Current.CancellationToken); Assert.Equal([1, 2, 3, 4], buffer); } -} \ No newline at end of file + + [Fact] + public async Task GetSongStreamAsync_UnsupportedManifestMimeType_ThrowsInvalidOperationException() { + var playbackInfo = new TidalPlaybackInfo(1, "FULL", "LOSSLESS", "application/dash+xml", "invalid-manifest"); + var (client, _) = CreateClientWithMockResponse(playbackInfo); + + await Assert.ThrowsAsync(() => + client.GetSongStreamAsync("1", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task GetSongStreamAsync_InvalidBase64Manifest_ThrowsInvalidOperationException() { + var playbackInfo = new TidalPlaybackInfo(1, "FULL", "LOSSLESS", "application/vnd.tidal.bts", "not-valid-base64!!!"); + var (client, _) = CreateClientWithMockResponse(playbackInfo); + + await Assert.ThrowsAsync(() => + client.GetSongStreamAsync("1", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task GetSongStreamAsync_EmptyUrlsInManifest_ThrowsInvalidOperationException() { + var manifest = new TidalManifest("audio/flac", "flac", "none", []); + var manifestBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(manifest))); + var playbackInfo = new TidalPlaybackInfo(1, "FULL", "LOSSLESS", "application/vnd.tidal.bts", manifestBase64); + var (client, _) = CreateClientWithMockResponse(playbackInfo); + + await Assert.ThrowsAsync(() => + client.GetSongStreamAsync("1", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task GetAsync_When401Unauthorized_RefreshesTokenAndRetriesRequest() { + var handlerMock = new Mock(MockBehavior.Strict); + var track = new TidalTrack(123, "Refreshed Track", 200, 1, null, null, null); + var tokenResponse = new TidalTokenResponse("refreshed-access-token", "refreshed-refresh-token", 3600, "Bearer"); + + // 1. Initial request with initial token fails with 401 + handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => req.Method == HttpMethod.Get && + req.RequestUri!.ToString().Contains("/tracks/123") && + req.Headers.Authorization != null && + req.Headers.Authorization.Parameter == "initial-expired-token"), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage { + StatusCode = HttpStatusCode.Unauthorized, + }); + + // 2. Token refresh request + handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => req.Method == HttpMethod.Post && req.RequestUri!.ToString().Contains("/token")), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage { + StatusCode = HttpStatusCode.OK, + Content = JsonContent.Create(tokenResponse), + }); + + // 3. Retried GET request with new token succeeds + handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => req.Method == HttpMethod.Get && + req.RequestUri!.ToString().Contains("/tracks/123") && + req.Headers.Authorization != null && + req.Headers.Authorization.Parameter == "refreshed-access-token"), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage { + StatusCode = HttpStatusCode.OK, + Content = JsonContent.Create(track), + }); + + TidalConfig.Defaults.AccessToken = "initial-expired-token"; + TidalConfig.Defaults.TokenExpiry = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds(); + TidalConfig.Defaults.RefreshToken = "initial-refresh-token"; + TidalConfig.Defaults.ClientId = "my-client"; + + var httpClient = new HttpClient(handlerMock.Object); + var client = new TidalStreamingClient(httpClient); + + var song = await client.GetSongAsync("123", TestContext.Current.CancellationToken); + + Assert.Equal("123", song.Id); + Assert.Equal("Refreshed Track", song.Title); + Assert.Equal("refreshed-access-token", TidalConfig.Defaults.AccessToken); + Assert.Equal("refreshed-refresh-token", TidalConfig.Defaults.RefreshToken); + } + + [Fact] + public async Task GetPlaylistSongsFromUrlAsync_TrackUrl_ReturnsSong() { + var artist = new TidalArtist(1, "Track Artist", null); + var album = new TidalAlbum(10, "Track Album", null, "2022", artist); + var track = new TidalTrack(12345, "URL Track", 190, 1, album, artist, [artist]); + + var (client, _) = CreateClientWithMockResponse(track); + + var songs = await client.GetPlaylistSongsFromUrlAsync( + "https://tidal.com/browse/track/12345?si=sample", + TestContext.Current.CancellationToken); + + Assert.Single(songs); + Assert.Equal("12345", songs[0].Id); + Assert.Equal("URL Track", songs[0].Title); + } + + [Fact] + public async Task GetPlaylistSongsFromUrlAsync_AlbumUrl_ReturnsSongs() { + var artist = new TidalArtist(2, "Album Artist", null); + var album = new TidalAlbum(67890, "URL Album", null, "2021", artist); + var track = new TidalTrack(987, "Album Track", 150, 1, album, artist, [artist]); + var searchResponse = new TidalSearchResponse([track], 1); + + var (client, _) = CreateClientWithMockResponse(searchResponse); + + var songs = await client.GetPlaylistSongsFromUrlAsync( + "https://tidal.com/album/67890/", + TestContext.Current.CancellationToken); + + Assert.Single(songs); + Assert.Equal("987", songs[0].Id); + } + + [Fact] + public async Task GetPlaylistSongsFromUrlAsync_PlaylistUrl_ReturnsSongs() { + var artist = new TidalArtist(3, "Playlist Artist", null); + var album = new TidalAlbum(30, "Playlist Album", null, "2020", artist); + var track = new TidalTrack(54321, "Playlist Track", 210, 1, album, artist, [artist]); + var searchResponse = new TidalSearchResponse([track], 1); + + var (client, _) = CreateClientWithMockResponse(searchResponse); + + var songs = await client.GetPlaylistSongsFromUrlAsync( + "https://listen.tidal.com/playlist/7e452778-d5a2-4a0e-953e-b49d6350f0ec", + TestContext.Current.CancellationToken); + + Assert.Single(songs); + Assert.Equal("54321", songs[0].Id); + } + + [Fact] + public async Task GetPlaylistSongsFromUrlAsync_InvalidUrl_ReturnsEmptyList() { + var (client, _) = CreateClientWithMockResponse(new object()); + + var songs = await client.GetPlaylistSongsFromUrlAsync( + "https://example.com/not-tidal/12345", + TestContext.Current.CancellationToken); + + Assert.Empty(songs); + } + + [Fact] + public async Task GetAlbumArtAsync_ReturnsImageOnSuccess() { + using var image = new Image(16, 16); + using var stream = new MemoryStream(); + await image.SaveAsPngAsync(stream, TestContext.Current.CancellationToken); + var contentBytes = stream.ToArray(); + + var handlerMock = new Mock(MockBehavior.Strict); + handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => req.RequestUri!.ToString().Contains("cover.jpg")), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage { + StatusCode = HttpStatusCode.OK, + Content = new ByteArrayContent(contentBytes), + }); + + var httpClient = new HttpClient(handlerMock.Object); + var client = new TidalStreamingClient(httpClient); + + var album = new Album( + "alb-1", + new Artist("art-1", "Artist"), + "Album", + [new AlbumCover("https://resources.tidal.com/images/cover.jpg", 16, 16)]); + + var resultImage = await client.GetAlbumArtAsync(album, null, TestContext.Current.CancellationToken); + + Assert.NotNull(resultImage); + Assert.Equal(16, resultImage.Width); + Assert.Equal(16, resultImage.Height); + } + + [Fact] + public async Task GetAlbumArtAsync_ThrowsIfNoCovers() { + var (client, _) = CreateClientWithMockResponse(new object()); + var album = new Album("alb-2", new Artist("art-2", "Artist"), "Album Without Covers", []); + + await Assert.ThrowsAsync(() => + client.GetAlbumArtAsync(album, null, TestContext.Current.CancellationToken)); + } +} diff --git a/smoc.Tests/Streaming/Tidal/TidalUrlParserTest.cs b/smoc.Tests/Streaming/Tidal/TidalUrlParserTest.cs new file mode 100644 index 0000000..90d3da2 --- /dev/null +++ b/smoc.Tests/Streaming/Tidal/TidalUrlParserTest.cs @@ -0,0 +1,38 @@ +using Smoc.Streaming.Tidal; +using Xunit; + +namespace smoc.Tests.Streaming.Tidal; + +public class TidalUrlParserTest { + [Theory] + [InlineData("https://tidal.com/browse/track/123456", "track", "123456")] + [InlineData("https://tidal.com/track/987654?si=abc123xyz", "track", "987654")] + [InlineData("https://listen.tidal.com/track/112233/", "track", "112233")] + [InlineData("https://tidal.com/browse/album/554433", "album", "554433")] + [InlineData("https://tidal.com/album/443322/?si=queryParam", "album", "443322")] + [InlineData("https://listen.tidal.com/album/998877", "album", "998877")] + [InlineData("https://tidal.com/playlist/7e452778-d5a2-4a0e-953e-b49d6350f0ec", "playlist", "7e452778-d5a2-4a0e-953e-b49d6350f0ec")] + [InlineData("https://tidal.com/browse/playlist/7e452778-d5a2-4a0e-953e-b49d6350f0ec?si=xyz", "playlist", "7e452778-d5a2-4a0e-953e-b49d6350f0ec")] + [InlineData("https://listen.tidal.com/playlist/7e452778-d5a2-4a0e-953e-b49d6350f0ec/", "playlist", "7e452778-d5a2-4a0e-953e-b49d6350f0ec")] + public void TryParseUrl_ValidUrls_ParsesSuccessfully(string url, string expectedType, string expectedId) { + var success = TidalUrlParser.TryParseUrl(url, out var type, out var id); + + Assert.True(success); + Assert.Equal(expectedType, type); + Assert.Equal(expectedId, id); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("https://tidal.com/")] + [InlineData("https://tidal.com/browse/artist/12345")] + [InlineData("https://example.com/other/path")] + public void TryParseUrl_InvalidUrls_ReturnsFalse(string url) { + var success = TidalUrlParser.TryParseUrl(url, out var type, out var id); + + Assert.False(success); + Assert.Empty(type); + Assert.Empty(id); + } +} diff --git a/smoc/Configuration/TidalConfig.cs b/smoc/Configuration/TidalConfig.cs index cece5d9..9237698 100644 --- a/smoc/Configuration/TidalConfig.cs +++ b/smoc/Configuration/TidalConfig.cs @@ -1,50 +1,46 @@ -using Terminal.Gui.Configuration; - namespace Smoc.Configuration; /// /// Configuration for Tidal. /// -public static class TidalConfig { +public class TidalConfig { /// /// Gets or sets the Tidal Client ID. /// - [ConfigurationProperty(Scope = typeof(SettingsScope))] - public static string? ClientId { get; set; } = null; + public string? ClientId { get; set; } = null; /// /// Gets or sets the Tidal Client Secret. /// - [ConfigurationProperty(Scope = typeof(SettingsScope))] - public static string? ClientSecret { get; set; } = null; + public string? ClientSecret { get; set; } = null; /// /// Gets or sets the Tidal access token. /// - [ConfigurationProperty(Scope = typeof(SettingsScope))] - public static string? AccessToken { get; set; } = null; + public string? AccessToken { get; set; } = null; /// /// Gets or sets the Tidal refresh token. /// - [ConfigurationProperty(Scope = typeof(SettingsScope))] - public static string? RefreshToken { get; set; } = null; + public string? RefreshToken { get; set; } = null; + + /// + /// Gets or sets the Tidal token expiry timestamp in epoch seconds. + /// + public long? TokenExpiry { get; set; } = null; /// - /// Gets or sets the Tidal token expiry time. + /// Gets or sets the Tidal audio quality setting (e.g. LOW, HIGH, LOSSLESS, HI_RES). /// - [ConfigurationProperty(Scope = typeof(SettingsScope))] - public static DateTime? TokenExpiry { get; set; } = null; + public string Quality { get; set; } = "LOSSLESS"; /// - /// Gets or sets the Tidal country code. + /// Gets or sets the Tidal ISO country code. /// - [ConfigurationProperty(Scope = typeof(SettingsScope))] - public static string CountryCode { get; set; } = "US"; + public string CountryCode { get; set; } = "US"; /// - /// Gets or sets the Tidal audio quality. + /// The static facade instance containing the current effective values. /// - [ConfigurationProperty(Scope = typeof(SettingsScope))] - public static string AudioQuality { get; set; } = "LOSSLESS"; -} \ No newline at end of file + public static TidalConfig Defaults { get; set; } = new(); +} diff --git a/smoc/Program.cs b/smoc/Program.cs index dc4d78d..429460e 100644 --- a/smoc/Program.cs +++ b/smoc/Program.cs @@ -5,9 +5,11 @@ using Smoc.Configuration; using Smoc.Services.Caching; using Smoc.Streaming; +using Smoc.Streaming.SoundCloud; using Smoc.Streaming.Subsonic; +using Smoc.Streaming.Tidal; +using Smoc.Streaming.Tidal.Models; using Smoc.Streaming.YouTubeMusic; -using Smoc.Streaming.SoundCloud; using Smoc.Ui; using Terminal.Gui; using Terminal.Gui.App; @@ -23,6 +25,7 @@ public static class Program { private static readonly string _configPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "/.config/smoc/config.json"; private static readonly string _cookiesPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "/.config/smoc/cookies.txt"; private static readonly string _tokensPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "/.config/smoc/tokens.json"; + private static readonly string _tidalTokensPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "/.config/smoc/tidal_tokens.json"; private static readonly string _logPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "/.config/smoc/log_{Date}.txt"; public static readonly string ProductName = "SMoC"; @@ -40,6 +43,20 @@ public static async Task Main(string[] args) { File.WriteAllText(_tokensPath, JsonSerializer.Serialize(tokens)); }); + Command authTidalCommand = new Command("--auth-tidal", "Authenticate with Tidal using OAuth device flow."); + rootCommand.Subcommands.Add(authTidalCommand); + authTidalCommand.SetAction(async (_) => { + if (File.Exists(_configPath)) { + var configFile = File.ReadAllText(_configPath); + var configBuilder = new TuiConfigurationBuilder(ProductName) { RuntimeConfig = configFile }; + configBuilder.BindAppSettings("TidalConfig", s => TidalConfig.Defaults = s); + configBuilder.ApplyToStaticFacades(); + } + + var clientId = TidalConfig.Defaults.ClientId; + await TidalStreamingClient.AuthenticateInteractiveAsync(clientId, _tidalTokensPath); + }); + rootCommand.SetAction((_) => { var builder = new TuiConfigurationBuilder(ProductName); if (File.Exists(_configPath)) { @@ -48,12 +65,36 @@ 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("SubsonicConfig", s => SubsonicConfig.Defaults = s) - .BindAppSettings("YouTubeMusicConfig", s => YouTubeMusicConfig.Defaults = s); + .BindAppSettings("ListenHistoryConfig", s => ListenHistoryConfig.Defaults = s) + .BindAppSettings("SoundCloudConfig", s => SoundCloudConfig.Defaults = s) + .BindAppSettings("SubsonicConfig", s => SubsonicConfig.Defaults = s) + .BindAppSettings("TidalConfig", s => TidalConfig.Defaults = s) + .BindAppSettings("YouTubeMusicConfig", s => YouTubeMusicConfig.Defaults = s); builder.ApplyToStaticFacades(); + + if (File.Exists(_tidalTokensPath)) { + try { + var savedTokens = JsonSerializer.Deserialize(File.ReadAllText(_tidalTokensPath)); + if (savedTokens != null) { + if (string.IsNullOrEmpty(TidalConfig.Defaults.AccessToken)) { + TidalConfig.Defaults.AccessToken = savedTokens.AccessToken; + } + if (string.IsNullOrEmpty(TidalConfig.Defaults.RefreshToken)) { + TidalConfig.Defaults.RefreshToken = savedTokens.RefreshToken; + } + if (TidalConfig.Defaults.TokenExpiry == null) { + TidalConfig.Defaults.TokenExpiry = savedTokens.TokenExpiry; + } + if (string.IsNullOrEmpty(TidalConfig.Defaults.ClientId)) { + TidalConfig.Defaults.ClientId = savedTokens.ClientId; + } + } + } catch { + // Ignore corrupted tokens file + } + } + AppSchemes.RegisterDefaultSchemes(); Application.SetDefaultKeyBinding(Terminal.Gui.Input.Command.Quit, new Terminal.Gui.Input.PlatformKeyBinding() { @@ -118,6 +159,10 @@ private static IStreamingClient CreateStreamingClient() { Logging.Information("Creating SoundCloud streaming client..."); return SoundCloudStreamingClient.Create(songCache, artCache); + case StreamingService.Tidal: + Logging.Information("Creating Tidal streaming client..."); + return TidalStreamingClient.Create(songCache, artCache); + case StreamingService.YouTubeMusic: Logging.Information("Creating YouTube Music streaming client..."); if (!File.Exists(_cookiesPath) || !File.Exists(_tokensPath)) { diff --git a/smoc/Streaming/Tidal/Models/TidalAlbum.cs b/smoc/Streaming/Tidal/Models/TidalAlbum.cs index c33591f..fb8ffd2 100644 --- a/smoc/Streaming/Tidal/Models/TidalAlbum.cs +++ b/smoc/Streaming/Tidal/Models/TidalAlbum.cs @@ -2,10 +2,18 @@ namespace Smoc.Streaming.Tidal.Models; +/// +/// Represents album metadata from the Tidal API. +/// +/// The unique identifier of the album. +/// The title of the album. +/// The album cover image UUID. +/// The release date of the album. +/// The primary artist of the album. 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 + [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 +); diff --git a/smoc/Streaming/Tidal/Models/TidalArtist.cs b/smoc/Streaming/Tidal/Models/TidalArtist.cs index b90d5a8..d92a7f6 100644 --- a/smoc/Streaming/Tidal/Models/TidalArtist.cs +++ b/smoc/Streaming/Tidal/Models/TidalArtist.cs @@ -2,8 +2,14 @@ namespace Smoc.Streaming.Tidal.Models; +/// +/// Represents artist information from the Tidal API. +/// +/// The artist's identifier. +/// The artist's name. +/// The artist's picture identifier. 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 + [property: JsonPropertyName("id")] long Id, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("picture")] string? Picture = null +); diff --git a/smoc/Streaming/Tidal/Models/TidalDeviceAuthResponse.cs b/smoc/Streaming/Tidal/Models/TidalDeviceAuthResponse.cs index 5b664de..bb2e744 100644 --- a/smoc/Streaming/Tidal/Models/TidalDeviceAuthResponse.cs +++ b/smoc/Streaming/Tidal/Models/TidalDeviceAuthResponse.cs @@ -2,11 +2,20 @@ namespace Smoc.Streaming.Tidal.Models; +/// +/// Response payload for Tidal OAuth device authorization request. +/// +/// The device verification code. +/// The end-user verification code to enter. +/// The URL where the end-user inputs the code. +/// The complete URL with user code embedded. +/// Lifetime in seconds of device_code and user_code. +/// The minimum interval in seconds to poll for token. 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 -); \ No newline at end of file + [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 +); diff --git a/smoc/Streaming/Tidal/Models/TidalManifest.cs b/smoc/Streaming/Tidal/Models/TidalManifest.cs index e2c32f2..8ce73b9 100644 --- a/smoc/Streaming/Tidal/Models/TidalManifest.cs +++ b/smoc/Streaming/Tidal/Models/TidalManifest.cs @@ -2,9 +2,16 @@ namespace Smoc.Streaming.Tidal.Models; +/// +/// Playback manifest extracted from Tidal playback info. +/// +/// MIME type of the media stream. +/// Audio codec identifier. +/// Type of encryption used, if any. +/// List of stream URLs. 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 + [property: JsonPropertyName("mimeType")] string MimeType, + [property: JsonPropertyName("codecs")] string Codecs, + [property: JsonPropertyName("encryptionType")] string EncryptionType, + [property: JsonPropertyName("urls")] List Urls +); diff --git a/smoc/Streaming/Tidal/Models/TidalPlaybackInfo.cs b/smoc/Streaming/Tidal/Models/TidalPlaybackInfo.cs index ffe263b..d513cc2 100644 --- a/smoc/Streaming/Tidal/Models/TidalPlaybackInfo.cs +++ b/smoc/Streaming/Tidal/Models/TidalPlaybackInfo.cs @@ -2,10 +2,18 @@ namespace Smoc.Streaming.Tidal.Models; +/// +/// Playback stream info response from the Tidal API. +/// +/// The track identifier. +/// Asset presentation mode (e.g. FULL). +/// Audio quality level (e.g. LOSSLESS, HIGH). +/// The MIME type of the playback manifest. +/// Base64 encoded manifest data. 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 -); \ No newline at end of file + [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 +); diff --git a/smoc/Streaming/Tidal/Models/TidalPlaylist.cs b/smoc/Streaming/Tidal/Models/TidalPlaylist.cs index 367b4fd..feb2bf4 100644 --- a/smoc/Streaming/Tidal/Models/TidalPlaylist.cs +++ b/smoc/Streaming/Tidal/Models/TidalPlaylist.cs @@ -2,8 +2,14 @@ namespace Smoc.Streaming.Tidal.Models; +/// +/// Playlist metadata from the Tidal API. +/// +/// Unique playlist UUID. +/// Title of the playlist. +/// Optional description of the playlist. 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 + [property: JsonPropertyName("uuid")] string Uuid, + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("description")] string? Description = null +); diff --git a/smoc/Streaming/Tidal/Models/TidalSearchContainer.cs b/smoc/Streaming/Tidal/Models/TidalSearchContainer.cs index 8b9fc2b..6bf4983 100644 --- a/smoc/Streaming/Tidal/Models/TidalSearchContainer.cs +++ b/smoc/Streaming/Tidal/Models/TidalSearchContainer.cs @@ -2,9 +2,16 @@ namespace Smoc.Streaming.Tidal.Models; +/// +/// Container for Tidal search query results across artists, albums, tracks, and playlists. +/// +/// Search results for artists. +/// Search results for albums. +/// Search results for tracks. +/// Search results for playlists. public record TidalSearchContainer( - [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 + [property: JsonPropertyName("artists")] TidalSearchResponse? Artists = null, + [property: JsonPropertyName("albums")] TidalSearchResponse? Albums = null, + [property: JsonPropertyName("tracks")] TidalSearchResponse? Tracks = null, + [property: JsonPropertyName("playlists")] TidalSearchResponse? Playlists = null +); diff --git a/smoc/Streaming/Tidal/Models/TidalSearchResponse.cs b/smoc/Streaming/Tidal/Models/TidalSearchResponse.cs index cf19cf0..b171c50 100644 --- a/smoc/Streaming/Tidal/Models/TidalSearchResponse.cs +++ b/smoc/Streaming/Tidal/Models/TidalSearchResponse.cs @@ -2,7 +2,13 @@ namespace Smoc.Streaming.Tidal.Models; +/// +/// Generic paginated search or list response from the Tidal API. +/// +/// The type of items contained in the response. +/// List of items returned. +/// Total number of available items. public record TidalSearchResponse( - [property: JsonPropertyName("items")] List Items, - [property: JsonPropertyName("totalNumberOfItems")] int TotalNumberOfItems = 0 -); \ No newline at end of file + [property: JsonPropertyName("items")] List Items, + [property: JsonPropertyName("totalNumberOfItems")] int TotalNumberOfItems = 0 +); diff --git a/smoc/Streaming/Tidal/Models/TidalTokenResponse.cs b/smoc/Streaming/Tidal/Models/TidalTokenResponse.cs index 29fad06..fde35c1 100644 --- a/smoc/Streaming/Tidal/Models/TidalTokenResponse.cs +++ b/smoc/Streaming/Tidal/Models/TidalTokenResponse.cs @@ -2,9 +2,16 @@ namespace Smoc.Streaming.Tidal.Models; +/// +/// OAuth token response from the Tidal authentication endpoint. +/// +/// The OAuth bearer access token. +/// The OAuth refresh token, if provided. +/// Lifetime of the access token in seconds. +/// The type of the token (typically "Bearer"). 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 + [property: JsonPropertyName("access_token")] string AccessToken, + [property: JsonPropertyName("refresh_token")] string? RefreshToken = null, + [property: JsonPropertyName("expires_in")] int ExpiresIn = 0, + [property: JsonPropertyName("token_type")] string TokenType = "Bearer" +); diff --git a/smoc/Streaming/Tidal/Models/TidalTokens.cs b/smoc/Streaming/Tidal/Models/TidalTokens.cs new file mode 100644 index 0000000..6749bc7 --- /dev/null +++ b/smoc/Streaming/Tidal/Models/TidalTokens.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace Smoc.Streaming.Tidal.Models; + +/// +/// Saved authentication tokens and credentials for Tidal. +/// +/// The OAuth access token. +/// The OAuth refresh token. +/// The expiration timestamp in epoch seconds. +/// The client ID associated with the tokens. +public record TidalTokens( + [property: JsonPropertyName("access_token")] string? AccessToken, + [property: JsonPropertyName("refresh_token")] string? RefreshToken, + [property: JsonPropertyName("token_expiry")] long? TokenExpiry, + [property: JsonPropertyName("client_id")] string? ClientId +); diff --git a/smoc/Streaming/Tidal/Models/TidalTrack.cs b/smoc/Streaming/Tidal/Models/TidalTrack.cs index e5d7192..9bfae4d 100644 --- a/smoc/Streaming/Tidal/Models/TidalTrack.cs +++ b/smoc/Streaming/Tidal/Models/TidalTrack.cs @@ -2,12 +2,22 @@ namespace Smoc.Streaming.Tidal.Models; +/// +/// Track metadata from the Tidal API. +/// +/// The unique track identifier. +/// The title of the track. +/// The duration of the track in seconds. +/// The track number on its album. +/// The album the track belongs to. +/// The primary artist of the track. +/// The list of contributing artists. 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 + [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 = null, + [property: JsonPropertyName("artist")] TidalArtist? Artist = null, + [property: JsonPropertyName("artists")] List? Artists = null +); diff --git a/smoc/Streaming/Tidal/TidalStreamingClient.cs b/smoc/Streaming/Tidal/TidalStreamingClient.cs index d8bd560..9898436 100644 --- a/smoc/Streaming/Tidal/TidalStreamingClient.cs +++ b/smoc/Streaming/Tidal/TidalStreamingClient.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text; @@ -13,266 +14,445 @@ namespace Smoc.Streaming.Tidal; +/// +/// Client for interacting with the Tidal music streaming service. +/// 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 static readonly string DefaultTokensPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".config", + "smoc", + "tidal_tokens.json"); + private readonly HttpClient _httpClient; private readonly ICacheService _songCacheService; private readonly ICacheService _albumArtCacheService; + private readonly SemaphoreSlim _authSemaphore = new(1, 1); private bool _isDisposed; - private TidalStreamingClient(ICacheService? songCacheService = null, ICacheService? albumArtCacheService = null) { - _httpClient = new HttpClient(); + /// + /// Initializes a new instance of the class. + /// + /// The HTTP client to use for network calls. + /// Optional cache service for songs. + /// Optional cache service for album art. + internal TidalStreamingClient(HttpClient httpClient, ICacheService? songCacheService = null, ICacheService? albumArtCacheService = null) { + _httpClient = 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; + /// + /// Creates a new instance of . + /// + /// Optional cache service for songs. + /// Optional cache service for album art. + /// A new instance. + public static TidalStreamingClient Create(ICacheService? songCacheService = null, ICacheService? albumArtCacheService = null) { + return new TidalStreamingClient(new HttpClient(), songCacheService, albumArtCacheService); } - public static TidalStreamingClient Create(ICacheService? songCacheService = null, ICacheService? albumArtCacheService = null) { - return new TidalStreamingClient(songCacheService, albumArtCacheService); + private static bool IsTokenExpired() { + if (string.IsNullOrEmpty(TidalConfig.Defaults.AccessToken)) { + return true; + } + + if (TidalConfig.Defaults.TokenExpiry.HasValue) { + var nowEpoch = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + return TidalConfig.Defaults.TokenExpiry.Value < (nowEpoch + 300); + } + + return false; } 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); + if (!IsTokenExpired()) { + return; + } + + await _authSemaphore.WaitAsync(cancellationToken); + try { + if (!IsTokenExpired()) { + return; + } + + if (!string.IsNullOrEmpty(TidalConfig.Defaults.RefreshToken)) { + await RefreshTokenAsync(cancellationToken); + } else { + Logging.Warning("Tidal access token is missing or expired, and no refresh token is configured."); + } + } finally { + _authSemaphore.Release(); } } - private async Task RefreshTokenAsync(CancellationToken cancellationToken = default) { - if (string.IsNullOrEmpty(TidalConfig.RefreshToken) || string.IsNullOrEmpty(TidalConfig.ClientId)) { - await AuthorizeDeviceAsync(cancellationToken); + internal async Task RefreshTokenAsync(CancellationToken cancellationToken = default) { + if (string.IsNullOrEmpty(TidalConfig.Defaults.RefreshToken) || string.IsNullOrEmpty(TidalConfig.Defaults.ClientId)) { + Logging.Warning("Tidal token refresh skipped: RefreshToken or ClientId is not configured."); return; } - Logging.Information("Refreshing Tidal token..."); + Logging.Information("Refreshing Tidal access token..."); var refreshRequest = new Dictionary { - { "client_id", TidalConfig.ClientId }, - { "refresh_token", TidalConfig.RefreshToken }, + { "client_id", TidalConfig.Defaults.ClientId }, + { "refresh_token", TidalConfig.Defaults.RefreshToken }, { "grant_type", "refresh_token" } }; - var response = await _httpClient.PostAsync($"{AuthUrl}/token", new FormUrlEncodedContent(refreshRequest), cancellationToken); + using 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 + TidalConfig.Defaults.AccessToken = tokenData.AccessToken; + TidalConfig.Defaults.TokenExpiry = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + tokenData.ExpiresIn; if (!string.IsNullOrEmpty(tokenData.RefreshToken)) { - TidalConfig.RefreshToken = tokenData.RefreshToken; + TidalConfig.Defaults.RefreshToken = tokenData.RefreshToken; } - Logging.Information("Tidal token refreshed."); + + SaveTokens(TidalConfig.Defaults.AccessToken, TidalConfig.Defaults.RefreshToken, TidalConfig.Defaults.TokenExpiry, TidalConfig.Defaults.ClientId); + Logging.Information("Tidal access token refreshed successfully."); return; } } - Logging.Warning("Tidal token refresh failed. Re-authorizing device..."); - await AuthorizeDeviceAsync(cancellationToken); + Logging.Warning($"Tidal token refresh failed with status: {response.StatusCode}"); } - private async Task AuthorizeDeviceAsync(CancellationToken cancellationToken = default) { - if (string.IsNullOrEmpty(TidalConfig.ClientId)) { - throw new InvalidOperationException("Tidal Client ID not configured."); + /// + /// Authenticates interactively with Tidal using OAuth device authorization flow. + /// + /// The client ID to use, or null to prompt/use default. + /// The path where tokens should be saved. + /// Cancellation token. + public static async Task AuthenticateInteractiveAsync(string? clientId, string tokensPath, CancellationToken cancellationToken = default) { + using var httpClient = new HttpClient(); + var activeClientId = clientId; + + if (string.IsNullOrWhiteSpace(activeClientId)) { + Console.Write("Enter Tidal Client ID [press Enter for default]: "); + var input = Console.ReadLine()?.Trim(); + activeClientId = string.IsNullOrEmpty(input) ? "zU4XHVVkc2tDPo4t" : input; } - Logging.Information("Starting Tidal Device Authorization flow..."); + Console.WriteLine("Requesting Tidal device authorization..."); var authRequest = new Dictionary { - { "client_id", TidalConfig.ClientId }, + { "client_id", activeClientId }, { "scope", "user" } }; - var response = await _httpClient.PostAsync($"{AuthUrl}/device/authorization", new FormUrlEncodedContent(authRequest), cancellationToken); - var authData = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + using var authResponse = await httpClient.PostAsync($"{AuthUrl}/device/authorization", new FormUrlEncodedContent(authRequest), cancellationToken); + authResponse.EnsureSuccessStatusCode(); + + var authData = await authResponse.Content.ReadFromJsonAsync(cancellationToken: cancellationToken) + ?? throw new InvalidOperationException("Failed to parse device authorization response."); - if (authData == null) throw new InvalidOperationException("Failed to get device authorization data."); + Console.WriteLine(); + Console.WriteLine($"Please visit: {authData.VerificationUriComplete}"); + Console.WriteLine($"Or navigate to {authData.VerificationUri} and enter code: {authData.UserCode}"); + Console.WriteLine("Waiting for authorization..."); - 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 }, + { "client_id", activeClientId }, { "device_code", authData.DeviceCode }, { "grant_type", "urn:ietf:params:oauth:grant-type:device_code" } }; + var intervalSeconds = Math.Max(authData.Interval, 1); while (!cancellationToken.IsCancellationRequested) { - await Task.Delay(authData.Interval * 1000, cancellationToken); - var tokenResponse = await _httpClient.PostAsync($"{AuthUrl}/token", new FormUrlEncodedContent(tokenRequest), cancellationToken); - + await Task.Delay(TimeSpan.FromSeconds(intervalSeconds), cancellationToken); + + using 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; - TidalConfig.TokenExpiry = DateTime.UtcNow.AddSeconds(tokenData.ExpiresIn); - Logging.Information("Tidal authentication successful."); + var tokenExpiry = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + tokenData.ExpiresIn; + TidalConfig.Defaults.AccessToken = tokenData.AccessToken; + TidalConfig.Defaults.RefreshToken = tokenData.RefreshToken; + TidalConfig.Defaults.TokenExpiry = tokenExpiry; + TidalConfig.Defaults.ClientId = activeClientId; + + SaveTokens(tokenData.AccessToken, tokenData.RefreshToken, tokenExpiry, activeClientId, tokensPath); + Console.WriteLine(); + Console.WriteLine($"Tidal authentication successful! Tokens saved to {tokensPath}"); return; } } var errorContent = await tokenResponse.Content.ReadAsStringAsync(cancellationToken); - if (!errorContent.Contains("authorization_pending")) { - throw new InvalidOperationException($"Tidal auth failed: {errorContent}"); + if (!errorContent.Contains("authorization_pending", StringComparison.OrdinalIgnoreCase)) { + throw new InvalidOperationException($"Tidal authentication failed: {errorContent}"); } } } - private async Task GetAsync(string endpoint, Dictionary? parameters = null, CancellationToken cancellationToken = default) { - await EnsureAuthenticatedAsync(cancellationToken); - + private static void SaveTokens(string? accessToken, string? refreshToken, long? tokenExpiry, string? clientId, string? path = null) { + try { + var filePath = path ?? DefaultTokensPath; + var dir = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(dir)) { + Directory.CreateDirectory(dir); + } + + var tokens = new TidalTokens(accessToken, refreshToken, tokenExpiry, clientId); + File.WriteAllText(filePath, JsonSerializer.Serialize(tokens, new JsonSerializerOptions { WriteIndented = true })); + } catch (Exception ex) { + Logging.Warning($"Failed to save Tidal tokens: {ex.Message}"); + } + } + + private async Task SendRequestAsync(string endpoint, Dictionary? parameters, CancellationToken cancellationToken) { var url = $"{ApiUrl}/{endpoint.TrimStart('/')}"; - var queryParams = parameters ?? new Dictionary(); - queryParams["countryCode"] = TidalConfig.CountryCode; - + var queryParams = parameters != null ? new Dictionary(parameters) : new Dictionary(); + if (!string.IsNullOrEmpty(TidalConfig.Defaults.CountryCode)) { + queryParams["countryCode"] = TidalConfig.Defaults.CountryCode; + } + var queryString = string.Join("&", queryParams.Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}")); - var fullUrl = $"{url}?{queryString}"; + var fullUrl = string.IsNullOrEmpty(queryString) ? url : $"{url}?{queryString}"; var request = new HttpRequestMessage(HttpMethod.Get, fullUrl); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", TidalConfig.AccessToken); + if (!string.IsNullOrEmpty(TidalConfig.Defaults.AccessToken)) { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", TidalConfig.Defaults.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 _httpClient.SendAsync(request, cancellationToken); + } + + private async Task GetAsync(string endpoint, Dictionary? parameters = null, CancellationToken cancellationToken = default) { + await EnsureAuthenticatedAsync(cancellationToken); + + var response = await SendRequestAsync(endpoint, parameters, cancellationToken); + if (response.StatusCode == HttpStatusCode.Unauthorized) { + response.Dispose(); + await _authSemaphore.WaitAsync(cancellationToken); + try { + await RefreshTokenAsync(cancellationToken); + } finally { + _authSemaphore.Release(); + } + + response = await SendRequestAsync(endpoint, parameters, cancellationToken); } - return await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken) ?? throw new InvalidOperationException("API returned null."); + using (response) { + response.EnsureSuccessStatusCode(); + 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); + 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); + 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); + 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); + 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 }, + var playbackInfo = await GetAsync( + $"/tracks/{songId}/playbackinfo", + new Dictionary { + { "audioquality", TidalConfig.Defaults.Quality }, { "playbackmode", "STREAM" }, { "assetpresentation", "FULL" } - }, cancellationToken); + }, + cancellationToken); + + if (playbackInfo.ManifestMimeType != null && + !playbackInfo.ManifestMimeType.Contains("bts", StringComparison.OrdinalIgnoreCase) && + !playbackInfo.ManifestMimeType.Contains("json", StringComparison.OrdinalIgnoreCase)) { + throw new InvalidOperationException($"Unsupported manifest MIME type: {playbackInfo.ManifestMimeType}"); + } + + byte[] manifestBytes; + try { + manifestBytes = Convert.FromBase64String(playbackInfo.Manifest); + } catch (FormatException ex) { + throw new InvalidOperationException("Failed to decode base64 manifest from Tidal playback info.", ex); + } - var manifestBytes = Convert.FromBase64String(playbackInfo.Manifest); var manifestJson = Encoding.UTF8.GetString(manifestBytes); - var manifest = JsonSerializer.Deserialize(manifestJson); + TidalManifest? manifest; + try { + manifest = JsonSerializer.Deserialize(manifestJson); + } catch (JsonException ex) { + throw new InvalidOperationException("Failed to deserialize Tidal manifest JSON.", ex); + } - if (manifest == null || manifest.Urls.Count == 0) throw new InvalidOperationException("No stream URLs found in manifest."); + if (manifest == null || manifest.Urls == 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); + $"{songId}-{playbackInfo.AudioQuality}", + async ct => await _httpClient.GetStreamAsync(streamUrl, ct), + cancellationToken); + + var codec = (manifest.MimeType != null && manifest.MimeType.Contains("flac", StringComparison.OrdinalIgnoreCase)) || + (manifest.Codecs != null && manifest.Codecs.Contains("flac", StringComparison.OrdinalIgnoreCase)) + ? "flac" + : "aac"; - return new SongStream(songId, manifest.MimeType.Contains("flac") ? "flac" : "aac", stream); + return new SongStream(songId, codec, stream); } + /// public async Task> GetLikedSongsAsync(CancellationToken cancellationToken = default) { - // Requires user authentication and specific scopes - return []; + return await Task.FromResult(new List()); } + /// public async Task> SearchPlaylistsAsync(string query, CancellationToken cancellationToken = default) { - var response = await GetAsync("search", new Dictionary { { "query", query }, { "types", "PLAYLISTS" }, { "limit", "20" } }, cancellationToken); + 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); + 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); + if (!TidalUrlParser.TryParseUrl(url, out var type, out var id)) { + return []; } - return []; + + return type switch { + "track" => [await GetSongAsync(id, cancellationToken)], + "album" => await GetSongsByAlbumAsync(new Album(id, new Artist(string.Empty, string.Empty), string.Empty, []), cancellationToken), + "playlist" => await GetPlaylistSongsAsync(new Playlist(id, string.Empty), cancellationToken), + _ => [] + }; } + /// 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()) + 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); + using var albumResponse = await _httpClient.GetAsync(cover.Url, ct); + albumResponse.EnsureSuccessStatusCode(); + var memoryStream = new MemoryStream(); + await albumResponse.Content.CopyToAsync(memoryStream, ct); + memoryStream.Position = 0; + return memoryStream; }, 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); + internal static Song MapTrackToSong(TidalTrack track) { + var artist = track.Artist != null + ? new Artist(track.Artist.Id.ToString(), track.Artist.Name ?? string.Empty) + : (track.Album?.Artist != null + ? new Artist(track.Album.Artist.Id.ToString(), track.Album.Artist.Name ?? string.Empty) + : new Artist(string.Empty, "Unknown Artist")); + + var album = track.Album != null + ? MapAlbumToAlbum(track.Album, artist) + : new Album(string.Empty, artist, "Unknown Album", []); + + return new Song(track.Id.ToString(), album, track.Title ?? "Unknown Track", TimeSpan.FromSeconds(track.Duration), track.TrackNumber); } - private Album MapAlbumToAlbum(TidalAlbum album, Artist artist) { + internal static Album MapAlbumToAlbum(TidalAlbum album, Artist? artist = null) { + var resolvedArtist = artist + ?? (album.Artist != null ? new Artist(album.Artist.Id.ToString(), album.Artist.Name ?? string.Empty) : new Artist(string.Empty, "Unknown 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)); + 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)); } + int? releaseYear = null; - if (DateTime.TryParse(album.ReleaseDate, out var date)) { - releaseYear = date.Year; + if (!string.IsNullOrEmpty(album.ReleaseDate) && DateTime.TryParse(album.ReleaseDate, out var date)) { + releaseYear = date.Year; } - return new Album(album.Id.ToString(), artist, album.Title, covers, releaseYear); + + return new Album(album.Id.ToString(), resolvedArtist, album.Title ?? "Unknown Album", covers, releaseYear); } + /// public void Dispose() { - if (_isDisposed) return; + if (_isDisposed) { + return; + } + _httpClient.Dispose(); + _authSemaphore.Dispose(); _isDisposed = true; } -} \ No newline at end of file +} diff --git a/smoc/Streaming/Tidal/TidalUrlParser.cs b/smoc/Streaming/Tidal/TidalUrlParser.cs new file mode 100644 index 0000000..f623d57 --- /dev/null +++ b/smoc/Streaming/Tidal/TidalUrlParser.cs @@ -0,0 +1,35 @@ +using System.Text.RegularExpressions; + +namespace Smoc.Streaming.Tidal; + +/// +/// Parser for Tidal URLs identifying tracks, albums, and playlists. +/// +public static class TidalUrlParser { + private static readonly Regex UrlRegex = new(@"/(track|album|playlist)/([a-zA-Z0-9\-]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /// + /// Parses the specified Tidal URL and returns the entity type and ID if valid. + /// + /// The Tidal URL to parse. + /// The parsed entity type ("track", "album", or "playlist"). + /// The parsed ID or UUID. + /// True if parsing succeeded; otherwise false. + public static bool TryParseUrl(string url, out string type, out string id) { + type = string.Empty; + id = string.Empty; + + if (string.IsNullOrWhiteSpace(url)) { + return false; + } + + var match = UrlRegex.Match(url); + if (!match.Success) { + return false; + } + + type = match.Groups[1].Value.ToLowerInvariant(); + id = match.Groups[2].Value; + return true; + } +}