mirror of
https://github.com/lidarr/lidarr.git
synced 2025-08-21 22:13:29 -07:00
New: Write out .m3u files for spotify playlists
(cherry picked from commit 7aa051caf25031c7af3bdace8f4c5e73a41c5fe5)
This commit is contained in:
parent
0267c04884
commit
580afe4642
16 changed files with 507 additions and 58 deletions
|
@ -19,11 +19,11 @@ namespace NzbDrone.Core.Test.ImportListTests
|
||||||
public void should_not_throw_if_playlist_tracks_is_null()
|
public void should_not_throw_if_playlist_tracks_is_null()
|
||||||
{
|
{
|
||||||
Mocker.GetMock<ISpotifyProxy>().
|
Mocker.GetMock<ISpotifyProxy>().
|
||||||
Setup(x => x.GetPlaylistTracks(It.IsAny<SpotifyPlaylist>(),
|
Setup(x => x.GetPlaylist(It.IsAny<SpotifyPlaylist>(),
|
||||||
It.IsAny<SpotifyWebAPI>(),
|
It.IsAny<SpotifyWebAPI>(),
|
||||||
It.IsAny<string>(),
|
It.IsAny<string>(),
|
||||||
It.IsAny<string>()))
|
It.IsAny<string>()))
|
||||||
.Returns(default(Paging<PlaylistTrack>));
|
.Returns(default(FullPlaylist));
|
||||||
|
|
||||||
var result = Subject.Fetch(_api, "playlistid");
|
var result = Subject.Fetch(_api, "playlistid");
|
||||||
|
|
||||||
|
@ -39,11 +39,11 @@ namespace NzbDrone.Core.Test.ImportListTests
|
||||||
};
|
};
|
||||||
|
|
||||||
Mocker.GetMock<ISpotifyProxy>().
|
Mocker.GetMock<ISpotifyProxy>().
|
||||||
Setup(x => x.GetPlaylistTracks(It.IsAny<SpotifyPlaylist>(),
|
Setup(x => x.GetPlaylist(It.IsAny<SpotifyPlaylist>(),
|
||||||
It.IsAny<SpotifyWebAPI>(),
|
It.IsAny<SpotifyWebAPI>(),
|
||||||
It.IsAny<string>(),
|
It.IsAny<string>(),
|
||||||
It.IsAny<string>()))
|
It.IsAny<string>()))
|
||||||
.Returns(playlistTracks);
|
.Returns(new FullPlaylist { Tracks = playlistTracks });
|
||||||
|
|
||||||
var result = Subject.Fetch(_api, "playlistid");
|
var result = Subject.Fetch(_api, "playlistid");
|
||||||
|
|
||||||
|
@ -62,11 +62,11 @@ namespace NzbDrone.Core.Test.ImportListTests
|
||||||
};
|
};
|
||||||
|
|
||||||
Mocker.GetMock<ISpotifyProxy>().
|
Mocker.GetMock<ISpotifyProxy>().
|
||||||
Setup(x => x.GetPlaylistTracks(It.IsAny<SpotifyPlaylist>(),
|
Setup(x => x.GetPlaylist(It.IsAny<SpotifyPlaylist>(),
|
||||||
It.IsAny<SpotifyWebAPI>(),
|
It.IsAny<SpotifyWebAPI>(),
|
||||||
It.IsAny<string>(),
|
It.IsAny<string>(),
|
||||||
It.IsAny<string>()))
|
It.IsAny<string>()))
|
||||||
.Returns(playlistTracks);
|
.Returns(new FullPlaylist { Tracks = playlistTracks });
|
||||||
|
|
||||||
var result = Subject.Fetch(_api, "playlistid");
|
var result = Subject.Fetch(_api, "playlistid");
|
||||||
|
|
||||||
|
@ -108,11 +108,11 @@ namespace NzbDrone.Core.Test.ImportListTests
|
||||||
};
|
};
|
||||||
|
|
||||||
Mocker.GetMock<ISpotifyProxy>().
|
Mocker.GetMock<ISpotifyProxy>().
|
||||||
Setup(x => x.GetPlaylistTracks(It.IsAny<SpotifyPlaylist>(),
|
Setup(x => x.GetPlaylist(It.IsAny<SpotifyPlaylist>(),
|
||||||
It.IsAny<SpotifyWebAPI>(),
|
It.IsAny<SpotifyWebAPI>(),
|
||||||
It.IsAny<string>(),
|
It.IsAny<string>(),
|
||||||
It.IsAny<string>()))
|
It.IsAny<string>()))
|
||||||
.Returns(playlistTracks);
|
.Returns(new FullPlaylist { Tracks = playlistTracks });
|
||||||
|
|
||||||
var result = Subject.Fetch(_api, "playlistid");
|
var result = Subject.Fetch(_api, "playlistid");
|
||||||
|
|
||||||
|
@ -155,11 +155,11 @@ namespace NzbDrone.Core.Test.ImportListTests
|
||||||
};
|
};
|
||||||
|
|
||||||
Mocker.GetMock<ISpotifyProxy>().
|
Mocker.GetMock<ISpotifyProxy>().
|
||||||
Setup(x => x.GetPlaylistTracks(It.IsAny<SpotifyPlaylist>(),
|
Setup(x => x.GetPlaylist(It.IsAny<SpotifyPlaylist>(),
|
||||||
It.IsAny<SpotifyWebAPI>(),
|
It.IsAny<SpotifyWebAPI>(),
|
||||||
It.IsAny<string>(),
|
It.IsAny<string>(),
|
||||||
It.IsAny<string>()))
|
It.IsAny<string>()))
|
||||||
.Returns(playlistTracks);
|
.Returns(new FullPlaylist { Tracks = playlistTracks });
|
||||||
|
|
||||||
var result = Subject.Fetch(_api, "playlistid");
|
var result = Subject.Fetch(_api, "playlistid");
|
||||||
|
|
||||||
|
@ -204,11 +204,11 @@ namespace NzbDrone.Core.Test.ImportListTests
|
||||||
};
|
};
|
||||||
|
|
||||||
Mocker.GetMock<ISpotifyProxy>().
|
Mocker.GetMock<ISpotifyProxy>().
|
||||||
Setup(x => x.GetPlaylistTracks(It.IsAny<SpotifyPlaylist>(),
|
Setup(x => x.GetPlaylist(It.IsAny<SpotifyPlaylist>(),
|
||||||
It.IsAny<SpotifyWebAPI>(),
|
It.IsAny<SpotifyWebAPI>(),
|
||||||
It.IsAny<string>(),
|
It.IsAny<string>(),
|
||||||
It.IsAny<string>()))
|
It.IsAny<string>()))
|
||||||
.Returns(playlistTracks);
|
.Returns(new FullPlaylist { Tracks = playlistTracks });
|
||||||
|
|
||||||
var result = Subject.Fetch(_api, "playlistid");
|
var result = Subject.Fetch(_api, "playlistid");
|
||||||
|
|
||||||
|
@ -251,11 +251,11 @@ namespace NzbDrone.Core.Test.ImportListTests
|
||||||
};
|
};
|
||||||
|
|
||||||
Mocker.GetMock<ISpotifyProxy>().
|
Mocker.GetMock<ISpotifyProxy>().
|
||||||
Setup(x => x.GetPlaylistTracks(It.IsAny<SpotifyPlaylist>(),
|
Setup(x => x.GetPlaylist(It.IsAny<SpotifyPlaylist>(),
|
||||||
It.IsAny<SpotifyWebAPI>(),
|
It.IsAny<SpotifyWebAPI>(),
|
||||||
It.IsAny<string>(),
|
It.IsAny<string>(),
|
||||||
It.IsAny<string>()))
|
It.IsAny<string>()))
|
||||||
.Returns(playlistTracks);
|
.Returns(new FullPlaylist { Tracks = playlistTracks });
|
||||||
|
|
||||||
Mocker.GetMock<ISpotifyProxy>()
|
Mocker.GetMock<ISpotifyProxy>()
|
||||||
.Setup(x => x.GetNextPage(It.IsAny<SpotifyFollowedArtists>(),
|
.Setup(x => x.GetNextPage(It.IsAny<SpotifyFollowedArtists>(),
|
||||||
|
|
23
src/NzbDrone.Core/Datastore/Migration/048_add_playlists.cs
Normal file
23
src/NzbDrone.Core/Datastore/Migration/048_add_playlists.cs
Normal file
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -195,6 +195,11 @@ namespace NzbDrone.Core.Datastore
|
||||||
Mapper.Entity<DownloadHistory>("DownloadHistory").RegisterModel();
|
Mapper.Entity<DownloadHistory>("DownloadHistory").RegisterModel();
|
||||||
|
|
||||||
Mapper.Entity<UpdateHistory>("UpdateHistory").RegisterModel();
|
Mapper.Entity<UpdateHistory>("UpdateHistory").RegisterModel();
|
||||||
|
|
||||||
|
Mapper.Entity<Playlist>("Playlists").RegisterModel()
|
||||||
|
.Ignore(p => p.Items);
|
||||||
|
|
||||||
|
Mapper.Entity<PlaylistEntry>("PlaylistEntries").RegisterModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void RegisterMappers()
|
private static void RegisterMappers()
|
||||||
|
|
|
@ -110,6 +110,8 @@ namespace NzbDrone.Core.ImportLists.Spotify
|
||||||
// map to musicbrainz ids
|
// map to musicbrainz ids
|
||||||
releases = MapSpotifyReleases(releases);
|
releases = MapSpotifyReleases(releases);
|
||||||
|
|
||||||
|
ProcessMappedReleases(releases);
|
||||||
|
|
||||||
return CleanupListItems(releases);
|
return CleanupListItems(releases);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -291,6 +293,11 @@ namespace NzbDrone.Core.ImportLists.Spotify
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected virtual void ProcessMappedReleases(IList<SpotifyImportListItemInfo> items)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
protected override void Test(List<ValidationFailure> failures)
|
protected override void Test(List<ValidationFailure> failures)
|
||||||
{
|
{
|
||||||
failures.AddIfNotNull(TestConnection());
|
failures.AddIfNotNull(TestConnection());
|
||||||
|
|
|
@ -5,26 +5,33 @@ using NLog;
|
||||||
using NzbDrone.Common.Extensions;
|
using NzbDrone.Common.Extensions;
|
||||||
using NzbDrone.Common.Http;
|
using NzbDrone.Common.Http;
|
||||||
using NzbDrone.Core.Configuration;
|
using NzbDrone.Core.Configuration;
|
||||||
|
using NzbDrone.Core.ImportLists.Exclusions;
|
||||||
using NzbDrone.Core.MetadataSource;
|
using NzbDrone.Core.MetadataSource;
|
||||||
|
using NzbDrone.Core.Music;
|
||||||
using NzbDrone.Core.Parser;
|
using NzbDrone.Core.Parser;
|
||||||
using NzbDrone.Core.Validation;
|
using NzbDrone.Core.Validation;
|
||||||
using SpotifyAPI.Web;
|
using SpotifyAPI.Web;
|
||||||
|
using SpotifyAPI.Web.Enums;
|
||||||
using SpotifyAPI.Web.Models;
|
using SpotifyAPI.Web.Models;
|
||||||
|
|
||||||
namespace NzbDrone.Core.ImportLists.Spotify
|
namespace NzbDrone.Core.ImportLists.Spotify
|
||||||
{
|
{
|
||||||
public class SpotifyPlaylist : SpotifyImportListBase<SpotifyPlaylistSettings>
|
public class SpotifyPlaylist : SpotifyImportListBase<SpotifyPlaylistSettings>
|
||||||
{
|
{
|
||||||
|
private readonly IPlaylistService _playlistService;
|
||||||
|
|
||||||
public SpotifyPlaylist(ISpotifyProxy spotifyProxy,
|
public SpotifyPlaylist(ISpotifyProxy spotifyProxy,
|
||||||
IMetadataRequestBuilder requestBuilder,
|
IMetadataRequestBuilder requestBuilder,
|
||||||
IImportListStatusService importListStatusService,
|
IImportListStatusService importListStatusService,
|
||||||
IImportListRepository importListRepository,
|
IImportListRepository importListRepository,
|
||||||
|
IPlaylistService playlistService,
|
||||||
IConfigService configService,
|
IConfigService configService,
|
||||||
IParsingService parsingService,
|
IParsingService parsingService,
|
||||||
IHttpClient httpClient,
|
IHttpClient httpClient,
|
||||||
Logger logger)
|
Logger logger)
|
||||||
: base(spotifyProxy, requestBuilder, importListStatusService, importListRepository, configService, parsingService, httpClient, logger)
|
: base(spotifyProxy, requestBuilder, importListStatusService, importListRepository, configService, parsingService, httpClient, logger)
|
||||||
{
|
{
|
||||||
|
_playlistService = playlistService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string Name => "Spotify Playlists";
|
public override string Name => "Spotify Playlists";
|
||||||
|
@ -40,7 +47,9 @@ namespace NzbDrone.Core.ImportLists.Spotify
|
||||||
|
|
||||||
_logger.Trace($"Processing playlist {playlistId}");
|
_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)
|
while (true)
|
||||||
{
|
{
|
||||||
|
@ -51,7 +60,7 @@ namespace NzbDrone.Core.ImportLists.Spotify
|
||||||
|
|
||||||
foreach (var playlistTrack in playlistTracks.Items)
|
foreach (var playlistTrack in playlistTracks.Items)
|
||||||
{
|
{
|
||||||
result.AddIfNotNull(ParsePlaylistTrack(playlistTrack));
|
result.AddIfNotNull(ParsePlaylistTrack(api, playlistTrack, playlistId, playlist.Name, ref order));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!playlistTracks.HasNextPage())
|
if (!playlistTracks.HasNextPage())
|
||||||
|
@ -65,29 +74,87 @@ namespace NzbDrone.Core.ImportLists.Spotify
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private SpotifyImportListItemInfo ParsePlaylistTrack(PlaylistTrack playlistTrack)
|
protected override void ProcessMappedReleases(IList<SpotifyImportListItemInfo> 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."
|
// 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;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
var albumName = album.Name;
|
var album = playlistTrack.Track.Album;
|
||||||
|
var trackName = playlistTrack.Track.Name;
|
||||||
var artistName = album.Artists?.FirstOrDefault()?.Name ?? playlistTrack.Track?.Artists?.FirstOrDefault()?.Name;
|
var artistName = album.Artists?.FirstOrDefault()?.Name ?? playlistTrack.Track?.Artists?.FirstOrDefault()?.Name;
|
||||||
|
|
||||||
if (albumName.IsNotNullOrWhiteSpace() && artistName.IsNotNullOrWhiteSpace())
|
if (artistName.IsNullOrWhiteSpace())
|
||||||
{
|
{
|
||||||
return new SpotifyImportListItemInfo
|
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,
|
Artist = artistName,
|
||||||
Album = album.Name,
|
Album = album.Name,
|
||||||
AlbumSpotifyId = album.Id,
|
AlbumSpotifyId = album.Id,
|
||||||
|
PlaylistTitle = playlistName,
|
||||||
|
TrackTitle = playlistTrack.Track.Name,
|
||||||
|
Order = ++order,
|
||||||
ReleaseDate = ParseSpotifyDate(album.ReleaseDate, album.ReleaseDatePrecision)
|
ReleaseDate = ParseSpotifyDate(album.ReleaseDate, album.ReleaseDatePrecision)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
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<string, string> query)
|
public override object RequestAction(string action, IDictionary<string, string> query)
|
||||||
|
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
|
@ -26,5 +26,8 @@ namespace NzbDrone.Core.ImportLists.Spotify
|
||||||
|
|
||||||
[FieldDefinition(1, Label = "Playlists", Type = FieldType.Playlist)]
|
[FieldDefinition(1, Label = "Playlists", Type = FieldType.Playlist)]
|
||||||
public IEnumerable<string> PlaylistIds { get; set; }
|
public IEnumerable<string> 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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,12 +16,14 @@ namespace NzbDrone.Core.ImportLists.Spotify
|
||||||
where TSettings : SpotifySettingsBase<TSettings>, new();
|
where TSettings : SpotifySettingsBase<TSettings>, new();
|
||||||
Paging<SavedAlbum> GetSavedAlbums<TSettings>(SpotifyImportListBase<TSettings> list, SpotifyWebAPI api)
|
Paging<SavedAlbum> GetSavedAlbums<TSettings>(SpotifyImportListBase<TSettings> list, SpotifyWebAPI api)
|
||||||
where TSettings : SpotifySettingsBase<TSettings>, new();
|
where TSettings : SpotifySettingsBase<TSettings>, new();
|
||||||
Paging<PlaylistTrack> GetPlaylistTracks<TSettings>(SpotifyImportListBase<TSettings> list, SpotifyWebAPI api, string id, string fields)
|
FullPlaylist GetPlaylist<TSettings>(SpotifyImportListBase<TSettings> list, SpotifyWebAPI api, string id, string fields)
|
||||||
where TSettings : SpotifySettingsBase<TSettings>, new();
|
where TSettings : SpotifySettingsBase<TSettings>, new();
|
||||||
Paging<T> GetNextPage<T, TSettings>(SpotifyImportListBase<TSettings> list, SpotifyWebAPI api, Paging<T> item)
|
Paging<T> GetNextPage<T, TSettings>(SpotifyImportListBase<TSettings> list, SpotifyWebAPI api, Paging<T> item)
|
||||||
where TSettings : SpotifySettingsBase<TSettings>, new();
|
where TSettings : SpotifySettingsBase<TSettings>, new();
|
||||||
FollowedArtists GetNextPage<TSettings>(SpotifyImportListBase<TSettings> list, SpotifyWebAPI api, FollowedArtists item)
|
FollowedArtists GetNextPage<TSettings>(SpotifyImportListBase<TSettings> list, SpotifyWebAPI api, FollowedArtists item)
|
||||||
where TSettings : SpotifySettingsBase<TSettings>, new();
|
where TSettings : SpotifySettingsBase<TSettings>, new();
|
||||||
|
SearchItem SearchItems<TSettings>(SpotifyImportListBase<TSettings> list, SpotifyWebAPI api, string query, SearchType type)
|
||||||
|
where TSettings : SpotifySettingsBase<TSettings>, new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SpotifyProxy : ISpotifyProxy
|
public class SpotifyProxy : ISpotifyProxy
|
||||||
|
@ -57,10 +59,10 @@ namespace NzbDrone.Core.ImportLists.Spotify
|
||||||
return Execute(list, api, x => x.GetSavedAlbums(50));
|
return Execute(list, api, x => x.GetSavedAlbums(50));
|
||||||
}
|
}
|
||||||
|
|
||||||
public Paging<PlaylistTrack> GetPlaylistTracks<TSettings>(SpotifyImportListBase<TSettings> list, SpotifyWebAPI api, string id, string fields)
|
public FullPlaylist GetPlaylist<TSettings>(SpotifyImportListBase<TSettings> list, SpotifyWebAPI api, string id, string fields)
|
||||||
where TSettings : SpotifySettingsBase<TSettings>, new()
|
where TSettings : SpotifySettingsBase<TSettings>, new()
|
||||||
{
|
{
|
||||||
return Execute(list, api, x => x.GetPlaylistTracks(id, fields: fields));
|
return Execute(list, api, x => x.GetPlaylist(id, fields: fields));
|
||||||
}
|
}
|
||||||
|
|
||||||
public Paging<T> GetNextPage<T, TSettings>(SpotifyImportListBase<TSettings> list, SpotifyWebAPI api, Paging<T> item)
|
public Paging<T> GetNextPage<T, TSettings>(SpotifyImportListBase<TSettings> list, SpotifyWebAPI api, Paging<T> item)
|
||||||
|
@ -75,6 +77,12 @@ namespace NzbDrone.Core.ImportLists.Spotify
|
||||||
return Execute(list, api, (x) => x.GetNextPage<FollowedArtists, FullArtist>(item.Artists));
|
return Execute(list, api, (x) => x.GetNextPage<FollowedArtists, FullArtist>(item.Artists));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public SearchItem SearchItems<TSettings>(SpotifyImportListBase<TSettings> list, SpotifyWebAPI api, string query, SearchType type)
|
||||||
|
where TSettings : SpotifySettingsBase<TSettings>, new()
|
||||||
|
{
|
||||||
|
return Execute(list, api, (x) => x.SearchItems(query, type));
|
||||||
|
}
|
||||||
|
|
||||||
public T Execute<T, TSettings>(SpotifyImportListBase<TSettings> list, SpotifyWebAPI api, Func<SpotifyWebAPI, T> method, bool allowReauth = true)
|
public T Execute<T, TSettings>(SpotifyImportListBase<TSettings> list, SpotifyWebAPI api, Func<SpotifyWebAPI, T> method, bool allowReauth = true)
|
||||||
where T : BasicModel
|
where T : BasicModel
|
||||||
where TSettings : SpotifySettingsBase<TSettings>, new()
|
where TSettings : SpotifySettingsBase<TSettings>, new()
|
||||||
|
|
13
src/NzbDrone.Core/Music/Model/Playlist.cs
Normal file
13
src/NzbDrone.Core/Music/Model/Playlist.cs
Normal file
|
@ -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<PlaylistEntry> Items { get; set; }
|
||||||
|
}
|
||||||
|
}
|
12
src/NzbDrone.Core/Music/Model/PlaylistEntry.cs
Normal file
12
src/NzbDrone.Core/Music/Model/PlaylistEntry.cs
Normal file
|
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<PlaylistEntry>
|
||||||
|
{
|
||||||
|
List<PlaylistEntry> UpsertMany(List<PlaylistEntry> entries);
|
||||||
|
List<PlaylistEntry> FindByPlaylistId(int playlistId);
|
||||||
|
List<int> FindPlaylistsByForeignAlbumId(string foreignAlbumId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PlaylistEntryRepository : BasicRepository<PlaylistEntry>, IPlaylistEntryRepository
|
||||||
|
{
|
||||||
|
public PlaylistEntryRepository(IMainDatabase database, IEventAggregator eventAggregator)
|
||||||
|
: base(database, eventAggregator)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<PlaylistEntry> UpsertMany(List<PlaylistEntry> entries)
|
||||||
|
{
|
||||||
|
var playlistIds = entries.Select(x => x.PlaylistId).Distinct();
|
||||||
|
|
||||||
|
Delete(x => playlistIds.Contains(x.PlaylistId));
|
||||||
|
|
||||||
|
InsertMany(entries);
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<PlaylistEntry> FindByPlaylistId(int playlistId)
|
||||||
|
{
|
||||||
|
return Query(x => x.PlaylistId == playlistId).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<PlaylistEntry> FindByPlaylistId(List<int> playlistIds)
|
||||||
|
{
|
||||||
|
return Query(x => playlistIds.Contains(x.Id)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<int> FindPlaylistsByForeignAlbumId(string foreignAlbumId)
|
||||||
|
{
|
||||||
|
return Query(x => x.ForeignAlbumId == foreignAlbumId).Select(x => x.PlaylistId).Distinct().ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
25
src/NzbDrone.Core/Music/Repositories/PlaylistRepository.cs
Normal file
25
src/NzbDrone.Core/Music/Repositories/PlaylistRepository.cs
Normal file
|
@ -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>
|
||||||
|
{
|
||||||
|
Playlist GetPlaylist(string foreignPlaylistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PlaylistRepository : BasicRepository<Playlist>, IPlaylistRepository
|
||||||
|
{
|
||||||
|
public PlaylistRepository(IMainDatabase database, IEventAggregator eventAggregator)
|
||||||
|
: base(database, eventAggregator)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public Playlist GetPlaylist(string foreignPlaylistId)
|
||||||
|
{
|
||||||
|
return Query(x => x.ForeignPlaylistId == foreignPlaylistId).FirstOrDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,6 +10,7 @@ namespace NzbDrone.Core.Music
|
||||||
{
|
{
|
||||||
List<Track> GetTracks(int artistId);
|
List<Track> GetTracks(int artistId);
|
||||||
List<Track> GetTracksByAlbum(int albumId);
|
List<Track> GetTracksByAlbum(int albumId);
|
||||||
|
List<Track> GetTracksByForeignAlbumId(string foreignAlbumId);
|
||||||
List<Track> GetTracksByRelease(int albumReleaseId);
|
List<Track> GetTracksByRelease(int albumReleaseId);
|
||||||
List<Track> GetTracksByReleases(List<int> albumReleaseIds);
|
List<Track> GetTracksByReleases(List<int> albumReleaseIds);
|
||||||
List<Track> GetTracksForRefresh(int albumReleaseId, IEnumerable<string> foreignTrackIds);
|
List<Track> GetTracksForRefresh(int albumReleaseId, IEnumerable<string> foreignTrackIds);
|
||||||
|
@ -47,6 +48,15 @@ namespace NzbDrone.Core.Music
|
||||||
.Where<Album>(x => x.Id == albumId));
|
.Where<Album>(x => x.Id == albumId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Track> GetTracksByForeignAlbumId(string foreignAlbumId)
|
||||||
|
{
|
||||||
|
return Query(Builder()
|
||||||
|
.Join<Track, AlbumRelease>((t, r) => t.AlbumReleaseId == r.Id)
|
||||||
|
.Join<AlbumRelease, Album>((r, a) => r.AlbumId == a.Id)
|
||||||
|
.Where<AlbumRelease>(r => r.Monitored == true)
|
||||||
|
.Where<Album>(x => x.ForeignAlbumId == foreignAlbumId));
|
||||||
|
}
|
||||||
|
|
||||||
public List<Track> GetTracksByRelease(int albumReleaseId)
|
public List<Track> GetTracksByRelease(int albumReleaseId)
|
||||||
{
|
{
|
||||||
return Query(t => t.AlbumReleaseId == albumReleaseId).ToList();
|
return Query(t => t.AlbumReleaseId == albumReleaseId).ToList();
|
||||||
|
|
37
src/NzbDrone.Core/Music/Services/PlaylistEntryService.cs
Normal file
37
src/NzbDrone.Core/Music/Services/PlaylistEntryService.cs
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using NzbDrone.Core.Music;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.ImportLists.Exclusions
|
||||||
|
{
|
||||||
|
public interface IPlaylistEntryService
|
||||||
|
{
|
||||||
|
List<PlaylistEntry> UpsertMany(List<PlaylistEntry> entries);
|
||||||
|
List<PlaylistEntry> FindByPlaylistId(int playlistId);
|
||||||
|
List<int> FindPlaylistsByForeignAlbumId(string foreignAlbumId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PlaylistEntryService : IPlaylistEntryService
|
||||||
|
{
|
||||||
|
private readonly IPlaylistEntryRepository _repo;
|
||||||
|
|
||||||
|
public PlaylistEntryService(IPlaylistEntryRepository repo)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<PlaylistEntry> FindByPlaylistId(int playlistId)
|
||||||
|
{
|
||||||
|
return _repo.FindByPlaylistId(playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<PlaylistEntry> UpsertMany(List<PlaylistEntry> entries)
|
||||||
|
{
|
||||||
|
return _repo.UpsertMany(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<int> FindPlaylistsByForeignAlbumId(string foreignAlbumId)
|
||||||
|
{
|
||||||
|
return _repo.FindPlaylistsByForeignAlbumId(foreignAlbumId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
125
src/NzbDrone.Core/Music/Services/PlaylistService.cs
Normal file
125
src/NzbDrone.Core/Music/Services/PlaylistService.cs
Normal file
|
@ -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<AlbumImportedEvent>,
|
||||||
|
IHandleAsync<TrackFileRenamedEvent>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,15 +1,19 @@
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using NLog;
|
using NLog;
|
||||||
|
using NzbDrone.Common.Extensions;
|
||||||
using NzbDrone.Core.MediaFiles.Events;
|
using NzbDrone.Core.MediaFiles.Events;
|
||||||
using NzbDrone.Core.Messaging.Events;
|
using NzbDrone.Core.Messaging.Events;
|
||||||
using NzbDrone.Core.Music.Events;
|
using NzbDrone.Core.Music.Events;
|
||||||
|
using NzbDrone.Core.Parser;
|
||||||
|
|
||||||
namespace NzbDrone.Core.Music
|
namespace NzbDrone.Core.Music
|
||||||
{
|
{
|
||||||
public interface ITrackService
|
public interface ITrackService
|
||||||
{
|
{
|
||||||
Track GetTrack(int id);
|
Track GetTrack(int id);
|
||||||
|
Track FindTrackByTitleInexact(string foreignAlbumId, string title);
|
||||||
List<Track> GetTracks(IEnumerable<int> ids);
|
List<Track> GetTracks(IEnumerable<int> ids);
|
||||||
List<Track> GetTracksByArtist(int artistId);
|
List<Track> GetTracksByArtist(int artistId);
|
||||||
List<Track> GetTracksByAlbum(int albumId);
|
List<Track> GetTracksByAlbum(int albumId);
|
||||||
|
@ -46,6 +50,31 @@ namespace NzbDrone.Core.Music
|
||||||
return _trackRepository.Get(id);
|
return _trackRepository.Get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Track FindTrackByTitleInexact(string foreignAlbumId, string title)
|
||||||
|
{
|
||||||
|
var normalizedTitle = title.NormalizeTrackTitle().Replace(".", " ");
|
||||||
|
var tracks = _trackRepository.GetTracksByForeignAlbumId(foreignAlbumId);
|
||||||
|
|
||||||
|
Func<Func<Track, string, double>, string, Tuple<Func<Track, string, double>, string>> tc = Tuple.Create;
|
||||||
|
var scoringFunctions = new List<Tuple<Func<Track, string, double>, 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<Track> GetTracks(IEnumerable<int> ids)
|
public List<Track> GetTracks(IEnumerable<int> ids)
|
||||||
{
|
{
|
||||||
return _trackRepository.Get(ids).ToList();
|
return _trackRepository.Get(ids).ToList();
|
||||||
|
@ -133,5 +162,32 @@ namespace NzbDrone.Core.Music
|
||||||
_logger.Debug($"Detaching tracks from file {message.TrackFile}");
|
_logger.Debug($"Detaching tracks from file {message.TrackFile}");
|
||||||
_trackRepository.DetachTrackFile(message.TrackFile.Id);
|
_trackRepository.DetachTrackFile(message.TrackFile.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Track FindByStringInexact(List<Track> tracks, Func<Track, string, double> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue