Remove ASP.NET app

This commit is contained in:
Maksym Pavlenko
2017-07-19 19:41:17 -07:00
parent 9aa28ae051
commit b755dfa789
100 changed files with 0 additions and 4773 deletions
-62
View File
@@ -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
-8
View File
@@ -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/)
-3
View File
@@ -1,3 +0,0 @@
{
"directory": "wwwroot/lib"
}
-104
View File
@@ -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}");
});
}
}
}
-12
View File
@@ -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}";
}
}
}
-13
View File
@@ -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
}
}
}
}
}
-82
View File
@@ -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);
}
}
}
}
-90
View File
@@ -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>
-20
View File
@@ -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"
}
}
}
}
-38
View File
@@ -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";
}
}
}
-17
View File
@@ -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);
}
}
-11
View File
@@ -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; }
}
}
-169
View File
@@ -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);
}
}
}
-12
View File
@@ -1,12 +0,0 @@
namespace Podsync.Services.Links
{
public enum LinkType
{
Unknown = 0,
Video,
Playlist,
User,
Channel,
Group
}
}
-9
View File
@@ -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);
}
}
}
-9
View File
@@ -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();
}
}
}
}
-101
View File
@@ -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/";
}
}
-55
View File
@@ -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;
}
}
}
-16
View File
@@ -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);
}
}
-16
View File
@@ -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);
}
}
-17
View File
@@ -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; }
}
}
-169
View File
@@ -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?}");
});
}
}
}
-105
View File
@@ -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>
-78
View File
@@ -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>
-2
View File
@@ -1,2 +0,0 @@
@using Podsync
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
-3
View File
@@ -1,3 +0,0 @@
@{
Layout = "_Layout";
}
-19
View File
@@ -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"
}
}
-8
View File
@@ -1,8 +0,0 @@
{
"name": "podsync",
"private": true,
"dependencies": {
"jquery": "2.2.4",
"fontawesome": "4.7.0"
}
}
-19
View File
@@ -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
}
]
-5
View File
@@ -1,5 +0,0 @@
{
"configProperties": {
"System.GC.Server": true
}
}
-14
View File
@@ -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>
-3
View File
@@ -1,3 +0,0 @@
/// <autosync enabled="true" />
/// <reference path="js/site.js" />
/// <reference path="lib/jquery/dist/jquery.js" />
-338
View File
@@ -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;
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

-218
View File
@@ -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();
}
});
-4
View File
@@ -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");
}
}
}
-39
View File
@@ -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 &amp; 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();
}
}
}
-29
View File
@@ -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;
}
}