Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
Some checks failed
release / build-release (push) Has been cancelled
Some checks failed
release / build-release (push) Has been cancelled
- IChannel: creators -> sub-channels -> video posts (public listing API, search mapped) - IRequiresMediaInfoCallback -> signed proxy URLs; stream controller rewrites HLS so the AES-128 key is fetched server-side with the session cookie, segments stay on the CDN - delivery/info cached per token lifetime and throttled (~2/min) - config page: paste sails.sid + validate, extra creators, quality, search test - scripts/package.sh -> plugin folder + zip + md5 + manifest.json; Gitea release workflow - research docs, HAR analysis (HAR itself gitignored), install-pi5.sh Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
472
Jellyfin.Plugin.Floatplane/Channel/FloatplaneChannel.cs
Normal file
472
Jellyfin.Plugin.Floatplane/Channel/FloatplaneChannel.cs
Normal file
@@ -0,0 +1,472 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Floatplane.Api;
|
||||
using Jellyfin.Plugin.Floatplane.Api.Models;
|
||||
using Jellyfin.Plugin.Floatplane.Configuration;
|
||||
using Jellyfin.Plugin.Floatplane.Playback;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Channels;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Channels;
|
||||
using MediaBrowser.Model.Drawing;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Floatplane.Channel;
|
||||
|
||||
/// <summary>
|
||||
/// The "Floatplane" channel: root → creators → sub-channels → video posts.
|
||||
/// Item ids are stable strings (<c>creator:…</c>, <c>channel:…</c>, <c>post:…</c>); a post
|
||||
/// only ever appears under its own channel, as Jellyfin allows one parent per item.
|
||||
/// </summary>
|
||||
public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, IHasCacheKey, ISupportsLatestMedia
|
||||
{
|
||||
private const string CreatorPrefix = "creator:";
|
||||
private const string ChannelPrefix = "channel:";
|
||||
private const string PostPrefix = "post:";
|
||||
|
||||
private readonly CatalogService _catalog;
|
||||
private readonly IServerApplicationHost _appHost;
|
||||
private readonly ILogger<FloatplaneChannel> _logger;
|
||||
private readonly Func<PluginConfiguration> _config;
|
||||
|
||||
public FloatplaneChannel(CatalogService catalog, IServerApplicationHost appHost, ILogger<FloatplaneChannel> logger)
|
||||
{
|
||||
_catalog = catalog;
|
||||
_appHost = appHost;
|
||||
_logger = logger;
|
||||
_config = () => Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Floatplane"; // never rename: the channel's library id derives from it
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Your Floatplane subscriptions";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DataVersion => "1";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string HomePageUrl => "https://www.floatplane.com";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ChannelParentalRating ParentalRating => ChannelParentalRating.GeneralAudience;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsEnabledFor(string userId) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? GetCacheKey(string? userId) => "g" + _catalog.Generation.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
/// <inheritdoc />
|
||||
public InternalChannelFeatures GetChannelFeatures() => new()
|
||||
{
|
||||
MediaTypes = [ChannelMediaType.Video],
|
||||
ContentTypes = [ChannelMediaContentType.Clip],
|
||||
DefaultSortFields = [ChannelItemSortField.PremiereDate, ChannelItemSortField.Name, ChannelItemSortField.Runtime],
|
||||
SupportsSortOrderToggle = true,
|
||||
MaxPageSize = 100,
|
||||
AutoRefreshLevels = 3,
|
||||
SupportsContentDownloading = false
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<ImageType> GetSupportedChannelImages() => [ImageType.Primary, ImageType.Thumb];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken cancellationToken)
|
||||
{
|
||||
var stream = typeof(Plugin).Assembly.GetManifestResourceStream("Jellyfin.Plugin.Floatplane.Resources.logo.png");
|
||||
return Task.FromResult(new DynamicImageResponse
|
||||
{
|
||||
HasImage = stream != null,
|
||||
Stream = stream,
|
||||
Format = ImageFormat.Png
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ChannelItemResult> GetChannelItems(InternalChannelItemQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var folderId = query.FolderId ?? string.Empty;
|
||||
_logger.LogDebug("GetChannelItems folder='{Folder}' start={Start} limit={Limit}", folderId, query.StartIndex, query.Limit);
|
||||
|
||||
List<ChannelItemInfo> items;
|
||||
if (string.IsNullOrEmpty(folderId))
|
||||
{
|
||||
items = await GetRootItemsAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (folderId.StartsWith(CreatorPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
items = await GetCreatorItemsAsync(folderId[CreatorPrefix.Length..], cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (folderId.StartsWith(ChannelPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
var rest = folderId[ChannelPrefix.Length..];
|
||||
var sep = rest.IndexOf(':', StringComparison.Ordinal);
|
||||
var creatorId = sep > 0 ? rest[..sep] : rest;
|
||||
var channelId = sep > 0 ? rest[(sep + 1)..] : string.Empty;
|
||||
items = await GetPostItemsAsync(creatorId, channelId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Unknown folder id {Folder}", folderId);
|
||||
items = new List<ChannelItemInfo>();
|
||||
}
|
||||
|
||||
items = ApplySort(items, query);
|
||||
var total = items.Count;
|
||||
if (query.StartIndex.HasValue && query.StartIndex.Value > 0)
|
||||
{
|
||||
items = items.Skip(query.StartIndex.Value).ToList();
|
||||
}
|
||||
|
||||
if (query.Limit.HasValue && query.Limit.Value > 0 && items.Count > query.Limit.Value)
|
||||
{
|
||||
items = items.Take(query.Limit.Value).ToList();
|
||||
}
|
||||
|
||||
return new ChannelItemResult { Items = items, TotalRecordCount = total };
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<ChannelItemInfo>> GetLatestMedia(ChannelLatestMediaSearch request, CancellationToken cancellationToken)
|
||||
{
|
||||
var creators = await _catalog.GetCreatorsAsync(false, cancellationToken).ConfigureAwait(false);
|
||||
var latest = new List<ChannelItemInfo>();
|
||||
foreach (var creator in creators)
|
||||
{
|
||||
try
|
||||
{
|
||||
var posts = await _catalog.FetchPostsAsync(creator.Id, string.Empty, 20, cancellationToken).ConfigureAwait(false);
|
||||
latest.AddRange(posts.Select(p => ToItem(p, creator, _catalog.HasSession)));
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Latest media for {Creator} failed", creator.Urlname);
|
||||
}
|
||||
}
|
||||
|
||||
return latest.OrderByDescending(i => i.PremiereDate ?? DateTime.MinValue).Take(40).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<MediaSourceInfo>> GetChannelItemMediaInfo(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!id.StartsWith(PostPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
return Array.Empty<MediaSourceInfo>();
|
||||
}
|
||||
|
||||
var postId = id[PostPrefix.Length..];
|
||||
PostModel post;
|
||||
try
|
||||
{
|
||||
post = await _catalog.GetPostDetailsAsync(postId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (FloatplaneAuthException ex)
|
||||
{
|
||||
_logger.LogWarning("Cannot play Floatplane post {Post}: {Message}. Paste a valid sails.sid cookie in the plugin settings.", postId, ex.Message);
|
||||
return Array.Empty<MediaSourceInfo>();
|
||||
}
|
||||
|
||||
var attachments = OrderedVideos(post);
|
||||
if (attachments.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("Post {Post} has no playable video attachment", postId);
|
||||
return Array.Empty<MediaSourceInfo>();
|
||||
}
|
||||
|
||||
var cfg = _config();
|
||||
var baseUrl = ProxyBaseUrl();
|
||||
var sources = new List<MediaSourceInfo>();
|
||||
var first = true;
|
||||
foreach (var video in attachments)
|
||||
{
|
||||
var levels = video.Levels
|
||||
.Where(l => l.Height > 0 && l.Height <= Math.Max(360, cfg.MaxHeight))
|
||||
.OrderByDescending(l => Math.Abs(l.Height - DeliveryVariant.ParseHeight(cfg.PreferredQuality)) == 0 ? 1 : 0)
|
||||
.ThenByDescending(l => l.Height)
|
||||
.ToList();
|
||||
if (levels.Count == 0)
|
||||
{
|
||||
levels = video.Levels.OrderByDescending(l => l.Height).Take(1).ToList();
|
||||
}
|
||||
|
||||
if (levels.Count == 0)
|
||||
{
|
||||
// No level info (still processing?) — still try the preferred quality.
|
||||
levels = [new VideoLevel { Name = cfg.PreferredQuality, Height = DeliveryVariant.ParseHeight(cfg.PreferredQuality), Label = cfg.PreferredQuality + "p" }];
|
||||
}
|
||||
|
||||
var titlePrefix = attachments.Count > 1 ? (string.IsNullOrWhiteSpace(video.Title) ? "Video" : video.Title) + " · " : string.Empty;
|
||||
foreach (var level in levels)
|
||||
{
|
||||
var signature = ProxySignature.Create("hls", postId, video.Id, level.Name);
|
||||
var path = $"{baseUrl}/Floatplane/Stream/{Uri.EscapeDataString(postId)}/{Uri.EscapeDataString(video.Id)}/{Uri.EscapeDataString(level.Name)}/index.m3u8?{ProxySignature.QueryParameter}={signature}";
|
||||
sources.Add(BuildSource(id, first, titlePrefix + (level.Label ?? level.Name + "p"), path, "ts", video, level, hls: true));
|
||||
first = false;
|
||||
}
|
||||
|
||||
if (cfg.EnableMp4Sources)
|
||||
{
|
||||
var level = levels[0];
|
||||
var signature = ProxySignature.Create("mp4", postId, video.Id, level.Name);
|
||||
var path = $"{baseUrl}/Floatplane/Stream/{Uri.EscapeDataString(postId)}/{Uri.EscapeDataString(video.Id)}/{Uri.EscapeDataString(level.Name)}/video.mp4?{ProxySignature.QueryParameter}={signature}";
|
||||
sources.Add(BuildSource(id, false, titlePrefix + (level.Label ?? level.Name + "p") + " (MP4)", path, "mp4", video, level, hls: false));
|
||||
}
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
// ───────────────────────────── folders ─────────────────────────────
|
||||
|
||||
private async Task<List<ChannelItemInfo>> GetRootItemsAsync(CancellationToken ct)
|
||||
{
|
||||
var creators = await _catalog.GetCreatorsAsync(false, ct).ConfigureAwait(false);
|
||||
return creators.Select(c => new ChannelItemInfo
|
||||
{
|
||||
Id = CreatorPrefix + c.Id,
|
||||
Name = c.Title,
|
||||
Type = ChannelItemType.Folder,
|
||||
FolderType = ChannelFolderType.Container,
|
||||
Overview = c.Description,
|
||||
ImageUrl = c.Icon?.BestPath(600) ?? c.Card?.BestPath(1200) ?? c.Cover?.BestPath(1200),
|
||||
HomePageUrl = "https://www.floatplane.com/channel/" + c.Urlname + "/home"
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<ChannelItemInfo>> GetCreatorItemsAsync(string creatorId, CancellationToken ct)
|
||||
{
|
||||
var creators = await _catalog.GetCreatorsAsync(false, ct).ConfigureAwait(false);
|
||||
var creator = creators.FirstOrDefault(c => c.Id == creatorId);
|
||||
if (creator == null)
|
||||
{
|
||||
return new List<ChannelItemInfo>();
|
||||
}
|
||||
|
||||
if (creator.Channels.Count == 0)
|
||||
{
|
||||
return await GetPostItemsAsync(creator.Id, string.Empty, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return creator.Channels.Select(ch => new ChannelItemInfo
|
||||
{
|
||||
Id = ChannelPrefix + creator.Id + ":" + ch.Id,
|
||||
Name = ch.Title,
|
||||
Type = ChannelItemType.Folder,
|
||||
FolderType = ChannelFolderType.Container,
|
||||
Overview = ch.About,
|
||||
ImageUrl = ch.Icon?.BestPath(600) ?? ch.Card?.BestPath(1200) ?? ch.Cover?.BestPath(1200) ?? creator.Icon?.BestPath(600),
|
||||
HomePageUrl = "https://www.floatplane.com/channel/" + creator.Urlname + "/home/" + ch.Urlname
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<ChannelItemInfo>> GetPostItemsAsync(string creatorId, string channelId, CancellationToken ct)
|
||||
{
|
||||
var creator = _catalog.FindCreator(creatorId);
|
||||
var posts = await _catalog.GetPostsAsync(creatorId, channelId, ct).ConfigureAwait(false);
|
||||
// Anonymous listings flag everything as inaccessible; only mark locks when logged in.
|
||||
var markLocked = _catalog.HasSession;
|
||||
return posts.Select(p => ToItem(p, creator, markLocked)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Maps a post to a channel item. Internal for tests.</summary>
|
||||
internal static ChannelItemInfo ToItem(PostModel post, CreatorModel? creator, bool markLocked = true)
|
||||
{
|
||||
var name = post.IsAccessible || !markLocked ? post.Title : "🔒 " + post.Title;
|
||||
var item = new ChannelItemInfo
|
||||
{
|
||||
Id = PostPrefix + post.Id,
|
||||
Name = name,
|
||||
Type = ChannelItemType.Media,
|
||||
MediaType = ChannelMediaType.Video,
|
||||
ContentType = ChannelMediaContentType.Clip,
|
||||
Overview = Overview(post),
|
||||
ImageUrl = post.Thumbnail?.BestPath(1200),
|
||||
PremiereDate = post.ReleaseDate?.UtcDateTime,
|
||||
DateCreated = post.ReleaseDate?.UtcDateTime,
|
||||
ProductionYear = post.ReleaseDate?.Year,
|
||||
RunTimeTicks = post.DurationSeconds > 0 ? (long)(post.DurationSeconds * TimeSpan.TicksPerSecond) : null,
|
||||
HomePageUrl = "https://www.floatplane.com/post/" + post.Id,
|
||||
Tags = post.Tags.ToList(),
|
||||
Studios = creator != null ? [creator.Title] : [],
|
||||
Genres = post.Channel is { IsReferenceOnly: false, Title.Length: > 0 } ? [post.Channel.Title] : [],
|
||||
ProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { ["Floatplane"] = post.Id }
|
||||
};
|
||||
if (post.Score != 0)
|
||||
{
|
||||
item.CommunityRating = (float)Math.Clamp(5 + Math.Log10(Math.Max(1, post.Score)) * 1.5, 0, 10);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
private static string? Overview(PostModel post)
|
||||
{
|
||||
var text = post.TextMarkdown;
|
||||
if (string.IsNullOrWhiteSpace(text) && !string.IsNullOrWhiteSpace(post.Text))
|
||||
{
|
||||
text = StripHtml(post.Text);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
text = text.Replace("\\-", "-", StringComparison.Ordinal).Replace("\\_", "_", StringComparison.Ordinal).Trim();
|
||||
return text.Length > 2000 ? text[..2000] + "…" : text;
|
||||
}
|
||||
|
||||
[GeneratedRegex("<br\\s*/?>|</p>|</div>|</li>", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex BlockBreaks();
|
||||
|
||||
[GeneratedRegex("<[^>]+>", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex AnyTag();
|
||||
|
||||
internal static string StripHtml(string html)
|
||||
{
|
||||
var text = BlockBreaks().Replace(html, "\n");
|
||||
text = AnyTag().Replace(text, string.Empty);
|
||||
text = System.Net.WebUtility.HtmlDecode(text);
|
||||
return Regex.Replace(text, "\n{3,}", "\n\n").Trim();
|
||||
}
|
||||
|
||||
private static List<ChannelItemInfo> ApplySort(List<ChannelItemInfo> items, InternalChannelItemQuery query)
|
||||
{
|
||||
IEnumerable<ChannelItemInfo> sorted = query.SortBy switch
|
||||
{
|
||||
ChannelItemSortField.Name => items.OrderBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase),
|
||||
ChannelItemSortField.Runtime => items.OrderBy(i => i.RunTimeTicks ?? 0),
|
||||
ChannelItemSortField.PremiereDate or ChannelItemSortField.DateCreated => items.OrderBy(i => i.PremiereDate ?? DateTime.MinValue),
|
||||
_ => null!
|
||||
};
|
||||
if (sorted == null)
|
||||
{
|
||||
// default: folders alphabetical as given, media newest first
|
||||
return items.All(i => i.Type == ChannelItemType.Folder)
|
||||
? items
|
||||
: items.OrderByDescending(i => i.PremiereDate ?? DateTime.MinValue).ToList();
|
||||
}
|
||||
|
||||
return (query.SortDescending ? sorted.Reverse() : sorted).ToList();
|
||||
}
|
||||
|
||||
// ───────────────────────────── media sources ─────────────────────────────
|
||||
|
||||
private static List<VideoModel> OrderedVideos(PostModel post)
|
||||
{
|
||||
var videos = (post.VideoAttachments ?? new List<VideoModel>())
|
||||
.Where(v => !v.IsReferenceOnly && !v.IsProcessing)
|
||||
.ToList();
|
||||
if (post.AttachmentOrder.Count > 0)
|
||||
{
|
||||
videos = videos.OrderBy(v =>
|
||||
{
|
||||
var i = post.AttachmentOrder.IndexOf(v.Id);
|
||||
return i < 0 ? int.MaxValue : i;
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
return videos;
|
||||
}
|
||||
|
||||
private string ProxyBaseUrl()
|
||||
{
|
||||
var configured = _config().ProxyBaseUrl?.Trim().TrimEnd('/');
|
||||
if (!string.IsNullOrEmpty(configured))
|
||||
{
|
||||
return configured;
|
||||
}
|
||||
|
||||
return _appHost.GetApiUrlForLocalAccess(null, false).TrimEnd('/');
|
||||
}
|
||||
|
||||
private static MediaSourceInfo BuildSource(string itemId, bool isPrimary, string name, string path, string container, VideoModel video, VideoLevel level, bool hls)
|
||||
{
|
||||
var width = level.Width > 0 ? level.Width : (int)Math.Round(level.Height * 16.0 / 9.0);
|
||||
var streams = new List<MediaStream>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Type = MediaStreamType.Video,
|
||||
Index = 0,
|
||||
Codec = "h264",
|
||||
Profile = "High",
|
||||
Level = level.Height >= 2160 ? 51 : level.Height >= 1080 ? 40 : 31,
|
||||
Width = width,
|
||||
Height = level.Height,
|
||||
IsDefault = true,
|
||||
IsInterlaced = false,
|
||||
AverageFrameRate = 30,
|
||||
RealFrameRate = 30,
|
||||
PixelFormat = "yuv420p",
|
||||
BitRate = EstimateBitrate(level.Height)
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = MediaStreamType.Audio,
|
||||
Index = 1,
|
||||
Codec = "aac",
|
||||
Profile = "LC",
|
||||
Channels = 2,
|
||||
SampleRate = 48000,
|
||||
BitRate = 160000,
|
||||
IsDefault = true,
|
||||
Language = "eng"
|
||||
}
|
||||
};
|
||||
|
||||
return new MediaSourceInfo
|
||||
{
|
||||
// The first source keeps the item id (what clients echo back as mediaSourceId).
|
||||
Id = isPrimary ? DeterministicGuid(itemId).ToString("N") : DeterministicGuid($"{itemId}|{video.Id}|{level.Name}|{container}").ToString("N"),
|
||||
Name = name,
|
||||
Path = path,
|
||||
Protocol = MediaProtocol.Http,
|
||||
Container = container,
|
||||
VideoType = VideoType.VideoFile,
|
||||
IsRemote = false,
|
||||
IsInfiniteStream = false,
|
||||
RunTimeTicks = video.Duration > 0 ? (long)(video.Duration * TimeSpan.TicksPerSecond) : null,
|
||||
Bitrate = EstimateBitrate(level.Height) + 160000,
|
||||
SupportsProbing = false,
|
||||
SupportsDirectPlay = false,
|
||||
SupportsDirectStream = !hls,
|
||||
SupportsTranscoding = true,
|
||||
RequiresOpening = false,
|
||||
RequiresClosing = false,
|
||||
ReadAtNativeFramerate = false,
|
||||
DefaultAudioStreamIndex = 1,
|
||||
MediaStreams = streams
|
||||
};
|
||||
}
|
||||
|
||||
private static int EstimateBitrate(int height) => height switch
|
||||
{
|
||||
>= 2160 => 8_000_000,
|
||||
>= 1080 => 2_500_000,
|
||||
>= 720 => 1_200_000,
|
||||
>= 480 => 600_000,
|
||||
_ => 350_000
|
||||
};
|
||||
|
||||
internal static Guid DeterministicGuid(string input)
|
||||
{
|
||||
var hash = MD5.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return new Guid(hash);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user