Tests: xUnit project with HAR fixtures (50 tests); fix relative delivery/HLS URLs resolving to file:// on Linux
All checks were successful
release / build-release (push) Successful in 1m19s
All checks were successful
release / build-release (push) Successful in 1m19s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
178
Jellyfin.Plugin.Floatplane.Tests/ApiClientTests.cs
Normal file
178
Jellyfin.Plugin.Floatplane.Tests/ApiClientTests.cs
Normal file
@@ -0,0 +1,178 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Floatplane.Api;
|
||||
using Jellyfin.Plugin.Floatplane.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.Floatplane.Tests;
|
||||
|
||||
public class ApiClientTests
|
||||
{
|
||||
private static (FloatplaneApiClient Client, FakeHttpHandler Handler) Build(PluginConfiguration config, Action<FakeHttpHandler>? routes = null)
|
||||
{
|
||||
var handler = new FakeHttpHandler();
|
||||
routes?.Invoke(handler);
|
||||
var client = new FloatplaneApiClient(new FakeHttpClientFactory(handler), NullLogger<FloatplaneApiClient>.Instance, () => config);
|
||||
return (client, handler);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListPosts_builds_query_and_sends_cookie_and_user_agent()
|
||||
{
|
||||
var (client, handler) = Build(
|
||||
new PluginConfiguration { SessionCookie = "s%3Aabc.def" },
|
||||
h => h.On("/api/v3/content/creator", _ => FakeHttpHandler.Json(Fixture.Text("content-creator.json"))));
|
||||
|
||||
var posts = await client.ListPostsAsync(
|
||||
new PostQuery { CreatorId = "cre1", ChannelId = "ch1", Limit = 20, FetchAfter = 40, HasVideo = true, Search = "frame work" },
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(5, posts.Count);
|
||||
var request = Assert.Single(handler.Requests);
|
||||
Assert.Equal("www.floatplane.com", request.RequestUri!.Host);
|
||||
Assert.Equal("/api/v3/content/creator", request.RequestUri.AbsolutePath);
|
||||
var query = request.RequestUri.Query.TrimStart('?').Split('&');
|
||||
Assert.Contains("id=cre1", query);
|
||||
Assert.Contains("limit=20", query);
|
||||
Assert.Contains("fetchAfter=40", query);
|
||||
Assert.Contains("sort=DESC", query);
|
||||
Assert.Contains("hasVideo=true", query);
|
||||
Assert.Contains("search=frame%20work", query);
|
||||
Assert.Contains("channel=ch1", query);
|
||||
|
||||
Assert.True(request.Headers.TryGetValues("Cookie", out var cookies));
|
||||
Assert.Equal("sails.sid=s%3Aabc.def", Assert.Single(cookies));
|
||||
Assert.Contains("Jellyfin-Floatplane/", request.Headers.UserAgent.ToString());
|
||||
Assert.True(client.HasSession);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListPosts_omits_cookie_and_optional_params_when_unset()
|
||||
{
|
||||
var (client, handler) = Build(
|
||||
new PluginConfiguration(),
|
||||
h => h.On("/api/v3/content/creator", _ => FakeHttpHandler.Json("[]")));
|
||||
|
||||
var posts = await client.ListPostsAsync(new PostQuery { CreatorId = "cre1", Limit = 50 }, CancellationToken.None);
|
||||
|
||||
Assert.Empty(posts);
|
||||
var request = Assert.Single(handler.Requests);
|
||||
Assert.False(request.Headers.Contains("Cookie"));
|
||||
var query = request.RequestUri!.Query;
|
||||
Assert.Contains("limit=20", query); // clamped to Floatplane's max
|
||||
Assert.DoesNotContain("hasVideo", query);
|
||||
Assert.DoesNotContain("search", query);
|
||||
Assert.DoesNotContain("channel", query);
|
||||
Assert.False(client.HasSession);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSelf_401_with_envelope_throws_auth_exception()
|
||||
{
|
||||
var (client, _) = Build(
|
||||
new PluginConfiguration { SessionCookie = "expired" },
|
||||
h => h.On("/api/v3/user/self", _ => FakeHttpHandler.Json(Fixture.Text("error-not-logged-in.json"), HttpStatusCode.Unauthorized)));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<FloatplaneAuthException>(() => client.GetSelfAsync(CancellationToken.None));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, ex.Status);
|
||||
Assert.Equal("notLoggedInError", ex.ErrorName);
|
||||
Assert.Equal("You must be logged-in to access this resource.", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotLoggedIn_envelope_with_200_family_status_is_still_auth_error()
|
||||
{
|
||||
// Floatplane sometimes answers 403 for notSubscribedError; make sure the name alone is enough.
|
||||
var (client, _) = Build(
|
||||
new PluginConfiguration { SessionCookie = "x" },
|
||||
h => h.On("/api/v3/delivery/info", _ => FakeHttpHandler.Json("""{"errors":[{"name":"notSubscribedError","message":"nope"}],"message":"nope"}""", HttpStatusCode.BadRequest)));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<FloatplaneAuthException>(() => client.GetDeliveryInfoAsync("onDemand", "v1", CancellationToken.None));
|
||||
|
||||
Assert.Equal("notSubscribedError", ex.ErrorName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cookie_override_is_used_instead_of_configuration()
|
||||
{
|
||||
var (client, handler) = Build(
|
||||
new PluginConfiguration { SessionCookie = "configured" },
|
||||
h => h.On("/api/v3/user/self", _ => FakeHttpHandler.Json(Fixture.Text("user-self.json"))));
|
||||
|
||||
var self = await client.GetSelfAsync(CancellationToken.None, "sails.sid=override");
|
||||
|
||||
Assert.False(string.IsNullOrEmpty(self.Username));
|
||||
var cookie = Assert.Single(handler.Requests).Headers.GetValues("Cookie").Single();
|
||||
Assert.Equal("sails.sid=sails.sid=override", cookie); // raw passthrough: callers strip the prefix (admin controller does)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rate_limit_throws_with_retry_after()
|
||||
{
|
||||
var (client, _) = Build(
|
||||
new PluginConfiguration { SessionCookie = "x" },
|
||||
h => h.On("/api/v3/delivery/info", _ =>
|
||||
{
|
||||
var r = new HttpResponseMessage(HttpStatusCode.TooManyRequests) { Content = new StringContent("error code: 1015") };
|
||||
r.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(42));
|
||||
return r;
|
||||
}));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<FloatplaneRateLimitException>(() => client.GetDeliveryInfoAsync("onDemand", "v1", CancellationToken.None));
|
||||
|
||||
Assert.Equal(TimeSpan.FromSeconds(42), ex.RetryAfter);
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, ex.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Other_errors_throw_generic_api_exception()
|
||||
{
|
||||
var (client, _) = Build(
|
||||
new PluginConfiguration(),
|
||||
h => h.On("/api/v3/content/creator", _ => FakeHttpHandler.Json("""{"errors":[{"name":"paramValidationError","message":"\"limit\" must be less than or equal to 20"}],"message":"\"limit\" must be less than or equal to 20"}""", HttpStatusCode.BadRequest)));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<FloatplaneApiException>(() => client.ListPostsAsync(new PostQuery { CreatorId = "c" }, CancellationToken.None));
|
||||
|
||||
Assert.IsNotType<FloatplaneAuthException>(ex);
|
||||
Assert.Equal("paramValidationError", ex.ErrorName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCreatorsByUrlName_and_channels_use_indexed_array_params()
|
||||
{
|
||||
var (client, handler) = Build(
|
||||
new PluginConfiguration(),
|
||||
h => h.On("/api/v3/creator/named", _ => FakeHttpHandler.Json("[" + Fixture.Text("creator-info.json") + "]"))
|
||||
.On("/api/v3/creator/channels/list", _ => FakeHttpHandler.Json("[]")));
|
||||
|
||||
var creators = await client.GetCreatorsByUrlNameAsync(new[] { "linustechtips", "bitwit" }, CancellationToken.None);
|
||||
await client.GetCreatorChannelsAsync(new[] { "a", "b" }, CancellationToken.None);
|
||||
|
||||
Assert.Single(creators);
|
||||
Assert.Contains("creatorURL%5B0%5D=linustechtips&creatorURL%5B1%5D=bitwit", handler.Requests[0].RequestUri!.Query);
|
||||
Assert.Contains("ids%5B0%5D=a&ids%5B1%5D=b", handler.Requests[1].RequestUri!.Query);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetWatchKey_sends_cookie_and_referer_and_returns_bytes()
|
||||
{
|
||||
var key = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 };
|
||||
var (client, handler) = Build(
|
||||
new PluginConfiguration { SessionCookie = "sid" },
|
||||
h => h.On("/api/video/watchKey", _ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(key) }));
|
||||
|
||||
var result = await client.GetWatchKeyAsync("https://www.floatplane.com/api/video/watchKey?token=T", CancellationToken.None);
|
||||
|
||||
Assert.Equal(key, result);
|
||||
var request = Assert.Single(handler.Requests);
|
||||
Assert.Equal("sails.sid=sid", request.Headers.GetValues("Cookie").Single());
|
||||
Assert.Equal("https://www.floatplane.com/", request.Headers.Referrer!.ToString());
|
||||
}
|
||||
}
|
||||
206
Jellyfin.Plugin.Floatplane.Tests/CatalogServiceTests.cs
Normal file
206
Jellyfin.Plugin.Floatplane.Tests/CatalogServiceTests.cs
Normal file
@@ -0,0 +1,206 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Floatplane.Api;
|
||||
using Jellyfin.Plugin.Floatplane.Api.Models;
|
||||
using Jellyfin.Plugin.Floatplane.Channel;
|
||||
using Jellyfin.Plugin.Floatplane.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.Floatplane.Tests;
|
||||
|
||||
public class CatalogServiceTests
|
||||
{
|
||||
/// <summary>Builds <paramref name="count"/> distinct posts from the 5 fixture posts.</summary>
|
||||
private static List<PostModel> MakePosts(int count, int startIndex = 0, Func<int, bool>? locked = null)
|
||||
{
|
||||
var template = Fixture.Json<List<PostModel>>("content-creator.json");
|
||||
var result = new List<PostModel>();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var n = startIndex + i;
|
||||
var p = Fixture.Json<List<PostModel>>("content-creator.json")[n % template.Count];
|
||||
p.Id = $"post{n:D3}";
|
||||
p.Guid = p.Id;
|
||||
p.IsAccessible = locked == null || !locked(n);
|
||||
result.Add(p);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string Json(List<PostModel> posts) => JsonSerializer.Serialize(posts, FloatplaneJson.Options);
|
||||
|
||||
private static (CatalogService Catalog, FakeHttpHandler Handler) Build(PluginConfiguration config, Func<int, List<PostModel>> pageForOffset)
|
||||
{
|
||||
var handler = new FakeHttpHandler().On("/api/v3/content/creator", r =>
|
||||
{
|
||||
var query = r.RequestUri!.Query.TrimStart('?').Split('&').Select(p => p.Split('=', 2)).ToDictionary(p => p[0], p => Uri.UnescapeDataString(p[1]));
|
||||
var offset = int.Parse(query["fetchAfter"], System.Globalization.CultureInfo.InvariantCulture);
|
||||
return FakeHttpHandler.Json(Json(pageForOffset(offset)));
|
||||
});
|
||||
var api = new FloatplaneApiClient(new FakeHttpClientFactory(handler), NullLogger<FloatplaneApiClient>.Instance, () => config);
|
||||
var catalog = new CatalogService(api, NullLogger<CatalogService>.Instance, () => config);
|
||||
return (catalog, handler);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchPosts_pages_until_short_page()
|
||||
{
|
||||
var (catalog, handler) = Build(
|
||||
new PluginConfiguration(),
|
||||
offset => offset switch
|
||||
{
|
||||
0 => MakePosts(20, 0),
|
||||
20 => MakePosts(5, 20),
|
||||
_ => new List<PostModel>()
|
||||
});
|
||||
|
||||
var posts = await catalog.FetchPostsAsync("cre1", string.Empty, 100, CancellationToken.None);
|
||||
|
||||
Assert.Equal(25, posts.Count);
|
||||
Assert.Equal(25, posts.Select(p => p.Id).Distinct().Count());
|
||||
Assert.Equal(2, handler.Requests.Count);
|
||||
Assert.Contains("fetchAfter=0", handler.Requests[0].RequestUri!.Query);
|
||||
Assert.Contains("fetchAfter=20", handler.Requests[1].RequestUri!.Query);
|
||||
Assert.All(handler.Requests, r => Assert.Contains("hasVideo=true", r.RequestUri!.Query));
|
||||
Assert.All(handler.Requests, r => Assert.DoesNotContain("channel=", r.RequestUri!.Query));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchPosts_stops_on_empty_page()
|
||||
{
|
||||
var (catalog, handler) = Build(
|
||||
new PluginConfiguration(),
|
||||
offset => offset == 0 ? MakePosts(20, 0) : new List<PostModel>());
|
||||
|
||||
var posts = await catalog.FetchPostsAsync("cre1", "chan1", 100, CancellationToken.None);
|
||||
|
||||
Assert.Equal(20, posts.Count);
|
||||
Assert.Equal(2, handler.Requests.Count);
|
||||
Assert.All(handler.Requests, r => Assert.Contains("channel=chan1", r.RequestUri!.Query));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchPosts_respects_max()
|
||||
{
|
||||
var (catalog, handler) = Build(
|
||||
new PluginConfiguration(),
|
||||
offset => MakePosts(20, offset));
|
||||
|
||||
var posts = await catalog.FetchPostsAsync("cre1", string.Empty, 30, CancellationToken.None);
|
||||
|
||||
Assert.Equal(30, posts.Count);
|
||||
Assert.Equal(2, handler.Requests.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchPosts_filters_locked_posts_unless_configured()
|
||||
{
|
||||
// Without a session Floatplane marks everything inaccessible, so the filter only applies when logged in.
|
||||
var config = new PluginConfiguration { ShowLockedPosts = false, SessionCookie = "sid" };
|
||||
var (catalog, _) = Build(config, offset => offset == 0 ? MakePosts(10, 0, n => n % 2 == 1) : new List<PostModel>());
|
||||
|
||||
var visible = await catalog.FetchPostsAsync("cre1", string.Empty, 100, CancellationToken.None);
|
||||
Assert.Equal(5, visible.Count);
|
||||
Assert.All(visible, p => Assert.True(p.IsAccessible));
|
||||
|
||||
config.ShowLockedPosts = true;
|
||||
var all = await catalog.FetchPostsAsync("cre1", string.Empty, 100, CancellationToken.None);
|
||||
Assert.Equal(10, all.Count);
|
||||
Assert.Equal(5, all.Count(p => !p.IsAccessible));
|
||||
|
||||
// No session: locked posts are always listed (browsing is public; Floatplane reports everything locked).
|
||||
config.ShowLockedPosts = false;
|
||||
config.SessionCookie = string.Empty;
|
||||
var anonymous = await catalog.FetchPostsAsync("cre1", string.Empty, 100, CancellationToken.None);
|
||||
Assert.Equal(10, anonymous.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchPosts_passes_search_and_skips_posts_without_video()
|
||||
{
|
||||
var pages = MakePosts(3, 0);
|
||||
pages[1].Metadata!.HasVideo = false;
|
||||
var (catalog, handler) = Build(new PluginConfiguration(), offset => offset == 0 ? pages : new List<PostModel>());
|
||||
|
||||
var posts = await catalog.FetchPostsAsync("cre1", string.Empty, 100, CancellationToken.None, "framework");
|
||||
|
||||
Assert.Equal(2, posts.Count);
|
||||
Assert.Contains("search=framework", handler.Requests[0].RequestUri!.Query);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPosts_caches_and_Invalidate_bumps_generation()
|
||||
{
|
||||
var (catalog, handler) = Build(new PluginConfiguration { CatalogCacheMinutes = 30, MaxPostsPerChannel = 20 }, offset => offset == 0 ? MakePosts(5, 0) : new List<PostModel>());
|
||||
|
||||
var first = await catalog.GetPostsAsync("cre1", "chan1", CancellationToken.None);
|
||||
var second = await catalog.GetPostsAsync("cre1", "chan1", CancellationToken.None);
|
||||
|
||||
Assert.Same(first, second);
|
||||
Assert.Single(handler.Requests);
|
||||
Assert.Equal(1, catalog.CachedPostListCount);
|
||||
Assert.NotNull(catalog.FindCachedPost("post002"));
|
||||
Assert.Null(catalog.FindCachedPost("nope"));
|
||||
|
||||
var generation = catalog.Generation;
|
||||
catalog.Invalidate();
|
||||
|
||||
Assert.Equal(generation + 1, catalog.Generation);
|
||||
Assert.Equal(0, catalog.CachedPostListCount);
|
||||
await catalog.GetPostsAsync("cre1", "chan1", CancellationToken.None);
|
||||
Assert.Equal(2, handler.Requests.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCreators_without_session_resolves_extra_creators_publicly()
|
||||
{
|
||||
var config = new PluginConfiguration { ExtraCreators = "linustechtips, , bitwit" };
|
||||
var handler = new FakeHttpHandler()
|
||||
.On("/api/v3/creator/named", _ => FakeHttpHandler.Json("[" + Fixture.Text("creator-info.json") + "]"));
|
||||
var api = new FloatplaneApiClient(new FakeHttpClientFactory(handler), NullLogger<FloatplaneApiClient>.Instance, () => config);
|
||||
var catalog = new CatalogService(api, NullLogger<CatalogService>.Instance, () => config);
|
||||
|
||||
var creators = await catalog.GetCreatorsAsync(false, CancellationToken.None);
|
||||
|
||||
var creator = Assert.Single(creators);
|
||||
Assert.Equal("LinusTechTips", creator.Title);
|
||||
Assert.Equal(11, creator.Channels.Count);
|
||||
Assert.Equal("none", catalog.SessionState);
|
||||
Assert.Contains("creatorURL%5B0%5D=linustechtips&creatorURL%5B1%5D=bitwit", Assert.Single(handler.Requests).RequestUri!.Query);
|
||||
Assert.Same(creator, catalog.FindCreator(creator.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCreators_with_session_uses_subscriptions_and_marks_invalid_cookie()
|
||||
{
|
||||
var config = new PluginConfiguration { SessionCookie = "sid" };
|
||||
var handler = new FakeHttpHandler()
|
||||
.On("/api/v3/user/subscriptions", _ => FakeHttpHandler.Json(Fixture.Text("user-subscriptions.json")))
|
||||
.On("/api/v3/creator/info", _ => FakeHttpHandler.Json(Fixture.Text("creator-info.json")));
|
||||
var api = new FloatplaneApiClient(new FakeHttpClientFactory(handler), NullLogger<FloatplaneApiClient>.Instance, () => config);
|
||||
var catalog = new CatalogService(api, NullLogger<CatalogService>.Instance, () => config);
|
||||
|
||||
var creators = await catalog.GetCreatorsAsync(false, CancellationToken.None);
|
||||
|
||||
Assert.Single(creators);
|
||||
Assert.Equal("ok", catalog.SessionState);
|
||||
Assert.Contains("id=59f94c0bdd241b70349eb72b", handler.Requests[1].RequestUri!.Query);
|
||||
|
||||
var rejected = new FakeHttpHandler()
|
||||
.On("/api/v3/user/subscriptions", _ => FakeHttpHandler.Json(Fixture.Text("error-not-logged-in.json"), System.Net.HttpStatusCode.Unauthorized));
|
||||
var api2 = new FloatplaneApiClient(new FakeHttpClientFactory(rejected), NullLogger<FloatplaneApiClient>.Instance, () => config);
|
||||
var catalog2 = new CatalogService(api2, NullLogger<CatalogService>.Instance, () => config);
|
||||
|
||||
var none = await catalog2.GetCreatorsAsync(true, CancellationToken.None);
|
||||
|
||||
Assert.Empty(none);
|
||||
Assert.Equal("invalid", catalog2.SessionState);
|
||||
Assert.NotNull(catalog2.LastError);
|
||||
}
|
||||
}
|
||||
84
Jellyfin.Plugin.Floatplane.Tests/ChannelMappingTests.cs
Normal file
84
Jellyfin.Plugin.Floatplane.Tests/ChannelMappingTests.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Jellyfin.Plugin.Floatplane.Api.Models;
|
||||
using Jellyfin.Plugin.Floatplane.Channel;
|
||||
using MediaBrowser.Controller.Channels;
|
||||
using MediaBrowser.Model.Channels;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.Floatplane.Tests;
|
||||
|
||||
public class ChannelMappingTests
|
||||
{
|
||||
private static PostModel FirstPost() => Fixture.Json<List<PostModel>>("content-creator.json")[0];
|
||||
|
||||
[Fact]
|
||||
public void ToItem_maps_post_fields()
|
||||
{
|
||||
var post = FirstPost();
|
||||
var creator = new CreatorModel { Id = "59f94c0bdd241b70349eb72b", Title = "LinusTechTips", Urlname = "linustechtips" };
|
||||
|
||||
var item = FloatplaneChannel.ToItem(post, creator);
|
||||
|
||||
Assert.Equal("post:SSvhppI28t", item.Id);
|
||||
Assert.Equal(post.Title, item.Name);
|
||||
Assert.Equal(ChannelItemType.Media, item.Type);
|
||||
Assert.Equal(ChannelMediaType.Video, item.MediaType);
|
||||
Assert.Equal(ChannelMediaContentType.Clip, item.ContentType);
|
||||
Assert.Equal(1104 * TimeSpan.TicksPerSecond, item.RunTimeTicks);
|
||||
Assert.NotNull(item.PremiereDate);
|
||||
Assert.Equal(new DateTime(2026, 10, 24, 18, 24, 0, 36, DateTimeKind.Utc), item.PremiereDate!.Value);
|
||||
Assert.Equal(2026, item.ProductionYear);
|
||||
Assert.EndsWith("_1200x675.jpeg", item.ImageUrl);
|
||||
Assert.Contains("ShortCircuit", item.Genres);
|
||||
Assert.Contains("LinusTechTips", item.Studios);
|
||||
Assert.Equal("https://www.floatplane.com/post/SSvhppI28t", item.HomePageUrl);
|
||||
Assert.Equal("SSvhppI28t", item.ProviderIds["Floatplane"]);
|
||||
Assert.Equal("Sample description.", item.Overview);
|
||||
Assert.NotNull(item.CommunityRating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToItem_prefixes_locked_posts()
|
||||
{
|
||||
var post = FirstPost();
|
||||
post.IsAccessible = false;
|
||||
|
||||
var item = FloatplaneChannel.ToItem(post, null);
|
||||
|
||||
Assert.StartsWith("🔒 ", item.Name);
|
||||
Assert.Empty(item.Studios);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToItem_uses_stripped_html_when_markdown_missing()
|
||||
{
|
||||
var post = FirstPost();
|
||||
post.TextMarkdown = null;
|
||||
post.Text = "<p>First <b>bold</b> line.</p><p>Second & third</p>";
|
||||
|
||||
var item = FloatplaneChannel.ToItem(post, null);
|
||||
|
||||
Assert.Equal("First bold line.\nSecond & third", item.Overview);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StripHtml_converts_breaks_and_entities()
|
||||
{
|
||||
var text = FloatplaneChannel.StripHtml("<p>One<br/>Two</p><p>Three <x></p><ul><li>a</li><li>b</li></ul>");
|
||||
|
||||
Assert.Equal("One\nTwo\nThree <x>\na\nb", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeterministicGuid_is_stable_and_distinct()
|
||||
{
|
||||
var a = FloatplaneChannel.DeterministicGuid("post:abc");
|
||||
var b = FloatplaneChannel.DeterministicGuid("post:abc");
|
||||
var c = FloatplaneChannel.DeterministicGuid("post:abd");
|
||||
|
||||
Assert.Equal(a, b);
|
||||
Assert.NotEqual(a, c);
|
||||
Assert.NotEqual(Guid.Empty, a);
|
||||
}
|
||||
}
|
||||
69
Jellyfin.Plugin.Floatplane.Tests/DeliveryResolverTests.cs
Normal file
69
Jellyfin.Plugin.Floatplane.Tests/DeliveryResolverTests.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Jellyfin.Plugin.Floatplane.Api.Models;
|
||||
using Jellyfin.Plugin.Floatplane.Playback;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.Floatplane.Tests;
|
||||
|
||||
public class DeliveryResolverTests
|
||||
{
|
||||
private static ResolvedVariant[] Variants() => Fixture.Json<DeliveryInfoModel>("delivery-info.json").Resolve().ToArray();
|
||||
|
||||
[Fact]
|
||||
public void SelectVariant_exact_level()
|
||||
{
|
||||
var v = DeliveryResolver.SelectVariant(Variants(), "720");
|
||||
|
||||
Assert.NotNull(v);
|
||||
Assert.Equal("720-avc1", v!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectVariant_falls_back_to_not_taller()
|
||||
{
|
||||
// 2160 is denied → best usable ≤ 2160 is 1080
|
||||
var v = DeliveryResolver.SelectVariant(Variants(), "2160");
|
||||
|
||||
Assert.Equal("1080-avc1", v!.Name);
|
||||
|
||||
var v2 = DeliveryResolver.SelectVariant(Variants(), "600");
|
||||
Assert.Equal("480-avc1", v2!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectVariant_falls_back_to_best_when_nothing_fits()
|
||||
{
|
||||
var v = DeliveryResolver.SelectVariant(Variants(), "240");
|
||||
|
||||
Assert.Equal("1080-avc1", v!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectVariant_returns_null_without_usable_variants()
|
||||
{
|
||||
var disabled = Variants().Select(v => new ResolvedVariant(new DeliveryVariant { Name = v.Name, Url = string.Empty, Enabled = false }, v.Url)).ToList();
|
||||
|
||||
Assert.Null(DeliveryResolver.SelectVariant(disabled, "1080"));
|
||||
Assert.Null(DeliveryResolver.SelectVariant(Array.Empty<ResolvedVariant>(), "1080"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TokenExpiry_reads_exp_from_jwt()
|
||||
{
|
||||
var url = Variants().First(v => v.Height == 1080).Url;
|
||||
|
||||
var exp = DeliveryResolver.TokenExpiry(url);
|
||||
|
||||
Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1792935296), exp);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://cdn.example/x.m3u8")]
|
||||
[InlineData("https://cdn.example/x.m3u8?token=not-a-jwt")]
|
||||
[InlineData("not a url")]
|
||||
public void TokenExpiry_returns_null_without_jwt(string url)
|
||||
{
|
||||
Assert.Null(DeliveryResolver.TokenExpiry(url));
|
||||
}
|
||||
}
|
||||
66
Jellyfin.Plugin.Floatplane.Tests/FakeHttp.cs
Normal file
66
Jellyfin.Plugin.Floatplane.Tests/FakeHttp.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Jellyfin.Plugin.Floatplane.Tests;
|
||||
|
||||
/// <summary>Maps request URLs (prefix match on path, or a predicate) to canned responses and records requests.</summary>
|
||||
internal sealed class FakeHttpHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly List<(Func<HttpRequestMessage, bool> Match, Func<HttpRequestMessage, HttpResponseMessage> Respond)> _routes = new();
|
||||
|
||||
public List<HttpRequestMessage> Requests { get; } = new();
|
||||
|
||||
public FakeHttpHandler On(string pathPrefix, Func<HttpRequestMessage, HttpResponseMessage> respond)
|
||||
{
|
||||
_routes.Add((r => r.RequestUri!.AbsolutePath.StartsWith(pathPrefix, StringComparison.Ordinal), respond));
|
||||
return this;
|
||||
}
|
||||
|
||||
public FakeHttpHandler On(Func<HttpRequestMessage, bool> match, Func<HttpRequestMessage, HttpResponseMessage> respond)
|
||||
{
|
||||
_routes.Add((match, respond));
|
||||
return this;
|
||||
}
|
||||
|
||||
public static HttpResponseMessage Json(string body, HttpStatusCode status = HttpStatusCode.OK)
|
||||
=> new(status) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") };
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(request);
|
||||
foreach (var (match, respond) in _routes)
|
||||
{
|
||||
if (match(request))
|
||||
{
|
||||
var response = respond(request);
|
||||
response.RequestMessage = request;
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
var notFound = new HttpResponseMessage(HttpStatusCode.NotFound) { RequestMessage = request, Content = new StringContent("no route for " + request.RequestUri) };
|
||||
return Task.FromResult(notFound);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mirrors the registrator: one named client with the plugin User-Agent.</summary>
|
||||
internal sealed class FakeHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpMessageHandler _handler;
|
||||
|
||||
public FakeHttpClientFactory(HttpMessageHandler handler)
|
||||
{
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name)
|
||||
{
|
||||
var client = new HttpClient(_handler, disposeHandler: false);
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd(Plugin.UserAgent);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
14
Jellyfin.Plugin.Floatplane.Tests/Fixture.cs
Normal file
14
Jellyfin.Plugin.Floatplane.Tests/Fixture.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using Jellyfin.Plugin.Floatplane.Api.Models;
|
||||
|
||||
namespace Jellyfin.Plugin.Floatplane.Tests;
|
||||
|
||||
internal static class Fixture
|
||||
{
|
||||
public static string Path(string name) => System.IO.Path.Combine(System.AppContext.BaseDirectory, "Fixtures", name);
|
||||
|
||||
public static string Text(string name) => File.ReadAllText(Path(name));
|
||||
|
||||
public static T Json<T>(string name) => JsonSerializer.Deserialize<T>(Text(name), FloatplaneJson.Options)!;
|
||||
}
|
||||
17
Jellyfin.Plugin.Floatplane.Tests/Fixtures/chunk.m3u8
Normal file
17
Jellyfin.Plugin.Floatplane.Tests/Fixtures/chunk.m3u8
Normal file
@@ -0,0 +1,17 @@
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:12
|
||||
#EXT-X-KEY:METHOD=AES-128,URI="https://www.floatplane.com/api/video/watchKey?token=eyJwYXRoIjoiL1ZpZGVvcy9kY20yalVJZzFKLzEwODAubXA0IiwidGltZXN0YW1wIjoxNzkyODg2NDAwMDAwfQ"
|
||||
#EXTINF:4.804,
|
||||
eyJmaWxlTmFtZSI6Im5fMF8wXzAudHMiLCJzZXF1ZW5jZSI6MCwidGltZXN0YW1wIjoxNzkyODg2NDAwMDAwfQ.ts?token=SEGTOKEN1
|
||||
#EXTINF:6.206,
|
||||
eyJmaWxlTmFtZSI6Im5fMV8wXzAudHMiLCJzZXF1ZW5jZSI6MSwidGltZXN0YW1wIjoxNzkyODg2NDAwMDAwfQ.ts?token=SEGTOKEN2
|
||||
#EXTINF:3.67,
|
||||
eyJmaWxlTmFtZSI6Im5fMl8wXzAudHMiLCJzZXF1ZW5jZSI6MiwidGltZXN0YW1wIjoxNzkyODg2NDAwMDAwfQ.ts?token=SEGTOKEN3
|
||||
#EXTINF:8.341,
|
||||
eyJmaWxlTmFtZSI6Im5fM18wXzAudHMiLCJzZXF1ZW5jZSI6MywidGltZXN0YW1wIjoxNzkyODg2NDAwMDAwfQ.ts?token=SEGTOKEN4
|
||||
#EXTINF:3.269,
|
||||
eyJmaWxlTmFtZSI6Im5fNF8wXzAudHMiLCJzZXF1ZW5jZSI6NCwidGltZXN0YW1wIjoxNzkyODg2NDAwMDAwfQ.ts?token=SEGTOKEN5
|
||||
#EXTINF:8.341,
|
||||
eyJmaWxlTmFtZSI6Im5fNV8wXzAudHMiLCJzZXF1ZW5jZSI6NSwidGltZXN0YW1wIjoxNzkyODg2NDAwMDAwfQ.ts?token=SEGTOKEN6
|
||||
#EXT-X-ENDLIST
|
||||
369
Jellyfin.Plugin.Floatplane.Tests/Fixtures/content-creator.json
Normal file
369
Jellyfin.Plugin.Floatplane.Tests/Fixtures/content-creator.json
Normal file
@@ -0,0 +1,369 @@
|
||||
[
|
||||
{
|
||||
"id": "SSvhppI28t",
|
||||
"guid": "SSvhppI28t",
|
||||
"title": "How Board Games Come ALIVE...- Board",
|
||||
"text": "<p>Sample description.</p>",
|
||||
"textMarkdown": "Sample description.",
|
||||
"type": "blogPost",
|
||||
"tags": [],
|
||||
"releaseDate": "2026-10-24T18:24:00.036Z",
|
||||
"likes": 59,
|
||||
"dislikes": 0,
|
||||
"score": 59,
|
||||
"comments": 6,
|
||||
"isAccessible": true,
|
||||
"metadata": {
|
||||
"hasVideo": true,
|
||||
"videoCount": 1,
|
||||
"videoDuration": 1104,
|
||||
"hasAudio": false,
|
||||
"audioCount": 0,
|
||||
"audioDuration": 0,
|
||||
"hasPicture": false,
|
||||
"pictureCount": 0,
|
||||
"isFeatured": false,
|
||||
"displayDuration": 1104,
|
||||
"hasGallery": false,
|
||||
"galleryCount": 0
|
||||
},
|
||||
"selfUserInteraction": null,
|
||||
"thumbnail": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"path": "https://pbs.floatplane.com/video_thumbnails/SSvhppI28t/385027366862101_1792699537143.jpeg",
|
||||
"childImages": [
|
||||
{
|
||||
"width": 400,
|
||||
"height": 225,
|
||||
"path": "https://pbs.floatplane.com/video_thumbnails/SSvhppI28t/385027366862101_1792699537143_400x225.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 1200,
|
||||
"height": 675,
|
||||
"path": "https://pbs.floatplane.com/video_thumbnails/SSvhppI28t/385027366862101_1792699537143_1200x675.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"path": "https://pbs.floatplane.com/video_thumbnails/SSvhppI28t/385027366862101_1792699537143_1920x1080.jpeg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"creator": {
|
||||
"id": "59f94c0bdd241b70349eb72b",
|
||||
"title": "LinusTechTips",
|
||||
"urlname": "linustechtips"
|
||||
},
|
||||
"channel": {
|
||||
"id": "64135da7ce81077a8480c679",
|
||||
"title": "ShortCircuit",
|
||||
"urlname": "shortcircuit",
|
||||
"about": "What's in the box? Let's find out!\n\nOfficial channel under Linus Media Group.",
|
||||
"icon": {
|
||||
"width": 88,
|
||||
"height": 88,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/64135da7ce81077a8480c679/470304051261927_1678990806883.jpeg",
|
||||
"childImages": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "xvpzv1kwk5",
|
||||
"guid": "xvpzv1kwk5",
|
||||
"title": "dbrand Forced me to do This Review - iPhone 18 Pro Review",
|
||||
"text": "<p>Sample description.</p>",
|
||||
"textMarkdown": "Sample description.",
|
||||
"type": "blogPost",
|
||||
"tags": [],
|
||||
"releaseDate": "2026-10-24T16:33:00.032Z",
|
||||
"likes": 158,
|
||||
"dislikes": 2,
|
||||
"score": 156,
|
||||
"comments": 14,
|
||||
"isAccessible": true,
|
||||
"metadata": {
|
||||
"hasVideo": true,
|
||||
"videoCount": 1,
|
||||
"videoDuration": 851,
|
||||
"hasAudio": false,
|
||||
"audioCount": 0,
|
||||
"audioDuration": 0,
|
||||
"hasPicture": false,
|
||||
"pictureCount": 0,
|
||||
"isFeatured": false,
|
||||
"displayDuration": 851,
|
||||
"hasGallery": false,
|
||||
"galleryCount": 0
|
||||
},
|
||||
"selfUserInteraction": null,
|
||||
"thumbnail": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"path": "https://pbs.floatplane.com/video_thumbnails/xvpzv1kwk5/583713206563780_1792800426306.jpeg",
|
||||
"childImages": [
|
||||
{
|
||||
"width": 400,
|
||||
"height": 225,
|
||||
"path": "https://pbs.floatplane.com/video_thumbnails/xvpzv1kwk5/583713206563780_1792800426306_400x225.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 1200,
|
||||
"height": 675,
|
||||
"path": "https://pbs.floatplane.com/video_thumbnails/xvpzv1kwk5/583713206563780_1792800426306_1200x675.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"path": "https://pbs.floatplane.com/video_thumbnails/xvpzv1kwk5/583713206563780_1792800426306_1920x1080.jpeg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"creator": {
|
||||
"id": "59f94c0bdd241b70349eb72b",
|
||||
"title": "LinusTechTips",
|
||||
"urlname": "linustechtips"
|
||||
},
|
||||
"channel": {
|
||||
"id": "63fe42c309e691e4e36de93d",
|
||||
"title": "Linus Tech Tips",
|
||||
"urlname": "main",
|
||||
"about": "# We're LinusTechTips\nWe make videos and stuff, cool eh?",
|
||||
"icon": {
|
||||
"width": 600,
|
||||
"height": 600,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/770551996990709_1551249357205.jpeg",
|
||||
"childImages": [
|
||||
{
|
||||
"width": 250,
|
||||
"height": 250,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/770551996990709_1551249357205_250x250.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 100,
|
||||
"height": 100,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/770551996990709_1551249357205_100x100.jpeg"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "uRa7Ckprjr",
|
||||
"guid": "uRa7Ckprjr",
|
||||
"title": "Livestream VOD \u2013 October 24, 2026 @ 00:52 \u2013 Most Of The Internet Needs To Be Fixed - WAN Show October 23, 2026",
|
||||
"text": "<p>Sample description.</p>",
|
||||
"textMarkdown": "Sample description.",
|
||||
"type": "blogPost",
|
||||
"tags": [],
|
||||
"releaseDate": "2026-10-24T04:47:00.031Z",
|
||||
"likes": 130,
|
||||
"dislikes": 1,
|
||||
"score": 129,
|
||||
"comments": 29,
|
||||
"isAccessible": true,
|
||||
"metadata": {
|
||||
"hasVideo": true,
|
||||
"videoCount": 1,
|
||||
"videoDuration": 11962,
|
||||
"hasAudio": false,
|
||||
"audioCount": 0,
|
||||
"audioDuration": 0,
|
||||
"hasPicture": false,
|
||||
"pictureCount": 0,
|
||||
"isFeatured": false,
|
||||
"displayDuration": 11962,
|
||||
"hasGallery": false,
|
||||
"galleryCount": 0
|
||||
},
|
||||
"selfUserInteraction": null,
|
||||
"thumbnail": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"path": "https://pbs.floatplane.com/stream_thumbnails/5c13f3c006f1be15e08e05c0/635074788591006_1792802818282.jpeg",
|
||||
"childImages": [
|
||||
{
|
||||
"width": 400,
|
||||
"height": 225,
|
||||
"path": "https://pbs.floatplane.com/stream_thumbnails/5c13f3c006f1be15e08e05c0/635074788591006_1792802818282_400x225.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 1200,
|
||||
"height": 675,
|
||||
"path": "https://pbs.floatplane.com/stream_thumbnails/5c13f3c006f1be15e08e05c0/635074788591006_1792802818282_1200x675.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"path": "https://pbs.floatplane.com/stream_thumbnails/5c13f3c006f1be15e08e05c0/635074788591006_1792802818282_1920x1080.jpeg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"creator": {
|
||||
"id": "59f94c0bdd241b70349eb72b",
|
||||
"title": "LinusTechTips",
|
||||
"urlname": "linustechtips"
|
||||
},
|
||||
"channel": {
|
||||
"id": "63fe42c309e691e4e36de93d",
|
||||
"title": "Linus Tech Tips",
|
||||
"urlname": "main",
|
||||
"about": "# We're LinusTechTips\nWe make videos and stuff, cool eh?",
|
||||
"icon": {
|
||||
"width": 600,
|
||||
"height": 600,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/770551996990709_1551249357205.jpeg",
|
||||
"childImages": [
|
||||
{
|
||||
"width": 250,
|
||||
"height": 250,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/770551996990709_1551249357205_250x250.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 100,
|
||||
"height": 100,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/770551996990709_1551249357205_100x100.jpeg"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0OuJLfU1mB",
|
||||
"guid": "0OuJLfU1mB",
|
||||
"title": "iPhone Duo Screens Crack, Spotify Requires Family Plan QR Scans, Meta Fined $25B in New Mexico + more! - TechLinked October 23, 2026",
|
||||
"text": "<p>Sample description.</p>",
|
||||
"textMarkdown": "Sample description.",
|
||||
"type": "blogPost",
|
||||
"tags": [],
|
||||
"releaseDate": "2026-10-24T01:52:00.037Z",
|
||||
"likes": 115,
|
||||
"dislikes": 0,
|
||||
"score": 115,
|
||||
"comments": 11,
|
||||
"isAccessible": true,
|
||||
"metadata": {
|
||||
"hasVideo": true,
|
||||
"videoCount": 1,
|
||||
"videoDuration": 635,
|
||||
"hasAudio": false,
|
||||
"audioCount": 0,
|
||||
"audioDuration": 0,
|
||||
"hasPicture": false,
|
||||
"pictureCount": 0,
|
||||
"isFeatured": false,
|
||||
"displayDuration": 635,
|
||||
"hasGallery": false,
|
||||
"galleryCount": 0
|
||||
},
|
||||
"selfUserInteraction": null,
|
||||
"thumbnail": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"path": "https://pbs.floatplane.com/video_thumbnails/0OuJLfU1mB/286127749764266_1792801117776.jpeg",
|
||||
"childImages": [
|
||||
{
|
||||
"width": 400,
|
||||
"height": 225,
|
||||
"path": "https://pbs.floatplane.com/video_thumbnails/0OuJLfU1mB/286127749764266_1792801117776_400x225.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 1200,
|
||||
"height": 675,
|
||||
"path": "https://pbs.floatplane.com/video_thumbnails/0OuJLfU1mB/286127749764266_1792801117776_1200x675.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"path": "https://pbs.floatplane.com/video_thumbnails/0OuJLfU1mB/286127749764266_1792801117776_1920x1080.jpeg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"creator": {
|
||||
"id": "59f94c0bdd241b70349eb72b",
|
||||
"title": "LinusTechTips",
|
||||
"urlname": "linustechtips"
|
||||
},
|
||||
"channel": {
|
||||
"id": "6413534d88c13c181c3e2809",
|
||||
"title": "TechLinked",
|
||||
"urlname": "techlinked",
|
||||
"about": "News about tech + gaming culture, delivered thrice weekly.\n\nWe're also doing long-form video essays now, apparently. \n\nThe TalkLinked talk show/podcast will probably come back at some point, too!\n\nWriters: Riley Murdock, Jon Martin, James Strieb",
|
||||
"icon": {
|
||||
"width": 88,
|
||||
"height": 88,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/6413534d88c13c181c3e2809/955526950207988_1678988110287.jpeg",
|
||||
"childImages": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nHOQZN21qW",
|
||||
"guid": "nHOQZN21qW",
|
||||
"title": "How Do Foldable Phones Fold?",
|
||||
"text": "<p>Sample description.</p>",
|
||||
"textMarkdown": "Sample description.",
|
||||
"type": "blogPost",
|
||||
"tags": [],
|
||||
"releaseDate": "2026-10-23T19:07:00.031Z",
|
||||
"likes": 109,
|
||||
"dislikes": 2,
|
||||
"score": 107,
|
||||
"comments": 7,
|
||||
"isAccessible": true,
|
||||
"metadata": {
|
||||
"hasVideo": true,
|
||||
"videoCount": 1,
|
||||
"videoDuration": 400,
|
||||
"hasAudio": false,
|
||||
"audioCount": 0,
|
||||
"audioDuration": 0,
|
||||
"hasPicture": false,
|
||||
"pictureCount": 0,
|
||||
"isFeatured": false,
|
||||
"displayDuration": 400,
|
||||
"hasGallery": false,
|
||||
"galleryCount": 0
|
||||
},
|
||||
"selfUserInteraction": null,
|
||||
"thumbnail": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"path": "https://pbs.floatplane.com/video_thumbnails/nHOQZN21qW/639416044080263_1792184912993.jpeg",
|
||||
"childImages": [
|
||||
{
|
||||
"width": 400,
|
||||
"height": 225,
|
||||
"path": "https://pbs.floatplane.com/video_thumbnails/nHOQZN21qW/639416044080263_1792184912993_400x225.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 1200,
|
||||
"height": 675,
|
||||
"path": "https://pbs.floatplane.com/video_thumbnails/nHOQZN21qW/639416044080263_1792184912993_1200x675.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"path": "https://pbs.floatplane.com/video_thumbnails/nHOQZN21qW/639416044080263_1792184912993_1920x1080.jpeg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"creator": {
|
||||
"id": "59f94c0bdd241b70349eb72b",
|
||||
"title": "LinusTechTips",
|
||||
"urlname": "linustechtips"
|
||||
},
|
||||
"channel": {
|
||||
"id": "64135e27c773b27ff22c97eb",
|
||||
"title": "Techquickie",
|
||||
"urlname": "techquickie",
|
||||
"about": "Ever wanted to learn more about your favorite gadgets or a trending topic in tech? \n\nWith a mix of humor, cynicism, and insight, Techquickie brings you the answers to all your tech questions every Tuesday and Friday.",
|
||||
"icon": {
|
||||
"width": 88,
|
||||
"height": 88,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/64135e27c773b27ff22c97eb/666841640245092_1678990909616.jpeg",
|
||||
"childImages": []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
178
Jellyfin.Plugin.Floatplane.Tests/Fixtures/content-post.json
Normal file
178
Jellyfin.Plugin.Floatplane.Tests/Fixtures/content-post.json
Normal file
@@ -0,0 +1,178 @@
|
||||
{
|
||||
"id": "SSvhppI28t",
|
||||
"guid": "SSvhppI28t",
|
||||
"title": "How Board Games Come ALIVE...- Board",
|
||||
"text": "<p>Sample <b>description</b>.<br/>Line two</p>",
|
||||
"textMarkdown": "Sample description.\n\nLine two",
|
||||
"type": "blogPost",
|
||||
"channel": {
|
||||
"id": "64135da7ce81077a8480c679",
|
||||
"creator": "59f94c0bdd241b70349eb72b",
|
||||
"title": "ShortCircuit",
|
||||
"urlname": "shortcircuit",
|
||||
"about": "What's in the box? Let's find out!\n\nOfficial channel under Linus Media Group.",
|
||||
"order": 2,
|
||||
"cover": null,
|
||||
"card": null,
|
||||
"icon": {
|
||||
"width": 88,
|
||||
"height": 88,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/64135da7ce81077a8480c679/470304051261927_1678990806883.jpeg",
|
||||
"childImages": []
|
||||
}
|
||||
},
|
||||
"tags": [],
|
||||
"attachmentOrder": [
|
||||
"dcm2jUIg1J"
|
||||
],
|
||||
"releaseDate": "2026-10-24T18:24:00.036Z",
|
||||
"likes": 59,
|
||||
"dislikes": 0,
|
||||
"score": 59,
|
||||
"comments": 6,
|
||||
"creator": {
|
||||
"id": "59f94c0bdd241b70349eb72b",
|
||||
"title": "LinusTechTips",
|
||||
"urlname": "linustechtips"
|
||||
},
|
||||
"wasReleasedSilently": false,
|
||||
"metadata": {
|
||||
"hasVideo": true,
|
||||
"videoCount": 1,
|
||||
"videoDuration": 1104,
|
||||
"hasAudio": false,
|
||||
"audioCount": 0,
|
||||
"audioDuration": 0,
|
||||
"hasPicture": false,
|
||||
"pictureCount": 0,
|
||||
"isFeatured": false,
|
||||
"displayDuration": 1104,
|
||||
"hasGallery": false,
|
||||
"galleryCount": 0
|
||||
},
|
||||
"galleryAttachments": [],
|
||||
"selfUserInteraction": null,
|
||||
"thumbnail": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"path": "https://pbs.floatplane.com/video_thumbnails/SSvhppI28t/385027366862101_1792699537143.jpeg",
|
||||
"childImages": [
|
||||
{
|
||||
"width": 400,
|
||||
"height": 225,
|
||||
"path": "https://pbs.floatplane.com/video_thumbnails/SSvhppI28t/385027366862101_1792699537143_400x225.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 1200,
|
||||
"height": 675,
|
||||
"path": "https://pbs.floatplane.com/video_thumbnails/SSvhppI28t/385027366862101_1792699537143_1200x675.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"path": "https://pbs.floatplane.com/video_thumbnails/SSvhppI28t/385027366862101_1792699537143_1920x1080.jpeg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"isAccessible": true,
|
||||
"userInteraction": [],
|
||||
"videoAttachments": [
|
||||
{
|
||||
"id": "dcm2jUIg1J",
|
||||
"guid": "dcm2jUIg1J",
|
||||
"title": "How Board Games Come ALIVE...- Board",
|
||||
"type": "video",
|
||||
"description": "",
|
||||
"releaseDate": null,
|
||||
"duration": 1104,
|
||||
"creator": "59f94c0bdd241b70349eb72b",
|
||||
"likes": 0,
|
||||
"dislikes": 0,
|
||||
"score": 0,
|
||||
"isProcessing": false,
|
||||
"primaryBlogPost": "SSvhppI28t",
|
||||
"levels": [
|
||||
{
|
||||
"name": "360",
|
||||
"width": 640,
|
||||
"height": 360,
|
||||
"label": "360p",
|
||||
"order": 0
|
||||
},
|
||||
{
|
||||
"name": "480",
|
||||
"width": 854,
|
||||
"height": 480,
|
||||
"label": "480p",
|
||||
"order": 1
|
||||
},
|
||||
{
|
||||
"name": "720",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"label": "720p",
|
||||
"order": 2
|
||||
},
|
||||
{
|
||||
"name": "1080",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"label": "1080p",
|
||||
"order": 4
|
||||
},
|
||||
{
|
||||
"name": "2160",
|
||||
"width": 3840,
|
||||
"height": 2160,
|
||||
"label": "4K",
|
||||
"order": 7
|
||||
}
|
||||
],
|
||||
"textTracks": [
|
||||
{
|
||||
"id": "6ada6d154381946fe2d43c87",
|
||||
"src": "https://example.invalid/text_tracks/sample.vtt?X-Amz-Expires=900",
|
||||
"kind": "captions",
|
||||
"language": "en",
|
||||
"generated": true,
|
||||
"processing": false
|
||||
}
|
||||
],
|
||||
"selfUserInteraction": null,
|
||||
"thumbnail": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"path": "https://pbs.floatplane.com/content_thumbnails/dcm2jUIg1J/752255832334873_1792699752579.jpeg",
|
||||
"childImages": [
|
||||
{
|
||||
"width": 400,
|
||||
"height": 225,
|
||||
"path": "https://pbs.floatplane.com/content_thumbnails/dcm2jUIg1J/752255832334873_1792699752579_400x225.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 1200,
|
||||
"height": 675,
|
||||
"path": "https://pbs.floatplane.com/content_thumbnails/dcm2jUIg1J/752255832334873_1792699752579_1200x675.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"path": "https://pbs.floatplane.com/content_thumbnails/dcm2jUIg1J/752255832334873_1792699752579_1920x1080.jpeg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"isAccessible": true,
|
||||
"blogPosts": [
|
||||
"SSvhppI28t"
|
||||
],
|
||||
"timelineSprite": {
|
||||
"width": 3840,
|
||||
"height": 2070,
|
||||
"path": "https://pbs.floatplane.com/timeline_sprite/dcm2jUIg1J/435111749793880_1792700514012.jpeg",
|
||||
"childImages": []
|
||||
}
|
||||
}
|
||||
],
|
||||
"audioAttachments": [],
|
||||
"pictureAttachments": []
|
||||
}
|
||||
96
Jellyfin.Plugin.Floatplane.Tests/Fixtures/content-video.json
Normal file
96
Jellyfin.Plugin.Floatplane.Tests/Fixtures/content-video.json
Normal file
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"id": "dcm2jUIg1J",
|
||||
"guid": "dcm2jUIg1J",
|
||||
"title": "How Board Games Come ALIVE...- Board",
|
||||
"type": "video",
|
||||
"description": "",
|
||||
"releaseDate": null,
|
||||
"duration": 1104,
|
||||
"creator": "59f94c0bdd241b70349eb72b",
|
||||
"likes": 0,
|
||||
"dislikes": 0,
|
||||
"score": 0,
|
||||
"isProcessing": false,
|
||||
"primaryBlogPost": "SSvhppI28t",
|
||||
"levels": [
|
||||
{
|
||||
"name": "360",
|
||||
"width": 640,
|
||||
"height": 360,
|
||||
"label": "360p",
|
||||
"order": 0
|
||||
},
|
||||
{
|
||||
"name": "480",
|
||||
"width": 854,
|
||||
"height": 480,
|
||||
"label": "480p",
|
||||
"order": 1
|
||||
},
|
||||
{
|
||||
"name": "720",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"label": "720p",
|
||||
"order": 2
|
||||
},
|
||||
{
|
||||
"name": "1080",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"label": "1080p",
|
||||
"order": 4
|
||||
},
|
||||
{
|
||||
"name": "2160",
|
||||
"width": 3840,
|
||||
"height": 2160,
|
||||
"label": "4K",
|
||||
"order": 7
|
||||
}
|
||||
],
|
||||
"textTracks": [
|
||||
{
|
||||
"id": "6ada6d154381946fe2d43c87",
|
||||
"src": "https://example.invalid/text_tracks/sample.vtt?X-Amz-Expires=900",
|
||||
"kind": "captions",
|
||||
"language": "en",
|
||||
"generated": true,
|
||||
"processing": false
|
||||
}
|
||||
],
|
||||
"selfUserInteraction": null,
|
||||
"thumbnail": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"path": "https://pbs.floatplane.com/content_thumbnails/dcm2jUIg1J/752255832334873_1792699752579.jpeg",
|
||||
"childImages": [
|
||||
{
|
||||
"width": 400,
|
||||
"height": 225,
|
||||
"path": "https://pbs.floatplane.com/content_thumbnails/dcm2jUIg1J/752255832334873_1792699752579_400x225.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 1200,
|
||||
"height": 675,
|
||||
"path": "https://pbs.floatplane.com/content_thumbnails/dcm2jUIg1J/752255832334873_1792699752579_1200x675.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"path": "https://pbs.floatplane.com/content_thumbnails/dcm2jUIg1J/752255832334873_1792699752579_1920x1080.jpeg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"isAccessible": true,
|
||||
"blogPosts": [
|
||||
"SSvhppI28t"
|
||||
],
|
||||
"timelineSprite": {
|
||||
"width": 3840,
|
||||
"height": 2070,
|
||||
"path": "https://pbs.floatplane.com/timeline_sprite/dcm2jUIg1J/435111749793880_1792700514012.jpeg",
|
||||
"childImages": []
|
||||
},
|
||||
"userInteraction": []
|
||||
}
|
||||
474
Jellyfin.Plugin.Floatplane.Tests/Fixtures/creator-info.json
Normal file
474
Jellyfin.Plugin.Floatplane.Tests/Fixtures/creator-info.json
Normal file
@@ -0,0 +1,474 @@
|
||||
{
|
||||
"id": "59f94c0bdd241b70349eb72b",
|
||||
"title": "LinusTechTips",
|
||||
"urlname": "linustechtips",
|
||||
"description": "We make entertaining videos about technology, including tech reviews, showcases, and other content.",
|
||||
"cover": {
|
||||
"width": 1990,
|
||||
"height": 519,
|
||||
"path": "https://pbs.floatplane.com/cover_images/59f94c0bdd241b70349eb72b/696951209272749_1521668313867.jpeg",
|
||||
"childImages": [
|
||||
{
|
||||
"width": 1245,
|
||||
"height": 325,
|
||||
"path": "https://pbs.floatplane.com/cover_images/59f94c0bdd241b70349eb72b/696951209272749_1521668313867_1245x325.jpeg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"icon": {
|
||||
"width": 600,
|
||||
"height": 600,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/770551996990709_1551249357205.jpeg",
|
||||
"childImages": [
|
||||
{
|
||||
"width": 250,
|
||||
"height": 250,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/770551996990709_1551249357205_250x250.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 100,
|
||||
"height": 100,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/770551996990709_1551249357205_100x100.jpeg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"card": {
|
||||
"width": 375,
|
||||
"height": 500,
|
||||
"path": "https://pbs.floatplane.com/creator_card/59f94c0bdd241b70349eb72b/281467946609369_1551250329871.jpeg",
|
||||
"childImages": [
|
||||
{
|
||||
"width": 300,
|
||||
"height": 400,
|
||||
"path": "https://pbs.floatplane.com/creator_card/59f94c0bdd241b70349eb72b/281467946609369_1551250329871_300x400.jpeg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"defaultChannel": "6413623f5b12cca228a28e78",
|
||||
"channels": [
|
||||
{
|
||||
"id": "63fe42c309e691e4e36de93d",
|
||||
"creator": "59f94c0bdd241b70349eb72b",
|
||||
"title": "Linus Tech Tips",
|
||||
"urlname": "main",
|
||||
"about": "# We're LinusTechTips\nWe make videos and stuff, cool eh?",
|
||||
"order": 0,
|
||||
"cover": {
|
||||
"width": 1990,
|
||||
"height": 519,
|
||||
"path": "https://pbs.floatplane.com/cover_images/59f94c0bdd241b70349eb72b/696951209272749_1521668313867.jpeg",
|
||||
"childImages": [
|
||||
{
|
||||
"width": 1245,
|
||||
"height": 325,
|
||||
"path": "https://pbs.floatplane.com/cover_images/59f94c0bdd241b70349eb72b/696951209272749_1521668313867_1245x325.jpeg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"card": null,
|
||||
"icon": {
|
||||
"width": 600,
|
||||
"height": 600,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/770551996990709_1551249357205.jpeg",
|
||||
"childImages": [
|
||||
{
|
||||
"width": 250,
|
||||
"height": 250,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/770551996990709_1551249357205_250x250.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 100,
|
||||
"height": 100,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/770551996990709_1551249357205_100x100.jpeg"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "65eba4d7788cf21d40c30f57",
|
||||
"creator": "59f94c0bdd241b70349eb72b",
|
||||
"title": "PSU Circuit",
|
||||
"urlname": "psucircuit",
|
||||
"about": "Powered by LTT LABS, PSU Circuit is here to determine if your power supply is up to snuff.",
|
||||
"order": 7,
|
||||
"cover": {
|
||||
"width": 1992,
|
||||
"height": 520,
|
||||
"path": "https://pbs.floatplane.com/cover_images/59f94c0bdd241b70349eb72b/65eba4d7788cf21d40c30f57/629715599270252_1709941980411.jpeg",
|
||||
"childImages": [
|
||||
{
|
||||
"width": 1245,
|
||||
"height": 325,
|
||||
"path": "https://pbs.floatplane.com/cover_images/59f94c0bdd241b70349eb72b/65eba4d7788cf21d40c30f57/629715599270252_1709941980411_1245x325.jpeg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"card": null,
|
||||
"icon": {
|
||||
"width": 720,
|
||||
"height": 720,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/65eba4d7788cf21d40c30f57/291852584471183_1709941981470.jpeg",
|
||||
"childImages": [
|
||||
{
|
||||
"width": 250,
|
||||
"height": 250,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/65eba4d7788cf21d40c30f57/291852584471183_1709941981470_250x250.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 100,
|
||||
"height": 100,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/65eba4d7788cf21d40c30f57/291852584471183_1709941981470_100x100.jpeg"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "64135f82fc76ab7f9fbdc876",
|
||||
"creator": "59f94c0bdd241b70349eb72b",
|
||||
"title": "They're Just Movies",
|
||||
"urlname": "tajm",
|
||||
"about": "Each week our small group of nerds sits down for a not-so-serious, SPOILER-FILLED, chat about the movies you love.\n\nFormerly known as Carpool Critics, we're part of Linus Media Group!",
|
||||
"order": 10,
|
||||
"cover": {
|
||||
"width": 1080,
|
||||
"height": 282,
|
||||
"path": "https://pbs.floatplane.com/cover_images/59f94c0bdd241b70349eb72b/64135f82fc76ab7f9fbdc876/190277198232475_1678991235439.jpeg",
|
||||
"childImages": []
|
||||
},
|
||||
"card": null,
|
||||
"icon": {
|
||||
"width": 88,
|
||||
"height": 88,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/64135f82fc76ab7f9fbdc876/570806971094170_1678991236419.jpeg",
|
||||
"childImages": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "649dbade3540dbc3945eeda7",
|
||||
"creator": "59f94c0bdd241b70349eb72b",
|
||||
"title": "GameLinked",
|
||||
"urlname": "gamelinked",
|
||||
"about": "Gaming news, delivered twice weekly. More to come! ",
|
||||
"order": 5,
|
||||
"cover": {
|
||||
"width": 1992,
|
||||
"height": 520,
|
||||
"path": "https://pbs.floatplane.com/cover_images/59f94c0bdd241b70349eb72b/649dbade3540dbc3945eeda7/538272237057071_1688058591194.jpeg",
|
||||
"childImages": [
|
||||
{
|
||||
"width": 1245,
|
||||
"height": 325,
|
||||
"path": "https://pbs.floatplane.com/cover_images/59f94c0bdd241b70349eb72b/649dbade3540dbc3945eeda7/538272237057071_1688058591194_1245x325.jpeg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"card": null,
|
||||
"icon": {
|
||||
"width": 720,
|
||||
"height": 720,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/649dbade3540dbc3945eeda7/375027779708988_1688058591701.jpeg",
|
||||
"childImages": [
|
||||
{
|
||||
"width": 100,
|
||||
"height": 100,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/649dbade3540dbc3945eeda7/375027779708988_1688058591701_100x100.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 250,
|
||||
"height": 250,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/649dbade3540dbc3945eeda7/375027779708988_1688058591701_250x250.jpeg"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "64135ed078d6262f717341b7",
|
||||
"creator": "59f94c0bdd241b70349eb72b",
|
||||
"title": "Channel Super Fun",
|
||||
"urlname": "channelsuperfun",
|
||||
"about": "Channel Super Fun is all about the name. Games, toys, and challenges. Expect to find them all here!",
|
||||
"order": 9,
|
||||
"cover": {
|
||||
"width": 1080,
|
||||
"height": 282,
|
||||
"path": "https://pbs.floatplane.com/cover_images/59f94c0bdd241b70349eb72b/64135ed078d6262f717341b7/881886551214964_1678991123807.jpeg",
|
||||
"childImages": []
|
||||
},
|
||||
"card": null,
|
||||
"icon": {
|
||||
"width": 88,
|
||||
"height": 88,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/64135ed078d6262f717341b7/317924815973639_1678991124672.jpeg",
|
||||
"childImages": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "64135e901ebaee42e258eb0b",
|
||||
"creator": "59f94c0bdd241b70349eb72b",
|
||||
"title": "Mac Address",
|
||||
"urlname": "macaddress",
|
||||
"about": "The exploration of all things Apple, from iPhones underwater to full iClouds in the sky. We want to be the channel that you come to first for an unexpected viewpoint about the devices you love.",
|
||||
"order": 6,
|
||||
"cover": {
|
||||
"width": 1080,
|
||||
"height": 282,
|
||||
"path": "https://pbs.floatplane.com/cover_images/59f94c0bdd241b70349eb72b/64135e901ebaee42e258eb0b/254417940627493_1678990992632.jpeg",
|
||||
"childImages": []
|
||||
},
|
||||
"card": null,
|
||||
"icon": {
|
||||
"width": 88,
|
||||
"height": 88,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/64135e901ebaee42e258eb0b/979475909700348_1678990993114.jpeg",
|
||||
"childImages": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "6413623f5b12cca228a28e78",
|
||||
"creator": "59f94c0bdd241b70349eb72b",
|
||||
"title": "FP Exclusives",
|
||||
"urlname": "fpexclusive",
|
||||
"about": "Floatplane Exclusive videos!",
|
||||
"order": 1,
|
||||
"cover": {
|
||||
"width": 960,
|
||||
"height": 251,
|
||||
"path": "https://pbs.floatplane.com/cover_images/59f94c0bdd241b70349eb72b/6413623f5b12cca228a28e78/183159108245625_1744048209567.jpeg",
|
||||
"childImages": []
|
||||
},
|
||||
"card": null,
|
||||
"icon": {
|
||||
"width": 720,
|
||||
"height": 720,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/6413623f5b12cca228a28e78/507483623280837_1744048210695.jpeg",
|
||||
"childImages": [
|
||||
{
|
||||
"width": 250,
|
||||
"height": 250,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/6413623f5b12cca228a28e78/507483623280837_1744048210695_250x250.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 100,
|
||||
"height": 100,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/6413623f5b12cca228a28e78/507483623280837_1744048210695_100x100.jpeg"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "6413534d88c13c181c3e2809",
|
||||
"creator": "59f94c0bdd241b70349eb72b",
|
||||
"title": "TechLinked",
|
||||
"urlname": "techlinked",
|
||||
"about": "News about tech + gaming culture, delivered thrice weekly.\n\nWe're also doing long-form video essays now, apparently. \n\nThe TalkLinked talk show/podcast will probably come back at some point, too!\n\nWriters: Riley Murdock, Jon Martin, James Strieb",
|
||||
"order": 4,
|
||||
"cover": {
|
||||
"width": 1080,
|
||||
"height": 282,
|
||||
"path": "https://pbs.floatplane.com/cover_images/59f94c0bdd241b70349eb72b/6413534d88c13c181c3e2809/231100243161134_1678988109632.jpeg",
|
||||
"childImages": []
|
||||
},
|
||||
"card": null,
|
||||
"icon": {
|
||||
"width": 88,
|
||||
"height": 88,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/6413534d88c13c181c3e2809/955526950207988_1678988110287.jpeg",
|
||||
"childImages": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "64135da7ce81077a8480c679",
|
||||
"creator": "59f94c0bdd241b70349eb72b",
|
||||
"title": "ShortCircuit",
|
||||
"urlname": "shortcircuit",
|
||||
"about": "What's in the box? Let's find out!\n\nOfficial channel under Linus Media Group.",
|
||||
"order": 2,
|
||||
"cover": {
|
||||
"width": 1084,
|
||||
"height": 283,
|
||||
"path": "https://pbs.floatplane.com/cover_images/59f94c0bdd241b70349eb72b/64135da7ce81077a8480c679/745715133852622_1678990806332.jpeg",
|
||||
"childImages": []
|
||||
},
|
||||
"card": null,
|
||||
"icon": {
|
||||
"width": 88,
|
||||
"height": 88,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/64135da7ce81077a8480c679/470304051261927_1678990806883.jpeg",
|
||||
"childImages": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "64135e27c773b27ff22c97eb",
|
||||
"creator": "59f94c0bdd241b70349eb72b",
|
||||
"title": "Techquickie",
|
||||
"urlname": "techquickie",
|
||||
"about": "Ever wanted to learn more about your favorite gadgets or a trending topic in tech? \n\nWith a mix of humor, cynicism, and insight, Techquickie brings you the answers to all your tech questions every Tuesday and Friday.",
|
||||
"order": 3,
|
||||
"cover": {
|
||||
"width": 1080,
|
||||
"height": 282,
|
||||
"path": "https://pbs.floatplane.com/cover_images/59f94c0bdd241b70349eb72b/64135e27c773b27ff22c97eb/721553790654237_1678990887992.jpeg",
|
||||
"childImages": []
|
||||
},
|
||||
"card": null,
|
||||
"icon": {
|
||||
"width": 88,
|
||||
"height": 88,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/64135e27c773b27ff22c97eb/666841640245092_1678990909616.jpeg",
|
||||
"childImages": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "64b9a4831786c66485efeb01",
|
||||
"creator": "59f94c0bdd241b70349eb72b",
|
||||
"title": "LTX Expo",
|
||||
"urlname": "ltxexpo",
|
||||
"about": "LTX is all about bringing together our community and our favourite creators to celebrate cool tech! This channel is reserved for LTX 2023 Digital Pass coverage, which is included in the LTT Support Plus membership.",
|
||||
"order": 8,
|
||||
"cover": {
|
||||
"width": 1992,
|
||||
"height": 520,
|
||||
"path": "https://pbs.floatplane.com/cover_images/59f94c0bdd241b70349eb72b/64b9a4831786c66485efeb01/751511305611500_1689887876121.jpeg",
|
||||
"childImages": [
|
||||
{
|
||||
"width": 1245,
|
||||
"height": 325,
|
||||
"path": "https://pbs.floatplane.com/cover_images/59f94c0bdd241b70349eb72b/64b9a4831786c66485efeb01/751511305611500_1689887876121_1245x325.jpeg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"card": null,
|
||||
"icon": {
|
||||
"width": 720,
|
||||
"height": 720,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/64b9a4831786c66485efeb01/279909104885134_1689887876587.jpeg",
|
||||
"childImages": [
|
||||
{
|
||||
"width": 100,
|
||||
"height": 100,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/64b9a4831786c66485efeb01/279909104885134_1689887876587_100x100.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 250,
|
||||
"height": 250,
|
||||
"path": "https://pbs.floatplane.com/creator_icons/59f94c0bdd241b70349eb72b/64b9a4831786c66485efeb01/279909104885134_1689887876587_250x250.jpeg"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"liveStream": {
|
||||
"id": "5c13f3c006f1be15e08e05c0",
|
||||
"title": "Most Of The Internet Needs To Be Fixed - WAN Show October 23, 2026",
|
||||
"description": "<p>Click this link <a href=\"https://boot.dev/?promo=WANSHOW\" rel=\"noopener noreferrer\" target=\"_blank\">https://boot.dev/?promo=WANSHOW</a> and use my code WANSHOW to get 25% off your first payment for boot.dev.</p><p><br></p><p>Want to add chat, video calls, or even build your own real-time AI Vision Agent? Check out Stream and get started for free at <a href=\"https://gstrm.io/4k5VYWt\" rel=\"noopener noreferrer\" target=\"_blank\">https://gstrm.io/4k5VYWt</a></p><p><br></p><p>Learn more at <a href=\"https://scribe.how/WAN\" rel=\"noopener noreferrer\" target=\"_blank\">https://scribe.how/WAN</a> and mention The Wan Show for your first month of Scribe Capture free on select plans.</p><p><br></p><p>Love Guitar Hero and Rockband and want a custom built guitar controller? Check out Hammer-On Guitar\u2019s builder tool: <a href=\"https://www.hammeronguitars.com/guitar-builder\" rel=\"noopener noreferrer\" target=\"_blank\">https://www.hammeronguitars.com/guitar-builder</a> </p><p><br></p><p>Get a Circuit Board skin for your device so dbrand can keep messing with Linus at <a href=\"https://dbrand.com/pcb\" rel=\"noopener noreferrer\" target=\"_blank\">https://dbrand.com/pcb</a></p><p><br></p><p>Check out the sleek, powerful new Dell XPS 14: <a href=\"https://del.ly/6003B1AI09\" rel=\"noopener noreferrer\" target=\"_blank\">https://del.ly/6003B1AI09</a> </p><p><br></p><p>Game or work in comfort on a Razer Iskur V2: <a href=\"https://lmg.gg/wanrazeriskur\" rel=\"noopener noreferrer\" target=\"_blank\">https://lmg.gg/wanrazeriskur</a></p><p><br></p><p><br></p><p>Get a special deal on Private Internet Access VPN today at <a href=\"https://www.piavpn.com/LinusWan\" rel=\"noopener noreferrer\" target=\"_blank\">https://www.piavpn.com/LinusWan</a></p><p><br></p><p>Purchases made through some store links may provide some compensation to Linus Media Group.</p><p><br></p><p>Podcast Download: TBD</p><p>Timestamps (courtesy of NoKi1119): TBD</p>",
|
||||
"thumbnail": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"path": "https://pbs.floatplane.com/stream_thumbnails/5c13f3c006f1be15e08e05c0/635074788591006_1792802818282.jpeg",
|
||||
"childImages": [
|
||||
{
|
||||
"width": 400,
|
||||
"height": 225,
|
||||
"path": "https://pbs.floatplane.com/stream_thumbnails/5c13f3c006f1be15e08e05c0/635074788591006_1792802818282_400x225.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 1200,
|
||||
"height": 675,
|
||||
"path": "https://pbs.floatplane.com/stream_thumbnails/5c13f3c006f1be15e08e05c0/635074788591006_1792802818282_1200x675.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"path": "https://pbs.floatplane.com/stream_thumbnails/5c13f3c006f1be15e08e05c0/635074788591006_1792802818282_1920x1080.jpeg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"owner": "59f94c0bdd241b70349eb72b",
|
||||
"channel": "63fe42c309e691e4e36de93d",
|
||||
"streamPath": "/api/video/v1/us-east-1.758417551536.channel.yKkxur4ukc0B.m3u8",
|
||||
"online": {
|
||||
"title": "Most Of The Internet Needs To Be Fixed - WAN Show October 23, 2026",
|
||||
"description": "<p>Click this link <a href=\"https://boot.dev/?promo=WANSHOW\" rel=\"noopener noreferrer\" target=\"_blank\">https://boot.dev/?promo=WANSHOW</a> and use my code WANSHOW to get 25% off your first payment for boot.dev.</p><p><br></p><p>Want to add chat, video calls, or even build your own real-time AI Vision Agent? Check out Stream and get started for free at <a href=\"https://gstrm.io/4k5VYWt\" rel=\"noopener noreferrer\" target=\"_blank\">https://gstrm.io/4k5VYWt</a></p><p><br></p><p>Learn more at <a href=\"https://scribe.how/WAN\" rel=\"noopener noreferrer\" target=\"_blank\">https://scribe.how/WAN</a> and mention The Wan Show for your first month of Scribe Capture free on select plans.</p><p><br></p><p>Love Guitar Hero and Rockband and want a custom built guitar controller? Check out Hammer-On Guitar\u2019s builder tool: <a href=\"https://www.hammeronguitars.com/guitar-builder\" rel=\"noopener noreferrer\" target=\"_blank\">https://www.hammeronguitars.com/guitar-builder</a> </p><p><br></p><p>Get a Circuit Board skin for your device so dbrand can keep messing with Linus at <a href=\"https://dbrand.com/pcb\" rel=\"noopener noreferrer\" target=\"_blank\">https://dbrand.com/pcb</a></p><p><br></p><p>Check out the sleek, powerful new Dell XPS 14: <a href=\"https://del.ly/6003B1AI09\" rel=\"noopener noreferrer\" target=\"_blank\">https://del.ly/6003B1AI09</a> </p><p><br></p><p>Game or work in comfort on a Razer Iskur V2: <a href=\"https://lmg.gg/wanrazeriskur\" rel=\"noopener noreferrer\" target=\"_blank\">https://lmg.gg/wanrazeriskur</a></p><p><br></p><p><br></p><p>Get a special deal on Private Internet Access VPN today at <a href=\"https://www.piavpn.com/LinusWan\" rel=\"noopener noreferrer\" target=\"_blank\">https://www.piavpn.com/LinusWan</a></p><p><br></p><p>Purchases made through some store links may provide some compensation to Linus Media Group.</p><p><br></p><p>Podcast Download: TBD</p><p>Timestamps (courtesy of NoKi1119): TBD</p>",
|
||||
"thumbnail": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"path": "https://pbs.floatplane.com/stream_thumbnails/5c13f3c006f1be15e08e05c0/635074788591006_1792802818282.jpeg",
|
||||
"childImages": [
|
||||
{
|
||||
"width": 400,
|
||||
"height": 225,
|
||||
"path": "https://pbs.floatplane.com/stream_thumbnails/5c13f3c006f1be15e08e05c0/635074788591006_1792802818282_400x225.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 1200,
|
||||
"height": 675,
|
||||
"path": "https://pbs.floatplane.com/stream_thumbnails/5c13f3c006f1be15e08e05c0/635074788591006_1792802818282_1200x675.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"path": "https://pbs.floatplane.com/stream_thumbnails/5c13f3c006f1be15e08e05c0/635074788591006_1792802818282_1920x1080.jpeg"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"offline": {
|
||||
"title": "Offline",
|
||||
"description": "We're offline for now \u2013 please check back later!",
|
||||
"thumbnail": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"path": "https://pbs.floatplane.com/stream_thumbnails/5c13f3c006f1be15e08e05c0/894654974252956_1549059179026.jpeg",
|
||||
"childImages": [
|
||||
{
|
||||
"width": 400,
|
||||
"height": 225,
|
||||
"path": "https://pbs.floatplane.com/stream_thumbnails/5c13f3c006f1be15e08e05c0/894654974252956_1549059179026_400x225.jpeg"
|
||||
},
|
||||
{
|
||||
"width": 1200,
|
||||
"height": 675,
|
||||
"path": "https://pbs.floatplane.com/stream_thumbnails/5c13f3c006f1be15e08e05c0/894654974252956_1549059179026_1200x675.jpeg"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"isAccessible": true
|
||||
},
|
||||
"subscriptionPlans": [
|
||||
{
|
||||
"id": "69561c587247b0ab681d4cd4",
|
||||
"title": "LTT Supporter",
|
||||
"description": "CONTENT\n\n- 2 new exclusives every week \u2014 including Why is WAN Late, behind-the-scenes, and bonus content\n\n- Access to exclusive livestreams\n\n- On-demand access to 400+ past exclusives\n\n- Save $16 by purchasing an annual subscription\n\n- Our gratitude for your support\n\nMERCH\n\n- Get access to purchase Floatplane-only merch on lttstore.com\n\n- Get access to quarterly limited-time drops\n\n- Enjoy early access to some sale events on lttstore.com",
|
||||
"price": "8.00",
|
||||
"priceYearly": "80.00",
|
||||
"currency": "usd",
|
||||
"creator": "59f94c0bdd241b70349eb72b",
|
||||
"logo": null,
|
||||
"featured": true,
|
||||
"allowGrandfatheredAccess": false,
|
||||
"discordServers": [],
|
||||
"discordRoles": [],
|
||||
"enabled": true,
|
||||
"published": true,
|
||||
"enabledGlobal": true
|
||||
},
|
||||
{
|
||||
"id": "69561cdd19415a592b34fd0f",
|
||||
"title": "LTT Supporters Plus",
|
||||
"description": "CONTENT\n\n- 4K Bitrate Streaming\n\n- 2 new exclusives every week \u2014 including Why is WAN Late, behind-the-scenes, and bonus content\n\n- Access to exclusive livestreams\n\n- On-demand access to 400+ past exclusives\n\n- Save $26 by purchasing an annual subscription\n\n- Our gratitude for your support\n\nMERCH\n\n- Get access to purchase Floatplane-only merch on lttstore.com\n\n- Get access to quarterly limited-time drops\n\n- Enjoy early access to some sale events on lttstore.com",
|
||||
"price": "13.00",
|
||||
"priceYearly": "130.00",
|
||||
"currency": "usd",
|
||||
"creator": "59f94c0bdd241b70349eb72b",
|
||||
"logo": null,
|
||||
"featured": true,
|
||||
"allowGrandfatheredAccess": false,
|
||||
"discordServers": [],
|
||||
"discordRoles": [],
|
||||
"enabled": true,
|
||||
"published": true,
|
||||
"enabledGlobal": true
|
||||
}
|
||||
]
|
||||
}
|
||||
179
Jellyfin.Plugin.Floatplane.Tests/Fixtures/delivery-info.json
Normal file
179
Jellyfin.Plugin.Floatplane.Tests/Fixtures/delivery-info.json
Normal file
@@ -0,0 +1,179 @@
|
||||
{
|
||||
"groups": [
|
||||
{
|
||||
"origins": [
|
||||
{
|
||||
"url": "https://cdn-vod-drm2.floatplane.com"
|
||||
}
|
||||
],
|
||||
"variants": [
|
||||
{
|
||||
"name": "360-avc1",
|
||||
"label": "360p",
|
||||
"url": "/Videos/dcm2jUIg1J/360.mp4/chunk.m3u8?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyZXNvdXJjZVBhdGgiOiAiL1ZpZGVvcy9kY20yalVJZzFKLzM2MC5tcDQvY2h1bmsubTN1OCIsICJ1c2VySWQiOiAiMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwgImlhdCI6IDE3OTI5MTM2OTYsICJleHAiOiAxNzkyOTM1Mjk2fQ.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"mimeType": "application/x-mpegURL",
|
||||
"order": 12392640,
|
||||
"hidden": false,
|
||||
"enabled": true,
|
||||
"meta": {
|
||||
"video": {
|
||||
"codec": "avc1.64001e",
|
||||
"codecSimple": "avc1",
|
||||
"bitrate": {
|
||||
"average": 277557
|
||||
},
|
||||
"width": 640,
|
||||
"height": 360,
|
||||
"isHdr": false,
|
||||
"fps": 29.97,
|
||||
"mimeType": "video/MP2T"
|
||||
},
|
||||
"audio": {
|
||||
"codec": "mp4a.40.2",
|
||||
"bitrate": {
|
||||
"average": 151115
|
||||
},
|
||||
"channelCount": 2,
|
||||
"samplerate": 48000,
|
||||
"mimeType": "video/MP2T"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "480-avc1",
|
||||
"label": "480p",
|
||||
"url": "/Videos/dcm2jUIg1J/480.mp4/chunk.m3u8?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyZXNvdXJjZVBhdGgiOiAiL1ZpZGVvcy9kY20yalVJZzFKLzQ4MC5tcDQvY2h1bmsubTN1OCIsICJ1c2VySWQiOiAiMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwgImlhdCI6IDE3OTI5MTM2OTYsICJleHAiOiAxNzkyOTM1Mjk2fQ.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"mimeType": "application/x-mpegURL",
|
||||
"order": 22091968,
|
||||
"hidden": false,
|
||||
"enabled": true,
|
||||
"meta": {
|
||||
"video": {
|
||||
"codec": "avc1.64001f",
|
||||
"codecSimple": "avc1",
|
||||
"bitrate": {
|
||||
"average": 431060
|
||||
},
|
||||
"width": 854,
|
||||
"height": 480,
|
||||
"isHdr": false,
|
||||
"fps": 29.97,
|
||||
"mimeType": "video/MP2T"
|
||||
},
|
||||
"audio": {
|
||||
"codec": "mp4a.40.2",
|
||||
"bitrate": {
|
||||
"average": 151115
|
||||
},
|
||||
"channelCount": 2,
|
||||
"samplerate": 48000,
|
||||
"mimeType": "video/MP2T"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "720-avc1",
|
||||
"label": "720p",
|
||||
"url": "/Videos/dcm2jUIg1J/720.mp4/chunk.m3u8?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyZXNvdXJjZVBhdGgiOiAiL1ZpZGVvcy9kY20yalVJZzFKLzcyMC5tcDQvY2h1bmsubTN1OCIsICJ1c2VySWQiOiAiMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwgImlhdCI6IDE3OTI5MTM2OTYsICJleHAiOiAxNzkyOTM1Mjk2fQ.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"mimeType": "application/x-mpegURL",
|
||||
"order": 49617088,
|
||||
"hidden": false,
|
||||
"enabled": true,
|
||||
"meta": {
|
||||
"video": {
|
||||
"codec": "avc1.64001f",
|
||||
"codecSimple": "avc1",
|
||||
"bitrate": {
|
||||
"average": 812585
|
||||
},
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"isHdr": false,
|
||||
"fps": 29.97,
|
||||
"mimeType": "video/MP2T"
|
||||
},
|
||||
"audio": {
|
||||
"codec": "mp4a.40.2",
|
||||
"bitrate": {
|
||||
"average": 151115
|
||||
},
|
||||
"channelCount": 2,
|
||||
"samplerate": 48000,
|
||||
"mimeType": "video/MP2T"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "1080-avc1",
|
||||
"label": "1080p",
|
||||
"url": "/Videos/dcm2jUIg1J/1080.mp4/chunk.m3u8?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyZXNvdXJjZVBhdGgiOiAiL1ZpZGVvcy9kY20yalVJZzFKLzEwODAubXA0L2NodW5rLm0zdTgiLCAidXNlcklkIjogIjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsICJpYXQiOiAxNzkyOTEzNjk2LCAiZXhwIjogMTc5MjkzNTI5Nn0.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"mimeType": "application/x-mpegURL",
|
||||
"order": 111745216,
|
||||
"hidden": false,
|
||||
"enabled": true,
|
||||
"meta": {
|
||||
"video": {
|
||||
"codec": "avc1.640028",
|
||||
"codecSimple": "avc1",
|
||||
"bitrate": {
|
||||
"average": 1661360
|
||||
},
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"isHdr": false,
|
||||
"fps": 29.97,
|
||||
"mimeType": "video/MP2T"
|
||||
},
|
||||
"audio": {
|
||||
"codec": "mp4a.40.2",
|
||||
"bitrate": {
|
||||
"average": 151115
|
||||
},
|
||||
"channelCount": 2,
|
||||
"samplerate": 48000,
|
||||
"mimeType": "video/MP2T"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "2160-avc1",
|
||||
"label": "4K",
|
||||
"url": "",
|
||||
"mimeType": "application/x-mpegURL",
|
||||
"order": 447027392,
|
||||
"hidden": false,
|
||||
"enabled": false,
|
||||
"meta": {
|
||||
"common": {
|
||||
"access": {
|
||||
"deniedReason": "isMissingPermission",
|
||||
"deniedMessage": "You lack permission to access this media."
|
||||
}
|
||||
},
|
||||
"video": {
|
||||
"codec": "avc1.640033",
|
||||
"codecSimple": "avc1",
|
||||
"bitrate": {
|
||||
"average": 7393319
|
||||
},
|
||||
"width": 3840,
|
||||
"height": 2160,
|
||||
"isHdr": false,
|
||||
"fps": 29.97,
|
||||
"mimeType": "video/MP2T"
|
||||
},
|
||||
"audio": {
|
||||
"codec": "mp4a.40.2",
|
||||
"bitrate": {
|
||||
"average": 151115
|
||||
},
|
||||
"channelCount": 2,
|
||||
"samplerate": 48000,
|
||||
"mimeType": "video/MP2T"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "b3b9-h1wq-aayv",
|
||||
"errors": [
|
||||
{
|
||||
"id": "b3b9-h1wq-aayv",
|
||||
"name": "notLoggedInError",
|
||||
"message": "You must be logged-in to access this resource."
|
||||
}
|
||||
],
|
||||
"message": "You must be logged-in to access this resource."
|
||||
}
|
||||
24
Jellyfin.Plugin.Floatplane.Tests/Fixtures/user-self.json
Normal file
24
Jellyfin.Plugin.Floatplane.Tests/Fixtures/user-self.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"id": "000000000000000000000000",
|
||||
"username": "MrLinuxGamer",
|
||||
"email": "user@example.invalid",
|
||||
"displayName": "",
|
||||
"profileImage": {
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"path": "https://pbs.floatplane.com/profile_images/default/user12.png",
|
||||
"childImages": [
|
||||
{
|
||||
"width": 100,
|
||||
"height": 100,
|
||||
"path": "https://pbs.floatplane.com/profile_images/default/user12_100x100.png"
|
||||
},
|
||||
{
|
||||
"width": 250,
|
||||
"height": 250,
|
||||
"path": "https://pbs.floatplane.com/profile_images/default/user12_250x250.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
"creators": []
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
[
|
||||
{
|
||||
"startDate": "2026-08-21T21:03:46.816Z",
|
||||
"endDate": "2026-11-21T21:03:46.816Z",
|
||||
"paymentID": 0,
|
||||
"interval": "month",
|
||||
"paymentCancelled": false,
|
||||
"plan": {
|
||||
"id": "69561c587247b0ab681d4cd4",
|
||||
"title": "LTT Supporter",
|
||||
"description": "CONTENT\n\n- 2 new exclusives every week \u2014 including Why is WAN Late, behind-the-scenes, and bonus content\n\n- Access to exclusive livestreams\n\n- On-demand access to 400+ past exclusives\n\n- Save $16 by purchasing an annual subscription\n\n- Our gratitude for your support\n\nMERCH\n\n- Get access to purchase Floatplane-only merch on lttstore.com\n\n- Get access to quarterly limited-time drops\n\n- Enjoy early access to some sale events on lttstore.com",
|
||||
"price": "8.00",
|
||||
"priceYearly": "80.00",
|
||||
"currency": "usd",
|
||||
"creator": "59f94c0bdd241b70349eb72b",
|
||||
"logo": null,
|
||||
"featured": true,
|
||||
"allowGrandfatheredAccess": false,
|
||||
"discordServers": [],
|
||||
"discordRoles": [],
|
||||
"enabled": true,
|
||||
"published": true,
|
||||
"enabledGlobal": true
|
||||
},
|
||||
"creator": "59f94c0bdd241b70349eb72b"
|
||||
}
|
||||
]
|
||||
97
Jellyfin.Plugin.Floatplane.Tests/HlsRewriterTests.cs
Normal file
97
Jellyfin.Plugin.Floatplane.Tests/HlsRewriterTests.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Jellyfin.Plugin.Floatplane.Playback;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.Floatplane.Tests;
|
||||
|
||||
public class HlsRewriterTests
|
||||
{
|
||||
private static readonly Uri PlaylistUrl = new("https://cdn-vod-drm2.floatplane.com/Videos/dcm2jUIg1J/1080.mp4/chunk.m3u8?token=X");
|
||||
|
||||
[Fact]
|
||||
public void Media_playlist_key_is_proxied_and_segments_made_absolute()
|
||||
{
|
||||
var original = Fixture.Text("chunk.m3u8");
|
||||
string? seenKey = null;
|
||||
|
||||
var rewritten = HlsRewriter.Rewrite(
|
||||
original,
|
||||
PlaylistUrl,
|
||||
key =>
|
||||
{
|
||||
seenKey = key;
|
||||
return "http://jellyfin/Floatplane/Stream/dcm2jUIg1J/Key?u=abc&t=sig";
|
||||
});
|
||||
|
||||
var lines = rewritten.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
Assert.Equal("https://www.floatplane.com/api/video/watchKey?token=eyJwYXRoIjoiL1ZpZGVvcy9kY20yalVJZzFKLzEwODAubXA0IiwidGltZXN0YW1wIjoxNzkyODg2NDAwMDAwfQ", seenKey);
|
||||
Assert.Contains("#EXT-X-KEY:METHOD=AES-128,URI=\"http://jellyfin/Floatplane/Stream/dcm2jUIg1J/Key?u=abc&t=sig\"", lines);
|
||||
Assert.DoesNotContain(lines, l => l.Contains("watchKey", StringComparison.Ordinal));
|
||||
|
||||
var segments = lines.Where(l => !l.StartsWith('#')).ToList();
|
||||
Assert.Equal(6, segments.Count);
|
||||
for (var i = 0; i < segments.Count; i++)
|
||||
{
|
||||
Assert.StartsWith("https://cdn-vod-drm2.floatplane.com/Videos/dcm2jUIg1J/1080.mp4/", segments[i]);
|
||||
Assert.EndsWith($".ts?token=SEGTOKEN{i + 1}", segments[i]);
|
||||
}
|
||||
|
||||
Assert.Equal(6, lines.Count(l => l.StartsWith("#EXTINF:", StringComparison.Ordinal)));
|
||||
Assert.Equal("#EXTM3U", lines[0]);
|
||||
Assert.Equal("#EXT-X-ENDLIST", lines[^1]);
|
||||
Assert.Contains("#EXT-X-TARGETDURATION:12", lines);
|
||||
Assert.EndsWith("\n", rewritten);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Segment_mapper_is_applied_when_given()
|
||||
{
|
||||
var rewritten = HlsRewriter.Rewrite(
|
||||
Fixture.Text("chunk.m3u8"),
|
||||
PlaylistUrl,
|
||||
key => "KEY",
|
||||
mapSegmentUri: seg => "http://proxy/seg?u=" + Uri.EscapeDataString(seg));
|
||||
|
||||
var segments = rewritten.Split('\n', StringSplitOptions.RemoveEmptyEntries).Where(l => !l.StartsWith('#')).ToList();
|
||||
Assert.Equal(6, segments.Count);
|
||||
Assert.All(segments, s => Assert.StartsWith("http://proxy/seg?u=https%3A%2F%2Fcdn-vod-drm2.floatplane.com", s));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Master_playlist_variants_are_mapped_through_playlist_mapper()
|
||||
{
|
||||
const string master = "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1800000,RESOLUTION=1920x1080\n1080.mp4/chunk.m3u8?token=A\n#EXT-X-STREAM-INF:BANDWIDTH=900000,RESOLUTION=1280x720\nhttps://other.cdn/720/chunk.m3u8?token=B\n";
|
||||
var masterUrl = new Uri("https://cdn-vod-drm2.floatplane.com/Videos/dcm2jUIg1J/master.m3u8?token=M");
|
||||
|
||||
var rewritten = HlsRewriter.Rewrite(
|
||||
master,
|
||||
masterUrl,
|
||||
key => "KEY",
|
||||
playlistUri => "http://proxy/pl?u=" + playlistUri);
|
||||
|
||||
var lines = rewritten.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.Equal("http://proxy/pl?u=https://cdn-vod-drm2.floatplane.com/Videos/dcm2jUIg1J/1080.mp4/chunk.m3u8?token=A", lines[2]);
|
||||
Assert.Equal("http://proxy/pl?u=https://other.cdn/720/chunk.m3u8?token=B", lines[4]);
|
||||
Assert.Equal("#EXT-X-STREAM-INF:BANDWIDTH=1800000,RESOLUTION=1920x1080", lines[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Master_playlist_without_mapper_keeps_absolute_urls()
|
||||
{
|
||||
const string master = "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1\nsub/chunk.m3u8\n";
|
||||
|
||||
var rewritten = HlsRewriter.Rewrite(master, new Uri("https://cdn.example/dir/master.m3u8"), key => "KEY");
|
||||
|
||||
Assert.Contains("https://cdn.example/dir/sub/chunk.m3u8", rewritten);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Absolute_resolves_relative_and_keeps_absolute()
|
||||
{
|
||||
Assert.Equal("https://cdn.example/a/b.ts", HlsRewriter.Absolute(new Uri("https://cdn.example/a/p.m3u8?x=1"), "b.ts"));
|
||||
Assert.Equal("https://cdn.example/b.ts", HlsRewriter.Absolute(new Uri("https://cdn.example/a/p.m3u8"), "/b.ts"));
|
||||
Assert.Equal("https://other/b.ts", HlsRewriter.Absolute(new Uri("https://cdn.example/a/p.m3u8"), "https://other/b.ts"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>Jellyfin.Plugin.Floatplane.Tests</RootNamespace>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591;CA1707</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<!-- The plugin excludes Jellyfin's runtime assets (the server provides them); the test host needs them copied. -->
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.10.7" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.10.7" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../Jellyfin.Plugin.Floatplane/Jellyfin.Plugin.Floatplane.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="Fixtures/**" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
153
Jellyfin.Plugin.Floatplane.Tests/ModelTests.cs
Normal file
153
Jellyfin.Plugin.Floatplane.Tests/ModelTests.cs
Normal file
@@ -0,0 +1,153 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Jellyfin.Plugin.Floatplane.Api.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.Floatplane.Tests;
|
||||
|
||||
public class ModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void ContentCreator_list_posts_have_channel_object_but_no_attachments()
|
||||
{
|
||||
var posts = Fixture.Json<List<PostModel>>("content-creator.json");
|
||||
|
||||
Assert.Equal(5, posts.Count);
|
||||
var first = posts[0];
|
||||
Assert.Equal("SSvhppI28t", first.Id);
|
||||
Assert.Equal("How Board Games Come ALIVE...- Board", first.Title);
|
||||
Assert.NotNull(first.Channel);
|
||||
Assert.False(first.Channel!.IsReferenceOnly);
|
||||
Assert.Equal("ShortCircuit", first.Channel.Title);
|
||||
Assert.Equal("64135da7ce81077a8480c679", first.Channel.Id);
|
||||
Assert.Null(first.VideoAttachments);
|
||||
Assert.True(first.HasVideo);
|
||||
Assert.Equal(1104, first.DurationSeconds);
|
||||
Assert.NotNull(first.ReleaseDate);
|
||||
Assert.Equal(2026, first.ReleaseDate!.Value.Year);
|
||||
Assert.True(first.IsAccessible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContentPost_expands_video_attachments()
|
||||
{
|
||||
var post = Fixture.Json<PostModel>("content-post.json");
|
||||
|
||||
Assert.Equal("SSvhppI28t", post.Id);
|
||||
Assert.NotNull(post.VideoAttachments);
|
||||
var video = Assert.Single(post.VideoAttachments!);
|
||||
Assert.False(video.IsReferenceOnly);
|
||||
Assert.Equal("dcm2jUIg1J", video.Id);
|
||||
Assert.Equal(1104, video.Duration);
|
||||
Assert.Equal(5, video.Levels.Count);
|
||||
Assert.Contains(video.Levels, l => l.Name == "1080" && l.Height == 1080 && l.Width == 1920);
|
||||
Assert.Single(video.TextTracks);
|
||||
Assert.Equal(new[] { "dcm2jUIg1J" }, post.AttachmentOrder);
|
||||
Assert.Equal("ShortCircuit", post.Channel!.Title);
|
||||
Assert.Equal("LinusTechTips", post.Creator!.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContentVideo_parses_levels_and_sprite()
|
||||
{
|
||||
var video = Fixture.Json<VideoModel>("content-video.json");
|
||||
|
||||
Assert.Equal("dcm2jUIg1J", video.Id);
|
||||
Assert.Equal("SSvhppI28t", video.PrimaryBlogPost);
|
||||
Assert.Equal(new[] { "360", "480", "720", "1080", "2160" }, video.Levels.Select(l => l.Name));
|
||||
Assert.NotNull(video.TimelineSprite);
|
||||
Assert.StartsWith("https://pbs.floatplane.com/timeline_sprite/", video.TimelineSprite!.Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IdOrObjectConverter_accepts_string_ids()
|
||||
{
|
||||
const string json = """
|
||||
{"id":"p1","title":"T","channel":"chan123","creator":"cre456","videoAttachments":["v1","v2"],"metadata":{"hasVideo":true,"videoCount":2}}
|
||||
""";
|
||||
|
||||
var post = JsonSerializer.Deserialize<PostModel>(json, FloatplaneJson.Options)!;
|
||||
|
||||
Assert.Equal("chan123", post.Channel!.Id);
|
||||
Assert.True(post.Channel.IsReferenceOnly);
|
||||
Assert.Equal("cre456", post.Creator!.Id);
|
||||
Assert.True(post.Creator.IsReferenceOnly);
|
||||
Assert.Equal(2, post.VideoAttachments!.Count);
|
||||
Assert.All(post.VideoAttachments, v => Assert.True(v.IsReferenceOnly));
|
||||
Assert.Equal(new[] { "v1", "v2" }, post.VideoAttachments.Select(v => v.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IdOrObjectConverter_still_reads_objects_and_null()
|
||||
{
|
||||
const string json = """
|
||||
{"id":"p1","channel":{"id":"c1","title":"Main","urlname":"main"},"creator":null}
|
||||
""";
|
||||
|
||||
var post = JsonSerializer.Deserialize<PostModel>(json, FloatplaneJson.Options)!;
|
||||
|
||||
Assert.Equal("Main", post.Channel!.Title);
|
||||
Assert.False(post.Channel.IsReferenceOnly);
|
||||
Assert.Null(post.Creator);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Error_envelope_parses()
|
||||
{
|
||||
var error = Fixture.Json<ApiErrorModel>("error-not-logged-in.json");
|
||||
|
||||
Assert.True(error.Has("notLoggedInError"));
|
||||
Assert.False(error.Has("notSubscribedError"));
|
||||
Assert.Equal("You must be logged-in to access this resource.", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeliveryInfo_resolves_usable_variants_and_reports_denied_4k()
|
||||
{
|
||||
var info = Fixture.Json<DeliveryInfoModel>("delivery-info.json");
|
||||
var resolved = info.Resolve().ToList();
|
||||
|
||||
Assert.Equal(4, resolved.Count);
|
||||
Assert.All(resolved, v => Assert.True(v.IsUsable));
|
||||
Assert.All(resolved, v => Assert.StartsWith("https://cdn-vod-drm2.floatplane.com/Videos/dcm2jUIg1J/", v.Url));
|
||||
Assert.Equal(new[] { 360, 480, 720, 1080 }, resolved.Select(v => v.Height));
|
||||
Assert.Equal("avc1.640028", resolved.Single(v => v.Height == 1080).Variant.Meta!.Video!.Codec);
|
||||
|
||||
var fourK = info.Groups.Single().Variants.Single(v => v.Name == "2160-avc1");
|
||||
Assert.False(fourK.IsUsable);
|
||||
Assert.False(fourK.Enabled);
|
||||
Assert.Equal("isMissingPermission", fourK.Meta!.Common!.Access!.DeniedReason);
|
||||
Assert.Equal(2160, fourK.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImageModel_BestPath_picks_largest_child_within_limit()
|
||||
{
|
||||
var post = Fixture.Json<List<PostModel>>("content-creator.json")[0];
|
||||
|
||||
var path = post.Thumbnail!.BestPath(1200);
|
||||
|
||||
Assert.EndsWith("_1200x675.jpeg", path);
|
||||
Assert.EndsWith("_400x225.jpeg", post.Thumbnail.BestPath(500));
|
||||
Assert.Equal(post.Thumbnail.Path, post.Thumbnail.BestPath(100));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Subscriptions_and_self_and_creator_parse()
|
||||
{
|
||||
var subs = Fixture.Json<List<SubscriptionModel>>("user-subscriptions.json");
|
||||
var self = Fixture.Json<UserSelfModel>("user-self.json");
|
||||
var creator = Fixture.Json<CreatorModel>("creator-info.json");
|
||||
|
||||
var sub = Assert.Single(subs);
|
||||
Assert.Equal("59f94c0bdd241b70349eb72b", sub.Creator);
|
||||
Assert.Equal("LTT Supporter", sub.Plan!.Title);
|
||||
Assert.False(string.IsNullOrEmpty(self.Username));
|
||||
Assert.Equal("LinusTechTips", creator.Title);
|
||||
Assert.Equal("linustechtips", creator.Urlname);
|
||||
Assert.Equal(11, creator.Channels.Count);
|
||||
Assert.Contains(creator.Channels, c => c.Urlname == "techlinked");
|
||||
Assert.NotNull(creator.LiveStream);
|
||||
}
|
||||
}
|
||||
51
Jellyfin.Plugin.Floatplane.Tests/ProxySignatureTests.cs
Normal file
51
Jellyfin.Plugin.Floatplane.Tests/ProxySignatureTests.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using Jellyfin.Plugin.Floatplane.Api;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.Floatplane.Tests;
|
||||
|
||||
public class ProxySignatureTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_Verify_roundtrip()
|
||||
{
|
||||
var sig = ProxySignature.Create("hls", "post1", "video1", "1080");
|
||||
|
||||
Assert.Equal(64, sig.Length);
|
||||
Assert.True(ProxySignature.Verify(sig, "hls", "post1", "video1", "1080"));
|
||||
Assert.Equal(sig, ProxySignature.Create("hls", "post1", "video1", "1080"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tampered_parts_or_signature_fail()
|
||||
{
|
||||
var sig = ProxySignature.Create("hls", "post1", "video1", "1080");
|
||||
|
||||
Assert.False(ProxySignature.Verify(sig, "hls", "post1", "video1", "2160"));
|
||||
Assert.False(ProxySignature.Verify(sig, "mp4", "post1", "video1", "1080"));
|
||||
Assert.False(ProxySignature.Verify(sig[..^1] + (sig[^1] == 'a' ? 'b' : 'a'), "hls", "post1", "video1", "1080"));
|
||||
Assert.False(ProxySignature.Verify(null, "hls", "post1", "video1", "1080"));
|
||||
Assert.False(ProxySignature.Verify(string.Empty, "hls", "post1", "video1", "1080"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://www.floatplane.com/api/video/watchKey?token=abc")]
|
||||
[InlineData("https://cdn-vod-drm2.floatplane.com/Videos/x/1080.mp4/chunk.m3u8?token=a.b.c&x=y%20z")]
|
||||
[InlineData("å ä ö")]
|
||||
public void EncodeUrl_DecodeUrl_roundtrip(string value)
|
||||
{
|
||||
var encoded = ProxySignature.EncodeUrl(value);
|
||||
|
||||
Assert.DoesNotContain('+', encoded);
|
||||
Assert.DoesNotContain('/', encoded);
|
||||
Assert.DoesNotContain('=', encoded);
|
||||
Assert.Equal(value, ProxySignature.DecodeUrl(encoded));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUrl_rejects_garbage()
|
||||
{
|
||||
Assert.Null(ProxySignature.DecodeUrl(null));
|
||||
Assert.Null(ProxySignature.DecodeUrl(string.Empty));
|
||||
Assert.Null(ProxySignature.DecodeUrl("!!!not base64!!!"));
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,9 @@ Without a cookie you can still browse creators listed under
|
||||
dotnet test # unit tests (models, HLS rewriter, API client with fixtures from the HAR)
|
||||
```
|
||||
|
||||
The test host needs the ASP.NET Core 8 shared runtime (the plugin references
|
||||
Jellyfin's controllers): on Arch `sudo pacman -S aspnet-runtime-8.0`.
|
||||
|
||||
Local end-to-end test with a disposable Jellyfin matching the Pi5 version:
|
||||
|
||||
```bash
|
||||
|
||||
28
jellyfin-plugin-floatplane.sln
Normal file
28
jellyfin-plugin-floatplane.sln
Normal file
@@ -0,0 +1,28 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.Floatplane", "Jellyfin.Plugin.Floatplane\Jellyfin.Plugin.Floatplane.csproj", "{81359798-8CDB-4AFF-B2C2-A8186F09FEF5}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.Floatplane.Tests", "Jellyfin.Plugin.Floatplane.Tests\Jellyfin.Plugin.Floatplane.Tests.csproj", "{66DB87A9-C783-4024-9D6C-DB04F515EF62}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{81359798-8CDB-4AFF-B2C2-A8186F09FEF5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{81359798-8CDB-4AFF-B2C2-A8186F09FEF5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{81359798-8CDB-4AFF-B2C2-A8186F09FEF5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{81359798-8CDB-4AFF-B2C2-A8186F09FEF5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{66DB87A9-C783-4024-9D6C-DB04F515EF62}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{66DB87A9-C783-4024-9D6C-DB04F515EF62}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{66DB87A9-C783-4024-9D6C-DB04F515EF62}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{66DB87A9-C783-4024-9D6C-DB04F515EF62}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
Reference in New Issue
Block a user