From 580afe46428b850958cd3fbbe1eb646e7aaaa9ad Mon Sep 17 00:00:00 2001 From: ta264 Date: Mon, 25 Jul 2022 21:19:02 +0100 Subject: [PATCH] New: Write out .m3u files for spotify playlists (cherry picked from commit 7aa051caf25031c7af3bdace8f4c5e73a41c5fe5) --- .../Spotify/SpotifyPlaylistFixture.cs | 70 +++++----- .../Datastore/Migration/048_add_playlists.cs | 23 ++++ src/NzbDrone.Core/Datastore/TableMapping.cs | 5 + .../Spotify/SpotifyImportListBase.cs | 7 + .../ImportLists/Spotify/SpotifyPlaylist.cs | 107 ++++++++++++--- .../Spotify/SpotifyPlaylistItemInfo.cs | 10 ++ .../Spotify/SpotifyPlaylistSettings.cs | 3 + .../ImportLists/Spotify/SpotifyProxy.cs | 14 +- src/NzbDrone.Core/Music/Model/Playlist.cs | 13 ++ .../Music/Model/PlaylistEntry.cs | 12 ++ .../Repositories/PlaylistEntryRepository.cs | 48 +++++++ .../Music/Repositories/PlaylistRepository.cs | 25 ++++ .../Music/Repositories/TrackRepository.cs | 10 ++ .../Music/Services/PlaylistEntryService.cs | 37 ++++++ .../Music/Services/PlaylistService.cs | 125 ++++++++++++++++++ .../Music/Services/TrackService.cs | 56 ++++++++ 16 files changed, 507 insertions(+), 58 deletions(-) create mode 100644 src/NzbDrone.Core/Datastore/Migration/048_add_playlists.cs create mode 100644 src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylistItemInfo.cs create mode 100644 src/NzbDrone.Core/Music/Model/Playlist.cs create mode 100644 src/NzbDrone.Core/Music/Model/PlaylistEntry.cs create mode 100644 src/NzbDrone.Core/Music/Repositories/PlaylistEntryRepository.cs create mode 100644 src/NzbDrone.Core/Music/Repositories/PlaylistRepository.cs create mode 100644 src/NzbDrone.Core/Music/Services/PlaylistEntryService.cs create mode 100644 src/NzbDrone.Core/Music/Services/PlaylistService.cs diff --git a/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifyPlaylistFixture.cs b/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifyPlaylistFixture.cs index a9f9c5bef..ef2d7ba9f 100644 --- a/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifyPlaylistFixture.cs +++ b/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifyPlaylistFixture.cs @@ -19,11 +19,11 @@ namespace NzbDrone.Core.Test.ImportListTests public void should_not_throw_if_playlist_tracks_is_null() { Mocker.GetMock(). - Setup(x => x.GetPlaylistTracks(It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .Returns(default(Paging)); + Setup(x => x.GetPlaylist(It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(default(FullPlaylist)); var result = Subject.Fetch(_api, "playlistid"); @@ -39,11 +39,11 @@ namespace NzbDrone.Core.Test.ImportListTests }; Mocker.GetMock(). - Setup(x => x.GetPlaylistTracks(It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .Returns(playlistTracks); + Setup(x => x.GetPlaylist(It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(new FullPlaylist { Tracks = playlistTracks }); var result = Subject.Fetch(_api, "playlistid"); @@ -62,11 +62,11 @@ namespace NzbDrone.Core.Test.ImportListTests }; Mocker.GetMock(). - Setup(x => x.GetPlaylistTracks(It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .Returns(playlistTracks); + Setup(x => x.GetPlaylist(It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(new FullPlaylist { Tracks = playlistTracks }); var result = Subject.Fetch(_api, "playlistid"); @@ -108,11 +108,11 @@ namespace NzbDrone.Core.Test.ImportListTests }; Mocker.GetMock(). - Setup(x => x.GetPlaylistTracks(It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .Returns(playlistTracks); + Setup(x => x.GetPlaylist(It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(new FullPlaylist { Tracks = playlistTracks }); var result = Subject.Fetch(_api, "playlistid"); @@ -155,11 +155,11 @@ namespace NzbDrone.Core.Test.ImportListTests }; Mocker.GetMock(). - Setup(x => x.GetPlaylistTracks(It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .Returns(playlistTracks); + Setup(x => x.GetPlaylist(It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(new FullPlaylist { Tracks = playlistTracks }); var result = Subject.Fetch(_api, "playlistid"); @@ -204,11 +204,11 @@ namespace NzbDrone.Core.Test.ImportListTests }; Mocker.GetMock(). - Setup(x => x.GetPlaylistTracks(It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .Returns(playlistTracks); + Setup(x => x.GetPlaylist(It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(new FullPlaylist { Tracks = playlistTracks }); var result = Subject.Fetch(_api, "playlistid"); @@ -251,11 +251,11 @@ namespace NzbDrone.Core.Test.ImportListTests }; Mocker.GetMock(). - Setup(x => x.GetPlaylistTracks(It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .Returns(playlistTracks); + Setup(x => x.GetPlaylist(It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(new FullPlaylist { Tracks = playlistTracks }); Mocker.GetMock() .Setup(x => x.GetNextPage(It.IsAny(), diff --git a/src/NzbDrone.Core/Datastore/Migration/048_add_playlists.cs b/src/NzbDrone.Core/Datastore/Migration/048_add_playlists.cs new file mode 100644 index 000000000..b50379b68 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/048_add_playlists.cs @@ -0,0 +1,23 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(48)] + public class add_playlists : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Create.TableForModel("Playlists") + .WithColumn("ForeignPlaylistId").AsString().Unique() + .WithColumn("Title").AsString() + .WithColumn("OutputFolder").AsString().Nullable(); + + Create.TableForModel("PlaylistEntries") + .WithColumn("PlaylistId").AsInt32().Indexed() + .WithColumn("Order").AsInt32() + .WithColumn("ForeignAlbumId").AsString().Indexed() + .WithColumn("TrackTitle").AsString(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index d57aa599f..e0e0a8344 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -195,6 +195,11 @@ namespace NzbDrone.Core.Datastore Mapper.Entity("DownloadHistory").RegisterModel(); Mapper.Entity("UpdateHistory").RegisterModel(); + + Mapper.Entity("Playlists").RegisterModel() + .Ignore(p => p.Items); + + Mapper.Entity("PlaylistEntries").RegisterModel(); } private static void RegisterMappers() diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyImportListBase.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyImportListBase.cs index f852acb1d..ba1b50fae 100644 --- a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyImportListBase.cs +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyImportListBase.cs @@ -110,6 +110,8 @@ namespace NzbDrone.Core.ImportLists.Spotify // map to musicbrainz ids releases = MapSpotifyReleases(releases); + ProcessMappedReleases(releases); + return CleanupListItems(releases); } @@ -291,6 +293,11 @@ namespace NzbDrone.Core.ImportLists.Spotify } } + protected virtual void ProcessMappedReleases(IList items) + { + return; + } + protected override void Test(List failures) { failures.AddIfNotNull(TestConnection()); diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylist.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylist.cs index 54bc232f0..e6d27dc95 100644 --- a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylist.cs +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylist.cs @@ -5,26 +5,33 @@ using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; +using NzbDrone.Core.ImportLists.Exclusions; using NzbDrone.Core.MetadataSource; +using NzbDrone.Core.Music; using NzbDrone.Core.Parser; using NzbDrone.Core.Validation; using SpotifyAPI.Web; +using SpotifyAPI.Web.Enums; using SpotifyAPI.Web.Models; namespace NzbDrone.Core.ImportLists.Spotify { public class SpotifyPlaylist : SpotifyImportListBase { + private readonly IPlaylistService _playlistService; + public SpotifyPlaylist(ISpotifyProxy spotifyProxy, IMetadataRequestBuilder requestBuilder, IImportListStatusService importListStatusService, IImportListRepository importListRepository, + IPlaylistService playlistService, IConfigService configService, IParsingService parsingService, IHttpClient httpClient, Logger logger) : base(spotifyProxy, requestBuilder, importListStatusService, importListRepository, configService, parsingService, httpClient, logger) { + _playlistService = playlistService; } public override string Name => "Spotify Playlists"; @@ -40,7 +47,9 @@ namespace NzbDrone.Core.ImportLists.Spotify _logger.Trace($"Processing playlist {playlistId}"); - var playlistTracks = _spotifyProxy.GetPlaylistTracks(this, api, playlistId, "next, items(track(name, artists(id, name), album(id, name, release_date, release_date_precision, artists(id, name))))"); + var playlist = _spotifyProxy.GetPlaylist(this, api, playlistId, "id, name, tracks.next, tracks.items(track(name, artists(id, name), album(id, album_type, name, release_date, release_date_precision, artists(id, name))))"); + var playlistTracks = playlist?.Tracks; + int order = 0; while (true) { @@ -51,7 +60,7 @@ namespace NzbDrone.Core.ImportLists.Spotify foreach (var playlistTrack in playlistTracks.Items) { - result.AddIfNotNull(ParsePlaylistTrack(playlistTrack)); + result.AddIfNotNull(ParsePlaylistTrack(api, playlistTrack, playlistId, playlist.Name, ref order)); } if (!playlistTracks.HasNextPage()) @@ -65,29 +74,87 @@ namespace NzbDrone.Core.ImportLists.Spotify return result; } - private SpotifyImportListItemInfo ParsePlaylistTrack(PlaylistTrack playlistTrack) + protected override void ProcessMappedReleases(IList items) + { + var spotifyPlaylistItems = items.Select(x => (SpotifyPlaylistItemInfo)x); + + var groups = spotifyPlaylistItems.GroupBy(x => x.ForeignPlaylistId); + + foreach (var group in groups) + { + var first = group.First(); + + var playlistItems = group.Select(x => new PlaylistEntry + { + Order = x.Order, + ForeignAlbumId = x.AlbumMusicBrainzId, + TrackTitle = x.TrackTitle + }).ToList(); + + var playlist = new Playlist + { + ForeignPlaylistId = first.ForeignPlaylistId, + Title = first.PlaylistTitle, + OutputFolder = Settings.PlaylistFolder, + Items = playlistItems + }; + + _playlistService.UpdatePlaylist(playlist); + } + } + + private SpotifyPlaylistItemInfo ParsePlaylistTrack(SpotifyWebAPI api, PlaylistTrack playlistTrack, string playlistId, string playlistName, ref int order) { // From spotify docs: "Note, a track object may be null. This can happen if a track is no longer available." - if (playlistTrack?.Track?.Album != null) + if (playlistTrack?.Track?.Album == null) { - var album = playlistTrack.Track.Album; - - var albumName = album.Name; - var artistName = album.Artists?.FirstOrDefault()?.Name ?? playlistTrack.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; } - return null; + var album = playlistTrack.Track.Album; + var trackName = playlistTrack.Track.Name; + var artistName = album.Artists?.FirstOrDefault()?.Name ?? playlistTrack.Track?.Artists?.FirstOrDefault()?.Name; + + if (artistName.IsNullOrWhiteSpace()) + { + return null; + } + + _logger.Trace($"album {album.Name} type: {album.AlbumType}"); + + if (album.AlbumType == "single") + { + album = GetBestAlbum(api, artistName, trackName) ?? album; + } + + _logger.Trace($"revised type: {album.AlbumType}"); + + var albumName = album.Name; + + if (albumName.IsNullOrWhiteSpace()) + { + return null; + } + + return new SpotifyPlaylistItemInfo + { + ForeignPlaylistId = playlistId, + Artist = artistName, + Album = album.Name, + AlbumSpotifyId = album.Id, + PlaylistTitle = playlistName, + TrackTitle = playlistTrack.Track.Name, + Order = ++order, + ReleaseDate = ParseSpotifyDate(album.ReleaseDate, album.ReleaseDatePrecision) + }; + } + + private SimpleAlbum GetBestAlbum(SpotifyWebAPI api, string artistName, string trackName) + { + _logger.Trace($"Finding full album for {artistName}: {trackName}"); + var search = _spotifyProxy.SearchItems(this, api, $"artist:\"{artistName}\" track:\"{trackName}\"", SearchType.Track); + + return search?.Tracks?.Items?.FirstOrDefault(x => x?.Album?.AlbumType == "album" && !(x?.Album?.Artists?.Any(a => a.Name == "Various Artists") ?? false))?.Album; } public override object RequestAction(string action, IDictionary query) diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylistItemInfo.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylistItemInfo.cs new file mode 100644 index 000000000..190e0035a --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylistItemInfo.cs @@ -0,0 +1,10 @@ +namespace NzbDrone.Core.ImportLists.Spotify +{ + public class SpotifyPlaylistItemInfo : SpotifyImportListItemInfo + { + public string ForeignPlaylistId { get; set; } + public string PlaylistTitle { get; set; } + public string TrackTitle { get; set; } + public int Order { get; set; } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylistSettings.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylistSettings.cs index ac4d87199..cc7862a6a 100644 --- a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylistSettings.cs +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylistSettings.cs @@ -26,5 +26,8 @@ namespace NzbDrone.Core.ImportLists.Spotify [FieldDefinition(1, Label = "Playlists", Type = FieldType.Playlist)] public IEnumerable PlaylistIds { get; set; } + + [FieldDefinition(1, Label = "Output folder", HelpText = "Folder for the .m3u files containing tracks of imported playlists", Type = FieldType.Path)] + public string PlaylistFolder { get; set; } } } diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyProxy.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyProxy.cs index 72e2464e9..7f7932ab2 100644 --- a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyProxy.cs +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyProxy.cs @@ -16,12 +16,14 @@ namespace NzbDrone.Core.ImportLists.Spotify where TSettings : SpotifySettingsBase, new(); Paging GetSavedAlbums(SpotifyImportListBase list, SpotifyWebAPI api) where TSettings : SpotifySettingsBase, new(); - Paging GetPlaylistTracks(SpotifyImportListBase list, SpotifyWebAPI api, string id, string fields) + FullPlaylist GetPlaylist(SpotifyImportListBase list, SpotifyWebAPI api, string id, string fields) where TSettings : SpotifySettingsBase, new(); Paging GetNextPage(SpotifyImportListBase list, SpotifyWebAPI api, Paging item) where TSettings : SpotifySettingsBase, new(); FollowedArtists GetNextPage(SpotifyImportListBase list, SpotifyWebAPI api, FollowedArtists item) where TSettings : SpotifySettingsBase, new(); + SearchItem SearchItems(SpotifyImportListBase list, SpotifyWebAPI api, string query, SearchType type) + where TSettings : SpotifySettingsBase, new(); } public class SpotifyProxy : ISpotifyProxy @@ -57,10 +59,10 @@ namespace NzbDrone.Core.ImportLists.Spotify return Execute(list, api, x => x.GetSavedAlbums(50)); } - public Paging GetPlaylistTracks(SpotifyImportListBase list, SpotifyWebAPI api, string id, string fields) + public FullPlaylist GetPlaylist(SpotifyImportListBase list, SpotifyWebAPI api, string id, string fields) where TSettings : SpotifySettingsBase, new() { - return Execute(list, api, x => x.GetPlaylistTracks(id, fields: fields)); + return Execute(list, api, x => x.GetPlaylist(id, fields: fields)); } public Paging GetNextPage(SpotifyImportListBase list, SpotifyWebAPI api, Paging item) @@ -75,6 +77,12 @@ namespace NzbDrone.Core.ImportLists.Spotify return Execute(list, api, (x) => x.GetNextPage(item.Artists)); } + public SearchItem SearchItems(SpotifyImportListBase list, SpotifyWebAPI api, string query, SearchType type) + where TSettings : SpotifySettingsBase, new() + { + return Execute(list, api, (x) => x.SearchItems(query, type)); + } + public T Execute(SpotifyImportListBase list, SpotifyWebAPI api, Func method, bool allowReauth = true) where T : BasicModel where TSettings : SpotifySettingsBase, new() diff --git a/src/NzbDrone.Core/Music/Model/Playlist.cs b/src/NzbDrone.Core/Music/Model/Playlist.cs new file mode 100644 index 000000000..29d456ab5 --- /dev/null +++ b/src/NzbDrone.Core/Music/Model/Playlist.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Music +{ + public class Playlist : ModelBase + { + public string ForeignPlaylistId { get; set; } + public string Title { get; set; } + public string OutputFolder { get; set; } + public List Items { get; set; } + } +} diff --git a/src/NzbDrone.Core/Music/Model/PlaylistEntry.cs b/src/NzbDrone.Core/Music/Model/PlaylistEntry.cs new file mode 100644 index 000000000..e7398d8ff --- /dev/null +++ b/src/NzbDrone.Core/Music/Model/PlaylistEntry.cs @@ -0,0 +1,12 @@ +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Music +{ + public class PlaylistEntry : ModelBase + { + public int PlaylistId { get; set; } + public int Order { get; set; } + public string ForeignAlbumId { get; set; } + public string TrackTitle { get; set; } + } +} diff --git a/src/NzbDrone.Core/Music/Repositories/PlaylistEntryRepository.cs b/src/NzbDrone.Core/Music/Repositories/PlaylistEntryRepository.cs new file mode 100644 index 000000000..ba2e74261 --- /dev/null +++ b/src/NzbDrone.Core/Music/Repositories/PlaylistEntryRepository.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Music +{ + public interface IPlaylistEntryRepository : IBasicRepository + { + List UpsertMany(List entries); + List FindByPlaylistId(int playlistId); + List FindPlaylistsByForeignAlbumId(string foreignAlbumId); + } + + public class PlaylistEntryRepository : BasicRepository, IPlaylistEntryRepository + { + public PlaylistEntryRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + + public List UpsertMany(List entries) + { + var playlistIds = entries.Select(x => x.PlaylistId).Distinct(); + + Delete(x => playlistIds.Contains(x.PlaylistId)); + + InsertMany(entries); + + return entries; + } + + public List FindByPlaylistId(int playlistId) + { + return Query(x => x.PlaylistId == playlistId).ToList(); + } + + public List FindByPlaylistId(List playlistIds) + { + return Query(x => playlistIds.Contains(x.Id)).ToList(); + } + + public List FindPlaylistsByForeignAlbumId(string foreignAlbumId) + { + return Query(x => x.ForeignAlbumId == foreignAlbumId).Select(x => x.PlaylistId).Distinct().ToList(); + } + } +} diff --git a/src/NzbDrone.Core/Music/Repositories/PlaylistRepository.cs b/src/NzbDrone.Core/Music/Repositories/PlaylistRepository.cs new file mode 100644 index 000000000..fd0b794ef --- /dev/null +++ b/src/NzbDrone.Core/Music/Repositories/PlaylistRepository.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Music +{ + public interface IPlaylistRepository : IBasicRepository + { + Playlist GetPlaylist(string foreignPlaylistId); + } + + public class PlaylistRepository : BasicRepository, IPlaylistRepository + { + public PlaylistRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + + public Playlist GetPlaylist(string foreignPlaylistId) + { + return Query(x => x.ForeignPlaylistId == foreignPlaylistId).FirstOrDefault(); + } + } +} diff --git a/src/NzbDrone.Core/Music/Repositories/TrackRepository.cs b/src/NzbDrone.Core/Music/Repositories/TrackRepository.cs index ce5906378..07790c865 100644 --- a/src/NzbDrone.Core/Music/Repositories/TrackRepository.cs +++ b/src/NzbDrone.Core/Music/Repositories/TrackRepository.cs @@ -10,6 +10,7 @@ namespace NzbDrone.Core.Music { List GetTracks(int artistId); List GetTracksByAlbum(int albumId); + List GetTracksByForeignAlbumId(string foreignAlbumId); List GetTracksByRelease(int albumReleaseId); List GetTracksByReleases(List albumReleaseIds); List GetTracksForRefresh(int albumReleaseId, IEnumerable foreignTrackIds); @@ -47,6 +48,15 @@ namespace NzbDrone.Core.Music .Where(x => x.Id == albumId)); } + public List GetTracksByForeignAlbumId(string foreignAlbumId) + { + return Query(Builder() + .Join((t, r) => t.AlbumReleaseId == r.Id) + .Join((r, a) => r.AlbumId == a.Id) + .Where(r => r.Monitored == true) + .Where(x => x.ForeignAlbumId == foreignAlbumId)); + } + public List GetTracksByRelease(int albumReleaseId) { return Query(t => t.AlbumReleaseId == albumReleaseId).ToList(); diff --git a/src/NzbDrone.Core/Music/Services/PlaylistEntryService.cs b/src/NzbDrone.Core/Music/Services/PlaylistEntryService.cs new file mode 100644 index 000000000..02b875ce2 --- /dev/null +++ b/src/NzbDrone.Core/Music/Services/PlaylistEntryService.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.ImportLists.Exclusions +{ + public interface IPlaylistEntryService + { + List UpsertMany(List entries); + List FindByPlaylistId(int playlistId); + List FindPlaylistsByForeignAlbumId(string foreignAlbumId); + } + + public class PlaylistEntryService : IPlaylistEntryService + { + private readonly IPlaylistEntryRepository _repo; + + public PlaylistEntryService(IPlaylistEntryRepository repo) + { + _repo = repo; + } + + public List FindByPlaylistId(int playlistId) + { + return _repo.FindByPlaylistId(playlistId); + } + + public List UpsertMany(List entries) + { + return _repo.UpsertMany(entries); + } + + public List FindPlaylistsByForeignAlbumId(string foreignAlbumId) + { + return _repo.FindPlaylistsByForeignAlbumId(foreignAlbumId); + } + } +} diff --git a/src/NzbDrone.Core/Music/Services/PlaylistService.cs b/src/NzbDrone.Core/Music/Services/PlaylistService.cs new file mode 100644 index 000000000..b1030ac96 --- /dev/null +++ b/src/NzbDrone.Core/Music/Services/PlaylistService.cs @@ -0,0 +1,125 @@ +using System.IO; +using System.Linq; +using System.Text; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.ImportLists.Exclusions +{ + public interface IPlaylistService + { + Playlist UpdatePlaylist(Playlist playlist); + } + + public class PlaylistService : IPlaylistService, + IHandleAsync, + IHandleAsync + { + private readonly IPlaylistRepository _repo; + private readonly IPlaylistEntryService _playlistEntryService; + private readonly ITrackService _trackService; + private readonly IMediaFileService _mediaFileService; + private readonly IDiskProvider _diskProvider; + private readonly Logger _logger; + + public PlaylistService(IPlaylistRepository repo, + IPlaylistEntryService playlistEntryService, + ITrackService trackService, + IMediaFileService mediaFileService, + IDiskProvider diskProvider, + Logger logger) + { + _repo = repo; + _playlistEntryService = playlistEntryService; + _trackService = trackService; + _mediaFileService = mediaFileService; + _diskProvider = diskProvider; + _logger = logger; + } + + public Playlist UpdatePlaylist(Playlist playlist) + { + var existing = _repo.GetPlaylist(playlist.ForeignPlaylistId); + if (existing != null) + { + playlist.Id = existing.Id; + } + + _repo.Upsert(playlist); + playlist.Items.ForEach(x => x.PlaylistId = playlist.Id); + + _playlistEntryService.UpsertMany(playlist.Items); + + WritePlaylist(playlist.Id); + + return playlist; + } + + public void WritePlaylist(int playlistId) + { + var playlist = _repo.Get(playlistId); + + _logger.Debug($"Writing playlist {playlist.Title}"); + + playlist.Items = _playlistEntryService.FindByPlaylistId(playlistId); + + _logger.Trace($"Got {playlist.Items.Count} tracks"); + + if (!playlist.Items.Any() || playlist.OutputFolder.IsNullOrWhiteSpace()) + { + return; + } + + var sb = new StringBuilder(); + bool doWrite = false; + + foreach (var item in playlist.Items.OrderBy(x => x.Order)) + { + _logger.Trace($"Getting track for {item.ForeignAlbumId} {item.TrackTitle}"); + var track = _trackService.FindTrackByTitleInexact(item.ForeignAlbumId, item.TrackTitle); + + if (track?.HasFile == true) + { + var file = _mediaFileService.Get(track.TrackFileId); + var relative = Path.GetRelativePath(playlist.OutputFolder, file.Path); + + _logger.Debug($"Got track {relative}"); + sb.AppendLine(relative); + doWrite = true; + } + } + + if (doWrite) + { + var filename = Path.Combine(playlist.OutputFolder, playlist.Title + ".m3u"); + _diskProvider.WriteAllText(filename, sb.ToString()); + } + } + + public void HandleAsync(AlbumImportedEvent message) + { + var playlistIds = _playlistEntryService.FindPlaylistsByForeignAlbumId(message.Album.ForeignAlbumId); + + foreach (var id in playlistIds) + { + WritePlaylist(id); + } + } + + public void HandleAsync(TrackFileRenamedEvent message) + { + var albumId = message.TrackFile.Album.Value.ForeignAlbumId; + var playlistIds = _playlistEntryService.FindPlaylistsByForeignAlbumId(albumId); + + foreach (var id in playlistIds) + { + WritePlaylist(id); + } + } + } +} diff --git a/src/NzbDrone.Core/Music/Services/TrackService.cs b/src/NzbDrone.Core/Music/Services/TrackService.cs index 50c4d8891..922b9af8a 100644 --- a/src/NzbDrone.Core/Music/Services/TrackService.cs +++ b/src/NzbDrone.Core/Music/Services/TrackService.cs @@ -1,15 +1,19 @@ +using System; using System.Collections.Generic; using System.Linq; using NLog; +using NzbDrone.Common.Extensions; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Music.Events; +using NzbDrone.Core.Parser; namespace NzbDrone.Core.Music { public interface ITrackService { Track GetTrack(int id); + Track FindTrackByTitleInexact(string foreignAlbumId, string title); List GetTracks(IEnumerable ids); List GetTracksByArtist(int artistId); List GetTracksByAlbum(int albumId); @@ -46,6 +50,31 @@ namespace NzbDrone.Core.Music return _trackRepository.Get(id); } + public Track FindTrackByTitleInexact(string foreignAlbumId, string title) + { + var normalizedTitle = title.NormalizeTrackTitle().Replace(".", " "); + var tracks = _trackRepository.GetTracksByForeignAlbumId(foreignAlbumId); + + Func, string, Tuple, string>> tc = Tuple.Create; + var scoringFunctions = new List, string>> + { + tc((a, t) => a.Title.NormalizeTrackTitle().FuzzyMatch(t), normalizedTitle), + tc((a, t) => a.Title.NormalizeTrackTitle().FuzzyContains(t), normalizedTitle), + tc((a, t) => t.FuzzyContains(a.Title.NormalizeTrackTitle()), normalizedTitle) + }; + + foreach (var func in scoringFunctions) + { + var track = FindByStringInexact(tracks, func.Item1, func.Item2); + if (track != null) + { + return track; + } + } + + return null; + } + public List GetTracks(IEnumerable ids) { return _trackRepository.Get(ids).ToList(); @@ -133,5 +162,32 @@ namespace NzbDrone.Core.Music _logger.Debug($"Detaching tracks from file {message.TrackFile}"); _trackRepository.DetachTrackFile(message.TrackFile.Id); } + + private Track FindByStringInexact(List tracks, Func scoreFunction, string title) + { + const double fuzzThreshold = 0.7; + const double fuzzGap = 0.2; + + var sortedTracks = tracks.Select(s => new + { + MatchProb = scoreFunction(s, title), + Track = s + }).ToList() + .OrderByDescending(s => s.MatchProb) + .ToList(); + + if (!sortedTracks.Any()) + { + return null; + } + + if (sortedTracks[0].MatchProb > fuzzThreshold + && (sortedTracks.Count == 1 || sortedTracks[0].MatchProb - sortedTracks[1].MatchProb > fuzzGap)) + { + return sortedTracks[0].Track; + } + + return null; + } } }