Remove ASP.NET app
@@ -1,62 +0,0 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio 15
|
||||
VisualStudioVersion = 15.0.26403.7
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{3EF2294C-C807-46F3-B3F4-98B7F30688D0}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{15355284-4B88-4EF3-B580-20907F343E43}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
.gitignore = .gitignore
|
||||
docker-compose.yml = docker-compose.yml
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Podsync", "src\Podsync\Podsync.csproj", "{C9283472-E95E-4517-AE3B-3E276B9851E4}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{D94416F2-31EA-4D38-A3D0-BB74180687D4}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Podsync.Tests", "test\Podsync.Tests\Podsync.Tests.csproj", "{81ABAF5F-4CB6-42A6-8EAA-19EB9BAF66AA}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{C9283472-E95E-4517-AE3B-3E276B9851E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C9283472-E95E-4517-AE3B-3E276B9851E4}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C9283472-E95E-4517-AE3B-3E276B9851E4}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C9283472-E95E-4517-AE3B-3E276B9851E4}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C9283472-E95E-4517-AE3B-3E276B9851E4}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C9283472-E95E-4517-AE3B-3E276B9851E4}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C9283472-E95E-4517-AE3B-3E276B9851E4}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C9283472-E95E-4517-AE3B-3E276B9851E4}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C9283472-E95E-4517-AE3B-3E276B9851E4}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C9283472-E95E-4517-AE3B-3E276B9851E4}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C9283472-E95E-4517-AE3B-3E276B9851E4}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C9283472-E95E-4517-AE3B-3E276B9851E4}.Release|x86.Build.0 = Release|Any CPU
|
||||
{81ABAF5F-4CB6-42A6-8EAA-19EB9BAF66AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{81ABAF5F-4CB6-42A6-8EAA-19EB9BAF66AA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{81ABAF5F-4CB6-42A6-8EAA-19EB9BAF66AA}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{81ABAF5F-4CB6-42A6-8EAA-19EB9BAF66AA}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{81ABAF5F-4CB6-42A6-8EAA-19EB9BAF66AA}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{81ABAF5F-4CB6-42A6-8EAA-19EB9BAF66AA}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{81ABAF5F-4CB6-42A6-8EAA-19EB9BAF66AA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{81ABAF5F-4CB6-42A6-8EAA-19EB9BAF66AA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{81ABAF5F-4CB6-42A6-8EAA-19EB9BAF66AA}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{81ABAF5F-4CB6-42A6-8EAA-19EB9BAF66AA}.Release|x64.Build.0 = Release|Any CPU
|
||||
{81ABAF5F-4CB6-42A6-8EAA-19EB9BAF66AA}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{81ABAF5F-4CB6-42A6-8EAA-19EB9BAF66AA}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{C9283472-E95E-4517-AE3B-3E276B9851E4} = {3EF2294C-C807-46F3-B3F4-98B7F30688D0}
|
||||
{81ABAF5F-4CB6-42A6-8EAA-19EB9BAF66AA} = {D94416F2-31EA-4D38-A3D0-BB74180687D4}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -1,8 +0,0 @@
|
||||
# Podsync
|
||||
Turn YouTube channels into podcast feeds
|
||||
|
||||
## Redis configuration notes
|
||||
- [Set overcommit_memory=1 on CoreOS](https://gist.github.com/PyYoshi/c7b271c8ba80ea350376)
|
||||
- [Set somaxconn=1024 on CoreOS](https://gist.github.com/PyYoshi/55c6953b7e8267d81daf)
|
||||
- [Tweak Redis](https://easyengine.io/tutorials/redis/)
|
||||
- [Performance tips for Redis Cache Server](https://www.techandme.se/performance-tips-for-redis-cache-server/)
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"directory": "wwwroot/lib"
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Podsync.Helpers;
|
||||
using Podsync.Services;
|
||||
using Podsync.Services.Links;
|
||||
using Podsync.Services.Rss;
|
||||
using Podsync.Services.Rss.Contracts;
|
||||
using Podsync.Services.Storage;
|
||||
|
||||
namespace Podsync.Controllers
|
||||
{
|
||||
[Route("feed")]
|
||||
[ServiceFilter(typeof(HandleExceptionAttribute), IsReusable = true)]
|
||||
public class FeedController : Controller
|
||||
{
|
||||
private static readonly IDictionary<string, string> Extensions = new Dictionary<string, string>
|
||||
{
|
||||
["video/mp4"] = "mp4",
|
||||
["audio/mp4"] = "m4a"
|
||||
};
|
||||
|
||||
private readonly ILinkService _linkService;
|
||||
private readonly IFeedService _feedService;
|
||||
|
||||
public FeedController(IRssBuilder rssBuilder, ILinkService linkService, IFeedService feedService)
|
||||
{
|
||||
_linkService = linkService;
|
||||
_feedService = feedService;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("create")]
|
||||
[ValidateModelState]
|
||||
public async Task<Uri> Create([FromBody] CreateFeedRequest request)
|
||||
{
|
||||
var linkInfo = _linkService.Parse(new Uri(request.Url));
|
||||
|
||||
var feed = new FeedMetadata
|
||||
{
|
||||
Provider = linkInfo.Provider,
|
||||
LinkType = linkInfo.LinkType,
|
||||
Id = linkInfo.Id,
|
||||
Quality = request.Quality ?? Constants.DefaultFormat,
|
||||
PageSize = request.PageSize ?? Constants.DefaultPageSize
|
||||
};
|
||||
|
||||
// Check if user eligible for Patreon features
|
||||
var allowFeatures = User.EnablePatreonFeatures();
|
||||
if (allowFeatures)
|
||||
{
|
||||
feed.PatreonId = User.GetClaim(ClaimTypes.NameIdentifier);
|
||||
}
|
||||
else
|
||||
{
|
||||
feed.Quality = Constants.DefaultFormat;
|
||||
feed.PageSize = Constants.DefaultPageSize;
|
||||
}
|
||||
|
||||
var feedId = await _feedService.Create(feed);
|
||||
var url = _linkService.Feed(Request.GetBaseUrl(), feedId);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Route("~/{feedId:length(4, 6)}")]
|
||||
[ValidateModelState]
|
||||
public async Task<IActionResult> Feed([Required] string feedId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var body = await _feedService.Get(feedId, FixLinks);
|
||||
return Content(body, "application/rss+xml; charset=UTF-8");
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound($"ERROR: No feed with id {feedId}");
|
||||
}
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
private void FixLinks(string feedId, Feed feed)
|
||||
{
|
||||
var selfHost = Request.GetBaseUrl();
|
||||
|
||||
// Set atom link to this feed
|
||||
// See https://validator.w3.org/feed/docs/warning/MissingAtomSelfLink.html
|
||||
var selfLink = new Uri(selfHost, Request.Path);
|
||||
feed.Channels.ForEach(x => x.AtomLink = selfLink);
|
||||
|
||||
// No magic here, just make download links to DownloadController.Download
|
||||
feed.Channels.SelectMany(x => x.Items).ForEach(item =>
|
||||
{
|
||||
var ext = Extensions[item.ContentType];
|
||||
item.DownloadLink = new Uri(selfHost, $"download/{feedId}/{item.Id}.{ext}");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Podsync.Controllers
|
||||
{
|
||||
public class HomeController : Controller
|
||||
{
|
||||
public IActionResult Index()
|
||||
{
|
||||
return View();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Podsync.Services.Resolver;
|
||||
using Podsync.Services.Storage;
|
||||
|
||||
namespace Podsync.Controllers
|
||||
{
|
||||
public class StatusController : Controller
|
||||
{
|
||||
private const string ErrorStatus = "ERROR";
|
||||
|
||||
private readonly IStorageService _storageService;
|
||||
private readonly IResolverService _resolverService;
|
||||
|
||||
public StatusController(IStorageService storageService, IResolverService resolverService)
|
||||
{
|
||||
_storageService = storageService;
|
||||
_resolverService = resolverService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Route("~/status")]
|
||||
public async Task<string> Index()
|
||||
{
|
||||
var storageStatus = ErrorStatus;
|
||||
|
||||
try
|
||||
{
|
||||
var time = await _storageService.Ping();
|
||||
storageStatus = time.ToString();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Nothing to do
|
||||
}
|
||||
|
||||
var resolverStatus = _resolverService.Version ?? ErrorStatus;
|
||||
|
||||
return $"Path: {Request.Path}\r\n" +
|
||||
$"Redis: {storageStatus}\r\n" +
|
||||
$"Resolve: {resolverStatus}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
FROM microsoft/aspnetcore-build AS build
|
||||
WORKDIR /workspace
|
||||
COPY . .
|
||||
RUN dotnet restore
|
||||
RUN dotnet publish --configuration release --output ./bin/
|
||||
|
||||
FROM microsoft/aspnetcore:1.1
|
||||
ENV ASPNETCORE_URLS http://*:8080
|
||||
ENV ASPNETCORE_ENVIRONMENT Production
|
||||
WORKDIR /app
|
||||
COPY --from=build /workspace/bin/ .
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["dotnet", "Podsync.dll"]
|
||||
@@ -1,76 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Podsync.Helpers
|
||||
{
|
||||
internal static class EnumerableExtensions
|
||||
{
|
||||
public static IEnumerable<IEnumerable<T>> Chunk<T>(this IEnumerable<T> source, int chunkSize)
|
||||
{
|
||||
if (chunkSize < 1)
|
||||
{
|
||||
throw new ArgumentException("Chunk size must be positive", nameof(chunkSize));
|
||||
}
|
||||
|
||||
while (source.Any())
|
||||
{
|
||||
yield return source.Take(chunkSize);
|
||||
source = source.Skip(chunkSize);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool SafeAny<T>(this IEnumerable<T> source)
|
||||
{
|
||||
if (source == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (source.Any())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void ForEach<T>(this IEnumerable<T> source, Action<T> action)
|
||||
{
|
||||
if (source == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var item in source)
|
||||
{
|
||||
action(item);
|
||||
}
|
||||
}
|
||||
|
||||
public static void AddTo<T>(this IEnumerable<T> collection, List<T> target)
|
||||
{
|
||||
target.AddRange(collection);
|
||||
}
|
||||
|
||||
public static void SafeForEach<T>(this IEnumerable<T> source, Action<T> action)
|
||||
{
|
||||
if (source == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var item in source)
|
||||
{
|
||||
try
|
||||
{
|
||||
action(item);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Nothing to do
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Podsync.Services;
|
||||
using Podsync.Services.Resolver;
|
||||
|
||||
namespace Podsync.Helpers
|
||||
{
|
||||
public static class Extensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a fully qualified URL to the specified content by using the specified content path.
|
||||
/// Converts a virtual (relative) path to an application absolute path.
|
||||
/// See http://stackoverflow.com/questions/30755827/getting-absolute-urls-using-asp-net-core-mvc-6
|
||||
/// </summary>
|
||||
/// <param name="url"></param>
|
||||
/// <param name="contentPath"></param>
|
||||
/// <returns></returns>
|
||||
public static string AbsoluteContent(this IUrlHelper url, string contentPath)
|
||||
{
|
||||
var request = url.ActionContext.HttpContext.Request;
|
||||
var baseUri = new Uri($"{request.Scheme}://{request.Host.Value}");
|
||||
var fullUri = new Uri(baseUri, url.Content(contentPath));
|
||||
|
||||
return fullUri.ToString();
|
||||
}
|
||||
|
||||
public static Uri GetBaseUrl(this HttpRequest request)
|
||||
{
|
||||
return new Uri($"{request.Scheme}://{request.Host}");
|
||||
}
|
||||
|
||||
private const string OwnerId = "2822191";
|
||||
|
||||
/// <summary>
|
||||
/// Check if user eligible for advanced features
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
/// <returns></returns>
|
||||
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(Constants.Patreon.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 :(";
|
||||
}
|
||||
|
||||
public static string GetClaim(this ClaimsPrincipal claimsPrincipal, string type)
|
||||
{
|
||||
return claimsPrincipal.Claims.FirstOrDefault(x => x.Type == type)?.Value;
|
||||
}
|
||||
|
||||
public static bool IsAudio(this ResolveFormat format)
|
||||
{
|
||||
return format == ResolveFormat.AudioHigh || format == ResolveFormat.AudioLow;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Podsync.Services;
|
||||
|
||||
namespace Podsync.Helpers
|
||||
{
|
||||
public class HandleExceptionAttribute : ExceptionFilterAttribute
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public HandleExceptionAttribute(ILogger<HandleExceptionAttribute> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public override void OnException(ExceptionContext context)
|
||||
{
|
||||
var exception = context.Exception;
|
||||
if (exception is ArgumentNullException || exception is ArgumentException)
|
||||
{
|
||||
context.Result = new BadRequestObjectResult(exception.Message);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Result = new StatusCodeResult((int)HttpStatusCode.InternalServerError);
|
||||
|
||||
_logger.LogCritical(Constants.Events.UnhandledError, context.Exception, "Unhandled exception");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Podsync.Helpers
|
||||
{
|
||||
internal static class ServiceProviderExtensions
|
||||
{
|
||||
public static T CreateInstance<T>(this IServiceProvider serviceProvider)
|
||||
{
|
||||
return serviceProvider.CreateInstance<T>(typeof(T));
|
||||
}
|
||||
|
||||
public static T CreateInstance<T>(this IServiceProvider serviceProvider, Type implementation)
|
||||
{
|
||||
return (T)serviceProvider.CreateInstance(implementation);
|
||||
}
|
||||
|
||||
public static object CreateInstance(this IServiceProvider serviceProvider, Type type)
|
||||
{
|
||||
return ActivatorUtilities.CreateInstance(serviceProvider, type);
|
||||
}
|
||||
|
||||
public static IEnumerable<Type> FindAllImplementationsOf(this IServiceProvider serviceProvider, Type interfaceType, Assembly assembly)
|
||||
{
|
||||
if (!interfaceType.GetTypeInfo().IsInterface)
|
||||
{
|
||||
throw new ArgumentException("T should be an interface");
|
||||
}
|
||||
|
||||
return GetLoadableTypes(assembly).Where(type => IsAssignableFrom(interfaceType, type));
|
||||
}
|
||||
|
||||
public static IEnumerable<Type> FindAllImplementationsOf<T>(this IServiceProvider serviceProvider, Assembly assembly)
|
||||
{
|
||||
return serviceProvider.FindAllImplementationsOf(typeof(T), assembly);
|
||||
}
|
||||
|
||||
private static bool IsAssignableFrom(Type interfaceType, Type serviceType)
|
||||
{
|
||||
var serviceTypeInfo = serviceType.GetTypeInfo();
|
||||
if (serviceTypeInfo.IsInterface || serviceTypeInfo.IsAbstract)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var interfaceTypeInfo = interfaceType.GetTypeInfo();
|
||||
if (!interfaceTypeInfo.IsGenericType)
|
||||
{
|
||||
return interfaceType.IsAssignableFrom(serviceType);
|
||||
}
|
||||
|
||||
return serviceType
|
||||
.GetInterfaces()
|
||||
.Where(type => type.GetTypeInfo().IsGenericType)
|
||||
.Any(type => type.GetGenericTypeDefinition() == interfaceType);
|
||||
}
|
||||
|
||||
private static IEnumerable<Type> GetLoadableTypes(Assembly assembly)
|
||||
{
|
||||
try
|
||||
{
|
||||
return assembly.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException e)
|
||||
{
|
||||
return e.Types.Where(x => x != null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace Podsync.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Validate the model state prior to action execution.
|
||||
/// See http://www.strathweb.com/2015/06/action-filters-service-filters-type-filters-asp-net-5-mvc-6/
|
||||
/// </summary>
|
||||
public class ValidateModelStateAttribute : ActionFilterAttribute
|
||||
{
|
||||
private readonly bool _validateNotNull;
|
||||
|
||||
public ValidateModelStateAttribute(bool validateNotNull = true)
|
||||
{
|
||||
_validateNotNull = validateNotNull;
|
||||
}
|
||||
|
||||
public override void OnActionExecuting(ActionExecutingContext context)
|
||||
{
|
||||
if (!context.ModelState.IsValid || _validateNotNull && context.ActionArguments.Any(p => p.Value == null))
|
||||
{
|
||||
context.Result = new BadRequestObjectResult(context.ModelState);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netcoreapp1.1</TargetFramework>
|
||||
<PreserveCompilationContext>true</PreserveCompilationContext>
|
||||
<AssemblyName>Podsync</AssemblyName>
|
||||
<OutputType>Exe</OutputType>
|
||||
<PackageId>Podsync</PackageId>
|
||||
<UserSecretsId>aspnet-Podsync-20161004104901</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="..\Shared\*.cs" Exclude="bin\**;obj\**;**\*.xproj;packages\**" />
|
||||
<Content Update="wwwroot\**\*;Views\**\*;Areas\**\Views;appsettings.json;web.config;Dockerfile;youtube-dl">
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Google.Apis.YouTube.v3">
|
||||
<Version>1.25.0.760</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Hashids.net" Version="1.2.2" />
|
||||
<PackageReference Include="MedallionShell" Version="1.2.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Cookies">
|
||||
<Version>1.1.2</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OAuth">
|
||||
<Version>1.1.2</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Diagnostics">
|
||||
<Version>1.1.2</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.HttpOverrides">
|
||||
<Version>1.1.2</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc">
|
||||
<Version>1.1.3</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Server.IISIntegration">
|
||||
<Version>1.1.2</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Server.Kestrel">
|
||||
<Version>1.1.2</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.StaticFiles">
|
||||
<Version>1.1.2</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.WebUtilities">
|
||||
<Version>1.1.2</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables">
|
||||
<Version>1.1.2</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json">
|
||||
<Version>1.1.2</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets">
|
||||
<Version>1.1.2</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging">
|
||||
<Version>1.1.2</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console">
|
||||
<Version>1.1.2</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug">
|
||||
<Version>1.1.2</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions">
|
||||
<Version>1.1.2</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.NETCore.App">
|
||||
<Version>1.1.1</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.VisualStudio.Web.BrowserLink.Loader" Version="14.1.0" />
|
||||
<PackageReference Include="StackExchange.Redis">
|
||||
<Version>1.2.3</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="System.Xml.XmlSerializer" Version="4.3.0" />
|
||||
</ItemGroup>
|
||||
<Target Name="PrecompileScript" BeforeTargets="BeforeBuild">
|
||||
<Exec Command="dotnet bundle" />
|
||||
</Target>
|
||||
<Target Name="PrepublishScript" BeforeTargets="PrepareForPublish">
|
||||
<Exec Command="bower install" />
|
||||
<Exec Command="dotnet bundle" />
|
||||
</Target>
|
||||
<ItemGroup>
|
||||
<DotNetCliToolReference Include="BundlerMinifier.Core" Version="2.2.301" />
|
||||
<DotNetCliToolReference Include="Microsoft.Extensions.SecretManager.Tools" Version="1.0.0-msbuild3-final" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,20 +0,0 @@
|
||||
using System.IO;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
|
||||
namespace Podsync
|
||||
{
|
||||
public class Program
|
||||
{
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
var host = new WebHostBuilder()
|
||||
.UseKestrel()
|
||||
.UseContentRoot(Directory.GetCurrentDirectory())
|
||||
.UseIISIntegration()
|
||||
.UseStartup<Startup>()
|
||||
.Build();
|
||||
|
||||
host.Run();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:56247/",
|
||||
"sslPort": 0
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Podsync": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "http://localhost:5000",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using Podsync.Services.Resolver;
|
||||
|
||||
namespace Podsync.Services
|
||||
{
|
||||
public static class Constants
|
||||
{
|
||||
public const int DefaultPageSize = 50;
|
||||
|
||||
public const ResolveFormat DefaultFormat = ResolveFormat.VideoHigh;
|
||||
|
||||
public static class Patreon
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
public static class Events
|
||||
{
|
||||
public const int RssError = 1;
|
||||
|
||||
public const int YtdlError = 2;
|
||||
|
||||
public const int UnhandledError = 3;
|
||||
}
|
||||
|
||||
public static class Cache
|
||||
{
|
||||
public const string VideosPrefix = "video_urls";
|
||||
|
||||
public const string FeedsPrefix = "feeds";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Podsync.Services.Resolver;
|
||||
|
||||
namespace Podsync.Services
|
||||
{
|
||||
public class CreateFeedRequest
|
||||
{
|
||||
[Required]
|
||||
[Url]
|
||||
public string Url { get; set; }
|
||||
|
||||
public ResolveFormat? Quality { get; set; }
|
||||
|
||||
[Range(50, 150)]
|
||||
public int? PageSize { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace Podsync.Services.Links
|
||||
{
|
||||
public interface ILinkService
|
||||
{
|
||||
LinkInfo Parse(Uri link);
|
||||
|
||||
Uri Make(LinkInfo info);
|
||||
|
||||
Uri Feed(Uri baseUrl, string feedId);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace Podsync.Services.Links
|
||||
{
|
||||
public struct LinkInfo
|
||||
{
|
||||
public string Id { get; set; }
|
||||
|
||||
public LinkType LinkType { get; set; }
|
||||
|
||||
public Provider Provider { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace Podsync.Services.Links
|
||||
{
|
||||
public class LinkService : ILinkService
|
||||
{
|
||||
private static readonly IDictionary<Provider, IDictionary<LinkType, string>> LinkFormats = new Dictionary<Provider, IDictionary<LinkType, string>>
|
||||
{
|
||||
[Provider.YouTube] = new Dictionary<LinkType, string>
|
||||
{
|
||||
[LinkType.Video] = "https://youtube.com/watch?v={0}",
|
||||
[LinkType.Channel] = "https://youtube.com/channel/{0}",
|
||||
[LinkType.Playlist] = "https://youtube.com/playlist?list={0}"
|
||||
},
|
||||
|
||||
[Provider.Vimeo] = new Dictionary<LinkType, string>
|
||||
{
|
||||
[LinkType.Channel] = "https://vimeo.com/channels/{0}",
|
||||
[LinkType.Group] = "https://vimeo.com/groups/{0}",
|
||||
[LinkType.User] = "https://vimeo.com/user{0}",
|
||||
[LinkType.Video] = "https://vimeo.com/{0}"
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
YouTube users, channels and playlists
|
||||
Test input:
|
||||
https://www.youtube.com/playlist?list=PLCB9F975ECF01953C
|
||||
https://www.youtube.com/channel/UC5XPnUk8Vvv_pWslhwom6Og
|
||||
https://www.youtube.com/user/fxigr1
|
||||
https://www.youtube.com/channel/UCrlakW-ewUT8sOod6Wmzyow/videos
|
||||
*/
|
||||
|
||||
private static readonly Regex YouTubeRegex = new Regex(@"^(?:https?://)?(?:www\.|m\.)?(?:youtube.com/)(?<type>user|channel|playlist|watch)/?(?<id>[-\w]+)?", RegexOptions.Compiled);
|
||||
|
||||
/*
|
||||
Vimeo groups, channels and users
|
||||
Test input:
|
||||
https://vimeo.com/groups/109
|
||||
http://vimeo.com/groups/109
|
||||
http://www.vimeo.com/groups/109
|
||||
https://vimeo.com/groups/109/videos/
|
||||
https://vimeo.com/channels/staffpicks
|
||||
https://vimeo.com/channels/staffpicks/146224925
|
||||
https://vimeo.com/awhitelabelproduct
|
||||
*/
|
||||
private static readonly Regex VimeoRegex = new Regex(@"^(?:https?://)?(?:www\.)?(?:vimeo.com/)(?<type>groups|channels)?/?(?<id>\w+)", RegexOptions.Compiled);
|
||||
|
||||
public LinkInfo Parse(Uri link)
|
||||
{
|
||||
if (link == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(link), "Link can't be null");
|
||||
}
|
||||
|
||||
var provider = Provider.Unknown;
|
||||
var linkType = LinkType.Unknown;
|
||||
|
||||
var id = string.Empty;
|
||||
|
||||
// YouTube
|
||||
var match = YouTubeRegex.Match(link.AbsoluteUri);
|
||||
if (match.Success)
|
||||
{
|
||||
provider = Provider.YouTube;
|
||||
|
||||
var type = match.Groups["type"]?.ToString();
|
||||
if (type == "user")
|
||||
{
|
||||
// https://www.youtube.com/user/fxigr1
|
||||
|
||||
id = match.Groups["id"]?.ToString();
|
||||
linkType = LinkType.User;
|
||||
}
|
||||
else if (type == "channel")
|
||||
{
|
||||
// https://www.youtube.com/channel/UC5XPnUk8Vvv_pWslhwom6Og
|
||||
|
||||
id = match.Groups["id"]?.ToString();
|
||||
linkType = LinkType.Channel;
|
||||
}
|
||||
else if (type == "playlist" || type == "watch")
|
||||
{
|
||||
// https://www.youtube.com/playlist?list=PLCB9F975ECF01953C
|
||||
// https://www.youtube.com/watch?v=otm9NaT9OWU&list=PLCB9F975ECF01953C
|
||||
|
||||
var qs = QueryHelpers.ParseQuery(link.Query);
|
||||
|
||||
StringValues list;
|
||||
if (qs.TryGetValue("list", out list))
|
||||
{
|
||||
id = list;
|
||||
}
|
||||
|
||||
linkType = LinkType.Playlist;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Vimeo
|
||||
match = VimeoRegex.Match(link.AbsoluteUri);
|
||||
if (match.Success)
|
||||
{
|
||||
provider = Provider.Vimeo;
|
||||
id = match.Groups["id"]?.ToString();
|
||||
|
||||
var type = match.Groups["type"]?.ToString();
|
||||
if (type == "groups")
|
||||
{
|
||||
// https://vimeo.com/groups/109
|
||||
|
||||
linkType = LinkType.Group;
|
||||
}
|
||||
else if (type == "channels")
|
||||
{
|
||||
// https://vimeo.com/channels/staffpicks
|
||||
|
||||
linkType = LinkType.Channel;
|
||||
}
|
||||
else
|
||||
{
|
||||
// https://vimeo.com/awhitelabelproduct
|
||||
|
||||
linkType = LinkType.User;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(id) || linkType == LinkType.Unknown || provider == Provider.Unknown)
|
||||
{
|
||||
throw new ArgumentException("Not supported provider or link format");
|
||||
}
|
||||
|
||||
return new LinkInfo
|
||||
{
|
||||
Id = id,
|
||||
LinkType = linkType,
|
||||
Provider = provider
|
||||
};
|
||||
}
|
||||
|
||||
public Uri Make(LinkInfo info)
|
||||
{
|
||||
if (info.Id == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(info.Id), "Id can't be empty");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var format = LinkFormats[info.Provider][info.LinkType];
|
||||
return new Uri(string.Format(format, info.Id));
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
throw new ArgumentException("Unsupported provider or link type", nameof(info), ex);
|
||||
}
|
||||
}
|
||||
|
||||
public Uri Feed(Uri baseUrl, string feedId)
|
||||
{
|
||||
return new Uri(baseUrl, feedId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace Podsync.Services.Links
|
||||
{
|
||||
public enum LinkType
|
||||
{
|
||||
Unknown = 0,
|
||||
Video,
|
||||
Playlist,
|
||||
User,
|
||||
Channel,
|
||||
Group
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Podsync.Services.Links
|
||||
{
|
||||
public enum Provider
|
||||
{
|
||||
Unknown = 0,
|
||||
YouTube,
|
||||
Vimeo,
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Podsync.Services.Patreon
|
||||
{
|
||||
public interface IPatreonApi : IDisposable
|
||||
{
|
||||
Task<dynamic> FetchProfile(Tokens tokens);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Podsync.Services.Patreon
|
||||
{
|
||||
public struct Tokens
|
||||
{
|
||||
public string AccessToken { get; set; }
|
||||
|
||||
public string RefreshToken { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using Podsync.Services.Patreon;
|
||||
|
||||
namespace Podsync.Services
|
||||
{
|
||||
public class PodsyncConfiguration
|
||||
{
|
||||
public string YouTubeApiKey { get; set; }
|
||||
|
||||
public string VimeoApiKey { get; set; }
|
||||
|
||||
public string RedisConnectionString { get; set; }
|
||||
|
||||
public string PatreonClientId { get; set; }
|
||||
|
||||
public string PatreonSecret { get; set; }
|
||||
|
||||
public Tokens CreatorTokens { get; set; }
|
||||
|
||||
public string RemoteResolverUrl { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Podsync.Services.Storage;
|
||||
|
||||
namespace Podsync.Services.Resolver
|
||||
{
|
||||
public abstract class CachedResolver : IResolverService
|
||||
{
|
||||
private readonly TimeSpan UrlExpiration = TimeSpan.FromHours(3);
|
||||
private readonly IStorageService _storageService;
|
||||
|
||||
protected CachedResolver(IStorageService storageService)
|
||||
{
|
||||
_storageService = storageService;
|
||||
}
|
||||
|
||||
public abstract string Version { get; }
|
||||
|
||||
public async Task<Uri> Resolve(Uri videoUrl, ResolveFormat format)
|
||||
{
|
||||
var id = videoUrl.GetHashCode().ToString();
|
||||
|
||||
// Check if this video URL was resolved within last 3 hours
|
||||
var value = await _storageService.GetCached(Constants.Cache.VideosPrefix, id);
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return new Uri(value);
|
||||
}
|
||||
|
||||
// Resolve and save to cache
|
||||
var uri = await ResolveInternal(videoUrl, format);
|
||||
await _storageService.Cache(Constants.Cache.VideosPrefix, id, uri.ToString(), UrlExpiration);
|
||||
|
||||
return uri;
|
||||
}
|
||||
|
||||
protected abstract Task<Uri> ResolveInternal(Uri videoUrl, ResolveFormat format);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Podsync.Services.Resolver
|
||||
{
|
||||
public interface IResolverService
|
||||
{
|
||||
string Version { get; }
|
||||
|
||||
Task<Uri> Resolve(Uri videoUrl, ResolveFormat format = ResolveFormat.VideoHigh);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Podsync.Services.Storage;
|
||||
|
||||
namespace Podsync.Services.Resolver
|
||||
{
|
||||
public class RemoteResolver : CachedResolver
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly HttpClient _client = new HttpClient();
|
||||
|
||||
public RemoteResolver(IStorageService storageService, IOptions<PodsyncConfiguration> options, ILogger<RemoteResolver> logger) : base(storageService)
|
||||
{
|
||||
_logger = logger;
|
||||
_client.BaseAddress = new Uri(options.Value.RemoteResolverUrl);
|
||||
|
||||
_logger.LogInformation($"Remote resolver URL: {_client.BaseAddress}");
|
||||
}
|
||||
|
||||
public override string Version { get; } = "Remote";
|
||||
|
||||
protected override async Task<Uri> ResolveInternal(Uri videoUrl, ResolveFormat format)
|
||||
{
|
||||
using(var response = await _client.GetAsync($"/resolve?url={videoUrl}&quality={format}"))
|
||||
{
|
||||
using(response.Content)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException(body);
|
||||
}
|
||||
|
||||
return new Uri(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace Podsync.Services.Resolver
|
||||
{
|
||||
public enum ResolveFormat
|
||||
{
|
||||
VideoHigh = 0,
|
||||
VideoLow,
|
||||
AudioHigh,
|
||||
AudioLow
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Podsync.Helpers;
|
||||
using Podsync.Services.Links;
|
||||
using Podsync.Services.Storage;
|
||||
|
||||
namespace Podsync.Services.Rss.Builders
|
||||
{
|
||||
// ReSharper disable once ClassNeverInstantiated.Global
|
||||
public class CompositeRssBuilder : RssBuilderBase
|
||||
{
|
||||
private readonly IDictionary<Provider, IRssBuilder> _builders;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public CompositeRssBuilder(IServiceProvider serviceProvider, IStorageService storageService, ILogger<CompositeRssBuilder> logger) : base(storageService)
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
// Find all RSS builders (all implementations of IRssBuilder), create instances and make dictionary for fast search by Provider type
|
||||
var buildTypes = serviceProvider.FindAllImplementationsOf<IRssBuilder>(Assembly.GetEntryAssembly()).Where(x => x != typeof(CompositeRssBuilder));
|
||||
var builders = buildTypes.Select(builderType => (IRssBuilder)serviceProvider.CreateInstance(builderType)).ToDictionary(builder => builder.Provider);
|
||||
|
||||
_logger.LogInformation($"Found {builders.Count} RSS builders");
|
||||
_builders = new ReadOnlyDictionary<Provider, IRssBuilder>(builders);
|
||||
}
|
||||
|
||||
public override Provider Provider => throw new NotSupportedException();
|
||||
|
||||
public override Task<Contracts.Feed> Query(FeedMetadata feed)
|
||||
{
|
||||
try
|
||||
{
|
||||
IRssBuilder builder;
|
||||
if (_builders.TryGetValue(feed.Provider, out builder))
|
||||
{
|
||||
return builder.Query(feed);
|
||||
}
|
||||
|
||||
throw new NotSupportedException("Not supported provider");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(Constants.Events.RssError, ex, "Failed to query RSS feed (id: {ID})", feed.Id);
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using System.Threading.Tasks;
|
||||
using Podsync.Services.Links;
|
||||
using Podsync.Services.Rss.Contracts;
|
||||
using Podsync.Services.Storage;
|
||||
|
||||
namespace Podsync.Services.Rss.Builders
|
||||
{
|
||||
public abstract class RssBuilderBase : IRssBuilder
|
||||
{
|
||||
private readonly IStorageService _storageService;
|
||||
|
||||
protected RssBuilderBase(IStorageService storageService)
|
||||
{
|
||||
_storageService = storageService;
|
||||
}
|
||||
|
||||
public abstract Provider Provider { get; }
|
||||
|
||||
public async Task<Contracts.Feed> Query(string feedId)
|
||||
{
|
||||
var metadata = await _storageService.Load(feedId);
|
||||
|
||||
return await Query(metadata);
|
||||
}
|
||||
|
||||
public abstract Task<Feed> Query(FeedMetadata metadata);
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Podsync.Services.Links;
|
||||
using Podsync.Services.Rss.Contracts;
|
||||
using Podsync.Services.Storage;
|
||||
using Podsync.Services.Videos.Vimeo;
|
||||
|
||||
namespace Podsync.Services.Rss.Builders
|
||||
{
|
||||
// ReSharper disable once ClassNeverInstantiated.Global
|
||||
public class VimeoRssBuilder : RssBuilderBase
|
||||
{
|
||||
private readonly IVimeoClient _client;
|
||||
|
||||
public VimeoRssBuilder(IStorageService storageService, IVimeoClient client) : base(storageService)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public override Provider Provider { get; } = Provider.Vimeo;
|
||||
|
||||
public override async Task<Feed> Query(FeedMetadata metadata)
|
||||
{
|
||||
var linkType = metadata.LinkType;
|
||||
|
||||
var id = metadata.Id;
|
||||
|
||||
var pageSize = metadata.PageSize;
|
||||
if (pageSize == 0)
|
||||
{
|
||||
pageSize = Constants.DefaultPageSize;
|
||||
}
|
||||
|
||||
Channel channel;
|
||||
if (linkType == LinkType.Channel)
|
||||
{
|
||||
channel = CreateChannel(await _client.Channel(id));
|
||||
channel.Items = CreateItems(await _client.ChannelVideos(id, pageSize));
|
||||
}
|
||||
else if (linkType == LinkType.Group)
|
||||
{
|
||||
channel = CreateChannel(await _client.Group(id));
|
||||
channel.Items = CreateItems(await _client.GroupVideos(id, pageSize));
|
||||
}
|
||||
else if (linkType == LinkType.User)
|
||||
{
|
||||
channel = CreateChannel(await _client.User(id));
|
||||
channel.Items = CreateItems(await _client.UserVideos(id, pageSize));
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new NotSupportedException("URL type is not supported");
|
||||
}
|
||||
|
||||
var rss = new Feed
|
||||
{
|
||||
Channels = new[] { channel }
|
||||
};
|
||||
|
||||
return rss;
|
||||
}
|
||||
|
||||
private static Channel CreateChannel(Group group)
|
||||
{
|
||||
return new Channel
|
||||
{
|
||||
Title = group.Name,
|
||||
Description = group.Description,
|
||||
Link = group.Link,
|
||||
PubDate = group.CreatedAt,
|
||||
Image = group.Thumbnail,
|
||||
Thumbnail = group.Thumbnail,
|
||||
Guid = group.Link.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static Channel CreateChannel(User user)
|
||||
{
|
||||
return new Channel
|
||||
{
|
||||
Title = user.Name,
|
||||
Description = user.Bio,
|
||||
Link = user.Link,
|
||||
PubDate = user.CreatedAt,
|
||||
Image = user.Thumbnail,
|
||||
Thumbnail = user.Thumbnail,
|
||||
Guid = user.Link.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static Item CreateItem(Video video)
|
||||
{
|
||||
return new Item
|
||||
{
|
||||
Id = video.Id,
|
||||
Title = video.Title,
|
||||
Description = video.Description,
|
||||
PubDate = video.CreatedAt,
|
||||
Link = video.Link,
|
||||
Duration = video.Duration,
|
||||
FileSize = video.Size,
|
||||
ContentType = "video/mp4",
|
||||
Author = video.Author
|
||||
};
|
||||
}
|
||||
|
||||
private static Item[] CreateItems(IEnumerable<Video> videos)
|
||||
{
|
||||
return videos.Select(CreateItem).ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Podsync.Services.Links;
|
||||
using Podsync.Services.Resolver;
|
||||
using Podsync.Services.Rss.Contracts;
|
||||
using Podsync.Services.Storage;
|
||||
using Podsync.Services.Videos.YouTube;
|
||||
using Channel = Podsync.Services.Rss.Contracts.Channel;
|
||||
using Video = Podsync.Services.Videos.YouTube.Video;
|
||||
|
||||
namespace Podsync.Services.Rss.Builders
|
||||
{
|
||||
public class YouTubeRssBuilder : RssBuilderBase
|
||||
{
|
||||
private static readonly Item[] NoVideos = new Item[0];
|
||||
|
||||
private readonly IYouTubeClient _youTube;
|
||||
|
||||
public YouTubeRssBuilder(IYouTubeClient youTube, IStorageService storageService) : base(storageService)
|
||||
{
|
||||
_youTube = youTube;
|
||||
}
|
||||
|
||||
public override Provider Provider { get; } = Provider.YouTube;
|
||||
|
||||
public override async Task<Feed> Query(FeedMetadata metadata)
|
||||
{
|
||||
if (metadata.Provider != Provider.YouTube)
|
||||
{
|
||||
throw new ArgumentException("Invalid provider");
|
||||
}
|
||||
|
||||
if (metadata.PageSize == 0)
|
||||
{
|
||||
metadata.PageSize = Constants.DefaultPageSize;
|
||||
}
|
||||
|
||||
var linkType = metadata.LinkType;
|
||||
|
||||
Channel channel;
|
||||
if (linkType == LinkType.Channel)
|
||||
{
|
||||
channel = await GetChannel(new ChannelQuery { ChannelId = metadata.Id });
|
||||
}
|
||||
else if (linkType == LinkType.User)
|
||||
{
|
||||
channel = await GetChannel(new ChannelQuery { Username = metadata.Id });
|
||||
}
|
||||
else if (linkType == LinkType.Playlist)
|
||||
{
|
||||
channel = await GetPlaylist(metadata.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new NotSupportedException("URL type is not supported");
|
||||
}
|
||||
|
||||
if (channel == null)
|
||||
{
|
||||
throw new ArgumentException("Invalid channel or playlist id");
|
||||
}
|
||||
|
||||
channel.Items = NoVideos;
|
||||
|
||||
// Get video ids from this playlist
|
||||
var ids = await _youTube.GetPlaylistItemIds(new PlaylistItemsQuery { PlaylistId = channel.Guid, Count = metadata.PageSize });
|
||||
|
||||
if (ids.Count > 0)
|
||||
{
|
||||
// Get video descriptions
|
||||
var videos = await _youTube.GetVideos(new VideoQuery { Ids = ids });
|
||||
|
||||
channel.Items = videos.Select(youtubeVideo => MakeItem(youtubeVideo, metadata)).ToArray();
|
||||
}
|
||||
|
||||
var rss = new Feed
|
||||
{
|
||||
Channels = new[] { channel }
|
||||
};
|
||||
|
||||
return rss;
|
||||
}
|
||||
|
||||
private async Task<Channel> GetChannel(ChannelQuery query)
|
||||
{
|
||||
var list = await _youTube.GetChannels(query);
|
||||
var item = list.FirstOrDefault();
|
||||
if (item == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var channel = MakeChannel(item);
|
||||
channel.Guid = item.PlaylistId;
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
private async Task<Channel> GetPlaylist(string playlistId)
|
||||
{
|
||||
var list = await _youTube.GetPlaylists(new PlaylistQuery { PlaylistId = playlistId });
|
||||
var item = list.FirstOrDefault();
|
||||
if (item == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var channel = MakeChannel(item);
|
||||
channel.Guid = item.PlaylistId;
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
private static Channel MakeChannel(YouTubeItem item)
|
||||
{
|
||||
return new Channel
|
||||
{
|
||||
Title = item.Title,
|
||||
Description = item.Description,
|
||||
Link = item.Link,
|
||||
PubDate = item.PublishedAt,
|
||||
Image = item.Thumbnail,
|
||||
Thumbnail = item.Thumbnail,
|
||||
};
|
||||
}
|
||||
|
||||
private Item MakeItem(Video video, FeedMetadata feed)
|
||||
{
|
||||
string contentType = GetContentType(feed.Quality);
|
||||
|
||||
return new Item
|
||||
{
|
||||
Id = video.VideoId,
|
||||
Title = video.Title,
|
||||
Description = video.Description,
|
||||
PubDate = video.PublishedAt,
|
||||
Link = video.Link,
|
||||
Duration = video.Duration,
|
||||
FileSize = video.Size,
|
||||
ContentType = contentType
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetContentType(ResolveFormat format)
|
||||
{
|
||||
if (format == ResolveFormat.VideoHigh || format == ResolveFormat.VideoLow)
|
||||
{
|
||||
return "video/mp4";
|
||||
}
|
||||
|
||||
if (format == ResolveFormat.AudioHigh || format == ResolveFormat.AudioLow)
|
||||
{
|
||||
return "audio/mp4";
|
||||
}
|
||||
|
||||
throw new ArgumentException("Unsupported resolve type");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
using System;
|
||||
using System.Xml;
|
||||
using System.Xml.Schema;
|
||||
using System.Xml.Serialization;
|
||||
using Podsync.Helpers;
|
||||
|
||||
namespace Podsync.Services.Rss.Contracts
|
||||
{
|
||||
[XmlRoot("channel")]
|
||||
public class Channel : IXmlSerializable
|
||||
{
|
||||
private const string PodsyncGeneratorName = "Podsync Generator";
|
||||
private const string DefaultItunesCategory = "TV & Film";
|
||||
|
||||
public Channel()
|
||||
{
|
||||
Category = DefaultItunesCategory;
|
||||
LastBuildDate = DateTime.Now;
|
||||
}
|
||||
|
||||
public string Guid { get; set; }
|
||||
|
||||
public string Title { get; set; }
|
||||
|
||||
public string Description { get; set; }
|
||||
|
||||
public Uri Link { get; set; }
|
||||
|
||||
public DateTime LastBuildDate { get; set; }
|
||||
|
||||
public DateTime PubDate { get; set; }
|
||||
|
||||
public string Subtitle { get; set; }
|
||||
|
||||
public string Summary { get; set; }
|
||||
|
||||
public string Category { get; set; }
|
||||
|
||||
public Uri Image { get; set; }
|
||||
|
||||
public Uri Thumbnail { get; set; }
|
||||
|
||||
public Item[] Items { get; set; }
|
||||
|
||||
public bool Explicit { get; set; }
|
||||
|
||||
public Uri AtomLink { get; set; }
|
||||
|
||||
public XmlSchema GetSchema()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public void ReadXml(XmlReader reader)
|
||||
{
|
||||
throw new NotSupportedException("Reading is not supported");
|
||||
}
|
||||
|
||||
public void WriteXml(XmlWriter writer)
|
||||
{
|
||||
writer.WriteElementString("title", Title);
|
||||
writer.WriteElementString("description", Description);
|
||||
writer.WriteElementString("link", Link.ToString());
|
||||
|
||||
writer.WriteElementString("generator", PodsyncGeneratorName);
|
||||
|
||||
writer.WriteElementString("lastBuildDate", LastBuildDate.ToString("R"));
|
||||
writer.WriteElementString("pubDate", PubDate.ToString("R"));
|
||||
|
||||
/*
|
||||
<itunes:subtitle>Laugh Factory</itunes:subtitle>
|
||||
<itunes:summary>The best stand up comedy clips online. That's it.</itunes:summary>
|
||||
*/
|
||||
|
||||
writer.WriteElementString("subtitle", Namespaces.Itunes, Title);
|
||||
writer.WriteElementString("summary", Namespaces.Itunes, Summary);
|
||||
writer.WriteElementString("explicit", Namespaces.Itunes, Explicit ? "yes" : "no");
|
||||
|
||||
if (AtomLink != null)
|
||||
{
|
||||
writer.WriteStartElement("link", Namespaces.Atom);
|
||||
writer.WriteAttributeString("href", AtomLink.ToString());
|
||||
writer.WriteAttributeString("rel", "self");
|
||||
writer.WriteAttributeString("type", "application/rss+xml");
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
/*
|
||||
<itunes:category text="TV & Film"/>
|
||||
*/
|
||||
|
||||
writer.WriteStartElement("category", Namespaces.Itunes);
|
||||
writer.WriteAttributeString("text", Category);
|
||||
writer.WriteEndElement();
|
||||
|
||||
/*
|
||||
<itunes:image href="https://yt3.ggpht.com/photo.jpg"/>
|
||||
*/
|
||||
|
||||
writer.WriteStartElement("image", Namespaces.Itunes);
|
||||
writer.WriteAttributeString("href", Image.ToString());
|
||||
writer.WriteEndElement();
|
||||
|
||||
/*
|
||||
<media:thumbnail url="https://yt3.ggpht.com//photo.jpg"/>
|
||||
*/
|
||||
|
||||
writer.WriteStartElement("thumbnail", Namespaces.Media);
|
||||
writer.WriteAttributeString("url", Thumbnail.ToString());
|
||||
writer.WriteEndElement();
|
||||
|
||||
// Items
|
||||
var serializer = new XmlSerializer(typeof(Item));
|
||||
Items.ForEach(item => serializer.Serialize(writer, item));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Xml;
|
||||
using System.Xml.Schema;
|
||||
using System.Xml.Serialization;
|
||||
using Podsync.Helpers;
|
||||
|
||||
namespace Podsync.Services.Rss.Contracts
|
||||
{
|
||||
[XmlRoot("rss")]
|
||||
public class Feed : IXmlSerializable
|
||||
{
|
||||
public const string Version = "2.0";
|
||||
|
||||
public IEnumerable<Channel> Channels { get; set; }
|
||||
|
||||
public Feed()
|
||||
{
|
||||
Channels = Enumerable.Empty<Channel>();
|
||||
}
|
||||
|
||||
public XmlSchema GetSchema()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public void ReadXml(XmlReader reader)
|
||||
{
|
||||
throw new NotSupportedException("Reading is not supported");
|
||||
}
|
||||
|
||||
public void WriteXml(XmlWriter writer)
|
||||
{
|
||||
writer.WriteAttributeString("xmlns", "dc", null, Namespaces.Dc);
|
||||
writer.WriteAttributeString("xmlns", "content", null, Namespaces.Content);
|
||||
writer.WriteAttributeString("xmlns", "atom", null, Namespaces.Atom);
|
||||
writer.WriteAttributeString("xmlns", "itunes", null, Namespaces.Itunes);
|
||||
writer.WriteAttributeString("xmlns", "media", null, Namespaces.Media);
|
||||
|
||||
writer.WriteAttributeString("version", Version);
|
||||
|
||||
var serializer = new XmlSerializer(typeof(Channel));
|
||||
Channels.ForEach(channel => serializer.Serialize(writer, channel));
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var serializer = new XmlSerializer(typeof(Feed));
|
||||
|
||||
// Serialize feed to XML string
|
||||
using (var writer = new Utf8StringWriter())
|
||||
{
|
||||
serializer.Serialize(writer, this);
|
||||
return writer.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Xml;
|
||||
using System.Xml.Schema;
|
||||
using System.Xml.Serialization;
|
||||
|
||||
namespace Podsync.Services.Rss.Contracts
|
||||
{
|
||||
[XmlRoot("item")]
|
||||
public class Item : IXmlSerializable
|
||||
{
|
||||
public string Title { get; set; }
|
||||
|
||||
public string Description { get; set; }
|
||||
|
||||
public string Author { get; set; }
|
||||
|
||||
public Uri Link { get; set; }
|
||||
|
||||
public DateTime PubDate { get; set; }
|
||||
|
||||
public string Summary { get; set; }
|
||||
|
||||
public TimeSpan Duration { get; set; }
|
||||
|
||||
public string Id { get; set; }
|
||||
|
||||
public long FileSize { get; set; }
|
||||
|
||||
public Uri DownloadLink { get; set; }
|
||||
|
||||
public string ContentType { get; set; }
|
||||
|
||||
public XmlSchema GetSchema()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public void ReadXml(XmlReader reader)
|
||||
{
|
||||
throw new NotSupportedException("Reading is not supported");
|
||||
}
|
||||
|
||||
public void WriteXml(XmlWriter writer)
|
||||
{
|
||||
writer.WriteElementString("title", Title);
|
||||
writer.WriteElementString("description", Description);
|
||||
writer.WriteElementString("link", Link.ToString());
|
||||
writer.WriteElementString("pubDate", PubDate.ToString("R"));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Author))
|
||||
{
|
||||
writer.WriteElementString("author", Author);
|
||||
}
|
||||
|
||||
/*
|
||||
<guid isPermaLink="true">https://youtube.com/watch?v=yp202t46OIE</guid>
|
||||
*/
|
||||
|
||||
writer.WriteStartElement("guid");
|
||||
writer.WriteAttributeString("isPermaLink", "true");
|
||||
writer.WriteString(Link?.ToString() ?? Id);
|
||||
writer.WriteEndElement();
|
||||
|
||||
/*
|
||||
<enclosure url="http://podsync.net/download/youtube/yp202t46OIE.mp4" length="48300000" type="video/mp4"/>
|
||||
*/
|
||||
|
||||
if (DownloadLink == null)
|
||||
{
|
||||
throw new InvalidDataException("Can't generate RSS item with no download link");
|
||||
}
|
||||
|
||||
writer.WriteStartElement("enclosure");
|
||||
writer.WriteAttributeString("url", DownloadLink.ToString());
|
||||
writer.WriteAttributeString("length", FileSize.ToString());
|
||||
writer.WriteAttributeString("type", ContentType);
|
||||
writer.WriteEndElement();
|
||||
|
||||
/*
|
||||
<media:content url="http://podsync.net/download/youtube/yp202t46OIE.mp4" fileSize="48300000" type="video/mp4"/>
|
||||
*/
|
||||
|
||||
writer.WriteStartElement("content", Namespaces.Media);
|
||||
writer.WriteAttributeString("url", DownloadLink.ToString());
|
||||
writer.WriteAttributeString("fileSize", FileSize.ToString());
|
||||
writer.WriteAttributeString("type", ContentType);
|
||||
writer.WriteEndElement();
|
||||
|
||||
/*
|
||||
<itunes:subtitle>Mike E. Winfield - Cheating (Stand up comedy)</itunes:subtitle>
|
||||
<itunes:summary>...</itunes:summary>
|
||||
<itunes:duration>00:02:18</itunes:duration>
|
||||
*/
|
||||
|
||||
writer.WriteElementString("subtitle", Namespaces.Itunes, Title);
|
||||
writer.WriteElementString("summary", Namespaces.Itunes, Summary);
|
||||
writer.WriteElementString("duration", Namespaces.Itunes, Duration.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
namespace Podsync.Services.Rss.Contracts
|
||||
{
|
||||
public static class Namespaces
|
||||
{
|
||||
public const string Itunes = "http://www.itunes.com/dtds/podcast-1.0.dtd";
|
||||
|
||||
public const string Media = "http://search.yahoo.com/mrss/";
|
||||
|
||||
public const string Atom = "http://www.w3.org/2005/Atom";
|
||||
|
||||
public const string Content = "http://purl.org/rss/1.0/modules/content/";
|
||||
|
||||
public const string Dc = "http://purl.org/dc/elements/1.1/";
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Podsync.Helpers;
|
||||
using Podsync.Services.Links;
|
||||
using Podsync.Services.Rss.Contracts;
|
||||
using Podsync.Services.Storage;
|
||||
|
||||
namespace Podsync.Services.Rss
|
||||
{
|
||||
public class FeedService : IFeedService
|
||||
{
|
||||
private readonly IStorageService _storageService;
|
||||
private readonly IRssBuilder _rssBuilder;
|
||||
|
||||
public FeedService(IStorageService storageService, IRssBuilder rssBuilder)
|
||||
{
|
||||
_storageService = storageService;
|
||||
_rssBuilder = rssBuilder;
|
||||
}
|
||||
|
||||
public Task<string> Create(FeedMetadata metadata)
|
||||
{
|
||||
if (metadata.Provider != Provider.YouTube && metadata.Quality.IsAudio())
|
||||
{
|
||||
throw new ArgumentException("Only YouTube supports audio feeds");
|
||||
}
|
||||
|
||||
return _storageService.Save(metadata);
|
||||
}
|
||||
|
||||
public Task<Feed> Get(string id)
|
||||
{
|
||||
return _rssBuilder.Query(id);
|
||||
}
|
||||
|
||||
public async Task<string> Get(string id, Action<string, Feed> fixup)
|
||||
{
|
||||
var serializedFeed = await _storageService.GetCached(Constants.Cache.FeedsPrefix, id);
|
||||
|
||||
if (string.IsNullOrEmpty(serializedFeed))
|
||||
{
|
||||
var feed = await Get(id);
|
||||
|
||||
// Fix download links
|
||||
fixup(id, feed);
|
||||
|
||||
// Add to cache
|
||||
serializedFeed = feed.ToString();
|
||||
await _storageService.Cache(Constants.Cache.FeedsPrefix, id, serializedFeed, TimeSpan.FromMinutes(3));
|
||||
}
|
||||
|
||||
return serializedFeed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Podsync.Services.Rss.Contracts;
|
||||
using Podsync.Services.Storage;
|
||||
|
||||
namespace Podsync.Services.Rss
|
||||
{
|
||||
public interface IFeedService
|
||||
{
|
||||
Task<string> Create(FeedMetadata metadata);
|
||||
|
||||
Task<Feed> Get(string id);
|
||||
|
||||
Task<string> Get(string id, Action<string, Feed> fixup);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
using System.Threading.Tasks;
|
||||
using Podsync.Services.Links;
|
||||
using Podsync.Services.Rss.Contracts;
|
||||
using Podsync.Services.Storage;
|
||||
|
||||
namespace Podsync.Services.Rss
|
||||
{
|
||||
public interface IRssBuilder
|
||||
{
|
||||
Provider Provider { get; }
|
||||
|
||||
Task<Feed> Query(string feedId);
|
||||
|
||||
Task<Feed> Query(FeedMetadata metadata);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace Podsync.Services.Rss
|
||||
{
|
||||
public class Utf8StringWriter : StringWriter
|
||||
{
|
||||
public override Encoding Encoding { get; } = Encoding.UTF8;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
using Podsync.Services.Links;
|
||||
using Podsync.Services.Resolver;
|
||||
|
||||
namespace Podsync.Services.Storage
|
||||
{
|
||||
public class FeedMetadata
|
||||
{
|
||||
public Provider Provider { get; set; }
|
||||
|
||||
public LinkType Type { get; set; }
|
||||
|
||||
public string Id { get; set; }
|
||||
|
||||
public ResolveFormat Quality { get; set; }
|
||||
|
||||
public int PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User id on Patreon
|
||||
/// </summary>
|
||||
public string PatreonId { get; set; }
|
||||
|
||||
public override string ToString() => $"{Provider} ({LinkType}) {Id}";
|
||||
|
||||
// Workaround for backward compatibility
|
||||
public LinkType LinkType
|
||||
{
|
||||
get { return Type; }
|
||||
set { Type = value; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Podsync.Services.Storage
|
||||
{
|
||||
public interface IStorageService : IDisposable
|
||||
{
|
||||
Task<TimeSpan> Ping();
|
||||
|
||||
Task<string> Save(FeedMetadata metadata);
|
||||
|
||||
Task<FeedMetadata> Load(string key);
|
||||
|
||||
Task Cache(string prefix, string id, string value, TimeSpan exp);
|
||||
|
||||
Task<string> GetCached(string prefix, string id);
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Net;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using HashidsNet;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Podsync.Services.Storage
|
||||
{
|
||||
public class RedisStorage : IStorageService
|
||||
{
|
||||
private const string CachePrefix = "cache";
|
||||
|
||||
private const string IdKey = "keygen";
|
||||
private const string IdSalt = "65fce519433f4218aa0cee6394225eea";
|
||||
private const int IdLength = 4;
|
||||
|
||||
private static readonly IHashids HashIds = new Hashids(IdSalt, IdLength);
|
||||
|
||||
private readonly string _cs;
|
||||
private IDatabase _db;
|
||||
|
||||
public RedisStorage(IOptions<PodsyncConfiguration> configuration)
|
||||
{
|
||||
_cs = configuration.Value.RedisConnectionString;
|
||||
}
|
||||
|
||||
private IDatabase Db
|
||||
{
|
||||
get { return LazyInitializer.EnsureInitialized(ref _db, () => Connect(_cs).GetAwaiter().GetResult().GetDatabase()); }
|
||||
}
|
||||
|
||||
private static async Task<ConnectionMultiplexer> Connect(string cs)
|
||||
{
|
||||
var options = ConfigurationOptions.Parse(cs);
|
||||
|
||||
try
|
||||
{
|
||||
return await ConnectionMultiplexer.ConnectAsync(options);
|
||||
}
|
||||
catch (PlatformNotSupportedException)
|
||||
{
|
||||
// Can't connect via address on Linux environments, using workaround
|
||||
// See https://github.com/StackExchange/StackExchange.Redis/issues/410#issuecomment-246332140
|
||||
var addressEndpoint = options.EndPoints.SingleOrDefault() as DnsEndPoint;
|
||||
if (addressEndpoint != null)
|
||||
{
|
||||
var ip = await Dns.GetHostEntryAsync(addressEndpoint.Host);
|
||||
options.EndPoints.Remove(addressEndpoint);
|
||||
options.EndPoints.Add(ip.AddressList.First(), addressEndpoint.Port);
|
||||
}
|
||||
|
||||
return await ConnectionMultiplexer.ConnectAsync(options);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_db != null)
|
||||
{
|
||||
_db.Multiplexer.Dispose();
|
||||
_db = null;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<TimeSpan> Ping()
|
||||
{
|
||||
return Db.PingAsync();
|
||||
}
|
||||
|
||||
public async Task<string> Save(FeedMetadata metadata)
|
||||
{
|
||||
var id = await MakeId();
|
||||
|
||||
var t = Db.CreateTransaction();
|
||||
t.AddCondition(Condition.KeyNotExists(id));
|
||||
|
||||
#pragma warning disable 4014
|
||||
// We should not await here because of transaction
|
||||
// See http://stackoverflow.com/questions/25976231/stackexchange-redis-transaction-methods-freezes
|
||||
t.HashSetAsync(id, BuildSet(metadata).ToArray());
|
||||
t.KeyExpireAsync(id, TimeSpan.FromDays(1));
|
||||
#pragma warning restore 4014
|
||||
|
||||
var succeeded = await t.ExecuteAsync();
|
||||
if (!succeeded)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to save feed");
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
public async Task<FeedMetadata> Load(string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
throw new ArgumentException("Feed key can't be empty");
|
||||
}
|
||||
|
||||
var entries = await Db.HashGetAllAsync(key);
|
||||
|
||||
// Expire after 3 month if no use
|
||||
await Db.KeyExpireAsync(key, TimeSpan.FromDays(90));
|
||||
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
throw new KeyNotFoundException("Invaid key");
|
||||
}
|
||||
|
||||
var metadata = new FeedMetadata();
|
||||
|
||||
// V1
|
||||
UnpackProperty(metadata, x => x.Id, entries);
|
||||
UnpackProperty(metadata, x => x.Type, entries);
|
||||
UnpackProperty(metadata, x => x.Provider, entries);
|
||||
|
||||
// V2
|
||||
UnpackProperty(metadata, x => x.Quality, entries, Constants.DefaultFormat);
|
||||
UnpackProperty(metadata, x => x.PageSize, entries, Constants.DefaultPageSize);
|
||||
UnpackProperty(metadata, x => x.PatreonId, entries, null);
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
public Task Cache(string prefix, string id, string value, TimeSpan exp)
|
||||
{
|
||||
var key = BuildCacheKey(prefix, id);
|
||||
return Db.StringSetAsync(key, value, exp);
|
||||
}
|
||||
|
||||
public async Task<string> GetCached(string prefix, string id)
|
||||
{
|
||||
var key = BuildCacheKey(prefix, id);
|
||||
var value = await Db.StringGetAsync(key);
|
||||
return value;
|
||||
}
|
||||
|
||||
public async Task<string> MakeId()
|
||||
{
|
||||
var id = await Db.StringIncrementAsync(IdKey);
|
||||
return HashIds.EncodeLong(id);
|
||||
}
|
||||
|
||||
private static string BuildCacheKey(string prefix, string id)
|
||||
{
|
||||
var key = $"{CachePrefix}:{prefix}:{id}";
|
||||
return key;
|
||||
}
|
||||
|
||||
private static void UnpackProperty<T, P>(T target, Expression<Func<T, P>> memberLamda, HashEntry[] entries)
|
||||
{
|
||||
UnpackProperty(target, memberLamda, entries, default(P), true);
|
||||
}
|
||||
|
||||
private static void UnpackProperty<T, P>(T target, Expression<Func<T, P>> memberLamda, HashEntry[] entries, P fallback, bool throwIfMissing = false)
|
||||
{
|
||||
var memberExpression = memberLamda.Body as MemberExpression;
|
||||
|
||||
// Get property name via reflection
|
||||
var entryName = memberExpression?.Member?.Name;
|
||||
if (string.IsNullOrEmpty(entryName))
|
||||
{
|
||||
throw new InvalidOperationException("Wrong property expression");
|
||||
}
|
||||
|
||||
P value;
|
||||
|
||||
// RedisValue is value type
|
||||
if (entries.Any(x => string.Equals(x.Name, entryName, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var entry = entries.Single(x => string.Equals(x.Name, entryName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var propertyType = typeof(P);
|
||||
if (propertyType.GetTypeInfo().IsEnum)
|
||||
{
|
||||
value = (P)Enum.Parse(propertyType, entry.Value, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
value = (P)Convert.ChangeType(entry.Value, propertyType);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (throwIfMissing)
|
||||
{
|
||||
throw new InvalidDataException("Missing mandatory property");
|
||||
}
|
||||
|
||||
value = fallback;
|
||||
}
|
||||
|
||||
var property = memberExpression.Member as PropertyInfo;
|
||||
property?.SetValue(target, value);
|
||||
}
|
||||
|
||||
private IEnumerable<HashEntry> BuildSet(FeedMetadata metadata)
|
||||
{
|
||||
// V1.0
|
||||
yield return new HashEntry(nameof(metadata.Provider), metadata.Provider.ToString());
|
||||
yield return new HashEntry(nameof(metadata.Type), metadata.Type.ToString());
|
||||
yield return new HashEntry(nameof(metadata.Id), metadata.Id);
|
||||
|
||||
// V2.0
|
||||
yield return new HashEntry(nameof(metadata.Quality), metadata.Quality.ToString());
|
||||
yield return new HashEntry(nameof(metadata.PageSize), metadata.PageSize);
|
||||
|
||||
// V2.1
|
||||
if (!string.IsNullOrEmpty(metadata.PatreonId))
|
||||
{
|
||||
yield return new HashEntry(nameof(metadata.PatreonId), metadata.PatreonId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace Podsync.Services.Videos.Vimeo
|
||||
{
|
||||
public class Group
|
||||
{
|
||||
public string Name { get; set; }
|
||||
|
||||
public string Description { get; set; }
|
||||
|
||||
public Uri Link { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public Uri Thumbnail { get; set; }
|
||||
|
||||
public string Author { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Podsync.Services.Videos.Vimeo
|
||||
{
|
||||
public interface IVimeoClient
|
||||
{
|
||||
Task<Group> Group(string id);
|
||||
|
||||
Task<Group> Channel(string id);
|
||||
|
||||
Task<User> User(string id);
|
||||
|
||||
Task<IEnumerable<Video>> GroupVideos(string id, int count);
|
||||
|
||||
Task<IEnumerable<Video>> UserVideos(string id, int count);
|
||||
|
||||
Task<IEnumerable<Video>> ChannelVideos(string id, int count);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace Podsync.Services.Videos.Vimeo
|
||||
{
|
||||
public class User
|
||||
{
|
||||
public string Name { get; set; }
|
||||
|
||||
public string Bio { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public Uri Link { get; set; }
|
||||
|
||||
public Uri Thumbnail { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace Podsync.Services.Videos.Vimeo
|
||||
{
|
||||
public class Video
|
||||
{
|
||||
public string Id { get; set; }
|
||||
|
||||
public string Title { get; set; }
|
||||
|
||||
public string Description { get; set; }
|
||||
|
||||
public Uri Link { get; set; }
|
||||
|
||||
public Uri Thumbnail { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public long Size { get; set; }
|
||||
|
||||
public TimeSpan Duration { get; set; }
|
||||
|
||||
public string Author { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Podsync.Services.Videos.Vimeo
|
||||
{
|
||||
// ReSharper disable once ClassNeverInstantiated.Global
|
||||
public sealed class VimeoClient : IVimeoClient, IDisposable
|
||||
{
|
||||
private const int MaxPageSize = 100;
|
||||
|
||||
private readonly HttpClient _client = new HttpClient();
|
||||
|
||||
public VimeoClient(IOptions<PodsyncConfiguration> configuration)
|
||||
{
|
||||
_client.BaseAddress = new Uri("https://api.vimeo.com/");
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", configuration.Value.VimeoApiKey);
|
||||
}
|
||||
|
||||
public Task<Group> Group(string id)
|
||||
{
|
||||
return QueryGroup($"groups/{id}");
|
||||
}
|
||||
|
||||
public Task<Group> Channel(string id)
|
||||
{
|
||||
return QueryGroup($"channels/{id}");
|
||||
}
|
||||
|
||||
public async Task<User> User(string id)
|
||||
{
|
||||
dynamic json = await QueryApi($"users/{id}");
|
||||
|
||||
return new User
|
||||
{
|
||||
Name = json.name,
|
||||
Bio = json.bio,
|
||||
Link = new Uri(json.link.ToString()),
|
||||
Thumbnail = new Uri(json.pictures.sizes[0].link.ToString()),
|
||||
CreatedAt = DateTime.Parse(json.created_time.ToString()),
|
||||
};
|
||||
}
|
||||
|
||||
public Task<IEnumerable<Video>> GroupVideos(string id, int count)
|
||||
{
|
||||
return QueryVideos($"groups/{id}/videos", count);
|
||||
}
|
||||
|
||||
public Task<IEnumerable<Video>> UserVideos(string id, int count)
|
||||
{
|
||||
return QueryVideos($"users/{id}/videos", count);
|
||||
}
|
||||
|
||||
public Task<IEnumerable<Video>> ChannelVideos(string id, int count)
|
||||
{
|
||||
return QueryVideos($"channels/{id}/videos", count);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_client.Dispose();
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<Video>> QueryVideos(string path, int count)
|
||||
{
|
||||
if (count <= 0)
|
||||
{
|
||||
throw new ArgumentException("Invalid item count", nameof(count));
|
||||
}
|
||||
|
||||
var collection = new List<Video>(count);
|
||||
var pageIndex = 1;
|
||||
|
||||
while (count > 0)
|
||||
{
|
||||
var pageSize = Math.Min(count, MaxPageSize);
|
||||
|
||||
var isLast = await GetPage(path, pageIndex, pageSize, collection);
|
||||
if (isLast)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
count -= pageSize;
|
||||
pageIndex++;
|
||||
}
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
private async Task<bool> GetPage(string path, int pageIndex, int pageSize, List<Video> output)
|
||||
{
|
||||
dynamic resp = await QueryApi($"{path}?per_page={pageSize}&page={pageIndex}");
|
||||
|
||||
foreach (dynamic v in resp.data)
|
||||
{
|
||||
// Approximated file size
|
||||
var size = Convert.ToInt64(
|
||||
v.width.ToObject<long>() *
|
||||
v.height.ToObject<long>() *
|
||||
v.duration.ToObject<long>() *
|
||||
0.38848958333);
|
||||
|
||||
// Extract id from uri like '/videos/50522981'
|
||||
var uri = v.uri.ToString();
|
||||
var id = uri.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries)[1];
|
||||
|
||||
Uri thumbnailUri = null;
|
||||
if (v.pictures != null)
|
||||
{
|
||||
thumbnailUri = new Uri(v.pictures.sizes[0].link.ToString());
|
||||
}
|
||||
else if (v.user?.pictures != null)
|
||||
{
|
||||
thumbnailUri = new Uri(v.user.pictures.sizes[0].link.ToString());
|
||||
}
|
||||
|
||||
var video = new Video
|
||||
{
|
||||
Id = id,
|
||||
Title = v.name,
|
||||
Description = v.description,
|
||||
Link = new Uri(v.link?.ToString()),
|
||||
Thumbnail = thumbnailUri,
|
||||
CreatedAt = DateTime.Parse(v.created_time?.ToString()),
|
||||
Duration = TimeSpan.FromSeconds(v.duration?.ToObject<int>()),
|
||||
Size = size,
|
||||
Author = v.user.name
|
||||
};
|
||||
|
||||
output.Add(video);
|
||||
}
|
||||
|
||||
// Is last page?
|
||||
return string.IsNullOrEmpty(resp.paging?.next?.ToString());
|
||||
}
|
||||
|
||||
private async Task<Group> QueryGroup(string path)
|
||||
{
|
||||
dynamic json = await QueryApi(path);
|
||||
|
||||
return new Group
|
||||
{
|
||||
Name = json.name,
|
||||
Description = json.description,
|
||||
Link = new Uri(json.link?.ToString()),
|
||||
Thumbnail = new Uri(json.pictures?.sizes[0]?.link?.ToString()),
|
||||
CreatedAt = DateTime.Parse(json.created_time?.ToString()),
|
||||
Author = json.user.name,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<JObject> QueryApi(string path)
|
||||
{
|
||||
var json = await _client.GetStringAsync(path);
|
||||
return JObject.Parse(json);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Podsync.Services.Videos.YouTube
|
||||
{
|
||||
public class Channel : YouTubeItem
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace Podsync.Services.Videos.YouTube
|
||||
{
|
||||
public struct ChannelQuery
|
||||
{
|
||||
public string ChannelId { get; set; }
|
||||
|
||||
public string Username { get; set; }
|
||||
|
||||
public int? Count { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Podsync.Services.Videos.YouTube
|
||||
{
|
||||
public interface IYouTubeClient
|
||||
{
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
/// <param name="query"></param>
|
||||
/// <returns></returns>
|
||||
Task<ICollection<Channel>> GetChannels(ChannelQuery query);
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
/// <param name="query"></param>
|
||||
/// <returns></returns>
|
||||
Task<ICollection<Playlist>> GetPlaylists(PlaylistQuery query);
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
/// <param name="query"></param>
|
||||
/// <returns></returns>
|
||||
Task<ICollection<Video>> GetVideos(VideoQuery query);
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
/// <param name="query"></param>
|
||||
/// <returns></returns>
|
||||
Task<ICollection<Video>> GetPlaylistItems(PlaylistItemsQuery query);
|
||||
|
||||
/// <summary>
|
||||
/// Optimized version of GetPlaylistItems to query video IDs only
|
||||
/// Cost: 3 (quota cost of 1 + id: 0, snippet: 2)
|
||||
/// </summary>
|
||||
/// <param name="query"></param>
|
||||
/// <returns></returns>
|
||||
Task<ICollection<string>> GetPlaylistItemIds(PlaylistItemsQuery query);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Podsync.Services.Videos.YouTube
|
||||
{
|
||||
public class Playlist : YouTubeItem
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
namespace Podsync.Services.Videos.YouTube
|
||||
{
|
||||
public struct PlaylistItemsQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// The id parameter specifies a comma-separated list of one or more unique playlist item IDs
|
||||
/// </summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string PlaylistId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The videoId parameter specifies that the request should return only the playlist items that contain the specified video
|
||||
/// </summary>
|
||||
public string VideoId { get; set; }
|
||||
|
||||
public int? Count { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace Podsync.Services.Videos.YouTube
|
||||
{
|
||||
public struct PlaylistQuery
|
||||
{
|
||||
public string PlaylistId { get; set; }
|
||||
|
||||
public string ChannelId { get; set; }
|
||||
|
||||
public int? Count { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace Podsync.Services.Videos.YouTube
|
||||
{
|
||||
public class Video : YouTubeItem
|
||||
{
|
||||
public TimeSpan Duration { get; set; }
|
||||
|
||||
public long Size { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Podsync.Services.Videos.YouTube
|
||||
{
|
||||
public struct VideoQuery
|
||||
{
|
||||
public ICollection<string> Ids { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
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.Helpers;
|
||||
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<PodsyncConfiguration> configuration)
|
||||
{
|
||||
_linkService = linkService;
|
||||
_youtube = new YouTubeService(new BaseClientService.Initializer
|
||||
{
|
||||
ApplicationName = "Podsync",
|
||||
ApiKey = configuration.Value.YouTubeApiKey
|
||||
});
|
||||
}
|
||||
|
||||
public Task<ICollection<Channel>> GetChannels(ChannelQuery query)
|
||||
{
|
||||
var request = _youtube.Channels.List("id,snippet,contentDetails");
|
||||
|
||||
request.Id = query.ChannelId;
|
||||
request.ForUsername = query.Username;
|
||||
|
||||
return AggregatePages<Channel>(query.Count, async (list, token, pageSize) =>
|
||||
{
|
||||
request.MaxResults = pageSize;
|
||||
request.PageToken = token;
|
||||
|
||||
var response = await request.ExecuteAsync();
|
||||
response.Items.Select(ConvertChannel).AddTo(list);
|
||||
|
||||
return response.NextPageToken;
|
||||
});
|
||||
}
|
||||
|
||||
public Task<ICollection<Playlist>> GetPlaylists(PlaylistQuery query)
|
||||
{
|
||||
var request = _youtube.Playlists.List("id,snippet");
|
||||
|
||||
request.Id = query.PlaylistId;
|
||||
request.ChannelId = query.ChannelId;
|
||||
|
||||
return AggregatePages<Playlist>(query.Count, async (list, token, pageSize) =>
|
||||
{
|
||||
request.MaxResults = pageSize;
|
||||
request.PageToken = token;
|
||||
|
||||
var response = await request.ExecuteAsync();
|
||||
response.Items.Select(ConvertPlaylist).AddTo(list);
|
||||
|
||||
return response.NextPageToken;
|
||||
});
|
||||
}
|
||||
|
||||
public Task<ICollection<Video>> GetVideos(VideoQuery query)
|
||||
{
|
||||
var request = _youtube.Videos.List("id,snippet,contentDetails");
|
||||
|
||||
var totalCount = query.Ids.Count;
|
||||
var pageIndex = 0;
|
||||
|
||||
return AggregatePages<Video>(totalCount, async (list, token, pageSize) =>
|
||||
{
|
||||
request.Id = string.Join(",", query.Ids.Skip(pageIndex * MaxResults).Take(pageSize));
|
||||
if (string.IsNullOrEmpty(request.Id))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
pageIndex++;
|
||||
|
||||
var response = await request.ExecuteAsync();
|
||||
response.Items.Select(ConvertVideo).AddTo(list);
|
||||
|
||||
return list.Count == totalCount ? null : true.ToString();
|
||||
});
|
||||
}
|
||||
|
||||
public Task<ICollection<Video>> GetPlaylistItems(PlaylistItemsQuery query)
|
||||
{
|
||||
var request = _youtube.PlaylistItems.List("id,snippet");
|
||||
|
||||
request.Id = query.Id;
|
||||
request.PlaylistId = query.PlaylistId;
|
||||
request.VideoId = query.VideoId;
|
||||
|
||||
return AggregatePages<Video>(query.Count, async (list, token, pageSize) =>
|
||||
{
|
||||
request.MaxResults = pageSize;
|
||||
request.PageToken = token;
|
||||
|
||||
var response = await request.ExecuteAsync();
|
||||
response.Items.Select(ConvertPlaylistItem).AddTo(list);
|
||||
|
||||
return response.NextPageToken;
|
||||
});
|
||||
}
|
||||
|
||||
public Task<ICollection<string>> GetPlaylistItemIds(PlaylistItemsQuery query)
|
||||
{
|
||||
var request = _youtube.PlaylistItems.List("id,snippet");
|
||||
|
||||
request.PlaylistId = query.PlaylistId;
|
||||
|
||||
return AggregatePages<string>(query.Count, async (list, token, pageSize) =>
|
||||
{
|
||||
request.MaxResults = pageSize;
|
||||
request.PageToken = token;
|
||||
|
||||
var response = await request.ExecuteAsync();
|
||||
response.Items.Select(x => x.Snippet.ResourceId.VideoId).AddTo(list);
|
||||
|
||||
return response.NextPageToken;
|
||||
});
|
||||
}
|
||||
|
||||
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 static async Task<ICollection<T>> AggregatePages<T>(int? totalCount, Func<List<T>, string, int, Task<string>> fetchPage)
|
||||
{
|
||||
var count = totalCount ?? MaxResults;
|
||||
if (count <= 0)
|
||||
{
|
||||
throw new ArgumentException("Invalid page count");
|
||||
}
|
||||
|
||||
var token = string.Empty;
|
||||
|
||||
var list = new List<T>(count);
|
||||
while (token != null && list.Count < count)
|
||||
{
|
||||
var pageSize = Math.Min(MaxResults, count - list.Count);
|
||||
token = await fetchPage(list, token, pageSize);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
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)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
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.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Podsync.Helpers;
|
||||
using Podsync.Services;
|
||||
using Podsync.Services.Links;
|
||||
using Podsync.Services.Patreon;
|
||||
using Podsync.Services.Resolver;
|
||||
using Podsync.Services.Rss;
|
||||
using Podsync.Services.Rss.Builders;
|
||||
using Podsync.Services.Storage;
|
||||
using Podsync.Services.Videos.Vimeo;
|
||||
using Podsync.Services.Videos.YouTube;
|
||||
|
||||
namespace Podsync
|
||||
{
|
||||
// ReSharper disable once ClassNeverInstantiated.Global
|
||||
public class Startup
|
||||
{
|
||||
public Startup(IHostingEnvironment env)
|
||||
{
|
||||
var builder = new ConfigurationBuilder()
|
||||
.SetBasePath(env.ContentRootPath)
|
||||
.AddJsonFile("appsettings.json", true, true)
|
||||
.AddJsonFile($"appsettings.{env.EnvironmentName}.json", true)
|
||||
.AddEnvironmentVariables();
|
||||
|
||||
if (env.IsDevelopment())
|
||||
{
|
||||
builder.AddUserSecrets();
|
||||
}
|
||||
|
||||
Configuration = builder.Build();
|
||||
}
|
||||
|
||||
public IConfigurationRoot Configuration { get; }
|
||||
|
||||
// This method gets called by the runtime. Use this method to add services to the container.
|
||||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
services.Configure<PodsyncConfiguration>(Configuration.GetSection("Podsync"));
|
||||
|
||||
// Register core services
|
||||
services.AddSingleton<ILinkService, LinkService>();
|
||||
services.AddSingleton<IYouTubeClient, YouTubeClient>();
|
||||
services.AddSingleton<IVimeoClient, VimeoClient>();
|
||||
services.AddSingleton<IResolverService, RemoteResolver>();
|
||||
services.AddSingleton<IStorageService, RedisStorage>();
|
||||
services.AddSingleton<IRssBuilder, CompositeRssBuilder>();
|
||||
services.AddSingleton<IPatreonApi, PatreonApi>();
|
||||
services.AddSingleton<IFeedService, FeedService>();
|
||||
|
||||
// Add authentication services
|
||||
services.AddAuthentication(config => config.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
|
||||
// Add framework services
|
||||
services.AddScoped<HandleExceptionAttribute>();
|
||||
services.AddMvc();
|
||||
}
|
||||
|
||||
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
|
||||
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
|
||||
{
|
||||
loggerFactory.AddConsole(Configuration.GetSection("Logging"));
|
||||
loggerFactory.AddDebug();
|
||||
|
||||
// See https://docs.microsoft.com/en-us/aspnet/core/publishing/linuxproduction
|
||||
app.UseForwardedHeaders(new ForwardedHeadersOptions
|
||||
{
|
||||
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
|
||||
RequireHeaderSymmetry = false,
|
||||
});
|
||||
|
||||
if (env.IsDevelopment())
|
||||
{
|
||||
app.UseDeveloperExceptionPage();
|
||||
app.UseBrowserLink();
|
||||
}
|
||||
|
||||
app.UseStaticFiles();
|
||||
|
||||
app.UseCookieAuthentication(new CookieAuthenticationOptions
|
||||
{
|
||||
AutomaticAuthenticate = true,
|
||||
AutomaticChallenge = true,
|
||||
CookieName = "podsync_cookies",
|
||||
LoginPath = new PathString("/login"),
|
||||
LogoutPath = new PathString("/logout")
|
||||
});
|
||||
|
||||
// Patreon authentication
|
||||
app.UseOAuthAuthentication(new OAuthOptions
|
||||
{
|
||||
AuthenticationScheme = Constants.Patreon.AuthenticationScheme,
|
||||
|
||||
ClientId = Configuration[$"Podsync:{nameof(PodsyncConfiguration.PatreonClientId)}"],
|
||||
ClientSecret = Configuration[$"Podsync:{nameof(PodsyncConfiguration.PatreonSecret)}"],
|
||||
|
||||
CallbackPath = new PathString("/oauth-patreon"),
|
||||
|
||||
AuthorizationEndpoint = Constants.Patreon.AuthorizationEndpoint,
|
||||
TokenEndpoint = Constants.Patreon.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(Constants.Patreon.AmountDonated, amountCents.ToString()));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.Map("/login", builder =>
|
||||
{
|
||||
builder.Run(async context =>
|
||||
{
|
||||
// Return a challenge to invoke the Patreon authentication scheme
|
||||
await context.Authentication.ChallengeAsync(Constants.Patreon.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?}");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
@using Podsync.Helpers
|
||||
@{
|
||||
var enableFeatures = User.EnablePatreonFeatures();
|
||||
}
|
||||
|
||||
<div class="title">
|
||||
<h1>Podsync</h1>
|
||||
|
||||
<h2>
|
||||
Simple and free service that lets you listen to any YouTube or
|
||||
Vimeo channels, playlists or user videos in podcast format.
|
||||
</h2>
|
||||
|
||||
<div class="login-block">
|
||||
@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>
|
||||
|
||||
<div class="main">
|
||||
<div class="main-border shadow">
|
||||
<div class="input-border">
|
||||
<input id="url-input" type="url" placeholder="paste your link here" spellcheck="false" autofocus />
|
||||
<div class="arrow-button">
|
||||
<a href="#" id="get-link">
|
||||
<i class="fa fa-arrow-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<p>
|
||||
@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 class="@(!enableFeatures ? "locked" : "")">
|
||||
<span id="control-panel">
|
||||
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 id="page-controls">
|
||||
episode count <a class="selected-option">50</a> <a>100</a> <a>150</a>
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="man"></div>
|
||||
|
||||
<div>
|
||||
<a href="https://www.patreon.com/podsync" target="_blank">
|
||||
<img class="patreon shadow" width="400" src="img/become_patreon.png" alt="Become my patron on Patreon" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="modal" style="display: none">
|
||||
<input type="url" id="output-input" readonly />
|
||||
<div class="modal-links">
|
||||
<span>
|
||||
<a id="modal-copy" href="#">
|
||||
<i class="fa fa-clone" aria-hidden="true"></i>
|
||||
Copy
|
||||
</a>
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<a id="modal-open" href="#" target="_blank">
|
||||
<i class="fa fa-external-link" aria-hidden="true"></i>
|
||||
Open
|
||||
</a>
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<a id="close-modal" href="#">
|
||||
<i class="fa fa-thumbs-up" aria-hidden="true"></i>
|
||||
Close
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,78 +0,0 @@
|
||||
@using Podsync.Helpers
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html;charset=UTF-8"/>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Podsync - Turn YouTube channels into podcast feeds</title>
|
||||
<meta property="og:title" content="Podsync - turn YouTube channels into podcast feeds" />
|
||||
<meta property="og:description" content="Simple and free service that lets you listen to any YouTube or Vimeo channels, playlists or user videos in podcast format" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:image" content="@Url.AbsoluteContent("~/img/og_image.png")" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="Simple and free service that lets you listen to any YouTube or Vimeo channels, playlists or user videos in podcast format"/>
|
||||
<link rel="icon" type="image/x-icon" href="@Url.Content("~/favicon.ico")" />
|
||||
|
||||
<environment Names="Development">
|
||||
<link rel="stylesheet" href="css/site.css" />
|
||||
<link rel="stylesheet" href="lib/fontawesome/css/font-awesome.css" />
|
||||
</environment>
|
||||
|
||||
<environment Names="Staging,Production">
|
||||
<link rel="stylesheet" href="css/site.min.css" asp-append-version="true" />
|
||||
|
||||
<link rel="stylesheet"
|
||||
href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css"
|
||||
asp-fallback-href="~/lib/fontawesome/css/font-awesome.min.css"
|
||||
asp-fallback-test-class="fa"
|
||||
asp-fallback-test-property="display"
|
||||
asp-fallback-test-value="inline-block"
|
||||
asp-append-version="true" />
|
||||
</environment>
|
||||
</head>
|
||||
<body>
|
||||
<div class="background-image">
|
||||
@RenderBody()
|
||||
|
||||
<div class="footer">
|
||||
<div class="twitter-link">
|
||||
<a href="https://twitter.com/pod_sync"><i class="fa fa-twitter" aria-hidden="true"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<environment Names="Development">
|
||||
<script src="~/lib/jquery/dist/jquery.js"></script>
|
||||
<script src="~/js/site.js"></script>
|
||||
</environment>
|
||||
|
||||
<environment Names="Staging,Production">
|
||||
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.2.4/jquery.min.js"
|
||||
asp-fallback-src="~/lib/jquery/dist/jquery.min.js"
|
||||
asp-fallback-test="window.jQuery"
|
||||
asp-append-version="true">
|
||||
</script>
|
||||
|
||||
<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),
|
||||
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
|
||||
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
|
||||
|
||||
ga('create', 'UA-64237526-1', 'auto');
|
||||
ga('send', 'pageview');
|
||||
</script>
|
||||
|
||||
<script>
|
||||
(function (w,i,d,g,e,t,s) {w[d] = w[d]||[];t= i.createElement(g);
|
||||
t.async=1;t.src=e;s=i.getElementsByTagName(g)[0];s.parentNode.insertBefore(t, s);
|
||||
})(window, document, '_gscq','script','//widgets.getsitecontrol.com/23626/script.js');
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,2 +0,0 @@
|
||||
@using Podsync
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@@ -1,3 +0,0 @@
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"IncludeScopes": false,
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"System": "Information",
|
||||
"Microsoft": "Information"
|
||||
}
|
||||
},
|
||||
"Podsync": {
|
||||
"YouTubeApiKey": "",
|
||||
"VimeoApiKey": "",
|
||||
"RedisConnectionString": "localhost",
|
||||
"BaseUrl": "",
|
||||
"PatreonClientId": "",
|
||||
"PatreonSecret": "",
|
||||
"RemoteResolverUrl": "http://localhost:8080"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "podsync",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"jquery": "2.2.4",
|
||||
"fontawesome": "4.7.0"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
[
|
||||
{
|
||||
"outputFileName": "wwwroot/css/site.min.css",
|
||||
"inputFiles": [
|
||||
"wwwroot/css/site.css"
|
||||
]
|
||||
},
|
||||
{
|
||||
"outputFileName": "wwwroot/js/site.min.js",
|
||||
"inputFiles": [
|
||||
"wwwroot/js/site.js"
|
||||
],
|
||||
"minify": {
|
||||
"enabled": true,
|
||||
"renameLocals": true
|
||||
},
|
||||
"sourceMap": false
|
||||
}
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"configProperties": {
|
||||
"System.GC.Server": true
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
|
||||
<!--
|
||||
Configure your application settings in appsettings.json. Learn more at http://go.microsoft.com/fwlink/?LinkId=786380
|
||||
-->
|
||||
|
||||
<system.webServer>
|
||||
<handlers>
|
||||
<add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModule" resourceType="Unspecified"/>
|
||||
</handlers>
|
||||
<aspNetCore processPath="%LAUNCHER_PATH%" arguments="%LAUNCHER_ARGS%" stdoutLogEnabled="false" stdoutLogFile=".\logs\stdout" forwardWindowsAuthToken="false"/>
|
||||
</system.webServer>
|
||||
</configuration>
|
||||
@@ -1,3 +0,0 @@
|
||||
/// <autosync enabled="true" />
|
||||
/// <reference path="js/site.js" />
|
||||
/// <reference path="lib/jquery/dist/jquery.js" />
|
||||
@@ -1,338 +0,0 @@
|
||||
@import url('https://fonts.googleapis.com/css?family=Ubuntu');
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Ubuntu', sans-serif;
|
||||
background-color: #f4eddb;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #EB8C11;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.background-image {
|
||||
background-image: url('/img/pc_bg.png');
|
||||
-ms-background-repeat: repeat-x;
|
||||
background-repeat: repeat-x;
|
||||
-ms-background-position: center bottom;
|
||||
background-position: center bottom;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-user-select: text;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
|
||||
.footer {
|
||||
background-image: url('/img/pc_footer.png');
|
||||
-ms-background-repeat: space;
|
||||
background-repeat: space;
|
||||
-ms-background-position: center bottom;
|
||||
background-position: center bottom;
|
||||
background-color: #aca496;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 69px;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-user-select: text;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.twitter-link {
|
||||
position: absolute;
|
||||
right: 2em;
|
||||
bottom: 0;
|
||||
font-size: 3em;
|
||||
}
|
||||
|
||||
.twitter-link a {
|
||||
color: rgb(62, 62, 41);
|
||||
}
|
||||
|
||||
.twitter-link a:hover {
|
||||
color: rgb(33, 33, 26);
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
|
||||
.title {
|
||||
padding: 50px;
|
||||
}
|
||||
|
||||
.title h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.title h2 {
|
||||
max-width: 500px;
|
||||
font-weight: normal;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.login-block {
|
||||
color: #e18712;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.login-block a {
|
||||
font-size: 1.2em;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* Main block + input */
|
||||
|
||||
.shadow {
|
||||
box-shadow: 0 12px 8px -6px #a79d87;
|
||||
box-shadow: 0 12px 8px -6px rgba(167, 157, 135, 0.4);
|
||||
}
|
||||
|
||||
.main {
|
||||
text-align: center;
|
||||
padding: 5em 2em 10em;
|
||||
max-width: 860px;
|
||||
width: 90%;
|
||||
margin: 0 auto;
|
||||
padding: 3em 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.main-border {
|
||||
border: thin solid #a39c8e;
|
||||
background: #f4ecda;
|
||||
}
|
||||
|
||||
.input-border {
|
||||
background-color: #e4dac8;
|
||||
border: none;
|
||||
border-bottom: 1px solid #a39c8e;
|
||||
}
|
||||
|
||||
.main-border input {
|
||||
border: none;
|
||||
padding: 0.5em 0 0.5em 0.5em;
|
||||
width: calc(100% - 2em);
|
||||
font-size: 2em;
|
||||
line-height: 1;
|
||||
color: #616161;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Input button (arrow) */
|
||||
|
||||
.arrow-button {
|
||||
float: right;
|
||||
font-size: 2em;
|
||||
padding-top: 0.5em;
|
||||
padding-right: 0.3em;
|
||||
}
|
||||
|
||||
.arrow-button a {
|
||||
color: rgb(142, 134, 119);
|
||||
}
|
||||
|
||||
.arrow-button a:hover {
|
||||
background: none;
|
||||
color: rgb(105, 98, 86);
|
||||
}
|
||||
|
||||
/* Controls under input */
|
||||
|
||||
.controls {
|
||||
padding-left: 1em;
|
||||
word-spacing: 0.15em;
|
||||
}
|
||||
|
||||
.controls a:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Options */
|
||||
.selected-option, a:hover {
|
||||
background: orange;
|
||||
color: whitesmoke;
|
||||
border: 1px orange;
|
||||
-ms-border-radius: 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.locked {
|
||||
-ms-opacity: 0.3;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
/* Man image */
|
||||
.man {
|
||||
width: 210px;
|
||||
height: 333px;
|
||||
background-image: url('/img/man.png');
|
||||
-ms-background-repeat: no-repeat;
|
||||
background-repeat: no-repeat;
|
||||
-ms-background-position: center bottom;
|
||||
background-position: center bottom;
|
||||
background-color: transparent;
|
||||
position: absolute;
|
||||
bottom: 100px;
|
||||
left: 30%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* Patreon block */
|
||||
|
||||
.patreon {
|
||||
background: rgb(171, 163, 149);
|
||||
border: 1px rgb(240, 232, 214) solid;
|
||||
bottom: 6em;
|
||||
float: right;
|
||||
position: absolute;
|
||||
right: 2em;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* Placeholder */
|
||||
::-webkit-input-placeholder { color: rgb(171, 163, 149); } /* Chrome/Opera/Safari */
|
||||
::-moz-placeholder { color: rgb(171, 163, 149); } /* Firefox 19+ */
|
||||
:-ms-input-placeholder { color: rgb(171, 163, 149); } /* IE 10+ */
|
||||
:-moz-placeholder { color: rgb(171, 163, 149); } /* Firefox 18- */
|
||||
|
||||
/* Tooltips */
|
||||
|
||||
.tooltip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
background: rgb(171, 163, 149);
|
||||
border: 1px rgb(244, 236, 218) solid;
|
||||
padding: 10px;
|
||||
color: #4a4030;
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
/* Modal popup */
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 20%;
|
||||
left: 50%;
|
||||
width: 50%;
|
||||
max-width: 700px;
|
||||
-ms-transform: translate3d(-50%,-50%, 0);
|
||||
-webkit-transform: translate3d(-50%,-50%, 0);
|
||||
transform: translate3d(-50%,-50%, 0);
|
||||
height: 80px;
|
||||
background: #56524b;
|
||||
text-align: center;
|
||||
z-index: 10;
|
||||
outline: 9999px solid rgba(0,0,0,0.8);
|
||||
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.modal > input {
|
||||
width: 100%;
|
||||
font-size: 3em;
|
||||
border: none;
|
||||
background: none;
|
||||
border-bottom: 1px dotted black;
|
||||
}
|
||||
|
||||
.modal-links {
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.modal-links span {
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.modal-links a:hover {
|
||||
color: #EB8C11;
|
||||
background: rgb(68, 65, 60);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 640px) {
|
||||
.background-image {
|
||||
background-image: url('/img/mobile_bg.png')
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-image: url('/img/mobile_footer.png');
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.title {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.controls {
|
||||
word-spacing: 0;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.patreon {
|
||||
right: 2em;
|
||||
}
|
||||
|
||||
.man {
|
||||
background-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
.modal {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-device-width: 1024px) {
|
||||
.modal > input {
|
||||
font-size: 1.9em;
|
||||
padding: 0;
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
.modal-links span {
|
||||
padding-right: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-height: 640px) {
|
||||
.patreon {
|
||||
width: 300px;
|
||||
bottom: 4em;
|
||||
}
|
||||
|
||||
.man {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-height: 840px) {
|
||||
.title {
|
||||
padding-top: 1em;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
@@ -1,218 +0,0 @@
|
||||
// Write your Javascript code.
|
||||
|
||||
$(function () {
|
||||
function err(msg) {
|
||||
alert(msg);
|
||||
}
|
||||
|
||||
function createFeed(data, done) {
|
||||
if (!data.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
dataType: 'text',
|
||||
url: '/feed/create',
|
||||
method: 'POST',
|
||||
data: JSON.stringify(data),
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
success: function (feedLink) {
|
||||
// HACK: remove quotes
|
||||
feedLink = JSON.parse(feedLink);
|
||||
done(feedLink);
|
||||
},
|
||||
error: function (xhr, status, error) {
|
||||
if (xhr.status === 400) {
|
||||
// Bad request
|
||||
var text = '';
|
||||
|
||||
try {
|
||||
var json = JSON.parse(xhr.responseText);
|
||||
$.each(json, function (key, value) {
|
||||
text += value + '\r\n';
|
||||
});
|
||||
} catch (e) {
|
||||
text = xhr.responseText;
|
||||
}
|
||||
|
||||
err(text);
|
||||
} else {
|
||||
// Generic error
|
||||
err('Server sad \'' + error + '\': ' + xhr.responseText);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function displayLink(link) {
|
||||
showModal(link);
|
||||
}
|
||||
|
||||
/*
|
||||
Tooltips
|
||||
*/
|
||||
|
||||
if (!isMobile()) {
|
||||
$(document).on('mouseenter', 'i', function () {
|
||||
var title = $(this).attr('title');
|
||||
if (!title) {
|
||||
return;
|
||||
}
|
||||
|
||||
$(this).data('tipText', title).removeAttr('title');
|
||||
$('<p class="tooltip"></p>').text(title).appendTo('body').fadeIn('fast');
|
||||
});
|
||||
|
||||
$(document).on('mouseleave', 'i', function () {
|
||||
var text = $(this).data('tipText');
|
||||
$(this).attr('title', text);
|
||||
$('.tooltip').remove();
|
||||
});
|
||||
|
||||
$(document).on('mousemove', 'i', function (e) {
|
||||
var x = e.pageX + 10;
|
||||
var y = e.pageY + 5;
|
||||
$('.tooltip').css({ top: y, left: x });
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
Control panel
|
||||
*/
|
||||
|
||||
function isLocked() {
|
||||
return $('#control-panel').hasClass('locked');
|
||||
}
|
||||
|
||||
/*
|
||||
Handlers
|
||||
*/
|
||||
|
||||
function formatSwith() {
|
||||
if (isLocked()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$('#video-format, #audio-format').toggleClass('selected-option');
|
||||
}
|
||||
|
||||
function qualitySwitch() {
|
||||
if (isLocked()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$('#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';
|
||||
}
|
||||
}
|
||||
|
||||
function pageSwitch(evt) {
|
||||
if (isLocked()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$('#page-controls > a').removeClass('selected-option');
|
||||
$(evt.target).addClass('selected-option');
|
||||
|
||||
getPageCount();
|
||||
}
|
||||
|
||||
function getPageCount() {
|
||||
try {
|
||||
var text = $('#page-controls > a.selected-option').text();
|
||||
return parseInt(text);
|
||||
} catch (e) {
|
||||
return 50;
|
||||
}
|
||||
}
|
||||
|
||||
$('#page-controls > a').click(pageSwitch);
|
||||
|
||||
/* Modal */
|
||||
|
||||
function closeModal() {
|
||||
$('#modal').hide();
|
||||
$('#url-input').val('');
|
||||
$('.main').show();
|
||||
}
|
||||
|
||||
function showModal(url) {
|
||||
// Hide main block on screen
|
||||
$('.main').hide();
|
||||
|
||||
// Set input URL
|
||||
$('#output-input').val(url);
|
||||
|
||||
// Update 'Open' button link
|
||||
$('#modal-open').attr('href', url);
|
||||
|
||||
// Show dialog itself
|
||||
$('#modal').show();
|
||||
|
||||
// Select modal output text
|
||||
$('#output-input').select();
|
||||
}
|
||||
|
||||
function copyLink() {
|
||||
$('#output-input').select();
|
||||
if (!document.execCommand('copy')) {
|
||||
err('Can\'t copy... Something went wrong...');
|
||||
}
|
||||
}
|
||||
|
||||
function isMobile() {
|
||||
return /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
function canCopy() {
|
||||
try {
|
||||
return document.queryCommandSupported('copy') && !isMobile();
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$('body').on('keydown', function (e) {
|
||||
// ESC
|
||||
if ($('#modal').is(':visible') && e.keyCode === 27) {
|
||||
$('#close-modal').click();
|
||||
}
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
/*
|
||||
Attach handlers
|
||||
*/
|
||||
|
||||
$('#get-link').click(function(e) {
|
||||
var url = $('#url-input').val();
|
||||
createFeed({ url: url, quality: getQuality(), pageSize: getPageCount() }, displayLink);
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
$('#url-input').keyup(function (e) {
|
||||
// 'Enter' click
|
||||
if (e.keyCode === 13) {
|
||||
$('#get-link').click();
|
||||
}
|
||||
});
|
||||
|
||||
$('#video-format, #audio-format').click(formatSwith);
|
||||
$('#best-quality, #worst-quality').click(qualitySwitch);
|
||||
|
||||
$('#close-modal').click(closeModal);
|
||||
$('#modal-copy').click(copyLink);
|
||||
|
||||
if (!canCopy()) {
|
||||
$('#modal-copy').hide();
|
||||
}
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
User-agent: *
|
||||
Allow: /$
|
||||
Disallow: /
|
||||
Host: www.podsync.net
|
||||
@@ -1,55 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Podsync.Services;
|
||||
using Podsync.Services.Resolver;
|
||||
using Xunit;
|
||||
|
||||
namespace Podsync.Tests.Controllers
|
||||
{
|
||||
public class FeedControllerTests : TestServer<Startup>
|
||||
{
|
||||
[Fact]
|
||||
public async Task ValidateCreateNullTest()
|
||||
{
|
||||
var response = await Client.PostAsync("/feed/create", new StringContent("", Encoding.UTF8, "application/json"));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null, null)]
|
||||
[InlineData("a", null, null)]
|
||||
[InlineData("http://youtube.com", null, 0)]
|
||||
[InlineData("http://youtube.com", null, 25)]
|
||||
[InlineData("http://youtube.com", null, 151)]
|
||||
public async Task ValidateCreateTest(string url, ResolveFormat? quality, int? pageSize)
|
||||
{
|
||||
var feed = new CreateFeedRequest
|
||||
{
|
||||
Url = url,
|
||||
Quality = quality,
|
||||
PageSize = pageSize
|
||||
};
|
||||
|
||||
var response = await Client.PostAsync("/feed/create", MakeHttpContent(feed));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateFeedTest()
|
||||
{
|
||||
var feed = new CreateFeedRequest
|
||||
{
|
||||
Url = "https://www.youtube.com/channel/UCKy1dAqELo0zrOtPkf0eTMw",
|
||||
Quality = ResolveFormat.AudioLow,
|
||||
};
|
||||
|
||||
var response = await Client.PostAsync("/feed/create", MakeHttpContent(feed));
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var id = await response.Content.ReadAsStringAsync();
|
||||
Assert.NotNull(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
|
||||
namespace Podsync.Tests.Controllers
|
||||
{
|
||||
public class StatusControllerTests : TestServer<Startup>
|
||||
{
|
||||
[Fact]
|
||||
public async Task StatusTest()
|
||||
{
|
||||
var response = await Client.GetAsync("/status");
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Contains("Path: /status", content);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Podsync.Tests.Controllers
|
||||
{
|
||||
public abstract class TestServer<TStartup> : TestBase, IDisposable where TStartup : class
|
||||
{
|
||||
private readonly TestServer _server;
|
||||
|
||||
protected TestServer()
|
||||
{
|
||||
var builder = new WebHostBuilder().UseStartup<TStartup>();
|
||||
_server = new TestServer(builder);
|
||||
|
||||
Client = _server.CreateClient();
|
||||
Client.BaseAddress = new Uri("http://localhost:5000");
|
||||
}
|
||||
|
||||
protected HttpClient Client { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Client.Dispose();
|
||||
_server.Dispose();
|
||||
}
|
||||
|
||||
public static HttpContent MakeHttpContent(object obj)
|
||||
{
|
||||
var json = JsonConvert.SerializeObject(obj);
|
||||
return new StringContent(json, Encoding.UTF8, "application/json");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netcoreapp1.1</TargetFramework>
|
||||
<AssemblyName>Podsync.Tests</AssemblyName>
|
||||
<PackageId>Podsync.Tests</PackageId>
|
||||
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
|
||||
<UserSecretsId>aspnet-Podsync-20161004104901</UserSecretsId>
|
||||
<GenerateAssemblyConfigurationAttribute>false</GenerateAssemblyConfigurationAttribute>
|
||||
<GenerateAssemblyCompanyAttribute>false</GenerateAssemblyCompanyAttribute>
|
||||
<GenerateAssemblyProductAttribute>false</GenerateAssemblyProductAttribute>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost">
|
||||
<Version>1.1.2</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets">
|
||||
<Version>1.1.2</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions">
|
||||
<Version>1.1.2</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk">
|
||||
<Version>15.0.0</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq">
|
||||
<Version>4.7.10</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="xunit">
|
||||
<Version>2.2.0</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="System.Xml.XmlSerializer" Version="4.3.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<Version>2.2.0</Version>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Podsync\Podsync.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,18 +0,0 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
// General Information about an assembly is controlled through the following
|
||||
// set of attributes. Change these attribute values to modify the information
|
||||
// associated with an assembly.
|
||||
[assembly: AssemblyConfiguration("")]
|
||||
[assembly: AssemblyCompany("")]
|
||||
[assembly: AssemblyProduct("Podsync.Tests")]
|
||||
[assembly: AssemblyTrademark("")]
|
||||
|
||||
// Setting ComVisible to false makes the types in this assembly not visible
|
||||
// to COM components. If you need to access a type in this assembly from
|
||||
// COM, set the ComVisible attribute to true on that type.
|
||||
[assembly: ComVisible(false)]
|
||||
|
||||
// The following GUID is for the ID of the typelib if this project is exposed to COM
|
||||
[assembly: Guid("990b29b3-c581-4d60-a34b-36499e2849ba")]
|
||||
@@ -1,77 +0,0 @@
|
||||
using System;
|
||||
using Podsync.Services.Links;
|
||||
using Xunit;
|
||||
|
||||
namespace Podsync.Tests.Services.Links
|
||||
{
|
||||
public class LinkServiceTests : TestBase
|
||||
{
|
||||
private readonly ILinkService _linkService = new LinkService();
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://www.youtube.com/playlist?list=PLCB9F975ECF01953C", LinkType.Playlist, "PLCB9F975ECF01953C")]
|
||||
[InlineData("https://www.youtube.com/watch?v=otm9NaT9OWU&list=PLCB9F975ECF01953C", LinkType.Playlist, "PLCB9F975ECF01953C")]
|
||||
[InlineData("https://www.youtube.com/channel/UC5XPnUk8Vvv_pWslhwom6Og", LinkType.Channel, "UC5XPnUk8Vvv_pWslhwom6Og")]
|
||||
[InlineData("https://www.youtube.com/user/UC5XPnUk8Vvv_pWslhwom6Og", LinkType.User, "UC5XPnUk8Vvv_pWslhwom6Og")]
|
||||
[InlineData("https://www.youtube.com/user/ComboBreakerVideo/videos", LinkType.User, "ComboBreakerVideo")]
|
||||
[InlineData("https://www.youtube.com/user/UC5XPnUk8Vvv_pWslhwom6Og/playlists", LinkType.User, "UC5XPnUk8Vvv_pWslhwom6Og")]
|
||||
[InlineData("https://www.youtube.com/playlist?list=PLP8qlV2aurYqdhyXW9ErqUW9Fw9F_mheM", LinkType.Playlist, "PLP8qlV2aurYqdhyXW9ErqUW9Fw9F_mheM")]
|
||||
[InlineData("https://www.youtube.com/user/NEMAGIA/videos", LinkType.User, "NEMAGIA")]
|
||||
[InlineData("https://www.youtube.com/channel/UCrlakW-ewUT8sOod6Wmzyow/videos", LinkType.Channel, "UCrlakW-ewUT8sOod6Wmzyow")]
|
||||
[InlineData("https://m.youtube.com/channel/UCrlakW-ewUT8sOod6Wmzyow/videos", LinkType.Channel, "UCrlakW-ewUT8sOod6Wmzyow")]
|
||||
[InlineData("http://m.youtube.com/user/NEMAGIA/videos", LinkType.User, "NEMAGIA")]
|
||||
[InlineData("http://m.youtube.com/playlist?list=PLCB9F975ECF01953C", LinkType.Playlist, "PLCB9F975ECF01953C")]
|
||||
public void ParseYoutubeLinks(string link, LinkType linkType, string id)
|
||||
{
|
||||
var info = _linkService.Parse(new Uri(link));
|
||||
|
||||
Assert.Equal(info.Id, id);
|
||||
Assert.Equal(info.LinkType, linkType);
|
||||
Assert.Equal(info.Provider, Provider.YouTube);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://vimeo.com/groups/101", LinkType.Group, "101")]
|
||||
[InlineData("http://vimeo.com/groups/102", LinkType.Group, "102")]
|
||||
[InlineData("http://www.vimeo.com/groups/103", LinkType.Group, "103")]
|
||||
[InlineData("https://vimeo.com/awhitelabelproduct", LinkType.User, "awhitelabelproduct")]
|
||||
[InlineData("https://vimeo.com/groups/104/videos/", LinkType.Group, "104")]
|
||||
[InlineData("https://vimeo.com/channels/staffpicks", LinkType.Channel, "staffpicks")]
|
||||
[InlineData("https://vimeo.com/channels/staffpicks/146224925", LinkType.Channel, "staffpicks")]
|
||||
public void ParseVimeoLinks(string link, LinkType linkType, string id)
|
||||
{
|
||||
var info = _linkService.Parse(new Uri(link));
|
||||
|
||||
Assert.Equal(info.Id, id);
|
||||
Assert.Equal(info.LinkType, linkType);
|
||||
Assert.Equal(info.Provider, Provider.Vimeo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseInvalidLinkTest()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => _linkService.Parse(null));
|
||||
Assert.Throws<ArgumentException>(() => _linkService.Parse(new Uri("http://www.apple.com")));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Provider.YouTube, LinkType.Channel, "123", "https://youtube.com/channel/123")]
|
||||
[InlineData(Provider.YouTube, LinkType.Playlist, "213", "https://youtube.com/playlist?list=213")]
|
||||
[InlineData(Provider.YouTube, LinkType.Video, "321", "https://youtube.com/watch?v=321")]
|
||||
[InlineData(Provider.Vimeo, LinkType.Channel, "yzx", "https://vimeo.com/channels/yzx")]
|
||||
[InlineData(Provider.Vimeo, LinkType.Group, "zyd", "https://vimeo.com/groups/zyd")]
|
||||
[InlineData(Provider.Vimeo, LinkType.User, "123", "https://vimeo.com/user123")]
|
||||
public void MakeLinkTest(Provider provider, LinkType linkType, string id, string expected)
|
||||
{
|
||||
var info = new LinkInfo
|
||||
{
|
||||
Id = id,
|
||||
LinkType = linkType,
|
||||
Provider = provider,
|
||||
};
|
||||
|
||||
var link = _linkService.Make(info);
|
||||
Assert.Equal(expected, link.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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(Skip = "Run manually with access token")]
|
||||
public async Task FetchProfileTest()
|
||||
{
|
||||
var user = await _api.FetchUserAndPledges(Tokens);
|
||||
|
||||
Assert.Equal("2822191", user.Id);
|
||||
Assert.Equal("[email protected]", user.Email);
|
||||
Assert.Equal("https://www.patreon.com/podsync", user.Url);
|
||||
Assert.Equal("Max", user.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Xml.Serialization;
|
||||
using Podsync.Services.Rss.Contracts;
|
||||
using Xunit;
|
||||
|
||||
namespace Podsync.Tests.Services.Rss.Contracts
|
||||
{
|
||||
public class FeedSerializationTests
|
||||
{
|
||||
private static readonly Uri ImageUrl = new Uri("https://yt3.ggpht.com/-OOqFwHRjeMQ/AAAAAAAAAAI/AAAAAAAAAAA/0XQZ2NeGp_0/s88-c-k-no-mo-rj-c0xffffff/photo.jpg");
|
||||
|
||||
[Fact]
|
||||
public void SerializeFeedTest()
|
||||
{
|
||||
var feed = new Feed();
|
||||
|
||||
var item = new Item
|
||||
{
|
||||
Title = "Steve Gillespie - Getting Arrested (Stand up Comedy)",
|
||||
Link = new Uri("https://youtube.com/watch?v=Jj22gfTnpAI"),
|
||||
PubDate = DateTime.Parse("Mon, 07 Nov 2016 20:02:26 GMT"),
|
||||
DownloadLink = new Uri("http://podsync.net/download/youtube/Jj22gfTnpAI.mp4"),
|
||||
FileSize = 52850000,
|
||||
ContentType = "video/mp4",
|
||||
Duration = new TimeSpan(0, 0, 2, 31)
|
||||
};
|
||||
|
||||
var channel = new Channel
|
||||
{
|
||||
Title = "Laugh Factory",
|
||||
Description = "The best stand up comedy clips online. That's it.",
|
||||
Link = new Uri("https://youtube.com/channel/UCxyCzPY2pjAjrxoSYclpuLg"),
|
||||
LastBuildDate = DateTime.Parse("Tue, 08 Nov 2016 05:55:25 GMT"),
|
||||
PubDate = DateTime.Parse("Mon, 31 Jul 2006 22:18:05 GMT"),
|
||||
Subtitle = "Laugh Factory",
|
||||
Summary = "The best stand up comedy clips online. That's it.",
|
||||
Category = "TV & Film",
|
||||
Image = ImageUrl,
|
||||
Thumbnail = ImageUrl,
|
||||
|
||||
Items = new[]
|
||||
{
|
||||
item
|
||||
}
|
||||
};
|
||||
|
||||
feed.Channels = new[]
|
||||
{
|
||||
channel
|
||||
};
|
||||
|
||||
var serializer = new XmlSerializer(typeof(Feed));
|
||||
|
||||
string body;
|
||||
using (var writer = new StringWriter())
|
||||
{
|
||||
serializer.Serialize(writer, feed);
|
||||
body = writer.ToString();
|
||||
}
|
||||
|
||||
Assert.NotEmpty(body);
|
||||
|
||||
// Channel tests
|
||||
|
||||
Assert.Contains("<title>Laugh Factory</title>", body);
|
||||
Assert.Contains("<description>The best stand up comedy clips online. That's it.</description>", body);
|
||||
Assert.Contains("<link>https://youtube.com/channel/UCxyCzPY2pjAjrxoSYclpuLg</link>", body);
|
||||
Assert.Contains("<generator>Podsync Generator</generator>", body);
|
||||
|
||||
Assert.Contains("<itunes:subtitle>Laugh Factory</itunes:subtitle>", body);
|
||||
Assert.Contains("<itunes:summary>The best stand up comedy clips online. That's it.</itunes:summary>", body);
|
||||
Assert.Contains("<itunes:category text=\"TV & Film\" />", body);
|
||||
Assert.Contains($"<itunes:image href=\"{ImageUrl}\" />", body);
|
||||
Assert.Contains($"<media:thumbnail url=\"{ImageUrl}\" />", body);
|
||||
|
||||
// Items tests
|
||||
Assert.Contains("<title>Steve Gillespie - Getting Arrested (Stand up Comedy)</title>", body);
|
||||
Assert.Contains("<itunes:subtitle>Steve Gillespie - Getting Arrested (Stand up Comedy)</itunes:subtitle>", body);
|
||||
|
||||
Assert.Contains("<link>https://youtube.com/watch?v=Jj22gfTnpAI</link>", body);
|
||||
Assert.Contains("<guid isPermaLink=\"true\">https://youtube.com/watch?v=Jj22gfTnpAI</guid>", body);
|
||||
|
||||
Assert.Contains("<itunes:duration>00:02:31</itunes:duration>", body);
|
||||
|
||||
Assert.Contains("<enclosure url=\"http://podsync.net/download/youtube/Jj22gfTnpAI.mp4\" length=\"52850000\" type=\"video/mp4\" />", body);
|
||||
Assert.Contains("<media:content url=\"http://podsync.net/download/youtube/Jj22gfTnpAI.mp4\" fileSize=\"52850000\" type=\"video/mp4\" />", body);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Moq;
|
||||
using Podsync.Services.Links;
|
||||
using Podsync.Services.Rss.Builders;
|
||||
using Podsync.Services.Storage;
|
||||
using Podsync.Services.Videos.Vimeo;
|
||||
using Xunit;
|
||||
|
||||
namespace Podsync.Tests.Services.Rss
|
||||
{
|
||||
public class VimeoRssBuilderTests : TestBase
|
||||
{
|
||||
private readonly Mock<IStorageService> _storageService = new Mock<IStorageService>();
|
||||
|
||||
private readonly VimeoRssBuilder _builder;
|
||||
|
||||
public VimeoRssBuilderTests()
|
||||
{
|
||||
_builder = new VimeoRssBuilder(_storageService.Object, new VimeoClient(Options));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(LinkType.Channel, "staffpicks")]
|
||||
[InlineData(LinkType.Group, "motion")]
|
||||
[InlineData(LinkType.User, "motionarray")]
|
||||
public async Task BuildRssTest(LinkType linkType, string id)
|
||||
{
|
||||
var feed = new FeedMetadata
|
||||
{
|
||||
Provider = Provider.YouTube,
|
||||
LinkType = linkType,
|
||||
Id = id
|
||||
};
|
||||
|
||||
var feedId = DateTime.UtcNow.Ticks.ToString();
|
||||
|
||||
_storageService.Setup(x => x.Load(feedId)).ReturnsAsync(feed);
|
||||
|
||||
var rss = await _builder.Query(feedId);
|
||||
|
||||
Assert.NotEmpty(rss.Channels);
|
||||
|
||||
var channel = rss.Channels.Single();
|
||||
|
||||
Assert.NotNull(channel.Title);
|
||||
Assert.NotNull(channel.Description);
|
||||
Assert.NotNull(channel.Image);
|
||||
Assert.NotNull(channel.Guid);
|
||||
Assert.NotEmpty(channel.Items);
|
||||
|
||||
foreach (var item in channel.Items)
|
||||
{
|
||||
Assert.NotNull(item.Id);
|
||||
Assert.NotNull(item.Title);
|
||||
Assert.NotNull(item.Link);
|
||||
Assert.True(item.Duration.TotalSeconds > 0);
|
||||
Assert.True(item.FileSize > 0);
|
||||
Assert.NotNull(item.ContentType);
|
||||
Assert.NotNull(item.PubDate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Moq;
|
||||
using Podsync.Services.Links;
|
||||
using Podsync.Services.Rss.Builders;
|
||||
using Podsync.Services.Storage;
|
||||
using Podsync.Services.Videos.YouTube;
|
||||
using Xunit;
|
||||
|
||||
namespace Podsync.Tests.Services.Rss
|
||||
{
|
||||
public class YouTubeRssBuilderTests : TestBase
|
||||
{
|
||||
private readonly Mock<IStorageService> _storageService = new Mock<IStorageService>();
|
||||
|
||||
private readonly YouTubeRssBuilder _builder;
|
||||
|
||||
public YouTubeRssBuilderTests()
|
||||
{
|
||||
var linkService = new LinkService();
|
||||
var client = new YouTubeClient(linkService, Options);
|
||||
|
||||
_builder = new YouTubeRssBuilder(client, _storageService.Object);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(LinkType.Channel, "UC0JB7TSe49lg56u6qH8y_MQ")]
|
||||
[InlineData(LinkType.User, "fxigr1")]
|
||||
[InlineData(LinkType.Playlist, "PL2e4mYbwSTbbiX2uwspn0xiYb8_P_cTAr")]
|
||||
public async Task BuildRssTest(LinkType linkType, string id)
|
||||
{
|
||||
var feed = new FeedMetadata
|
||||
{
|
||||
Provider = Provider.YouTube,
|
||||
LinkType = linkType,
|
||||
Id = id
|
||||
};
|
||||
|
||||
var feedId = DateTime.UtcNow.Ticks.ToString();
|
||||
|
||||
_storageService.Setup(x => x.Load(feedId)).ReturnsAsync(feed);
|
||||
|
||||
var rss = await _builder.Query(feedId);
|
||||
|
||||
Assert.NotEmpty(rss.Channels);
|
||||
|
||||
var channel = rss.Channels.Single();
|
||||
|
||||
Assert.NotNull(channel.Title);
|
||||
Assert.NotNull(channel.Description);
|
||||
Assert.NotNull(channel.Image);
|
||||
Assert.NotNull(channel.Guid);
|
||||
Assert.NotEmpty(channel.Items);
|
||||
|
||||
foreach (var item in channel.Items)
|
||||
{
|
||||
Assert.NotNull(item.Title);
|
||||
Assert.NotNull(item.Link);
|
||||
Assert.True(item.Duration.TotalSeconds > 0);
|
||||
Assert.True(item.FileSize > 0);
|
||||
Assert.NotNull(item.ContentType);
|
||||
Assert.NotNull(item.PubDate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Podsync.Services.Links;
|
||||
using Podsync.Services.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace Podsync.Tests.Services.Storage
|
||||
{
|
||||
public class RedisStorageTests : TestBase, IDisposable
|
||||
{
|
||||
private const string TestsCachePrefix = "tests";
|
||||
private readonly RedisStorage _storage;
|
||||
|
||||
public RedisStorageTests()
|
||||
{
|
||||
_storage = new RedisStorage(Options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PingTest()
|
||||
{
|
||||
var time = await _storage.Ping();
|
||||
Assert.True(time.TotalMilliseconds > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MakeIdTest()
|
||||
{
|
||||
const int idCount = 50;
|
||||
|
||||
|
||||
var results = new string[idCount];
|
||||
Parallel.For(0, results.Length, (i, _) => results[i] = _storage.MakeId().GetAwaiter().GetResult());
|
||||
|
||||
Assert.Equal(results.Length, results.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveLoadFeedTest()
|
||||
{
|
||||
var feed = new FeedMetadata
|
||||
{
|
||||
Id = "123",
|
||||
LinkType = LinkType.Channel,
|
||||
Provider = Provider.Vimeo,
|
||||
|
||||
PageSize = 45
|
||||
};
|
||||
|
||||
var id = await _storage.Save(feed);
|
||||
Assert.NotEmpty(id);
|
||||
Assert.Equal(4, id.Length);
|
||||
|
||||
var loaded = await _storage.Load(id);
|
||||
|
||||
Assert.Equal(feed.Id, loaded.Id);
|
||||
Assert.Equal(feed.LinkType, loaded.LinkType);
|
||||
Assert.Equal(feed.Provider, loaded.Provider);
|
||||
Assert.Equal(45, loaded.PageSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public Task LoadInvalidFeedTest()
|
||||
{
|
||||
return Assert.ThrowsAsync<KeyNotFoundException>(() => _storage.Load("test"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetInvalidCacheValueTest()
|
||||
{
|
||||
var value = await _storage.GetCached(TestsCachePrefix, "invalid");
|
||||
Assert.Null(value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExpireCacheValueTest()
|
||||
{
|
||||
await _storage.Cache(TestsCachePrefix, "exp", "1", TimeSpan.FromMilliseconds(500));
|
||||
|
||||
await Task.Delay(600);
|
||||
|
||||
var value = await _storage.GetCached(TestsCachePrefix, "exp");
|
||||
Assert.Null(value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCachedValueTest()
|
||||
{
|
||||
await _storage.Cache(TestsCachePrefix, "v", "1", TimeSpan.FromSeconds(1));
|
||||
var value = await _storage.GetCached(TestsCachePrefix, "v");
|
||||
Assert.Equal("1", value);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_storage.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Podsync.Services.Videos.Vimeo;
|
||||
using Xunit;
|
||||
|
||||
// ReSharper disable PossibleMultipleEnumeration
|
||||
namespace Podsync.Tests.Services.Videos.Vimeo
|
||||
{
|
||||
public class VimeoClientTests : TestBase, IDisposable
|
||||
{
|
||||
private readonly VimeoClient _client;
|
||||
|
||||
public VimeoClientTests()
|
||||
{
|
||||
_client = new VimeoClient(Options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChannelTest()
|
||||
{
|
||||
var channel = await _client.Channel("staffpicks");
|
||||
|
||||
Assert.Equal(new Uri("https://vimeo.com/channels/staffpicks"), channel.Link);
|
||||
Assert.Equal("Vimeo Staff Picks", channel.Name);
|
||||
Assert.Equal("Vimeo Curation", channel.Author);
|
||||
Assert.False(string.IsNullOrWhiteSpace(channel.Description));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GroupTest()
|
||||
{
|
||||
var group = await _client.Group("motion");
|
||||
|
||||
Assert.Equal(new Uri("https://vimeo.com/groups/motion"), group.Link);
|
||||
Assert.Equal("Motion Graphic Artists", group.Name);
|
||||
Assert.Equal("Danny Garcia", group.Author);
|
||||
Assert.False(string.IsNullOrWhiteSpace(group.Description));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UserTest()
|
||||
{
|
||||
var user = await _client.User("motionarray");
|
||||
|
||||
Assert.Equal("Motion Array", user.Name);
|
||||
Assert.False(string.IsNullOrWhiteSpace(user.Bio));
|
||||
Assert.Equal(new Uri("https://vimeo.com/motionarray"), user.Link);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GroupVideosTest()
|
||||
{
|
||||
var videos = await _client.GroupVideos("motion", 101);
|
||||
Assert.Equal(101, videos.Count());
|
||||
ValidateCollection(videos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public Task PaginationTest()
|
||||
{
|
||||
return _client.GroupVideos("scifilondon48hr2012", 300);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UserVideosTest()
|
||||
{
|
||||
var videos = await _client.UserVideos("motionarray", 7);
|
||||
Assert.Equal(7, videos.Count());
|
||||
ValidateCollection(videos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChannelVideosTest()
|
||||
{
|
||||
var videos = await _client.ChannelVideos("staffpicks", 44);
|
||||
Assert.Equal(44, videos.Count());
|
||||
ValidateCollection(videos);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_client.Dispose();
|
||||
}
|
||||
|
||||
private void ValidateCollection(IEnumerable<Video> videos)
|
||||
{
|
||||
foreach (var video in videos)
|
||||
{
|
||||
Assert.False(string.IsNullOrWhiteSpace(video.Title));
|
||||
Assert.True(video.Duration.TotalSeconds > 1);
|
||||
Assert.True(video.Size > 0);
|
||||
Assert.NotNull(video.Link);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
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), playlist.PublishedAt.Date);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPlaylistIdsTest()
|
||||
{
|
||||
var query = new PlaylistItemsQuery
|
||||
{
|
||||
PlaylistId = "PL2e4mYbwSTbbiX2uwspn0xiYb8_P_cTAr",
|
||||
Count = 3
|
||||
};
|
||||
|
||||
var list = await _client.GetPlaylistItemIds(query);
|
||||
Assert.NotEmpty(list);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPlaylistIdsPagesTest()
|
||||
{
|
||||
var query = new PlaylistItemsQuery
|
||||
{
|
||||
PlaylistId = "PL2e4mYbwSTbYAKa7Msaq24rT2lUOaVdcj",
|
||||
Count = 100
|
||||
};
|
||||
|
||||
var list = await _client.GetPlaylistItemIds(query);
|
||||
|
||||
Assert.True(list.Count > 50);
|
||||
Assert.True(list.Distinct().Count() == list.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVideosTest()
|
||||
{
|
||||
var query = new VideoQuery
|
||||
{
|
||||
Ids = new[] { "OlYH4gDi0Sk,kkcKnWrCZ7k" }
|
||||
};
|
||||
|
||||
var list = await _client.GetVideos(query);
|
||||
|
||||
Assert.Equal(2, list.Count);
|
||||
|
||||
var last = list.Last();
|
||||
|
||||
Assert.Equal("kkcKnWrCZ7k", last.VideoId);
|
||||
Assert.Equal("UCS-aAvZsVegeMDfpdCPjWnA", last.ChannelId);
|
||||
Assert.Equal("Deus Ex: Mankind Divided - Full Soundtrack OST", last.Title);
|
||||
Assert.False(string.IsNullOrEmpty(last.Description));
|
||||
Assert.Equal(new TimeSpan(0, 1, 57, 28), last.Duration);
|
||||
Assert.Equal(new DateTime(2016, 8, 24), last.PublishedAt.Date);
|
||||
Assert.Equal(new Uri("https://youtube.com/watch?v=kkcKnWrCZ7k"), last.Link);
|
||||
Assert.True(last.Size > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPlaylistItemsTest()
|
||||
{
|
||||
var query = new PlaylistItemsQuery
|
||||
{
|
||||
PlaylistId = "PLWG9qzvMcoJGw9AS0XgLfrwDR2kcAMLhs",
|
||||
Count = 10
|
||||
};
|
||||
|
||||
var response = await _client.GetPlaylistItems(query);
|
||||
var list = response as IList<Video> ?? response.ToList();
|
||||
|
||||
Assert.Equal(4, list.Count);
|
||||
|
||||
foreach (var video in list)
|
||||
{
|
||||
Assert.False(string.IsNullOrEmpty(video.VideoId));
|
||||
Assert.False(string.IsNullOrEmpty(video.ChannelId));
|
||||
Assert.False(string.IsNullOrEmpty(video.PlaylistId));
|
||||
|
||||
Assert.False(string.IsNullOrEmpty(video.Title));
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_client.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Podsync.Services;
|
||||
|
||||
namespace Podsync.Tests
|
||||
{
|
||||
public abstract class TestBase
|
||||
{
|
||||
private const string UserSecretsId = "aspnet-Podsync-20161004104901";
|
||||
|
||||
protected TestBase()
|
||||
{
|
||||
var configurationRoot = new ConfigurationBuilder()
|
||||
.AddUserSecrets(UserSecretsId)
|
||||
.Build();
|
||||
|
||||
var podsyncSection = configurationRoot.GetSection("Podsync");
|
||||
|
||||
var configuration = new PodsyncConfiguration();
|
||||
podsyncSection.Bind(configuration);
|
||||
|
||||
Options = new OptionsWrapper<PodsyncConfiguration>(configuration);
|
||||
}
|
||||
|
||||
protected IOptions<PodsyncConfiguration> Options { get; }
|
||||
|
||||
protected PodsyncConfiguration Configuration => Options.Value;
|
||||
}
|
||||
}
|
||||