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