Fixed: (Libble) Minor improvements

pull/1386/head
Bogdan 2 years ago
parent 19913e5b01
commit 15734ca0da

@ -3,11 +3,11 @@ using System.Collections.Generic;
using System.Collections.Specialized; using System.Collections.Specialized;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using AngleSharp.Dom;
using AngleSharp.Html.Parser; using AngleSharp.Html.Parser;
using NLog; using NLog;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
@ -21,352 +21,339 @@ using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Parser; using NzbDrone.Core.Parser;
using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Parser.Model;
namespace NzbDrone.Core.Indexers.Definitions namespace NzbDrone.Core.Indexers.Definitions;
public class Libble : TorrentIndexerBase<LibbleSettings>
{ {
internal class Libble : TorrentIndexerBase<LibbleSettings> 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() public override IIndexerRequestGenerator GetRequestGenerator()
{ {
return new LibbleRequestGenerator() { Settings = Settings, Capabilities = Capabilities }; return new LibbleRequestGenerator(Settings, Capabilities);
} }
public override IParseIndexerResponse GetParser() public override IParseIndexerResponse GetParser()
{ {
return new LibbleParser(Settings, Capabilities.Categories); return new LibbleParser(Settings);
} }
protected override async Task DoLogin() protected override async Task DoLogin()
{
var requestBuilder = new HttpRequestBuilder(LoginUrl)
{ {
var requestBuilder = new HttpRequestBuilder(LoginUrl) AllowAutoRedirect = true,
{ Method = HttpMethod.Post
Method = HttpMethod.Post, };
AllowAutoRedirect = true 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; protected override bool CheckIfLoginNeeded(HttpResponse httpResponse)
var authLoginRequest = requestBuilder {
.AddFormParameter("username", Settings.Username) return !httpResponse.Content.Contains("logout.php");
.AddFormParameter("password", Settings.Password) }
.AddFormParameter("code", Settings.TwoFactorAuthCode)
.AddFormParameter("keeplogged", "1")
.AddFormParameter("login", "Login")
.SetHeader("Content-Type", "multipart/form-data")
.Build();
var headers = new NameValueCollection private IndexerCapabilities SetCapabilities()
{
var caps = new IndexerCapabilities
{
MusicSearchParams = new List<MusicSearchParam>
{ {
{ "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)) public class LibbleRequestGenerator : IIndexerRequestGenerator
{ {
var parser = new HtmlParser(); private readonly LibbleSettings _settings;
var dom = parser.ParseDocument(response.Content); private readonly IndexerCapabilities _capabilities;
var errorMessage = dom.QuerySelector("#loginform > .warning")?.TextContent.Trim();
throw new IndexerAuthException($"Libble authentication failed. Error: \"{errorMessage}\""); public LibbleRequestGenerator(LibbleSettings settings, IndexerCapabilities capabilities)
} {
_settings = settings;
_capabilities = capabilities;
}
cookies = response.GetCookies(); public IndexerPageableRequestChain GetSearchRequests(MusicSearchCriteria searchCriteria)
UpdateCookies(cookies, DateTime.Now + TimeSpan.FromDays(30)); {
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 parameters.Set("recordlabel", searchCriteria.Label);
{
MusicSearchParams = new List<MusicSearchParam>
{
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;
} }
}
public class LibbleRequestGenerator : IIndexerRequestGenerator if (searchCriteria.Year.HasValue)
{
public LibbleSettings Settings { get; set; }
public IndexerCapabilities Capabilities { get; set; }
public LibbleRequestGenerator()
{ {
parameters.Set("year", searchCriteria.Year.ToString());
} }
private IEnumerable<IndexerRequest> GetPagedRequests(SearchCriteriaBase searchCriteria, NameValueCollection parameters) if (searchCriteria.Genre.IsNotNullOrWhiteSpace())
{ {
var term = searchCriteria.SanitizedSearchTerm.Trim(); parameters.Set("taglist", searchCriteria.Genre);
parameters.Set("tags_type", "0");
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");
}
}
if (searchCriteria.Offset.HasValue && searchCriteria.Limit.HasValue && searchCriteria.Offset > 0 && searchCriteria.Limit > 0) pageableRequests.Add(GetPagedRequests(searchCriteria, parameters));
{
var page = (int)(searchCriteria.Offset / searchCriteria.Limit) + 1;
parameters.Add("page", page.ToString());
}
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) return pageableRequests;
{ }
var pageableRequests = new IndexerPageableRequestChain();
var parameters = new NameValueCollection();
if (searchCriteria.Artist.IsNotNullOrWhiteSpace())
{
parameters.Add("artistname", searchCriteria.Artist);
}
if (searchCriteria.Album.IsNotNullOrWhiteSpace()) public IndexerPageableRequestChain GetSearchRequests(MovieSearchCriteria searchCriteria)
{ {
parameters.Add("groupname", searchCriteria.Album); return new IndexerPageableRequestChain();
} }
if (searchCriteria.Label.IsNotNullOrWhiteSpace()) public IndexerPageableRequestChain GetSearchRequests(TvSearchCriteria searchCriteria)
{ {
parameters.Add("recordlabel", searchCriteria.Label); return new IndexerPageableRequestChain();
} }
if (searchCriteria.Year.HasValue) public IndexerPageableRequestChain GetSearchRequests(BookSearchCriteria searchCriteria)
{ {
parameters.Add("year", searchCriteria.Year.ToString()); return new IndexerPageableRequestChain();
} }
if (searchCriteria.Genre.IsNotNullOrWhiteSpace()) private IEnumerable<IndexerRequest> GetPagedRequests(SearchCriteriaBase searchCriteria, NameValueCollection parameters)
{ {
parameters.Add("taglist", searchCriteria.Genre); 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 page = (int)(searchCriteria.Offset / searchCriteria.Limit) + 1;
var parameters = new NameValueCollection(); 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) yield return request;
{ }
return new IndexerPageableRequestChain();
}
public IndexerPageableRequestChain GetSearchRequests(TvSearchCriteria searchCriteria) public Func<IDictionary<string, string>> GetCookies { get; set; }
{ public Action<IDictionary<string, string>, DateTime?> CookiesUpdater { get; set; }
return new IndexerPageableRequestChain(); }
}
public IndexerPageableRequestChain GetSearchRequests(BookSearchCriteria searchCriteria) public class LibbleParser : IParseIndexerResponse
{ {
return new IndexerPageableRequestChain(); private readonly LibbleSettings _settings;
} private static Regex ReleaseYearRegex => new (@"\[(\d{4})\]$", RegexOptions.Compiled);
public Func<IDictionary<string, string>> GetCookies { get; set; } public LibbleParser(LibbleSettings settings)
public Action<IDictionary<string, string>, DateTime?> CookiesUpdater { get; set; } {
_settings = settings;
} }
public class LibbleParser : IParseIndexerResponse public IList<ReleaseInfo> ParseResponse(IndexerResponse indexerResponse)
{ {
private readonly LibbleSettings _settings; var releaseInfos = new List<ReleaseInfo>();
private readonly IndexerCapabilitiesCategories _categories;
public LibbleParser(LibbleSettings settings, IndexerCapabilitiesCategories categories) var parser = new HtmlParser();
{ var doc = parser.ParseDocument(indexerResponse.Content);
_settings = settings;
_categories = categories;
}
public IList<ReleaseInfo> 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<ReleaseInfo>(); var albumLinkNode = group.QuerySelector("strong > a[href*=\"torrents.php?id=\"]");
var groupId = ParseUtil.GetArgumentFromQueryString(albumLinkNode.GetAttribute("href"), "id");
var parser = new HtmlParser(); var artistsNodes = group.QuerySelectorAll("strong > a[href*=\"artist.php?id=\"]");
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})\]$");
foreach (var row in rows) var releaseArtist = "Various Artists";
if (artistsNodes.Any())
{ {
var albumLinkNode = row.QuerySelector("strong > a[href*=\"torrents.php?id=\"]"); releaseArtist = artistsNodes.Select(artist => artist.TextContent.Trim()).ToList().Join(", ");
var groupId = ParseUtil.GetArgumentFromQueryString(albumLinkNode.GetAttribute("href"), "id"); }
var artistsNodes = row.QuerySelectorAll("strong > a[href*=\"artist.php?id=\"]");
var releaseArtist = "Various Artists"; var releaseAlbumName = group.QuerySelector("strong > a[href*=\"torrents.php?id=\"]")?.TextContent.Trim();
if (artistsNodes.Count() > 0)
{
releaseArtist = artistsNodes.Select(artist => artist.TextContent.Trim()).ToList().Join(", ");
}
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 releaseDescription = group.QuerySelector("div.tags")?.TextContent.Trim();
var releaseAlbumYear = releaseYearRegex.Match(title); var releaseThumbnailUrl = group.QuerySelector(".thumbnail")?.GetAttribute("title")?.Trim();
var releaseDescription = row.QuerySelector("div.tags")?.TextContent.Trim(); var releaseGenres = new List<string>();
var releaseThumbnailUrl = row.QuerySelector(".thumbnail")?.GetAttribute("title").Trim(); if (!string.IsNullOrEmpty(releaseDescription))
{
releaseGenres = releaseDescription.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList();
}
var releaseGenres = new List<string>(); var rows = doc.QuerySelectorAll($"table#torrent_table > tbody > tr.group_torrent.groupid_{groupId}:has(a[href*=\"torrents.php?id=\"])");
if (!string.IsNullOrEmpty(releaseDescription)) foreach (var row in rows)
{ {
releaseGenres = releaseGenres.Union(releaseDescription.Split(',').Select(tag => tag.Trim()).ToList()).ToList(); 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"); var releaseTags = detailsNode.FirstChild?.TextContent.Trim(' ', '/');
if (matchCategory.Success) var seeders = ParseUtil.CoerceInt(row.QuerySelector("td:nth-child(6)").TextContent);
{
cat = matchCategory.Groups[1].Value.Trim();
}
var category = new List<IndexerCategory> var release = new TorrentInfo
{ {
cat switch Guid = infoUrl,
{ InfoUrl = infoUrl,
"music" => NewznabStandardCategory.Audio, DownloadUrl = downloadLink,
"libblemixtapes" => NewznabStandardCategory.Audio, Title = $"{releaseArtist} - {releaseAlbumName} {releaseAlbumYear} {releaseTags}".Trim(' ', '-'),
"musicvideos" => NewznabStandardCategory.AudioVideo, Categories = ParseCategories(group),
_ => NewznabStandardCategory.Other, 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(); case "neutral":
release.DownloadVolumeFactor = 0;
var detailsNode = releaseRow.QuerySelector("a[href^=\"torrents.php?id=\"]"); release.UploadVolumeFactor = 0;
var downloadLink = _settings.BaseUrl + releaseRow.QuerySelector("a[href^=\"torrents.php?action=download&id=\"]").GetAttribute("href").Trim(); break;
case "freeleech":
var releaseTags = detailsNode.FirstChild.TextContent.Trim(' ', '/'); release.DownloadVolumeFactor = 0;
release.UploadVolumeFactor = 1;
release.Title = string.Format("{0} - {1} {2} {3}", releaseArtist, releaseAlbumName, releaseAlbumYear, releaseTags).Trim(); break;
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);
} }
}
return torrentInfos.ToArray(); releaseInfos.Add(release);
}
} }
public Action<IDictionary<string, string>, DateTime?> CookiesUpdater { get; set; } return releaseInfos.ToArray();
} }
public class LibbleSettings : UserPassTorrentBaseSettings private IList<IndexerCategory> 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 <b>2FA code</b> box if you have enabled <b>2FA</b> on the Libble Web Site. Otherwise just leave it empty.")] return new List<IndexerCategory>
public string TwoFactorAuthCode { get; set; } {
cat switch
{
"music" => NewznabStandardCategory.Audio,
"libblemixtapes" => NewznabStandardCategory.Audio,
"musicvideos" => NewznabStandardCategory.AudioVideo,
_ => NewznabStandardCategory.Other,
}
};
}
public Action<IDictionary<string, string>, DateTime?> CookiesUpdater { get; set; }
}
public class LibbleSettings : UserPassTorrentBaseSettings
{
public LibbleSettings()
{
TwoFactorAuthCode = "";
} }
[FieldDefinition(4, Label = "2FA code", Type = FieldType.Textbox, HelpText = "Only fill in the <b>2FA code</b> box if you have enabled <b>2FA</b> on the Libble Web Site. Otherwise just leave it empty.")]
public string TwoFactorAuthCode { get; set; }
} }

Loading…
Cancel
Save