diff --git a/src/NzbDrone.Core/Indexers/Definitions/Libble.cs b/src/NzbDrone.Core/Indexers/Definitions/Libble.cs index f95f93687..d17f94da7 100644 --- a/src/NzbDrone.Core/Indexers/Definitions/Libble.cs +++ b/src/NzbDrone.Core/Indexers/Definitions/Libble.cs @@ -3,11 +3,11 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.Globalization; using System.Linq; -using System.Net; using System.Net.Http; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; +using AngleSharp.Dom; using AngleSharp.Html.Parser; using NLog; using NzbDrone.Common.Extensions; @@ -21,352 +21,339 @@ using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; -namespace NzbDrone.Core.Indexers.Definitions +namespace NzbDrone.Core.Indexers.Definitions; + +public class Libble : TorrentIndexerBase { - internal class Libble : TorrentIndexerBase + public override string Name => "Libble"; + public override string[] IndexerUrls => new[] { "https://libble.me/" }; + public override string Description => "Libble is a Private Torrent Tracker for MUSIC"; + private string LoginUrl => Settings.BaseUrl + "login.php"; + public override string Language => "en-US"; + public override Encoding Encoding => Encoding.UTF8; + public override DownloadProtocol Protocol => DownloadProtocol.Torrent; + public override IndexerPrivacy Privacy => IndexerPrivacy.Private; + public override int PageSize => 50; + public override IndexerCapabilities Capabilities => SetCapabilities(); + + public Libble(IIndexerHttpClient httpClient, IEventAggregator eventAggregator, IIndexerStatusService indexerStatusService, IConfigService configService, Logger logger) + : base(httpClient, eventAggregator, indexerStatusService, configService, logger) { - public override string Name => "Libble"; - public override string[] IndexerUrls => new string[] { "https://libble.me/" }; - public override string Description => "Libble is a Private Torrent Tracker for MUSIC"; - private string LoginUrl => Settings.BaseUrl + "login.php"; - public override string Language => "en-US"; - public override Encoding Encoding => Encoding.UTF8; - public override DownloadProtocol Protocol => DownloadProtocol.Torrent; - public override IndexerPrivacy Privacy => IndexerPrivacy.Private; - public override int PageSize => 50; - public override IndexerCapabilities Capabilities => SetCapabilities(); - - public Libble(IIndexerHttpClient httpClient, IEventAggregator eventAggregator, IIndexerStatusService indexerStatusService, IConfigService configService, Logger logger) - : base(httpClient, eventAggregator, indexerStatusService, configService, logger) - { - } + } - public override IIndexerRequestGenerator GetRequestGenerator() - { - return new LibbleRequestGenerator() { Settings = Settings, Capabilities = Capabilities }; - } + public override IIndexerRequestGenerator GetRequestGenerator() + { + return new LibbleRequestGenerator(Settings, Capabilities); + } - public override IParseIndexerResponse GetParser() - { - return new LibbleParser(Settings, Capabilities.Categories); - } + public override IParseIndexerResponse GetParser() + { + return new LibbleParser(Settings); + } - protected override async Task DoLogin() + protected override async Task DoLogin() + { + var requestBuilder = new HttpRequestBuilder(LoginUrl) { - var requestBuilder = new HttpRequestBuilder(LoginUrl) - { - Method = HttpMethod.Post, - AllowAutoRedirect = true - }; + AllowAutoRedirect = true, + Method = HttpMethod.Post + }; + requestBuilder.PostProcess += r => r.RequestTimeout = TimeSpan.FromSeconds(15); + + var cookies = Cookies; + + Cookies = null; + var authLoginRequest = requestBuilder + .AddFormParameter("username", Settings.Username) + .AddFormParameter("password", Settings.Password) + .AddFormParameter("code", Settings.TwoFactorAuthCode) + .AddFormParameter("keeplogged", "1") + .AddFormParameter("login", "Login") + .SetHeader("Content-Type", "application/x-www-form-urlencoded") + .SetHeader("Referer", LoginUrl) + .Build(); + + var response = await ExecuteAuth(authLoginRequest); + + if (CheckIfLoginNeeded(response)) + { + var parser = new HtmlParser(); + var dom = parser.ParseDocument(response.Content); + var errorMessage = dom.QuerySelector("#loginform > .warning")?.TextContent.Trim(); + + throw new IndexerAuthException(errorMessage ?? "Unknown error message, please report."); + } - requestBuilder.PostProcess += r => r.RequestTimeout = TimeSpan.FromSeconds(15); + cookies = response.GetCookies(); + UpdateCookies(cookies, DateTime.Now + TimeSpan.FromDays(30)); - var cookies = Cookies; + _logger.Debug("Authentication succeeded."); + } - Cookies = null; - var authLoginRequest = requestBuilder - .AddFormParameter("username", Settings.Username) - .AddFormParameter("password", Settings.Password) - .AddFormParameter("code", Settings.TwoFactorAuthCode) - .AddFormParameter("keeplogged", "1") - .AddFormParameter("login", "Login") - .SetHeader("Content-Type", "multipart/form-data") - .Build(); + protected override bool CheckIfLoginNeeded(HttpResponse httpResponse) + { + return !httpResponse.Content.Contains("logout.php"); + } - var headers = new NameValueCollection + private IndexerCapabilities SetCapabilities() + { + var caps = new IndexerCapabilities + { + MusicSearchParams = new List { - { "Referer", LoginUrl } - }; + MusicSearchParam.Q, MusicSearchParam.Artist, MusicSearchParam.Album, MusicSearchParam.Label, MusicSearchParam.Year, MusicSearchParam.Genre + } + }; - authLoginRequest.Headers.Add(headers); + caps.Categories.AddCategoryMapping(1, NewznabStandardCategory.Audio, "Music"); + caps.Categories.AddCategoryMapping(2, NewznabStandardCategory.Audio, "Libble Mixtapes"); + caps.Categories.AddCategoryMapping(7, NewznabStandardCategory.AudioVideo, "Music Videos"); - var response = await ExecuteAuth(authLoginRequest); + return caps; + } +} - if (CheckIfLoginNeeded(response)) - { - var parser = new HtmlParser(); - var dom = parser.ParseDocument(response.Content); - var errorMessage = dom.QuerySelector("#loginform > .warning")?.TextContent.Trim(); +public class LibbleRequestGenerator : IIndexerRequestGenerator +{ + private readonly LibbleSettings _settings; + private readonly IndexerCapabilities _capabilities; - throw new IndexerAuthException($"Libble authentication failed. Error: \"{errorMessage}\""); - } + public LibbleRequestGenerator(LibbleSettings settings, IndexerCapabilities capabilities) + { + _settings = settings; + _capabilities = capabilities; + } - cookies = response.GetCookies(); - UpdateCookies(cookies, DateTime.Now + TimeSpan.FromDays(30)); + public IndexerPageableRequestChain GetSearchRequests(MusicSearchCriteria searchCriteria) + { + var pageableRequests = new IndexerPageableRequestChain(); + var parameters = new NameValueCollection(); - _logger.Debug("Libble authentication succeeded."); + if (searchCriteria.Artist.IsNotNullOrWhiteSpace()) + { + parameters.Set("artistname", searchCriteria.Artist); } - protected override bool CheckIfLoginNeeded(HttpResponse httpResponse) + if (searchCriteria.Album.IsNotNullOrWhiteSpace()) { - return !httpResponse.Content.Contains("logout.php"); + parameters.Set("groupname", searchCriteria.Album); } - private IndexerCapabilities SetCapabilities() + if (searchCriteria.Label.IsNotNullOrWhiteSpace()) { - var caps = new IndexerCapabilities - { - MusicSearchParams = new List - { - MusicSearchParam.Q, MusicSearchParam.Artist, MusicSearchParam.Album, MusicSearchParam.Label, MusicSearchParam.Year, MusicSearchParam.Genre - } - }; - - caps.Categories.AddCategoryMapping(1, NewznabStandardCategory.Audio); - caps.Categories.AddCategoryMapping(2, NewznabStandardCategory.Audio); - caps.Categories.AddCategoryMapping(7, NewznabStandardCategory.AudioVideo); - - return caps; + parameters.Set("recordlabel", searchCriteria.Label); } - } - public class LibbleRequestGenerator : IIndexerRequestGenerator - { - public LibbleSettings Settings { get; set; } - public IndexerCapabilities Capabilities { get; set; } - - public LibbleRequestGenerator() + if (searchCriteria.Year.HasValue) { + parameters.Set("year", searchCriteria.Year.ToString()); } - private IEnumerable GetPagedRequests(SearchCriteriaBase searchCriteria, NameValueCollection parameters) + if (searchCriteria.Genre.IsNotNullOrWhiteSpace()) { - var term = searchCriteria.SanitizedSearchTerm.Trim(); - - parameters.Add("order_by", "time"); - parameters.Add("order_way", "desc"); - parameters.Add("searchstr", term); - - var queryCats = Capabilities.Categories.MapTorznabCapsToTrackers(searchCriteria.Categories); - - if (queryCats.Count > 0) - { - foreach (var cat in queryCats) - { - parameters.Add($"filter_cat[{cat}]", "1"); - } - } + parameters.Set("taglist", searchCriteria.Genre); + parameters.Set("tags_type", "0"); + } - if (searchCriteria.Offset.HasValue && searchCriteria.Limit.HasValue && searchCriteria.Offset > 0 && searchCriteria.Limit > 0) - { - var page = (int)(searchCriteria.Offset / searchCriteria.Limit) + 1; - parameters.Add("page", page.ToString()); - } + pageableRequests.Add(GetPagedRequests(searchCriteria, parameters)); - var searchUrl = string.Format("{0}/torrents.php?{1}", Settings.BaseUrl.TrimEnd('/'), parameters.GetQueryString()); + return pageableRequests; + } - var request = new IndexerRequest(searchUrl, HttpAccept.Html); + public IndexerPageableRequestChain GetSearchRequests(BasicSearchCriteria searchCriteria) + { + var pageableRequests = new IndexerPageableRequestChain(); + var parameters = new NameValueCollection(); - yield return request; - } + pageableRequests.Add(GetPagedRequests(searchCriteria, parameters)); - public IndexerPageableRequestChain GetSearchRequests(MusicSearchCriteria searchCriteria) - { - var pageableRequests = new IndexerPageableRequestChain(); - var parameters = new NameValueCollection(); - - if (searchCriteria.Artist.IsNotNullOrWhiteSpace()) - { - parameters.Add("artistname", searchCriteria.Artist); - } + return pageableRequests; + } - if (searchCriteria.Album.IsNotNullOrWhiteSpace()) - { - parameters.Add("groupname", searchCriteria.Album); - } + public IndexerPageableRequestChain GetSearchRequests(MovieSearchCriteria searchCriteria) + { + return new IndexerPageableRequestChain(); + } - if (searchCriteria.Label.IsNotNullOrWhiteSpace()) - { - parameters.Add("recordlabel", searchCriteria.Label); - } + public IndexerPageableRequestChain GetSearchRequests(TvSearchCriteria searchCriteria) + { + return new IndexerPageableRequestChain(); + } - if (searchCriteria.Year.HasValue) - { - parameters.Add("year", searchCriteria.Year.ToString()); - } + public IndexerPageableRequestChain GetSearchRequests(BookSearchCriteria searchCriteria) + { + return new IndexerPageableRequestChain(); + } - if (searchCriteria.Genre.IsNotNullOrWhiteSpace()) - { - parameters.Add("taglist", searchCriteria.Genre); - } + private IEnumerable GetPagedRequests(SearchCriteriaBase searchCriteria, NameValueCollection parameters) + { + var term = searchCriteria.SanitizedSearchTerm.Trim(); - pageableRequests.Add(GetPagedRequests(searchCriteria, parameters)); + parameters.Set("order_by", "time"); + parameters.Set("order_way", "desc"); + parameters.Set("searchstr", term); - return pageableRequests; + var queryCats = _capabilities.Categories.MapTorznabCapsToTrackers(searchCriteria.Categories); + if (queryCats.Any()) + { + queryCats.ForEach(cat => parameters.Set($"filter_cat[{cat}]", "1")); } - public IndexerPageableRequestChain GetSearchRequests(BasicSearchCriteria searchCriteria) + if (searchCriteria.Offset.HasValue && searchCriteria.Limit.HasValue && searchCriteria.Offset > 0 && searchCriteria.Limit > 0) { - var pageableRequests = new IndexerPageableRequestChain(); - var parameters = new NameValueCollection(); + var page = (int)(searchCriteria.Offset / searchCriteria.Limit) + 1; + parameters.Set("page", page.ToString()); + } - pageableRequests.Add(GetPagedRequests(searchCriteria, parameters)); + var searchUrl = $"{_settings.BaseUrl.TrimEnd('/')}/torrents.php?{parameters.GetQueryString()}"; - return pageableRequests; - } + var request = new IndexerRequest(searchUrl, HttpAccept.Html); - public IndexerPageableRequestChain GetSearchRequests(MovieSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); - } + yield return request; + } - public IndexerPageableRequestChain GetSearchRequests(TvSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); - } + public Func> GetCookies { get; set; } + public Action, DateTime?> CookiesUpdater { get; set; } +} - public IndexerPageableRequestChain GetSearchRequests(BookSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); - } +public class LibbleParser : IParseIndexerResponse +{ + private readonly LibbleSettings _settings; + private static Regex ReleaseYearRegex => new (@"\[(\d{4})\]$", RegexOptions.Compiled); - public Func> GetCookies { get; set; } - public Action, DateTime?> CookiesUpdater { get; set; } + public LibbleParser(LibbleSettings settings) + { + _settings = settings; } - public class LibbleParser : IParseIndexerResponse + public IList ParseResponse(IndexerResponse indexerResponse) { - private readonly LibbleSettings _settings; - private readonly IndexerCapabilitiesCategories _categories; + var releaseInfos = new List(); - public LibbleParser(LibbleSettings settings, IndexerCapabilitiesCategories categories) - { - _settings = settings; - _categories = categories; - } + var parser = new HtmlParser(); + var doc = parser.ParseDocument(indexerResponse.Content); - public IList ParseResponse(IndexerResponse indexerResponse) + var groups = doc.QuerySelectorAll("table#torrent_table > tbody > tr.group:has(strong > a[href*=\"torrents.php?id=\"])"); + foreach (var group in groups) { - var torrentInfos = new List(); + var albumLinkNode = group.QuerySelector("strong > a[href*=\"torrents.php?id=\"]"); + var groupId = ParseUtil.GetArgumentFromQueryString(albumLinkNode.GetAttribute("href"), "id"); - var parser = new HtmlParser(); - var doc = parser.ParseDocument(indexerResponse.Content); - var rows = doc.QuerySelectorAll("table#torrent_table > tbody > tr.group:has(strong > a[href*=\"torrents.php?id=\"])"); - - var releaseYearRegex = new Regex(@"\[(\d{4})\]$"); + var artistsNodes = group.QuerySelectorAll("strong > a[href*=\"artist.php?id=\"]"); - foreach (var row in rows) + var releaseArtist = "Various Artists"; + if (artistsNodes.Any()) { - var albumLinkNode = row.QuerySelector("strong > a[href*=\"torrents.php?id=\"]"); - var groupId = ParseUtil.GetArgumentFromQueryString(albumLinkNode.GetAttribute("href"), "id"); - - var artistsNodes = row.QuerySelectorAll("strong > a[href*=\"artist.php?id=\"]"); + releaseArtist = artistsNodes.Select(artist => artist.TextContent.Trim()).ToList().Join(", "); + } - var releaseArtist = "Various Artists"; - if (artistsNodes.Count() > 0) - { - releaseArtist = artistsNodes.Select(artist => artist.TextContent.Trim()).ToList().Join(", "); - } + var releaseAlbumName = group.QuerySelector("strong > a[href*=\"torrents.php?id=\"]")?.TextContent.Trim(); - var releaseAlbumName = row.QuerySelector("strong > a[href*=\"torrents.php?id=\"]")?.TextContent.Trim(); + var title = group.QuerySelector("td:nth-child(4) > strong")?.TextContent.Trim(); + var releaseAlbumYear = ReleaseYearRegex.Match(title); - var title = row.QuerySelector("td:nth-child(4) > strong")?.TextContent.Trim(); - var releaseAlbumYear = releaseYearRegex.Match(title); + var releaseDescription = group.QuerySelector("div.tags")?.TextContent.Trim(); + var releaseThumbnailUrl = group.QuerySelector(".thumbnail")?.GetAttribute("title")?.Trim(); - var releaseDescription = row.QuerySelector("div.tags")?.TextContent.Trim(); - var releaseThumbnailUrl = row.QuerySelector(".thumbnail")?.GetAttribute("title").Trim(); + var releaseGenres = new List(); + if (!string.IsNullOrEmpty(releaseDescription)) + { + releaseGenres = releaseDescription.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList(); + } - var releaseGenres = new List(); - if (!string.IsNullOrEmpty(releaseDescription)) - { - releaseGenres = releaseGenres.Union(releaseDescription.Split(',').Select(tag => tag.Trim()).ToList()).ToList(); - } + var rows = doc.QuerySelectorAll($"table#torrent_table > tbody > tr.group_torrent.groupid_{groupId}:has(a[href*=\"torrents.php?id=\"])"); + foreach (var row in rows) + { + var detailsNode = row.QuerySelector("a[href^=\"torrents.php?id=\"]"); - var cat = row.QuerySelector("td.cats_col div.cat_icon")?.GetAttribute("class").Trim(); + var infoUrl = _settings.BaseUrl + detailsNode.GetAttribute("href").Trim(); + var downloadLink = _settings.BaseUrl + row.QuerySelector("a[href^=\"torrents.php?action=download&id=\"]").GetAttribute("href").Trim(); - var matchCategory = Regex.Match(cat, @"\bcats_(.*?)\b"); - if (matchCategory.Success) - { - cat = matchCategory.Groups[1].Value.Trim(); - } + var releaseTags = detailsNode.FirstChild?.TextContent.Trim(' ', '/'); + var seeders = ParseUtil.CoerceInt(row.QuerySelector("td:nth-child(6)").TextContent); - var category = new List + var release = new TorrentInfo { - cat switch - { - "music" => NewznabStandardCategory.Audio, - "libblemixtapes" => NewznabStandardCategory.Audio, - "musicvideos" => NewznabStandardCategory.AudioVideo, - _ => NewznabStandardCategory.Other, - } + Guid = infoUrl, + InfoUrl = infoUrl, + DownloadUrl = downloadLink, + Title = $"{releaseArtist} - {releaseAlbumName} {releaseAlbumYear} {releaseTags}".Trim(' ', '-'), + Categories = ParseCategories(group), + Description = releaseDescription, + Size = ParseUtil.GetBytes(row.QuerySelector("td:nth-child(4)").TextContent.Trim()), + Files = ParseUtil.CoerceInt(row.QuerySelector("td:nth-child(2)").TextContent), + Grabs = ParseUtil.CoerceInt(row.QuerySelector("td:nth-child(5)").TextContent), + Seeders = seeders, + Peers = seeders + ParseUtil.CoerceInt(row.QuerySelector("td:nth-child(7)").TextContent), + DownloadVolumeFactor = 1, + UploadVolumeFactor = 1, + MinimumRatio = 1, + MinimumSeedTime = 259200, // 72 hours, + Genres = releaseGenres, + PosterUrl = releaseThumbnailUrl, }; - var releaseRows = doc.QuerySelectorAll(string.Format("table#torrent_table > tbody > tr.group_torrent.groupid_{0}:has(a[href*=\"torrents.php?id=\"])", groupId)); + try + { + var dateAdded = row.QuerySelector("td:nth-child(3) > span[title]").GetAttribute("title").Trim(); + release.PublishDate = DateTime.ParseExact(dateAdded, "MMM dd yyyy, HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal); + } + catch (Exception) + { + release.PublishDate = DateTimeUtil.FromTimeAgo(row.QuerySelector("td:nth-child(3)")?.TextContent.Trim()); + } - foreach (var releaseRow in releaseRows) + switch (row.QuerySelector("a[href^=\"torrents.php?id=\"] strong")?.TextContent.ToLower().Trim(' ', '!')) { - var release = new TorrentInfo(); - - var detailsNode = releaseRow.QuerySelector("a[href^=\"torrents.php?id=\"]"); - var downloadLink = _settings.BaseUrl + releaseRow.QuerySelector("a[href^=\"torrents.php?action=download&id=\"]").GetAttribute("href").Trim(); - - var releaseTags = detailsNode.FirstChild.TextContent.Trim(' ', '/'); - - release.Title = string.Format("{0} - {1} {2} {3}", releaseArtist, releaseAlbumName, releaseAlbumYear, releaseTags).Trim(); - release.Categories = category; - release.Description = releaseDescription; - release.Genres = releaseGenres; - release.PosterUrl = releaseThumbnailUrl; - - release.InfoUrl = _settings.BaseUrl + detailsNode.GetAttribute("href").Trim(); - release.DownloadUrl = downloadLink; - release.Guid = release.InfoUrl; - - release.Size = ParseUtil.GetBytes(releaseRow.QuerySelector("td:nth-child(4)").TextContent.Trim()); - release.Files = ParseUtil.CoerceInt(releaseRow.QuerySelector("td:nth-child(2)").TextContent); - release.Grabs = ParseUtil.CoerceInt(releaseRow.QuerySelector("td:nth-child(5)").TextContent); - release.Seeders = ParseUtil.CoerceInt(releaseRow.QuerySelector("td:nth-child(6)").TextContent); - release.Peers = release.Seeders + ParseUtil.CoerceInt(releaseRow.QuerySelector("td:nth-child(7)").TextContent); - - release.MinimumRatio = 1; - release.MinimumSeedTime = 259200; // 72 hours - - try - { - release.PublishDate = DateTime.ParseExact( - releaseRow.QuerySelector("td:nth-child(3) > span[title]").GetAttribute("title").Trim(), - "MMM dd yyyy, HH:mm", - CultureInfo.InvariantCulture, - DateTimeStyles.AssumeUniversal); - } - catch (Exception) - { - } - - switch (releaseRow.QuerySelector("a[href^=\"torrents.php?id=\"] strong")?.TextContent.Trim()) - { - case "Neutral!": - release.DownloadVolumeFactor = 0; - release.UploadVolumeFactor = 0; - break; - case "Freeleech!": - release.DownloadVolumeFactor = 0; - release.UploadVolumeFactor = 1; - break; - default: - release.DownloadVolumeFactor = 1; - release.UploadVolumeFactor = 1; - break; - } - - torrentInfos.Add(release); + case "neutral": + release.DownloadVolumeFactor = 0; + release.UploadVolumeFactor = 0; + break; + case "freeleech": + release.DownloadVolumeFactor = 0; + release.UploadVolumeFactor = 1; + break; } - } - return torrentInfos.ToArray(); + releaseInfos.Add(release); + } } - public Action, DateTime?> CookiesUpdater { get; set; } + return releaseInfos.ToArray(); } - public class LibbleSettings : UserPassTorrentBaseSettings + private IList ParseCategories(IElement group) { - public LibbleSettings() + var cat = group.QuerySelector("td.cats_col div.cat_icon")?.GetAttribute("class")?.Trim(); + + var matchCategory = Regex.Match(cat, @"\bcats_(.*?)\b"); + if (matchCategory.Success) { - TwoFactorAuthCode = ""; + cat = matchCategory.Groups[1].Value.Trim(); } - [FieldDefinition(4, Label = "2FA code", Type = FieldType.Textbox, HelpText = "Only fill in the 2FA code box if you have enabled 2FA on the Libble Web Site. Otherwise just leave it empty.")] - public string TwoFactorAuthCode { get; set; } + return new List + { + cat switch + { + "music" => NewznabStandardCategory.Audio, + "libblemixtapes" => NewznabStandardCategory.Audio, + "musicvideos" => NewznabStandardCategory.AudioVideo, + _ => NewznabStandardCategory.Other, + } + }; + } + + public Action, DateTime?> CookiesUpdater { get; set; } +} + +public class LibbleSettings : UserPassTorrentBaseSettings +{ + public LibbleSettings() + { + TwoFactorAuthCode = ""; } + + [FieldDefinition(4, Label = "2FA code", Type = FieldType.Textbox, HelpText = "Only fill in the 2FA code box if you have enabled 2FA on the Libble Web Site. Otherwise just leave it empty.")] + public string TwoFactorAuthCode { get; set; } }