Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions Morpheus.Tests/YoutubeUtilsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using System.Net;
using Morpheus.Utilities;

namespace Morpheus.Tests;

public class YoutubeUtilsTests
{
private const string ChannelId = "UCabcdefghijklmnopqrstuv";

[Theory]
[InlineData("https://example.com/@channel")]
[InlineData("http://127.0.0.1/private")]
[InlineData("https://www.youtube.com.example.com/@channel")]
[InlineData("https://example.com/channel/UCabcdefghijklmnopqrstuv")]
[InlineData("example.com/channel/UCabcdefghijklmnopqrstuv")]
[InlineData("//example.com/channel/UCabcdefghijklmnopqrstuv")]
[InlineData("example.com/@channel")]
[InlineData("https://www.youtube.com/redirect?q=http://127.0.0.1/private")]
public async Task ResolveChannelIdAsync_DoesNotRequestNonYoutubeUrls(string input)
{
RecordingHandler handler = new(_ => throw new InvalidOperationException("Unexpected HTTP request."));
using HttpClient httpClient = new(handler);

string? result = await YoutubeUtils.ResolveChannelIdAsync(httpClient, input);

Assert.Null(result);
Assert.Empty(handler.RequestedUris);
}

[Theory]
[InlineData("https://www.youtube.com/@channel", "/@channel", "")]
[InlineData("youtube.com/user/channel", "/user/channel", "")]
[InlineData("https://m.youtube.com/c/channel?feature=share", "/c/channel", "")]
[InlineData("https://youtu.be/dQw4w9WgXcQ?si=tracking", "/watch", "?v=dQw4w9WgXcQ")]
[InlineData("@channel", "/@channel", "")]
public async Task ResolveChannelIdAsync_RequestsCanonicalYoutubeUrls(
string input,
string expectedPath,
string expectedQuery)
{
RecordingHandler handler = new(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent($"\"channelId\":\"{ChannelId}\"")
});
using HttpClient httpClient = new(handler);

string? result = await YoutubeUtils.ResolveChannelIdAsync(httpClient, input);

Assert.Equal(ChannelId, result);
Uri requestedUri = Assert.Single(handler.RequestedUris);
Assert.Equal("www.youtube.com", requestedUri.Host);
Assert.Equal(expectedPath, requestedUri.AbsolutePath);
Assert.Equal(expectedQuery, requestedUri.Query);
}

[Theory]
[InlineData("https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv")]
[InlineData("youtube.com/channel/UCabcdefghijklmnopqrstuv")]
[InlineData("/channel/UCabcdefghijklmnopqrstuv")]
public async Task ResolveChannelIdAsync_ReturnsIdsFromYoutubeChannelPathsWithoutRequesting(string input)
{
RecordingHandler handler = new(_ => throw new InvalidOperationException("Unexpected HTTP request."));
using HttpClient httpClient = new(handler);

string? result = await YoutubeUtils.ResolveChannelIdAsync(httpClient, input);

Assert.Equal(ChannelId, result);
Assert.Empty(handler.RequestedUris);
}

private sealed class RecordingHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory) : HttpMessageHandler
{
public List<Uri> RequestedUris { get; } = [];

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.RequestUri != null)
RequestedUris.Add(request.RequestUri);

return Task.FromResult(responseFactory(request));
}
}
}
89 changes: 71 additions & 18 deletions Utilities/YoutubeUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ public static partial class YoutubeUtils
[GeneratedRegex("/channel/(UC[0-9A-Za-z_-]{22})")]
private static partial Regex ChannelIdInUrlRegex();

[GeneratedRegex("^[0-9A-Za-z_-]{11}$")]
private static partial Regex VideoIdRegex();

/// <summary>
/// Resolves a user-supplied reference to a canonical YouTube channel id.
/// Accepts: a raw channel id ("UC..."), a /channel/UC... URL, or a handle / custom / user
Expand All @@ -40,17 +43,19 @@ public static partial class YoutubeUtils
if (ChannelIdRegex().IsMatch(input))
return input;

// /channel/UC... anywhere in the string
Match urlMatch = ChannelIdInUrlRegex().Match(input);
// Normalize supported references before inspecting or fetching them. This prevents
// URL-shaped user input from selecting an arbitrary host or YouTube redirect path.
Uri? scrapeUri = BuildScrapeUri(input);
if (scrapeUri == null)
return null;

Match urlMatch = ChannelIdInUrlRegex().Match(scrapeUri.AbsolutePath);
if (urlMatch.Success)
return urlMatch.Groups[1].Value;

// Build a URL to scrape for handles / custom names / bare "@handle"
string url = BuildScrapeUrl(input);

try
{
using HttpRequestMessage req = new(HttpMethod.Get, url);
using HttpRequestMessage req = new(HttpMethod.Get, scrapeUri);
req.Headers.UserAgent.ParseAdd(BrowserUserAgent);
using HttpResponseMessage resp = await httpClient.SendAsync(req).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode)
Expand All @@ -77,26 +82,74 @@ public static partial class YoutubeUtils
}
}

private static string BuildScrapeUrl(string input)
private static Uri? BuildScrapeUri(string input)
{
// Already an http(s) URL — scrape it directly.
if (input.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
input.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
return input;

// Bare handle: "@name"
if (input.StartsWith('@'))
return $"https://www.youtube.com/{input}";
return BuildHandleUri(input[1..]);

// Something like "youtube.com/@name" without scheme
if (input.Contains("youtube.com", StringComparison.OrdinalIgnoreCase) ||
input.Contains("youtu.be", StringComparison.OrdinalIgnoreCase))
return $"https://{input.TrimStart('/')}";
// Relative YouTube channel paths are unambiguous and safe to normalize.
if (input.StartsWith('/') && !input.StartsWith("//", StringComparison.Ordinal))
{
if (!Uri.TryCreate($"https://www.youtube.com{input}", UriKind.Absolute, out Uri? relativeUri))
return null;

return BuildCanonicalYoutubeUri(relativeUri);
}

if (Uri.TryCreate(input, UriKind.Absolute, out Uri? absoluteUri))
return BuildCanonicalYoutubeUri(absoluteUri);

// Something like "youtube.com/@name" without a scheme. Other slash-containing
// values are rejected by the host check instead of being treated as handles.
if (input.Contains('/'))
{
if (!Uri.TryCreate($"https://{input.TrimStart('/')}", UriKind.Absolute, out Uri? schemelessUri))
return null;

return BuildCanonicalYoutubeUri(schemelessUri);
}

// Otherwise treat it as a handle
return $"https://www.youtube.com/@{input}";
return BuildHandleUri(input);
}

private static Uri? BuildCanonicalYoutubeUri(Uri uri)
{
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
return null;

if (uri.Host.Equals("youtu.be", StringComparison.OrdinalIgnoreCase))
{
string videoId = uri.AbsolutePath.Trim('/');
return VideoIdRegex().IsMatch(videoId)
? new Uri($"https://www.youtube.com/watch?v={videoId}")
: null;
}

if (!IsYoutubeHost(uri.Host) || !IsSupportedYoutubePath(uri.AbsolutePath))
return null;

// Canonicalize the host and discard user-controlled query/fragment values. The
// resolver only needs the channel path to scrape the corresponding YouTube page.
return new Uri($"https://www.youtube.com{uri.AbsolutePath}");
}

private static Uri? BuildHandleUri(string handle) =>
string.IsNullOrWhiteSpace(handle) || handle.Contains('/')
? null
: new Uri($"https://www.youtube.com/@{Uri.EscapeDataString(handle)}");

private static bool IsYoutubeHost(string host) =>
host.Equals("youtube.com", StringComparison.OrdinalIgnoreCase) ||
host.EndsWith(".youtube.com", StringComparison.OrdinalIgnoreCase);

private static bool IsSupportedYoutubePath(string path) =>
path.StartsWith("/@", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/c/", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/user/", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/channel/", StringComparison.OrdinalIgnoreCase);

/// <summary>
/// Uses the Innertube (youtubei) browse API to fetch a channel's avatar URL.
/// Returns null on error or if thumbnails are not found.
Expand Down