1
0
mirror of https://github.com/mxpv/podsync.git synced 2024-05-11 05:55:04 +00:00

Add Patreon integration

This commit is contained in:
Maksym Pavlenko
2016-12-24 13:50:11 -08:00
parent 7629b42cf5
commit 5332c13a2a
24 changed files with 427 additions and 52 deletions

View File

@@ -23,6 +23,7 @@ namespace Podsync.Controllers
}
// Main video download endpoint, don't forget to reflect any changes in LinkService.Download
[HttpGet]
[Route("{feedId}/{videoId}/")]
public async Task<IActionResult> Download(string feedId, string videoId)
{
@@ -31,7 +32,7 @@ namespace Podsync.Controllers
var url = _linkService.Make(new LinkInfo
{
Provider = metadata.Provider,
LinkType = metadata.LinkType,
LinkType = LinkType.Video,
Id = videoId
});

View File

@@ -50,6 +50,12 @@ namespace Podsync.Controllers
PageSize = request.PageSize ?? DefaultPageSize
};
if (!User.EnablePatreonFeatures())
{
feed.Quality = ResolveType.VideoHigh;
feed.PageSize = DefaultPageSize;
}
return _storageService.Save(feed);
}

View File

@@ -0,0 +1,46 @@
using System.Linq;
using System.Security.Claims;
using Podsync.Services.Patreon;
namespace Podsync.Helpers
{
public static class UserExtensions
{
private const string OwnerId = "2822191";
public static bool EnablePatreonFeatures(this ClaimsPrincipal user)
{
if (!user.Identity.IsAuthenticated)
{
return false;
}
if (user.GetClaim(ClaimTypes.NameIdentifier) == OwnerId)
{
return true;
}
const int MinAmountCents = 100;
int amount;
if (int.TryParse(user.GetClaim(PatreonConstants.AmountDonated), out amount))
{
return amount >= MinAmountCents;
}
return false;
}
public static string GetName(this ClaimsPrincipal claimsPrincipal)
{
return claimsPrincipal.GetClaim(ClaimTypes.Name)
?? claimsPrincipal.GetClaim(ClaimTypes.Email)
?? "noname :(";
}
private static string GetClaim(this ClaimsPrincipal claimsPrincipal, string type)
{
return claimsPrincipal.Claims.FirstOrDefault(x => x.Type == type)?.Value;
}
}
}

View File

@@ -21,11 +21,11 @@ namespace Podsync.Services.Builder
get { throw new NotSupportedException(); }
}
public override Task<Rss> Query(FeedMetadata feed)
public override Task<Rss> Query(string feedId, FeedMetadata feed)
{
if (feed.Provider == Provider.YouTube)
{
return _youTubeBuilder.Query(feed);
return _youTubeBuilder.Query(feedId, feed);
}
throw new NotSupportedException("Not supported provider");

View File

@@ -8,6 +8,6 @@ namespace Podsync.Services.Builder
{
Task<Rss> Query(string feedId);
Task<Rss> Query(FeedMetadata feed);
Task<Rss> Query(string feedId, FeedMetadata feed);
}
}

View File

@@ -20,9 +20,9 @@ namespace Podsync.Services.Builder
{
var metadata = await _storageService.Load(feedId);
return await Query(metadata);
return await Query(feedId, metadata);
}
public abstract Task<Rss> Query(FeedMetadata metadata);
public abstract Task<Rss> Query(string feedId, FeedMetadata metadata);
}
}

View File

@@ -24,7 +24,7 @@ namespace Podsync.Services.Builder
public override Provider Provider { get; } = Provider.YouTube;
public override async Task<Rss> Query(FeedMetadata metadata)
public override async Task<Rss> Query(string feedId, FeedMetadata metadata)
{
if (metadata.Provider != Provider.YouTube)
{
@@ -57,7 +57,7 @@ namespace Podsync.Services.Builder
// Get video descriptions
var videos = await _youTube.GetVideos(new VideoQuery { Id = string.Join(",", ids) });
channel.Items = videos.Select(x => MakeItem(x, metadata));
channel.Items = videos.Select(x => MakeItem(x, feedId, metadata));
var rss = new Rss
{
@@ -103,7 +103,7 @@ namespace Podsync.Services.Builder
};
}
private Item MakeItem(Video video, FeedMetadata feed)
private Item MakeItem(Video video, string feedId, FeedMetadata feed)
{
return new Item
{
@@ -116,7 +116,7 @@ namespace Podsync.Services.Builder
{
Length = video.Size,
MediaType = SelectMediaType(feed.Quality),
Url = _linkService.Download(feed.Id, video.VideoId)
Url = _linkService.Download(feedId, video.VideoId)
}
};
}

View File

@@ -0,0 +1,17 @@
using System;
namespace Podsync.Services.Patreon.Contracts
{
public class Pledge
{
public string Id { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime DeclinedSince { get; set; }
public int AmountCents { get; set; }
public int PledgeCapCents { get; set; }
}
}

View File

@@ -0,0 +1,23 @@
using System.Collections.Generic;
using System.Linq;
namespace Podsync.Services.Patreon.Contracts
{
public class User
{
public User()
{
Pledges = Enumerable.Empty<Pledge>();
}
public string Id { get; set; }
public string Email { get; set; }
public string Name { get; set; }
public string Url { get; set; }
public IEnumerable<Pledge> Pledges { get; set; }
}
}

View File

@@ -0,0 +1,10 @@
using System;
using System.Threading.Tasks;
namespace Podsync.Services.Patreon
{
public interface IPatreonApi : IDisposable
{
Task<dynamic> FetchProfile(Tokens tokens);
}
}

View File

@@ -0,0 +1,46 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
namespace Podsync.Services.Patreon
{
public sealed class PatreonApi : IPatreonApi
{
private readonly HttpClient _client;
public PatreonApi()
{
_client = new HttpClient
{
BaseAddress = new Uri("https://api.patreon.com/oauth2/api/")
};
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer");
}
public Task<dynamic> FetchProfile(Tokens tokens)
{
return Query("current_user", tokens);
}
public void Dispose()
{
_client.Dispose();
}
private async Task<dynamic> Query(string path, Tokens tokens)
{
var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
var response = await _client.SendAsync(request);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
return JObject.Parse(json);
}
}
}

View File

@@ -0,0 +1,13 @@
namespace Podsync.Services.Patreon
{
public static class PatreonConstants
{
public const string AuthenticationScheme = "Patreon";
public const string AuthorizationEndpoint = "https://www.patreon.com/oauth2/authorize";
public const string TokenEndpoint = "https://api.patreon.com/oauth2/token";
public const string AmountDonated = "Patreon/" + nameof(AmountDonated);
}
}

View File

@@ -0,0 +1,72 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Podsync.Services.Patreon.Contracts;
namespace Podsync.Services.Patreon
{
public static class PatreonExtensions
{
public static async Task<User> FetchUserAndPledges(this IPatreonApi api, Tokens tokens)
{
var resp = await api.FetchProfile(tokens);
dynamic userAttrs = resp.data.attributes;
var user = new User
{
Id = resp.data.id,
Email = userAttrs.email,
Name = userAttrs.first_name ?? userAttrs.full_name,
Url = userAttrs.url,
Pledges = ParsePledges(resp)
};
return user;
}
private static IEnumerable<Pledge> ParsePledges(dynamic resp)
{
dynamic pledges = resp.data.relationships.pledges.data;
foreach (var pledge in pledges)
{
var id = pledge.id;
var type = pledge.type;
foreach (var include in resp.included)
{
if (include.id == id && include.type == type)
{
dynamic attrs = include.attributes;
yield return new Pledge
{
Id = include.id,
CreatedAt = ParseDate(attrs.created_at),
DeclinedSince = ParseDate(attrs.declined_since),
AmountCents = attrs.amount_cents,
PledgeCapCents = attrs.pledge_cap_cents
};
break;
}
}
}
}
private static DateTime ParseDate(object obj)
{
var date = obj?.ToString();
if (string.IsNullOrWhiteSpace(date))
{
return DateTime.MinValue;
}
var dateTime = DateTime.Parse(date);
return DateTime.SpecifyKind(dateTime, DateTimeKind.Utc);
}
}
}

View File

@@ -0,0 +1,9 @@
namespace Podsync.Services.Patreon
{
public struct Tokens
{
public string AccessToken { get; set; }
public string RefreshToken { get; set; }
}
}

View File

@@ -1,4 +1,6 @@
namespace Podsync.Services
using Podsync.Services.Patreon;
namespace Podsync.Services
{
public class PodsyncConfiguration
{
@@ -7,5 +9,11 @@
public string RedisConnectionString { get; set; }
public string BaseUrl { get; set; }
public string PatreonClientId { get; set; }
public string PatreonSecret { get; set; }
public Tokens CreatorTokens { get; set; }
}
}

View File

@@ -1,11 +1,18 @@
using Microsoft.AspNetCore.Builder;
using System.Linq;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OAuth;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Authentication;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Podsync.Services;
using Podsync.Services.Builder;
using Podsync.Services.Links;
using Podsync.Services.Patreon;
using Podsync.Services.Resolver;
using Podsync.Services.Storage;
using Podsync.Services.Videos.YouTube;
@@ -43,8 +50,12 @@ namespace Podsync
services.AddSingleton<IResolverService, YtdlWrapper>();
services.AddSingleton<IStorageService, RedisStorage>();
services.AddSingleton<IRssBuilder, CompositeRssBuilder>();
services.AddSingleton<IPatreonApi, PatreonApi>();
// Add framework services.
// Add authentication services
services.AddAuthentication(config => config.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme);
// Add framework services
services.AddMvc();
}
@@ -62,6 +73,77 @@ namespace Podsync
app.UseStaticFiles();
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AutomaticAuthenticate = true,
AutomaticChallenge = true,
LoginPath = new PathString("/login"),
LogoutPath = new PathString("/logout")
});
// Patreon authentication
app.UseOAuthAuthentication(new OAuthOptions
{
AuthenticationScheme = PatreonConstants.AuthenticationScheme,
ClientId = Configuration[$"Podsync:{nameof(PodsyncConfiguration.PatreonClientId)}"],
ClientSecret = Configuration[$"Podsync:{nameof(PodsyncConfiguration.PatreonSecret)}"],
CallbackPath = new PathString("/oauth-patreon"),
AuthorizationEndpoint = PatreonConstants.AuthorizationEndpoint,
TokenEndpoint = PatreonConstants.TokenEndpoint,
SaveTokens = true,
Scope = { "users", "pledges-to-me", "my-campaign" },
Events = new OAuthEvents
{
OnCreatingTicket = async context =>
{
var patreonApi = app.ApplicationServices.GetService<IPatreonApi>();
var tokens = new Tokens
{
AccessToken = context.AccessToken,
RefreshToken = context.RefreshToken
};
var user = await patreonApi.FetchUserAndPledges(tokens);
context.Identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id));
context.Identity.AddClaim(new Claim(ClaimTypes.Name, user.Name));
context.Identity.AddClaim(new Claim(ClaimTypes.Email, user.Email));
context.Identity.AddClaim(new Claim(ClaimTypes.Uri, user.Url));
var amountCents = user.Pledges.Sum(x => x.AmountCents);
context.Identity.AddClaim(new Claim(PatreonConstants.AmountDonated, amountCents.ToString()));
}
}
});
app.Map("/login", builder =>
{
builder.Run(async context =>
{
// Return a challenge to invoke the Patreon authentication scheme
await context.Authentication.ChallengeAsync(PatreonConstants.AuthenticationScheme, new AuthenticationProperties { RedirectUri = "/" });
});
});
app.Map("/logout", builder =>
{
builder.Run(async context =>
{
// Sign the user out of the authentication middleware (i.e. it will clear the Auth cookie)
await context.Authentication.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
// Redirect the user to the home page after signing out
context.Response.Redirect("/");
});
});
app.UseMvc(routes =>
{
routes.MapRoute("default", "{controller=Home}/{action=Index}/{id?}");

View File

@@ -1,4 +1,8 @@

@using Podsync.Helpers
@{
var enableFeatures = User.EnablePatreonFeatures();
}
<div class="title">
<h1>Podsync</h1>
@@ -8,16 +12,22 @@
</h2>
<div class="login-block">
<h5>Login to unlock Patreon features <i class="fa fa-question-circle" aria-hidden="true"></i></h5>
<a href="#">
<i class="fa fa-facebook" aria-hidden="true"></i>
</a>
<a href="#">
<i class="fa fa-twitter" aria-hidden="true"></i>
</a>
<a href="#">
<i class="fa fa-google-plus" aria-hidden="true"></i>
</a>
@if (User.Identity.IsAuthenticated)
{
<p>
Yo, <i class="fa fa-user-circle" aria-hidden="true"></i> @User.GetName() ( <a href="~/logout"><i class="fa fa-sign-out" aria-hidden="true"></i>logout</a>)
</p>
}
else
{
<h5>
<a href="~/login">
Login with Patreon to unlock features
<i class="fa fa-question-circle master-tooltip" aria-hidden="true" title="We have added advanced features for those who supported development. Login with your Patreon account to unlock them.">
</i>
</a>
</h5>
}
</div>
</div>
@@ -34,11 +44,22 @@
<div class="controls">
<p>
<i id="control-icon" class="fa fa-question-circle" aria-hidden="true"></i>
<span id="control-panel" class="locked">
@if (enableFeatures)
{
<i class="fa fa-wrench" aria-hidden="true"></i>
}
else
{
<i class="fa fa-question-circle master-tooltip"
aria-hidden="true"
title="This features are available for patrons only. You may support us and unlock this features"></i>
}
<span id="control-panel" class="@(!enableFeatures ? "locked" : "")">
Selected format <a class="selected-option" id="video-format">video</a> <a id="audio-format">audio</a>,
quality <a class="selected-option" id="best-quality">best</a> <a id="worst-quality">worst</a>,
</span>
<span class="locked" style="text-decoration: line-through">
<i class="fa fa-question-circle master-tooltip" title="Still under development. We will keep you updated via Patreon's news feed" aria-hidden="true"></i>
episode count <a class="selected-option">50</a> <a>100</a> <a>150</a>

View File

@@ -48,8 +48,7 @@
<script src="~/js/site.min.js" asp-append-version="true">
</script>
</environment>
<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),

View File

@@ -10,6 +10,9 @@
"Podsync": {
"YouTubeApiKey": "",
"RedisConnectionString": "localhost"
"RedisConnectionString": "localhost",
"BaseUrl": "",
"PatreonClientId": "",
"PatreonSecret": ""
}
}

View File

@@ -2,6 +2,8 @@
"dependencies": {
"Google.Apis.YouTube.v3": "1.16.0.582",
"Hashids.net": "1.2.2",
"Microsoft.AspNetCore.Authentication.Cookies": "1.0.0",
"Microsoft.AspNetCore.Authentication.OAuth": "1.0.0",
"Microsoft.AspNetCore.Diagnostics": "1.0.0",
"Microsoft.AspNetCore.Mvc": "1.0.0",
"Microsoft.AspNetCore.Razor.Tools": {

View File

@@ -64,12 +64,13 @@ a {
}
.login-block {
color: #EB8C11;
font-size: 1.3em;
color: #e18712;
font-size: 1em;
}
.login-block a {
margin-right: 0.5em;
font-size: 1.2em;
font-weight: normal;
}
/* Main block + input */

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -77,23 +77,6 @@ $(function () {
Control panel
*/
function lockControls(lock) {
if (lock) {
$('#control-panel').addClass('locked');
$('#control-icon')
.removeClass('fa-wrench')
.addClass('fa-question-circle master-tooltip')
.attr('title', 'This features are available for patrons only (please, login with your Patreon account). You may support us and unlock this features');
} else {
$('#control-panel')
.removeClass('locked');
$('#control-icon')
.removeClass('fa-question-circle master-tooltip')
.addClass('fa-wrench')
.removeAttr('title');
}
}
function isLocked() {
return $('#control-panel').hasClass('locked');
}
@@ -118,6 +101,17 @@ $(function () {
$('#best-quality, #worst-quality').toggleClass('selected-option');
}
function getQuality() {
var isAudio = $('#audio-format').hasClass('selected-option');
var isWorst = $('#worst-quality').hasClass('selected-option');
if (isAudio) {
return isWorst ? 'AudioLow' : 'AudioHigh';
} else {
return isWorst ? 'VideoLow' : 'VideoHigh';
}
}
/* Modal */
function closeModal() {
@@ -164,7 +158,7 @@ $(function () {
$('#get-link').click(function(e) {
var url = $('#url-input').val();
createFeed({ url: url, quality: 'VideoHigh' }, displayLink);
createFeed({ url: url, quality: getQuality() }, displayLink);
e.preventDefault();
});
@@ -192,6 +186,4 @@ $(function () {
}
e.stopPropagation();
});
lockControls(true);
});

View File

@@ -0,0 +1,24 @@
using System.Threading.Tasks;
using Podsync.Services.Patreon;
using Xunit;
namespace Podsync.Tests.Services.Patreon
{
public class PatreonApiTests : TestBase
{
private readonly IPatreonApi _api = new PatreonApi();
private Tokens Tokens => Configuration.CreatorTokens;
[Fact]
public async Task FetchProfileTest()
{
var user = await _api.FetchUserAndPledges(Tokens);
Assert.Equal("2822191", user.Id);
Assert.Equal("pavlenko.maksym@gmail.com", user.Email);
Assert.Equal("https://www.patreon.com/podsync", user.Url);
Assert.Equal("Max", user.Name);
}
}
}