New: Write out .m3u files for spotify playlists

(cherry picked from commit 7aa051caf25031c7af3bdace8f4c5e73a41c5fe5)
This commit is contained in:
ta264 2022-07-25 21:19:02 +01:00
commit 580afe4642
16 changed files with 507 additions and 58 deletions

View file

@ -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>(),

View 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();
}
}
}

View file

@ -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()

View file

@ -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());

View file

@ -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)

View file

@ -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; }
}
}

View file

@ -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; }
} }
} }

View file

@ -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()

View 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; }
}
}

View 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; }
}
}

View file

@ -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();
}
}
}

View 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();
}
}
}

View file

@ -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();

View 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);
}
}
}

View 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);
}
}
}
}

View file

@ -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;
}
} }
} }