diff --git a/src/NzbDrone.Core/ImportLists/ImportListType.cs b/src/NzbDrone.Core/ImportLists/ImportListType.cs index 6dd76ef31..d3be3b655 100644 --- a/src/NzbDrone.Core/ImportLists/ImportListType.cs +++ b/src/NzbDrone.Core/ImportLists/ImportListType.cs @@ -5,6 +5,7 @@ namespace NzbDrone.Core.ImportLists Program, Spotify, LastFm, + Youtube, Other, Advanced } diff --git a/src/NzbDrone.Core/ImportLists/Youtube/YoutubeImportListBase.cs b/src/NzbDrone.Core/ImportLists/Youtube/YoutubeImportListBase.cs new file mode 100644 index 000000000..391abc22d --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Youtube/YoutubeImportListBase.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using FluentValidation.Results; +using Google.Apis.Services; +using Google.Apis.YouTube.v3; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.ImportLists.Youtube +{ + public abstract class YoutubeImportListBase : ImportListBase + where TSettings : YoutubePlaylistSettings, new() + { + protected YoutubeImportListBase(IImportListStatusService importListStatusService, + IConfigService configService, + IParsingService parsingService, + IHttpClient httpClient, + Logger logger) + : base(importListStatusService, configService, parsingService, logger) + { + } + + public override ImportListType ListType => ImportListType.Youtube; + public override TimeSpan MinRefreshInterval => TimeSpan.FromSeconds(1); + + public override IList Fetch() + { + IList releases = new List(); + + using (var service = new YouTubeService(new BaseClientService.Initializer() + { + ApiKey = Settings.YoutubeApiKey, + })) + { + releases = Fetch(service); + } + + return CleanupListItems(releases); + } + + public abstract IList Fetch(YouTubeService service); + + protected override void Test(List failures) + { + failures.AddIfNotNull(TestConnection()); + } + + public abstract ValidationFailure TestConnection(YouTubeService service); + + private ValidationFailure TestConnection() + { + using (var service = new YouTubeService(new BaseClientService.Initializer() + { + ApiKey = Settings.YoutubeApiKey, + })) + { + return TestConnection(service); + } + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Youtube/YoutubeImportListItemInfo.cs b/src/NzbDrone.Core/ImportLists/Youtube/YoutubeImportListItemInfo.cs new file mode 100644 index 000000000..6c75bc5e6 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Youtube/YoutubeImportListItemInfo.cs @@ -0,0 +1,14 @@ +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.ImportLists.Youtube; + +public class YoutubeImportListItemInfo : ImportListItemInfo +{ + public string ArtistYoutubeId { get; set; } + public string AlbumYoutubeId { get; set; } + + public override string ToString() + { + return string.Format("[{0}] {1}", ArtistYoutubeId, AlbumYoutubeId); + } +} diff --git a/src/NzbDrone.Core/ImportLists/Youtube/YoutubePlaylist.cs b/src/NzbDrone.Core/ImportLists/Youtube/YoutubePlaylist.cs new file mode 100644 index 000000000..52f88988b --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Youtube/YoutubePlaylist.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using DryIoc.ImTools; +using FluentValidation.Results; +using Google.Apis.YouTube.v3; +using Google.Apis.YouTube.v3.Data; +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.ImportLists.Youtube +{ + public class YoutubePlaylist : YoutubeImportListBase + { + public YoutubePlaylist(IImportListStatusService importListStatusService, + IImportListRepository importListRepository, + IConfigService configService, + IParsingService parsingService, + IHttpClient httpClient, + Logger logger) + : base(importListStatusService, configService, parsingService, httpClient, logger) + { + } + + public override string Name => "Youtube Playlists"; + + public override IList Fetch(YouTubeService service) + { + return Settings.PlaylistIds.SelectMany(x => Fetch(service, x)).ToList(); + } + + public IList Fetch(YouTubeService service, string playlistId) + { + var results = new List(); + var req = service.PlaylistItems.List("contentDetails,snippet"); + req.PlaylistId = playlistId; + req.MaxResults = 50; + var page = 0; + var playlist = req.Execute(); + do + { + page++; + req.PageToken = playlist.NextPageToken; + + foreach (var song in playlist.Items) + { + var listItem = new YoutubeImportListItemInfo(); + var topicChannel = song.Snippet.VideoOwnerChannelTitle.EndsWith("- Topic"); + if (topicChannel) + { + ParseTopicChannel(song, ref listItem); + } + else + { + // No album name just video + listItem.ReleaseDate = ParseDateTimeOffset(song); + listItem.Artist = song.Snippet.VideoOwnerChannelTitle; + } + + results.Add(listItem); + } + + Thread.Sleep(TimeSpan.FromSeconds(1)); + playlist = req.Execute(); + } + while (playlist.NextPageToken != null && page < 10); + return results; + } + + public void ParseTopicChannel(PlaylistItem playlistItem, ref YoutubeImportListItemInfo listItem) + { + var description = playlistItem.Snippet.Description; + var descArgs = description.Split("\n\n"); + + listItem.Artist = playlistItem.Snippet.VideoOwnerChannelTitle.Contains("- Topic") ? + playlistItem.Snippet.VideoOwnerChannelTitle[.. (playlistItem.Snippet.VideoOwnerChannelTitle.LastIndexOf('-') - 1)] : + playlistItem.Snippet.VideoOwnerChannelTitle; + listItem.Album = descArgs[2]; + + if (descArgs.Any(s => s.StartsWith("Released on:"))) + { + // Custom release date + var release = descArgs.FindFirst(s => s.StartsWith("Released on:")); + var date = release.Substring(release.IndexOf(':') + 1); + listItem.ReleaseDate = DateTime.Parse(date); + } + else + { + listItem.ReleaseDate = ParseDateTimeOffset(playlistItem); + } + } + + private DateTime ParseDateTimeOffset(PlaylistItem playlistItem) + { + return (playlistItem.ContentDetails.VideoPublishedAtDateTimeOffset ?? DateTimeOffset.UnixEpoch).DateTime; + } + + public override ValidationFailure TestConnection(YouTubeService service) + { + foreach (var id in Settings.PlaylistIds) + { + try + { + var req = service.PlaylistItems.List("contentDetails,snippet"); + req.PlaylistId = id; + req.MaxResults = 1; + req.Execute(); + } + catch (Exception e) + { + return new ValidationFailure(string.Empty, e.Message); + } + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Youtube/YoutubePlaylistSettings.cs b/src/NzbDrone.Core/ImportLists/Youtube/YoutubePlaylistSettings.cs new file mode 100644 index 000000000..12c5eab17 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Youtube/YoutubePlaylistSettings.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using FluentValidation; +using NzbDrone.Core.Annotations; + +namespace NzbDrone.Core.ImportLists.Youtube +{ + public class YoutubePlaylistSettingsValidator : YoutubeSettingsBaseValidator + { + public YoutubePlaylistSettingsValidator() + : base() + { + RuleFor(c => c.PlaylistIds).NotEmpty(); + } + } + + public class YoutubePlaylistSettings : YoutubeSettingsBase + { + protected override AbstractValidator Validator => + new YoutubePlaylistSettingsValidator(); + + public YoutubePlaylistSettings() + { + PlaylistIds = System.Array.Empty(); + } + + [FieldDefinition(1, Label = "Youtube API key", Type = FieldType.Textbox)] + public string YoutubeApiKey { get; set; } + + [FieldDefinition(1, Label = "Playlists", Type = FieldType.Textbox)] + public IEnumerable PlaylistIds { get; set; } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Youtube/YoutubeSettingsBase.cs b/src/NzbDrone.Core/ImportLists/Youtube/YoutubeSettingsBase.cs new file mode 100644 index 000000000..73fd4191b --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Youtube/YoutubeSettingsBase.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.ImportLists.Youtube +{ + public class YoutubeSettingsBaseValidator : AbstractValidator + where TSettings : YoutubeSettingsBase + { + } + + public class YoutubeSettingsBase : IImportListSettings + where TSettings : YoutubeSettingsBase + { + protected virtual AbstractValidator Validator => new YoutubeSettingsBaseValidator(); + + public string BaseUrl { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate((TSettings)this)); + } + } +} diff --git a/src/NzbDrone.Core/Lidarr.Core.csproj b/src/NzbDrone.Core/Lidarr.Core.csproj index 14b90c62a..86d8e9be8 100644 --- a/src/NzbDrone.Core/Lidarr.Core.csproj +++ b/src/NzbDrone.Core/Lidarr.Core.csproj @@ -6,6 +6,7 @@ +