From 3533c49a1629769352b2ce137f8734f99a60c7fc Mon Sep 17 00:00:00 2001 From: eljose47 Date: Wed, 4 Mar 2026 16:24:55 +0000 Subject: [PATCH] New: Add SpotifySavedTracks import list --- .../Spotify/SpotifySavedTracksFixture.cs | 222 ++++++++++++++++++ .../ImportLists/Spotify/SpotifyProxy.cs | 8 + .../ImportLists/Spotify/SpotifySavedTracks.cs | 97 ++++++++ 3 files changed, 327 insertions(+) create mode 100644 src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifySavedTracksFixture.cs create mode 100644 src/NzbDrone.Core/ImportLists/Spotify/SpotifySavedTracks.cs diff --git a/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifySavedTracksFixture.cs b/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifySavedTracksFixture.cs new file mode 100644 index 000000000..dde47f57f --- /dev/null +++ b/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifySavedTracksFixture.cs @@ -0,0 +1,222 @@ +using System.Collections.Generic; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.ImportLists.Spotify; +using NzbDrone.Core.Test.Framework; +using SpotifyAPI.Web; +using SpotifyAPI.Web.Models; + +namespace NzbDrone.Core.Test.ImportListTests +{ + [TestFixture] + public class SpotifySavedTracksFixture : CoreTest + { + [Test] + public void should_not_throw_if_saved_tracks_is_null() + { + var paging = default(Paging); + + Mocker.GetMock(). + Setup(x => x.GetSavedTracks(It.IsAny(), + It.IsAny())) + .Returns(paging); + + var result = Subject.Fetch(null); + + result.Should().BeEmpty(); + } + + [Test] + public void should_not_throw_if_saved_track_items_is_null() + { + var savedTracks = new Paging + { + Items = null + }; + + Mocker.GetMock(). + Setup(x => x.GetSavedTracks(It.IsAny(), + It.IsAny())) + .Returns(savedTracks); + + var result = Subject.Fetch(null); + + result.Should().BeEmpty(); + } + + [Test] + public void should_not_throw_if_saved_track_is_null() + { + var savedTracks = new Paging + { + Items = new List + { + null + } + }; + + Mocker.GetMock(). + Setup(x => x.GetSavedTracks(It.IsAny(), + It.IsAny())) + .Returns(savedTracks); + + var result = Subject.Fetch(null); + + result.Should().BeEmpty(); + } + + [Test] + public void should_not_throw_if_saved_track_track_is_null() + { + var savedTracks = new Paging + { + Items = new List + { + new SavedTrack + { + Track = null + } + } + }; + + Mocker.GetMock(). + Setup(x => x.GetSavedTracks(It.IsAny(), + It.IsAny())) + .Returns(savedTracks); + + var result = Subject.Fetch(null); + + result.Should().BeEmpty(); + } + + [TestCase("Artist", "Album")] + public void should_parse_saved_track(string artistName, string albumName) + { + var savedTracks = new Paging + { + Items = new List + { + new SavedTrack + { + AddedAt = System.DateTime.Now, + Track = new FullTrack + { + Album = new SimpleAlbum + { + Name = albumName, + Artists = new List + { + new SimpleArtist + { + Name = artistName + } + } + } + } + } + } + }; + + Mocker.GetMock(). + Setup(x => x.GetSavedTracks(It.IsAny(), + It.IsAny())) + .Returns(savedTracks); + + var result = Subject.Fetch(null); + + result.Should().HaveCount(1); + } + + [Test] + public void should_not_throw_if_get_next_page_returns_null() + { + var savedTracks = new Paging + { + Items = new List + { + new SavedTrack + { + AddedAt = System.DateTime.Now, + Track = new FullTrack + { + Album = new SimpleAlbum + { + Name = "Album", + Artists = new List + { + new SimpleArtist + { + Name = "Artist" + } + } + } + } + } + }, + Next = "DummyToMakeHasNextTrue" + }; + + Mocker.GetMock(). + Setup(x => x.GetSavedTracks(It.IsAny(), + It.IsAny())) + .Returns(savedTracks); + + Mocker.GetMock() + .Setup(x => x.GetNextPage(It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Returns(default(Paging)); + + var result = Subject.Fetch(null); + + result.Should().HaveCount(1); + + Mocker.GetMock() + .Verify(x => x.GetNextPage(It.IsAny(), + It.IsAny(), + It.IsAny>()), + Times.Once()); + } + + [TestCase(null, "Album")] + [TestCase("Artist", null)] + [TestCase(null, null)] + public void should_skip_bad_artist_or_album_names(string artistName, string albumName) + { + var savedTracks = new Paging + { + Items = new List + { + new SavedTrack + { + AddedAt = System.DateTime.Now, + Track = new FullTrack + { + Album = new SimpleAlbum + { + Name = albumName, + Artists = new List + { + new SimpleArtist + { + Name = artistName + } + } + } + } + } + } + }; + + Mocker.GetMock(). + Setup(x => x.GetSavedTracks(It.IsAny(), + It.IsAny())) + .Returns(savedTracks); + + var result = Subject.Fetch(null); + + result.Should().BeEmpty(); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyProxy.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyProxy.cs index a36a63638..931a67f26 100644 --- a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyProxy.cs +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyProxy.cs @@ -18,6 +18,8 @@ Paging GetSavedAlbums(SpotifyImportListBase li where TSettings : SpotifySettingsBase, new(); Paging GetPlaylistTracks(SpotifyImportListBase list, SpotifyWebAPI api, string id, string fields) where TSettings : SpotifySettingsBase, new(); + Paging GetSavedTracks(SpotifyImportListBase list, SpotifyWebAPI api) + where TSettings : SpotifySettingsBase, new(); Paging GetNextPage(SpotifyImportListBase list, SpotifyWebAPI api, Paging item) where TSettings : SpotifySettingsBase, new(); FollowedArtists GetNextPage(SpotifyImportListBase list, SpotifyWebAPI api, FollowedArtists item) @@ -63,6 +65,12 @@ public Paging GetPlaylistTracks(SpotifyImportListBase< return Execute(list, api, x => x.GetPlaylistTracks(id, fields: fields)); } + public Paging GetSavedTracks(SpotifyImportListBase list, SpotifyWebAPI api) + where TSettings : SpotifySettingsBase, new() + { + return Execute(list, api, x => x.GetSavedTracks()); + } + public Paging GetNextPage(SpotifyImportListBase list, SpotifyWebAPI api, Paging item) where TSettings : SpotifySettingsBase, new() { diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifySavedTracks.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifySavedTracks.cs new file mode 100644 index 000000000..566022ef2 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifySavedTracks.cs @@ -0,0 +1,97 @@ +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.MetadataSource; +using NzbDrone.Core.Parser; +using SpotifyAPI.Web; +using SpotifyAPI.Web.Models; + +namespace NzbDrone.Core.ImportLists.Spotify +{ + public class SpotifySavedTracksSettings : SpotifySettingsBase + { + public override string Scope => "user-library-read"; + } + + public class SpotifySavedTracks : SpotifyImportListBase + { + public SpotifySavedTracks(ISpotifyProxy spotifyProxy, + IMetadataRequestBuilder requestBuilder, + IImportListStatusService importListStatusService, + IImportListRepository importListRepository, + IConfigService configService, + IParsingService parsingService, + IHttpClient httpClient, + Logger logger) + : base(spotifyProxy, requestBuilder, importListStatusService, importListRepository, configService, parsingService, httpClient, logger) + { + } + + public override string Name => "Spotify Saved Tracks"; + + public override IList Fetch(SpotifyWebAPI api) + { + var result = new List(); + + var savedTracks = _spotifyProxy.GetSavedTracks(this, api); + + // savedTracks may be null if the spotify proxy returns nothing (e.g. user has no saved tracks) + if (savedTracks == null) + { + _logger.Trace("No saved tracks returned"); + return result; + } + + _logger.Trace($"Got {savedTracks.Total} saved tracks"); + + while (true) + { + if (savedTracks?.Items == null) + { + return result; + } + + foreach (var savedTrack in savedTracks.Items) + { + result.AddIfNotNull(ParseSavedTrack(savedTrack)); + } + + if (!savedTracks.HasNextPage()) + { + break; + } + + savedTracks = _spotifyProxy.GetNextPage(this, api, savedTracks); + } + + return result; + } + + private SpotifyImportListItemInfo ParseSavedTrack(SavedTrack savedTrack) + { + // From spotify docs: "Note, a track object may be null. This can happen if a track is no longer available." + if (savedTrack?.Track?.Album != null) + { + var album = savedTrack.Track.Album; + var albumName = album.Name; + var artistName = album.Artists?.FirstOrDefault()?.Name ?? savedTrack.Track?.Artists?.FirstOrDefault()?.Name; + + if (albumName.IsNotNullOrWhiteSpace() && artistName.IsNotNullOrWhiteSpace()) + { + return new SpotifyImportListItemInfo + { + Artist = artistName, + Album = album.Name, + AlbumSpotifyId = album.Id, + ReleaseDate = ParseSpotifyDate(album.ReleaseDate, album.ReleaseDatePrecision) + }; + } + } + + return null; + } + } +}