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

Implement link service

This commit is contained in:
Maksym Pavlenko
2016-10-03 19:35:50 -04:00
parent c38d427009
commit 3565c8dbab
14 changed files with 543 additions and 93 deletions

View File

@@ -16,6 +16,13 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{D94416F2-3
EndProject
Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Podsync.Tests", "test\Podsync.Tests\Podsync.Tests.xproj", "{990B29B3-C581-4D60-A34B-36499E2849BA}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{35F181BF-B2AD-42AB-BB11-DC66D57E6514}"
ProjectSection(SolutionItems) = preProject
src\Shared\EnumerableExtensions.cs = src\Shared\EnumerableExtensions.cs
src\Shared\ListExtensions.cs = src\Shared\ListExtensions.cs
src\Shared\StringExtensions.cs = src\Shared\StringExtensions.cs
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -37,5 +44,6 @@ Global
GlobalSection(NestedProjects) = preSolution
{C9283472-E95E-4517-AE3B-3E276B9851E4} = {3EF2294C-C807-46F3-B3F4-98B7F30688D0}
{990B29B3-C581-4D60-A34B-36499E2849BA} = {D94416F2-31EA-4D38-A3D0-BB74180687D4}
{35F181BF-B2AD-42AB-BB11-DC66D57E6514} = {3EF2294C-C807-46F3-B3F4-98B7F30688D0}
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,11 @@
using System;
namespace Podsync.Services.Links
{
public interface ILinkService
{
LinkInfo Parse(Uri link);
Uri Make(LinkInfo info);
}
}

View File

@@ -0,0 +1,11 @@
namespace Podsync.Services.Links
{
public struct LinkInfo
{
public string Id { get; set; }
public LinkType LinkType { get; set; }
public Provider Provider { get; set; }
}
}

View File

@@ -0,0 +1,197 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.WebUtilities;
using Shared;
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}",
[LinkType.Info] = "https://youtube.com/get_video_info?video_id={0}"
},
[Provider.Vimeo] = new Dictionary<LinkType, string>
{
[LinkType.Category] = "https://vimeo.com/categories/{0}",
[LinkType.Channel] = "https://vimeo.com/channels/{0}",
[LinkType.Group] = "https://vimeo.com/groups/{0}",
[LinkType.User] = "https://vimeo.com/{0}",
[LinkType.Info] = "https://player.vimeo.com/video/{0}/config"
}
};
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;
var segments = link.Segments
.Select(x => x.TrimEnd('/'))
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToArray();
var host = link.Host.ToLowerInvariant().TrimStart("www.");
if (host == "youtu.be")
{
provider = Provider.YouTube;
if (segments.Length == 1)
{
// https://youtu.be/AAAAAAAAA01
// https://www.youtu.be/AAAAAAAAA08
linkType = LinkType.Video;
id = segments.Single();
}
}
else if (host == "youtube.com")
{
provider = Provider.YouTube;
var query = QueryHelpers.ParseQuery(link.Query);
if (segments.Length >= 2 && segments[0] == "user")
{
linkType = LinkType.User;
id = segments[1];
}
else if (segments.Length == 2)
{
if (string.Equals(segments[0], "embed"))
{
linkType = LinkType.Video;
if (string.Equals(segments[1], "watch"))
{
// http://www.youtube.com/embed/watch?feature=player_embedded&v=AAAAAAAAA02
// http://www.youtube.com/embed/watch?v=AAAAAAAAA03
id = query["v"];
}
else if (segments[1].StartsWith("v="))
{
// http://www.youtube.com/embed/v=AAAAAAAAA04
id = segments[1].TrimStart("v=");
}
}
else if (string.Equals(segments[0], "watch"))
{
// http://www.youtube.com/watch/jMeC7JFQ6811
linkType = LinkType.Video;
id = segments[1];
}
else if (string.Equals(segments[0], "v"))
{
// http://www.youtube.com/v/jMeC7JFQ6812
// http://www.youtube.com/v/A-AAAAAAA18?fs=1&rel=0
linkType = LinkType.Video;
id = segments[1];
}
else if (string.Equals(segments[0], "channel"))
{
// https://www.youtube.com/channel/UC5XPnUk8Vvv_pWslhwom6Og
linkType = LinkType.Channel;
id = segments[1];
}
}
else if (segments.Length == 1)
{
if (string.Equals(segments[0], "watch"))
{
if (query.ContainsKey("list"))
{
// https://www.youtube.com/watch?v=otm9NaT9OWU&list=PLCB9F975ECF01953C
linkType = LinkType.Playlist;
id = query["list"];
}
else
{
// http://www.youtube.com/watch?v=AAAAAAAAA06
// http://www.youtube.com/watch?feature=player_embedded&v=AAAAAAAAA05
linkType = LinkType.Video;
id = query["v"];
}
}
else if (string.Equals(segments[0], "attribution_link"))
{
// http://www.youtube.com/attribution_link?u=/watch?v=jMeC7JFQ6815&feature=share&a=9QlmP1yvjcllp0h3l0NwuA
// http://www.youtube.com/attribution_link?a=fF1CWYwxCQ4&u=/watch?v=jMeC7JFQ6816&feature=em-uploademail
// http://www.youtube.com/attribution_link?a=fF1CWYwxCQ4&feature=em-uploademail&u=/watch?v=jMeC7JFQ6817
string u = query["u"];
var pos = u?.IndexOf("?", StringComparison.OrdinalIgnoreCase) ?? -1;
if (pos != -1)
{
// ReSharper disable once PossibleNullReferenceException
var attrQueryParams = QueryHelpers.ParseQuery(u.Substring(pos));
linkType = LinkType.Video;
id = attrQueryParams["v"];
}
}
else if (string.Equals(segments[0], "playlist"))
{
// https://www.youtube.com/playlist?list=PLCB9F975ECF01953C
linkType = LinkType.Playlist;
id = query["list"];
}
}
}
if (string.IsNullOrWhiteSpace(id) || linkType == LinkType.Unknown || provider == Provider.Unknown)
{
throw new ArgumentException("Failed to parse URL");
}
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);
}
}
}
}

View File

@@ -0,0 +1,14 @@
namespace Podsync.Services.Links
{
public enum LinkType
{
Unknown = 0,
Video,
Playlist,
User,
Channel,
Info,
Category,
Group
}
}

View File

@@ -0,0 +1,9 @@
namespace Podsync.Services.Links
{
public enum Provider
{
Unknown = 0,
YouTube,
Vimeo,
}
}

View File

@@ -1,12 +1,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Podsync.Services.Links;
namespace Podsync
{
@@ -27,6 +24,9 @@ namespace Podsync
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
// Register core services
services.AddSingleton<ILinkService, LinkService>();
// Add framework services.
services.AddMvc();
}

View File

@@ -1,65 +1,68 @@
{
"dependencies": {
"Microsoft.NETCore.App": {
"version": "1.0.0",
"type": "platform"
{
"dependencies": {
"Microsoft.NETCore.App": {
"version": "1.0.0",
"type": "platform"
},
"Microsoft.AspNetCore.Diagnostics": "1.0.0",
"Microsoft.AspNetCore.Mvc": "1.0.0",
"Microsoft.AspNetCore.Razor.Tools": {
"version": "1.0.0-preview2-final",
"type": "build"
},
"Microsoft.AspNetCore.Server.IISIntegration": "1.0.0",
"Microsoft.AspNetCore.Server.Kestrel": "1.0.0",
"Microsoft.AspNetCore.StaticFiles": "1.0.0",
"Microsoft.Extensions.Configuration.EnvironmentVariables": "1.0.0",
"Microsoft.Extensions.Configuration.Json": "1.0.0",
"Microsoft.Extensions.Logging": "1.0.0",
"Microsoft.Extensions.Logging.Console": "1.0.0",
"Microsoft.Extensions.Logging.Debug": "1.0.0",
"Microsoft.Extensions.Options.ConfigurationExtensions": "1.0.0",
"Microsoft.VisualStudio.Web.BrowserLink.Loader": "14.0.0",
"Microsoft.AspNetCore.WebUtilities": "1.0.0"
},
"Microsoft.AspNetCore.Diagnostics": "1.0.0",
"Microsoft.AspNetCore.Mvc": "1.0.0",
"Microsoft.AspNetCore.Razor.Tools": {
"version": "1.0.0-preview2-final",
"type": "build"
"tools": {
"BundlerMinifier.Core": "2.0.238",
"Microsoft.AspNetCore.Razor.Tools": "1.0.0-preview2-final",
"Microsoft.AspNetCore.Server.IISIntegration.Tools": "1.0.0-preview2-final"
},
"Microsoft.AspNetCore.Server.IISIntegration": "1.0.0",
"Microsoft.AspNetCore.Server.Kestrel": "1.0.0",
"Microsoft.AspNetCore.StaticFiles": "1.0.0",
"Microsoft.Extensions.Configuration.EnvironmentVariables": "1.0.0",
"Microsoft.Extensions.Configuration.Json": "1.0.0",
"Microsoft.Extensions.Logging": "1.0.0",
"Microsoft.Extensions.Logging.Console": "1.0.0",
"Microsoft.Extensions.Logging.Debug": "1.0.0",
"Microsoft.Extensions.Options.ConfigurationExtensions": "1.0.0",
"Microsoft.VisualStudio.Web.BrowserLink.Loader": "14.0.0"
},
"tools": {
"BundlerMinifier.Core": "2.0.238",
"Microsoft.AspNetCore.Razor.Tools": "1.0.0-preview2-final",
"Microsoft.AspNetCore.Server.IISIntegration.Tools": "1.0.0-preview2-final"
},
"frameworks": {
"netcoreapp1.0": {
"imports": [
"dotnet5.6",
"portable-net45+win8"
]
}
},
"frameworks": {
"netcoreapp1.0": {
"imports": [
"dotnet5.6",
"portable-net45+win8"
]
"buildOptions": {
"emitEntryPoint": true,
"preserveCompilationContext": true,
"compile": "..\\Shared\\*.cs"
},
"runtimeOptions": {
"configProperties": {
"System.GC.Server": true
}
},
"publishOptions": {
"include": [
"wwwroot",
"Views",
"Areas/**/Views",
"appsettings.json",
"web.config"
]
},
"scripts": {
"prepublish": [ "bower install", "dotnet bundle" ],
"postpublish": [ "dotnet publish-iis --publish-folder %publish:OutputPath% --framework %publish:FullTargetFramework%" ]
}
},
"buildOptions": {
"emitEntryPoint": true,
"preserveCompilationContext": true
},
"runtimeOptions": {
"configProperties": {
"System.GC.Server": true
}
},
"publishOptions": {
"include": [
"wwwroot",
"Views",
"Areas/**/Views",
"appsettings.json",
"web.config"
]
},
"scripts": {
"prepublish": [ "bower install", "dotnet bundle" ],
"postpublish": [ "dotnet publish-iis --publish-folder %publish:OutputPath% --framework %publish:FullTargetFramework%" ]
}
}

View File

@@ -0,0 +1,71 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Shared
{
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 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
}
}
}
}
}

View File

@@ -0,0 +1,20 @@
using System.Collections.Generic;
namespace Shared
{
internal static class ListExtensions
{
public static void AddRange<T>(this IList<T> source, T[] elements)
{
if (source == null || elements == null)
{
return;
}
foreach (var element in elements)
{
source.Add(element);
}
}
}
}

View File

@@ -0,0 +1,60 @@
using System;
using System.Linq;
namespace Shared
{
internal static class StringExtensions
{
public static string TrimEnd(this string value, string word)
{
if (string.IsNullOrWhiteSpace(value) || string.IsNullOrWhiteSpace(word) || !value.EndsWith(word, StringComparison.OrdinalIgnoreCase))
{
return value;
}
return value.Substring(0, value.Length - word.Length);
}
public static string TrimStart(this string value, string word, StringComparison comparison = StringComparison.Ordinal)
{
if (string.IsNullOrWhiteSpace(value))
{
return value;
}
string result = value;
while (result.StartsWith(word, comparison))
{
result = result.Substring(word.Length);
}
return result;
}
public static string RemoveChars(this string value, char[] list)
{
if (string.IsNullOrWhiteSpace(value))
{
return value;
}
return string.Concat(value.Split(list, StringSplitOptions.RemoveEmptyEntries));
}
public static string FirstCharToUpperInvariant(this string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return value;
}
var first = value.First();
if (char.IsUpper(first))
{
return value;
}
return first.ToString().ToUpperInvariant() + value.Substring(1);
}
}
}

View File

@@ -1,28 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using NuGet.Packaging;
using Xunit;
namespace Podsync.Tests
{
public class Class1
{
public Class1()
{
}
[Fact]
public void PassingTest()
{
Assert.True(true);
}
[Fact]
public void FailingTest()
{
Assert.False(true);
}
}
}

View File

@@ -0,0 +1,73 @@
using System;
using Podsync.Services.Links;
using Xunit;
namespace Podsync.Tests.Services.Links
{
public class LinkServiceTests
{
private readonly ILinkService _linkService = new LinkService();
[Theory]
[InlineData("http://youtu.be/jMeC7JFQ6801", Provider.YouTube, LinkType.Video, "jMeC7JFQ6801")]
[InlineData("http://www.youtube.com/embed/watch?feature=player_embedded&v=jMeC7JFQ6802", Provider.YouTube, LinkType.Video, "jMeC7JFQ6802")]
[InlineData("http://www.youtube.com/embed/watch?v=jMeC7JFQ6803", Provider.YouTube, LinkType.Video, "jMeC7JFQ6803")]
[InlineData("http://www.youtube.com/embed/v=jMeC7JFQ6804", Provider.YouTube, LinkType.Video, "jMeC7JFQ6804")]
[InlineData("http://www.youtube.com/watch?v=jMeC7JFQ6806", Provider.YouTube, LinkType.Video, "jMeC7JFQ6806")]
[InlineData("http://www.youtube.com/watch?v=jMeC7JFQ6807", Provider.YouTube, LinkType.Video, "jMeC7JFQ6807")]
[InlineData("http://www.youtu.be/jMeC7JFQ6808", Provider.YouTube, LinkType.Video, "jMeC7JFQ6808")]
[InlineData("http://youtu.be/jMeC7JFQ6809", Provider.YouTube, LinkType.Video, "jMeC7JFQ6809")]
[InlineData("http://www.youtube.com/watch?feature=player_embedded&v=jMeC7JFQ6805", Provider.YouTube, LinkType.Video, "jMeC7JFQ6805")]
[InlineData("http://www.youtube.com/attribution_link?u=/watch?v=jMeC7JFQ6815&feature=share&a=9QlmP1yvjcllp0h3l0NwuA", Provider.YouTube, LinkType.Video, "jMeC7JFQ6815")]
[InlineData("http://www.youtube.com/attribution_link?a=fF1CWYwxCQ4&u=/watch?v=jMeC7JFQ6816&feature=em-uploademail", Provider.YouTube, LinkType.Video, "jMeC7JFQ6816")]
[InlineData("http://www.youtube.com/attribution_link?a=fF1CWYwxCQ4&feature=em-uploademail&u=/watch?v=jMeC7JFQ6817", Provider.YouTube, LinkType.Video, "jMeC7JFQ6817")]
[InlineData("http://youtube.com/watch?v=jMeC7JFQ6810", Provider.YouTube, LinkType.Video, "jMeC7JFQ6810")]
[InlineData("http://www.youtube.com/watch/jMeC7JFQ6811", Provider.YouTube, LinkType.Video, "jMeC7JFQ6811")]
[InlineData("http://www.youtube.com/v/jMeC7JFQ6812", Provider.YouTube, LinkType.Video, "jMeC7JFQ6812")]
[InlineData("http://WWW.YOUTUBE.COM/v/jMeC7JFQ6812", Provider.YouTube, LinkType.Video, "jMeC7JFQ6812")]
[InlineData("https://www.youtube.com/playlist?list=PLCB9F975ECF01953C", Provider.YouTube, LinkType.Playlist, "PLCB9F975ECF01953C")]
[InlineData("https://www.youtube.com/watch?v=otm9NaT9OWU&list=PLCB9F975ECF01953C", Provider.YouTube, LinkType.Playlist, "PLCB9F975ECF01953C")]
[InlineData("https://www.youtube.com/channel/UC5XPnUk8Vvv_pWslhwom6Og", Provider.YouTube, LinkType.Channel, "UC5XPnUk8Vvv_pWslhwom6Og")]
[InlineData("https://www.youtube.com/user/UC5XPnUk8Vvv_pWslhwom6Og", Provider.YouTube, LinkType.User, "UC5XPnUk8Vvv_pWslhwom6Og")]
[InlineData("https://www.youtube.com/user/ComboBreakerVideo/videos", Provider.YouTube, LinkType.User, "ComboBreakerVideo")]
[InlineData("https://www.youtube.com/user/UC5XPnUk8Vvv_pWslhwom6Og/playlists", Provider.YouTube, LinkType.User, "UC5XPnUk8Vvv_pWslhwom6Og")]
public void ParseLinkTest(string link, Provider provider, 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);
}
[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.YouTube, LinkType.Info, "111", "https://youtube.com/get_video_info?video_id=111")]
[InlineData(Provider.Vimeo, LinkType.Category, "xyz", "https://vimeo.com/categories/xyz")]
[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, "dfz", "https://vimeo.com/dfz")]
[InlineData(Provider.Vimeo, LinkType.Info, "xgd", "https://player.vimeo.com/video/xgd/config")]
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());
}
}
}

View File

@@ -1,4 +1,4 @@
{
{
"version": "1.0.0-*",
"testRunner": "xunit",
@@ -8,6 +8,7 @@
"version": "1.0.0",
"type": "platform"
},
"Podsync": "1.0.0-*",
"xunit": "2.2.0-beta2-build3300"
},