From e78fa82a49bdef9b1b1f2a5e970837ef3e0cc6e7 Mon Sep 17 00:00:00 2001 From: Maksym Pavlenko Date: Wed, 5 Oct 2016 18:34:27 -0400 Subject: [PATCH] Implement YouTube API client --- src/Podsync/Services/PodsyncConfiguration.cs | 7 + .../Services/Videos/YouTube/Channel.cs | 6 + .../Services/Videos/YouTube/ChannelQuery.cs | 11 + .../Services/Videos/YouTube/IYouTubeClient.cs | 54 +++++ .../Services/Videos/YouTube/Playlist.cs | 6 + .../Videos/YouTube/PlaylistItemsQuery.cs | 24 ++ .../Services/Videos/YouTube/PlaylistQuery.cs | 11 + src/Podsync/Services/Videos/YouTube/Video.cs | 11 + .../Services/Videos/YouTube/VideoQuery.cs | 9 + .../Services/Videos/YouTube/YouTubeClient.cs | 211 ++++++++++++++++++ .../Services/Videos/YouTube/YouTubeItem.cs | 23 ++ src/Podsync/Startup.cs | 15 +- src/Podsync/appsettings.json | 20 +- src/Podsync/project.json | 18 +- .../Videos/YouTube/YouTubeClientTests.cs | 112 ++++++++++ test/Podsync.Tests/TestBase.cs | 29 +++ test/Podsync.Tests/project.json | 3 + 17 files changed, 552 insertions(+), 18 deletions(-) create mode 100644 src/Podsync/Services/PodsyncConfiguration.cs create mode 100644 src/Podsync/Services/Videos/YouTube/Channel.cs create mode 100644 src/Podsync/Services/Videos/YouTube/ChannelQuery.cs create mode 100644 src/Podsync/Services/Videos/YouTube/IYouTubeClient.cs create mode 100644 src/Podsync/Services/Videos/YouTube/Playlist.cs create mode 100644 src/Podsync/Services/Videos/YouTube/PlaylistItemsQuery.cs create mode 100644 src/Podsync/Services/Videos/YouTube/PlaylistQuery.cs create mode 100644 src/Podsync/Services/Videos/YouTube/Video.cs create mode 100644 src/Podsync/Services/Videos/YouTube/VideoQuery.cs create mode 100644 src/Podsync/Services/Videos/YouTube/YouTubeClient.cs create mode 100644 src/Podsync/Services/Videos/YouTube/YouTubeItem.cs create mode 100644 test/Podsync.Tests/Services/Videos/YouTube/YouTubeClientTests.cs create mode 100644 test/Podsync.Tests/TestBase.cs diff --git a/src/Podsync/Services/PodsyncConfiguration.cs b/src/Podsync/Services/PodsyncConfiguration.cs new file mode 100644 index 0000000..f827ff3 --- /dev/null +++ b/src/Podsync/Services/PodsyncConfiguration.cs @@ -0,0 +1,7 @@ +namespace Podsync.Services +{ + public class PodsyncConfiguration + { + public string YouTubeApiKey { get; set; } + } +} \ No newline at end of file diff --git a/src/Podsync/Services/Videos/YouTube/Channel.cs b/src/Podsync/Services/Videos/YouTube/Channel.cs new file mode 100644 index 0000000..4db3503 --- /dev/null +++ b/src/Podsync/Services/Videos/YouTube/Channel.cs @@ -0,0 +1,6 @@ +namespace Podsync.Services.Videos.YouTube +{ + public class Channel : YouTubeItem + { + } +} \ No newline at end of file diff --git a/src/Podsync/Services/Videos/YouTube/ChannelQuery.cs b/src/Podsync/Services/Videos/YouTube/ChannelQuery.cs new file mode 100644 index 0000000..2da6207 --- /dev/null +++ b/src/Podsync/Services/Videos/YouTube/ChannelQuery.cs @@ -0,0 +1,11 @@ +namespace Podsync.Services.Videos.YouTube +{ + public struct ChannelQuery + { + public string ChannelId { get; set; } + + public string Username { get; set; } + + public uint? Count { get; set; } + } +} \ No newline at end of file diff --git a/src/Podsync/Services/Videos/YouTube/IYouTubeClient.cs b/src/Podsync/Services/Videos/YouTube/IYouTubeClient.cs new file mode 100644 index 0000000..b65d06d --- /dev/null +++ b/src/Podsync/Services/Videos/YouTube/IYouTubeClient.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Podsync.Services.Videos.YouTube +{ + public interface IYouTubeClient + { + /// + /// Returns a collection of zero or more channel resources that match the request criteria + /// Cost: 5 (quota cost of 1 + id: 0, snippet: 2, contentDetails: 2) + /// See https://developers.google.com/youtube/v3/docs/channels/list + /// + /// + /// + Task> GetChannels(ChannelQuery query); + + /// + /// Returns a collection of playlists that match the API request parameters + /// Cost: 3 (quota cost of 1 + id: 0, snippet: 2) + /// See https://developers.google.com/youtube/v3/docs/playlists/list + /// + /// + /// + Task> GetPlaylists(PlaylistQuery query); + + /// + /// Returns a list of videos that match the API request parameters + /// Cost: 5 (quota cost of 1 + id: 0, snippet: 2, contentDetails: 2) + /// See https://developers.google.com/youtube/v3/docs/videos/list + /// + /// + /// + Task> GetVideos(VideoQuery query); + + /// + /// Returns a collection of playlist items that match the API request parameters. + /// You can retrieve all of the playlist items in a specified playlist or + /// retrieve one or more playlist items by their unique IDs. + /// Cost: 3 (quota cost of 1 + id: 0, snippet: 2) + /// See https://developers.google.com/youtube/v3/docs/playlistItems/list + /// + /// + /// + Task> GetPlaylistItems(PlaylistItemsQuery query); + + /// + /// Optimized version of GetPlaylistItems to query video IDs only + /// Cost: 3 (quota cost of 1 + id: 0, snippet: 2) + /// + /// + /// + Task> GetPlaylistItemIds(PlaylistItemsQuery query); + } +} \ No newline at end of file diff --git a/src/Podsync/Services/Videos/YouTube/Playlist.cs b/src/Podsync/Services/Videos/YouTube/Playlist.cs new file mode 100644 index 0000000..56eab4f --- /dev/null +++ b/src/Podsync/Services/Videos/YouTube/Playlist.cs @@ -0,0 +1,6 @@ +namespace Podsync.Services.Videos.YouTube +{ + public class Playlist : YouTubeItem + { + } +} \ No newline at end of file diff --git a/src/Podsync/Services/Videos/YouTube/PlaylistItemsQuery.cs b/src/Podsync/Services/Videos/YouTube/PlaylistItemsQuery.cs new file mode 100644 index 0000000..36dfb29 --- /dev/null +++ b/src/Podsync/Services/Videos/YouTube/PlaylistItemsQuery.cs @@ -0,0 +1,24 @@ +namespace Podsync.Services.Videos.YouTube +{ + public struct PlaylistItemsQuery + { + /// + /// The id parameter specifies a comma-separated list of one or more unique playlist item IDs + /// + public string Id { get; set; } + + /// + /// The playlistId parameter specifies the unique ID of the playlist for which you want to retrieve playlist items. + /// Note that even though this is an optional parameter, every request to retrieve playlist items must specify + /// a value for either the id parameter or the playlistId parameter. + /// + public string PlaylistId { get; set; } + + /// + /// The videoId parameter specifies that the request should return only the playlist items that contain the specified video + /// + public string VideoId { get; set; } + + public uint? Count { get; set; } + } +} \ No newline at end of file diff --git a/src/Podsync/Services/Videos/YouTube/PlaylistQuery.cs b/src/Podsync/Services/Videos/YouTube/PlaylistQuery.cs new file mode 100644 index 0000000..82c56fc --- /dev/null +++ b/src/Podsync/Services/Videos/YouTube/PlaylistQuery.cs @@ -0,0 +1,11 @@ +namespace Podsync.Services.Videos.YouTube +{ + public struct PlaylistQuery + { + public string PlaylistId { get; set; } + + public string ChannelId { get; set; } + + public uint? Count { get; set; } + } +} \ No newline at end of file diff --git a/src/Podsync/Services/Videos/YouTube/Video.cs b/src/Podsync/Services/Videos/YouTube/Video.cs new file mode 100644 index 0000000..5e66c2a --- /dev/null +++ b/src/Podsync/Services/Videos/YouTube/Video.cs @@ -0,0 +1,11 @@ +using System; + +namespace Podsync.Services.Videos.YouTube +{ + public class Video : YouTubeItem + { + public TimeSpan Duration { get; set; } + + public long Size { get; set; } + } +} \ No newline at end of file diff --git a/src/Podsync/Services/Videos/YouTube/VideoQuery.cs b/src/Podsync/Services/Videos/YouTube/VideoQuery.cs new file mode 100644 index 0000000..0a19c0c --- /dev/null +++ b/src/Podsync/Services/Videos/YouTube/VideoQuery.cs @@ -0,0 +1,9 @@ +namespace Podsync.Services.Videos.YouTube +{ + public struct VideoQuery + { + public string Id { get; set; } + + public uint? Count { get; set; } + } +} \ No newline at end of file diff --git a/src/Podsync/Services/Videos/YouTube/YouTubeClient.cs b/src/Podsync/Services/Videos/YouTube/YouTubeClient.cs new file mode 100644 index 0000000..ccc99b5 --- /dev/null +++ b/src/Podsync/Services/Videos/YouTube/YouTubeClient.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Xml; +using Google.Apis.Services; +using Google.Apis.YouTube.v3; +using Google.Apis.YouTube.v3.Data; +using Microsoft.Extensions.Options; +using Podsync.Services.Links; + +namespace Podsync.Services.Videos.YouTube +{ + public sealed class YouTubeClient : IYouTubeClient, IDisposable + { + private const int MaxResults = 50; + + private readonly ILinkService _linkService; + private readonly YouTubeService _youtube; + + public YouTubeClient(ILinkService linkService, IOptions configuration) + { + _linkService = linkService; + _youtube = new YouTubeService(new BaseClientService.Initializer + { + ApplicationName = "Podsync", + ApiKey = configuration.Value.YouTubeApiKey + }); + } + + public async Task> GetChannels(ChannelQuery query) + { + var request = _youtube.Channels.List("id,snippet,contentDetails"); + + request.MaxResults = query.Count ?? MaxResults; + request.Id = query.ChannelId; + request.ForUsername = query.Username; + + var response = await request.ExecuteAsync(); + + return response.Items.Select(ConvertChannel); + } + + public async Task> GetPlaylists(PlaylistQuery query) + { + var request = _youtube.Playlists.List("id,snippet"); + + request.MaxResults = query.Count ?? MaxResults; + request.Id = query.PlaylistId; + request.ChannelId = query.ChannelId; + + var response = await request.ExecuteAsync(); + + return response.Items.Select(ConvertPlaylist); + } + + public async Task> GetVideos(VideoQuery query) + { + var request = _youtube.Videos.List("id,snippet,contentDetails"); + + request.MaxResults = query.Count ?? MaxResults; + request.Id = query.Id; + + var response = await request.ExecuteAsync(); + + return response.Items.Select(ConvertVideo); + } + + public async Task> GetPlaylistItems(PlaylistItemsQuery query) + { + var response = await QueryPlaylistItems(query); + + return response.Items.Select(ConvertPlaylistItem); + } + + public async Task> GetPlaylistItemIds(PlaylistItemsQuery query) + { + var response = await QueryPlaylistItems(query); + + return response.Items.Select(x => x.Snippet.ResourceId.VideoId); + } + + public void Dispose() + { + _youtube.Dispose(); + } + + private static long GetVideoSize(string definition, TimeSpan duration) + { + // Video size information requires 1 additional call for each video (1 feed = 50 videos = 50 calls), + // which is too expensive, so get approximated size depending on duration and definition params + + var totalSeconds = (long)duration.TotalSeconds; + + const long hdBytesPerSecond = 350000; + const long ldBytesPerSecond = 100000; + + return totalSeconds * (definition == "hd" ? hdBytesPerSecond : ldBytesPerSecond); + } + + private Task QueryPlaylistItems(PlaylistItemsQuery query) + { + var request = _youtube.PlaylistItems.List("id,snippet"); + + request.MaxResults = query.Count ?? MaxResults; + request.Id = query.Id; + request.PlaylistId = query.PlaylistId; + request.VideoId = query.VideoId; + + return request.ExecuteAsync(); + } + + private Video ConvertVideo(Google.Apis.YouTube.v3.Data.Video item) + { + var snippet = item.Snippet; + var details = item.ContentDetails; + + var link = _linkService.Make(new LinkInfo + { + Id = item.Id, + LinkType = LinkType.Video, + Provider = Provider.YouTube + }); + + var duration = XmlConvert.ToTimeSpan(details.Duration); + var size = GetVideoSize(details.Definition, duration); + + return new Video + { + VideoId = item.Id, + ChannelId = snippet.ChannelId, + Title = snippet.Title, + Description = snippet.Description, + PublishedAt = snippet.PublishedAt ?? DateTime.MinValue, + Link = link, + Duration = duration, + Size = size, + }; + } + + private Video ConvertPlaylistItem(PlaylistItem item) + { + var snippet = item.Snippet; + + var link = _linkService.Make(new LinkInfo + { + Id = item.Id, + LinkType = LinkType.Video, + Provider = Provider.YouTube + }); + + return new Video + { + VideoId = snippet.ResourceId.VideoId, + ChannelId = snippet.ChannelId, + PlaylistId = snippet.PlaylistId, + Link = link, + Title = snippet.Title, + Description = snippet.Description, + PublishedAt = snippet.PublishedAt ?? DateTime.MinValue, + Thumbnail = new Uri(snippet.Thumbnails.Default__.Url) + }; + } + + private Playlist ConvertPlaylist(Google.Apis.YouTube.v3.Data.Playlist item) + { + var link = _linkService.Make(new LinkInfo + { + Id = item.Id, + LinkType = LinkType.Playlist, + Provider = Provider.YouTube + }); + + var snippet = item.Snippet; + + return new Playlist + { + PlaylistId = item.Id, + ChannelId = snippet.ChannelId, + Link = link, + Title = $"{snippet.ChannelTitle}: {snippet.Title}", + Description = snippet.Description, + PublishedAt = snippet.PublishedAt ?? DateTime.MinValue, + Thumbnail = new Uri(snippet.Thumbnails.Default__.Url) + }; + } + + private Channel ConvertChannel(Google.Apis.YouTube.v3.Data.Channel item) + { + var link = _linkService.Make(new LinkInfo + { + Id = item.Id, + LinkType = LinkType.Channel, + Provider = Provider.YouTube + }); + + var snippet = item.Snippet; + + return new Channel + { + ChannelId = item.Id, + PlaylistId = item.ContentDetails.RelatedPlaylists.Uploads, + Link = link, + Title = snippet.Title, + Description = snippet.Description, + PublishedAt = snippet.PublishedAt ?? DateTime.MinValue, + Thumbnail = new Uri(snippet.Thumbnails.Default__.Url) + }; + } + } +} \ No newline at end of file diff --git a/src/Podsync/Services/Videos/YouTube/YouTubeItem.cs b/src/Podsync/Services/Videos/YouTube/YouTubeItem.cs new file mode 100644 index 0000000..5ab6b60 --- /dev/null +++ b/src/Podsync/Services/Videos/YouTube/YouTubeItem.cs @@ -0,0 +1,23 @@ +using System; + +namespace Podsync.Services.Videos.YouTube +{ + public class YouTubeItem + { + public string VideoId { get; set; } + + public string ChannelId { get; set; } + + public string PlaylistId { get; set; } + + public Uri Link { get; set; } + + public Uri Thumbnail { get; set; } + + public string Title { get; set; } + + public string Description { get; set; } + + public DateTime PublishedAt { get; set; } + } +} diff --git a/src/Podsync/Startup.cs b/src/Podsync/Startup.cs index c28f882..24749cc 100644 --- a/src/Podsync/Startup.cs +++ b/src/Podsync/Startup.cs @@ -3,7 +3,9 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Podsync.Services; using Podsync.Services.Links; +using Podsync.Services.Videos.YouTube; namespace Podsync { @@ -16,6 +18,12 @@ namespace Podsync .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true) .AddEnvironmentVariables(); + + if (env.IsDevelopment()) + { + builder.AddUserSecrets(); + } + Configuration = builder.Build(); } @@ -24,8 +32,11 @@ namespace Podsync // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { + services.Configure(Configuration.GetSection("Podsync")); + // Register core services services.AddSingleton(); + services.AddSingleton(); // Add framework services. services.AddMvc(); @@ -51,9 +62,7 @@ namespace Podsync app.UseMvc(routes => { - routes.MapRoute( - name: "default", - template: "{controller=Home}/{action=Index}/{id?}"); + routes.MapRoute("default", "{controller=Home}/{action=Index}/{id?}"); }); } } diff --git a/src/Podsync/appsettings.json b/src/Podsync/appsettings.json index fa8ce71..0c7b871 100644 --- a/src/Podsync/appsettings.json +++ b/src/Podsync/appsettings.json @@ -1,10 +1,14 @@ { - "Logging": { - "IncludeScopes": false, - "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" - } - } + "Logging": { + "IncludeScopes": false, + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + }, + + "Podsync": { + "YouTubeApiKey": "" + } } diff --git a/src/Podsync/project.json b/src/Podsync/project.json index c572aad..0136796 100644 --- a/src/Podsync/project.json +++ b/src/Podsync/project.json @@ -1,9 +1,6 @@ { "dependencies": { - "Microsoft.NETCore.App": { - "version": "1.0.0", - "type": "platform" - }, + "Google.Apis.YouTube.v3": "1.16.0.582", "Microsoft.AspNetCore.Diagnostics": "1.0.0", "Microsoft.AspNetCore.Mvc": "1.0.0", "Microsoft.AspNetCore.Razor.Tools": { @@ -13,14 +10,19 @@ "Microsoft.AspNetCore.Server.IISIntegration": "1.0.0", "Microsoft.AspNetCore.Server.Kestrel": "1.0.0", "Microsoft.AspNetCore.StaticFiles": "1.0.0", + "Microsoft.AspNetCore.WebUtilities": "1.0.0", "Microsoft.Extensions.Configuration.EnvironmentVariables": "1.0.0", "Microsoft.Extensions.Configuration.Json": "1.0.0", + "Microsoft.Extensions.Configuration.UserSecrets": "1.0.0", "Microsoft.Extensions.Logging": "1.0.0", "Microsoft.Extensions.Logging.Console": "1.0.0", "Microsoft.Extensions.Logging.Debug": "1.0.0", "Microsoft.Extensions.Options.ConfigurationExtensions": "1.0.0", - "Microsoft.VisualStudio.Web.BrowserLink.Loader": "14.0.0", - "Microsoft.AspNetCore.WebUtilities": "1.0.0" + "Microsoft.NETCore.App": { + "version": "1.0.0", + "type": "platform" + }, + "Microsoft.VisualStudio.Web.BrowserLink.Loader": "14.0.0" }, "tools": { @@ -64,5 +66,7 @@ "scripts": { "prepublish": [ "bower install", "dotnet bundle" ], "postpublish": [ "dotnet publish-iis --publish-folder %publish:OutputPath% --framework %publish:FullTargetFramework%" ] - } + }, + + "userSecretsId": "aspnet-Podsync-20161004104901" } diff --git a/test/Podsync.Tests/Services/Videos/YouTube/YouTubeClientTests.cs b/test/Podsync.Tests/Services/Videos/YouTube/YouTubeClientTests.cs new file mode 100644 index 0000000..1f5cfdd --- /dev/null +++ b/test/Podsync.Tests/Services/Videos/YouTube/YouTubeClientTests.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Linq; +using Podsync.Services.Links; +using Podsync.Services.Videos.YouTube; +using Xunit; + +namespace Podsync.Tests.Services.Videos.YouTube +{ + public class YouTubeClientTests : TestBase, IDisposable + { + private readonly YouTubeClient _client; + + public YouTubeClientTests() + { + _client = new YouTubeClient(new LinkService(), Options); + } + + [Fact] + public async Task GetChannelTest() + { + var query = new ChannelQuery + { + ChannelId = "UC0JB7TSe49lg56u6qH8y_MQ" + }; + + var list = await _client.GetChannels(query); + var channel = list.Single(); + + Assert.Equal(query.ChannelId, channel.ChannelId); + Assert.Equal("GDC", channel.Title); + Assert.False(string.IsNullOrEmpty(channel.Description)); + Assert.NotEqual(DateTime.MinValue, channel.PublishedAt); + Assert.NotNull(channel.Thumbnail); + } + + [Fact] + public async Task GetPlaylistsTest() + { + var query = new PlaylistQuery + { + PlaylistId = "PL2e4mYbwSTbbiX2uwspn0xiYb8_P_cTAr", + Count = 1 + }; + + var list = await _client.GetPlaylists(query); + var playlist = list.Single(); + + Assert.Equal("PL2e4mYbwSTbbiX2uwspn0xiYb8_P_cTAr", playlist.PlaylistId); + Assert.Equal("UC0JB7TSe49lg56u6qH8y_MQ", playlist.ChannelId); + Assert.Equal("GDC: Postmortems", playlist.Title); + Assert.Equal(new Uri("https://youtube.com/playlist?list=PL2e4mYbwSTbbiX2uwspn0xiYb8_P_cTAr"), playlist.Link); + Assert.NotNull(playlist.Thumbnail); + Assert.Equal(new DateTime(2015, 06, 29, 16, 58, 34), playlist.PublishedAt); + } + + [Fact] + public async Task GetVideosTest() + { + var query = new VideoQuery + { + Id = "OlYH4gDi0Sk,kkcKnWrCZ7k" + }; + + var response = await _client.GetVideos(query); + var list = response as IList