using Microsoft.Extensions.Logging; using RdtClient.Data.Enums; using RdtClient.Data.Models.Data; using RdtClient.Data.Models.QBittorrent; namespace RdtClient.Service.Services; public class QBittorrent(ILogger logger, ISettings settings, Authentication authentication, Torrents torrents, Downloads downloads, ITorrentRunnerState runnerState) { public async Task AuthLogin(String userName, String password) { logger.LogDebug("Auth login"); var login = await authentication.Login(userName, password); return login.Succeeded; } public async Task AuthLogout() { logger.LogDebug("Auth logout"); await authentication.Logout(); } public async Task AppPreferences() { var preferences = new AppPreferences { AddTrackers = "", AddTrackersEnabled = false, AltDlLimit = 10240, AltUpLimit = 10240, AlternativeWebuiEnabled = false, AlternativeWebuiPath = "", AnnounceIp = "", AnnounceToAllTiers = true, AnnounceToAllTrackers = false, AnonymousMode = false, AsyncIoThreads = 4, AutoDeleteMode = 0, AutoTmmEnabled = false, AutorunEnabled = false, AutorunProgram = "", BannedIPs = "", BittorrentProtocol = 0, BypassAuthSubnetWhitelist = "", BypassAuthSubnetWhitelistEnabled = false, BypassLocalAuth = false, CategoryChangedTmmEnabled = false, CheckingMemoryUse = 32, CreateSubfolderEnabled = true, CurrentInterfaceAddress = "", CurrentNetworkInterface = "", Dht = true, DiskCache = -1, DiskCacheTtl = 60, DlLimit = 0, DontCountSlowTorrents = false, DyndnsDomain = "changeme.dyndns.org", DyndnsEnabled = false, DyndnsPassword = "", DyndnsService = 0, DyndnsUsername = "", EmbeddedTrackerPort = 9000, EnableCoalesceReadWrite = true, EnableEmbeddedTracker = false, EnableMultiConnectionsFromSameIp = false, EnableOsCache = true, EnablePieceExtentAffinity = false, EnableSuperSeeding = false, EnableUploadSuggestions = false, Encryption = 0, ExportDir = "", ExportDirFin = "", FilePoolSize = 40, IncompleteFilesExt = false, IpFilterEnabled = false, IpFilterPath = "", IpFilterTrackers = false, LimitLanPeers = true, LimitTcpOverhead = false, LimitUtpRate = true, ListenPort = 31193, Locale = "en", Lsd = true, MailNotificationAuthEnabled = false, MailNotificationEmail = "", MailNotificationEnabled = false, MailNotificationPassword = "", MailNotificationSender = "qBittorrentNotification@example.com", MailNotificationSmtp = "smtp.changeme.com", MailNotificationSslEnabled = false, MailNotificationUsername = "", MaxActiveDownloads = 3, MaxActiveTorrents = 5, MaxActiveUploads = 3, MaxConnec = 500, MaxConnecPerTorrent = 100, MaxRatio = -1, MaxRatioAct = 0, MaxRatioEnabled = false, MaxSeedingTime = -1, MaxSeedingTimeEnabled = false, MaxUploads = -1, MaxUploadsPerTorrent = -1, OutgoingPortsMax = 0, OutgoingPortsMin = 0, Pex = true, PreallocateAll = false, ProxyAuthEnabled = false, ProxyIp = "0.0.0.0", ProxyPassword = "", ProxyPeerConnections = false, ProxyPort = 8080, ProxyTorrentsOnly = false, ProxyType = 0, ProxyUsername = "", QueueingEnabled = false, RandomPort = false, RecheckCompletedTorrents = false, ResolvePeerCountries = true, RssAutoDownloadingEnabled = false, RssMaxArticlesPerFeed = 50, RssProcessingEnabled = false, RssRefreshInterval = 30, SavePath = "", SavePathChangedTmmEnabled = false, SaveResumeDataInterval = 60, ScanDirs = new(), ScheduleFromHour = 8, ScheduleFromMin = 0, ScheduleToHour = 20, ScheduleToMin = 0, SchedulerDays = 0, SchedulerEnabled = false, SendBufferLowWatermark = 10, SendBufferWatermark = 500, SendBufferWatermarkFactor = 50, SlowTorrentDlRateThreshold = 2, SlowTorrentInactiveTimer = 60, SlowTorrentUlRateThreshold = 2, SocketBacklogSize = 30, StartPausedEnabled = false, StopTrackerTimeout = 1, TempPath = "", TempPathEnabled = false, TorrentChangedTmmEnabled = true, UpLimit = 0, UploadChokingAlgorithm = 1, UploadSlotsBehavior = 0, Upnp = true, UpnpLeaseDuration = 0, UseHttps = false, UtpTcpMixedMode = 0, WebUiAddress = "*", WebUiBanDuration = 3600, WebUiClickjackingProtectionEnabled = true, WebUiCsrfProtectionEnabled = true, WebUiDomainList = "*", WebUiHostHeaderValidationEnabled = true, WebUiHttpsCertPath = "", WebUiHttpsKeyPath = "", WebUiMaxAuthFailCount = 5, WebUiPort = 8080, WebUiSecureCookieEnabled = true, WebUiSessionTimeout = 3600, WebUiUpnp = false, WebUiUsername = "" }; var savePath = settings.DefaultSavePath; preferences.SavePath = savePath; preferences.TempPath = $"{savePath}temp{Path.DirectorySeparatorChar}"; var user = await authentication.GetUser(); if (user != null) { preferences.WebUiUsername = user.UserName; } return preferences; } public virtual async Task> TorrentInfo() { var savePath = settings.DefaultSavePath; var results = new List(); var allTorrents = await torrents.Get(); allTorrents = allTorrents.Where(m => m.Type == DownloadType.Torrent).ToList(); var prio = 0; foreach (var torrent in allTorrents) { var downloadPath = savePath; if (!String.IsNullOrWhiteSpace(torrent.Category)) { downloadPath = Path.Combine(downloadPath, torrent.Category); } var torrentPath = downloadPath; if (!String.IsNullOrWhiteSpace(torrent.RdName)) { // Alldebrid stores single file torrents at the root folder. if (torrent.ClientKind == Provider.AllDebrid && torrent.Files.Count == 1) { torrentPath = Path.Combine(downloadPath, torrent.Files[0].Path); } else { var existingContentPath = GetExistingContentPath(downloadPath, torrent); if (!String.IsNullOrWhiteSpace(existingContentPath)) { torrentPath = existingContentPath; } else { var contentPathName = GetContentPathName(torrent); torrentPath = Path.Combine(downloadPath, contentPathName) + Path.DirectorySeparatorChar; } } } var rdProgress = Math.Clamp(torrent.RdProgress ?? 0.0, 0.0, 100.0) / 100.0; var bytesTotal = torrent.RdSize ?? 0; var speed = torrent.RdSpeed ?? 0; var bytesDone = (Int64)(bytesTotal * rdProgress); Double progress; if (torrent.Completed != null) { progress = 1.0; } else if (torrent.Downloads is { Count: > 0 }) { var dlStats = torrent.Downloads.Select(m => torrents.GetDownloadStats(m.DownloadId)).ToList(); var dlBytesDone = dlStats.Sum(m => m.BytesDone); var dlBytesTotal = dlStats.Sum(m => m.BytesTotal); speed = (Int32)(dlStats.Any() ? dlStats.Average(m => m.Speed) : 0); var downloadProgress = dlBytesTotal > 0 ? Math.Clamp((Double)dlBytesDone / dlBytesTotal, 0.0, 1.0) : 0; if (rdProgress >= 1.0 && downloadProgress >= 1.0) { progress = 1.0; } else { progress = (rdProgress + downloadProgress) / 2.0; } } else { progress = rdProgress; } var remaining = TimeSpan.Zero; if (progress > 0 && progress < 1.0) { var startTime = torrent.Retry > torrent.Added ? torrent.Retry.Value : torrent.Added; var elapsed = DateTimeOffset.UtcNow - startTime; var totalEstimatedTime = TimeSpan.FromTicks((Int64)(elapsed.Ticks / progress)); remaining = totalEstimatedTime - elapsed; } var result = new TorrentInfo { AddedOn = torrent.Added.ToUnixTimeSeconds(), AmountLeft = bytesTotal - bytesDone, AutoTmm = false, Availability = 2, Category = torrent.Category ?? "", Completed = bytesDone, CompletionOn = torrent.Completed?.ToUnixTimeSeconds(), ContentPath = torrentPath, DlLimit = -1, Dlspeed = speed, Downloaded = bytesDone, DownloadedSession = bytesDone, Eta = (Int64)remaining.TotalSeconds, FlPiecePrio = false, ForceStart = false, Hash = torrent.Hash, LastActivity = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), MagnetUri = "", MaxRatio = -1, MaxSeedingTime = -1, Name = torrent.RdName, NumComplete = 10, NumIncomplete = 0, NumLeechs = 1, NumSeeds = torrent.RdSeeders ?? 1, Priority = ++prio, Progress = (Single)progress, Ratio = 1, RatioLimit = 1, SavePath = downloadPath, SeedingTimeLimit = 1, SeenComplete = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), SeqDl = false, Size = bytesTotal, State = "downloading", SuperSeeding = false, Tags = "", TimeActive = (Int64)(DateTimeOffset.UtcNow - torrent.Added).TotalSeconds, TotalSize = bytesTotal, Tracker = "udp://tracker.opentrackr.org:1337", UpLimit = -1, Uploaded = bytesDone, UploadedSession = bytesDone, Upspeed = speed }; if (!String.IsNullOrWhiteSpace(torrent.Error)) { result.State = "error"; } else if (torrent.Completed.HasValue) { result.State = "pausedUP"; } else if (torrent.RdStatus == TorrentStatus.Downloading && torrent.RdSeeders < 1) { result.State = "stalledDL"; } results.Add(result); } return results; } private static String GetContentPathName(Torrent torrent) { if (String.IsNullOrWhiteSpace(torrent.RdName)) { return torrent.RdName ?? String.Empty; } var topLevelSelectedFiles = torrent.Files .Where(m => m.Selected && !String.IsNullOrWhiteSpace(m.Path)) .Select(m => m.Path.Trim('/').Trim('\\')) .Where(m => m.IndexOfAny(['/', '\\']) < 0) .Select(Path.GetFileName) .Where(m => !String.IsNullOrWhiteSpace(m)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); if (topLevelSelectedFiles.Count == 1) { var selectedFileName = topLevelSelectedFiles[0]!; var selectedFileBaseName = Path.GetFileNameWithoutExtension(selectedFileName); if (torrent.ClientKind == Provider.TorBox) { return selectedFileBaseName; } if (!String.IsNullOrWhiteSpace(selectedFileBaseName) && selectedFileBaseName.Equals(torrent.RdName, StringComparison.OrdinalIgnoreCase)) { return selectedFileName; } } return torrent.RdName; } private static String? GetExistingContentPath(String downloadPath, Torrent torrent) { if (!torrent.Completed.HasValue || !Directory.Exists(downloadPath)) { return null; } var selectedFilePaths = torrent.Files .Where(m => m.Selected && !String.IsNullOrWhiteSpace(m.Path)) .Select(m => NormalizeRelativePath(m.Path!)) .Where(m => !String.IsNullOrWhiteSpace(m)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); var downloadFileNames = torrent.Downloads .Select(m => m.FileName) .Where(m => !String.IsNullOrWhiteSpace(m)) .Select(m => Path.GetFileName(m!)) .Where(m => !String.IsNullOrWhiteSpace(m)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); if (selectedFilePaths.Count == 0 && downloadFileNames.Count == 0) { return null; } foreach (var candidateRoot in GetCandidateContentRoots(downloadPath, torrent, selectedFilePaths, downloadFileNames)) { if (IsMatchingContentRoot(candidateRoot, selectedFilePaths, downloadFileNames)) { return candidateRoot + Path.DirectorySeparatorChar; } } return null; } private static IEnumerable GetCandidateContentRoots(String downloadPath, Torrent torrent, IEnumerable selectedFilePaths, IEnumerable downloadFileNames) { var yielded = new HashSet(StringComparer.OrdinalIgnoreCase); void AddCandidate(ICollection candidates, String? name) { if (!String.IsNullOrWhiteSpace(name)) { candidates.Add(Path.Combine(downloadPath, name)); } } var directCandidates = new List(); AddCandidate(directCandidates, torrent.RdName); foreach (var fileName in downloadFileNames) { AddCandidate(directCandidates, fileName); } foreach (var selectedFilePath in selectedFilePaths) { AddCandidate(directCandidates, Path.GetFileName(selectedFilePath)); AddCandidate(directCandidates, GetFirstPathComponent(selectedFilePath)); } foreach (var candidate in directCandidates) { if (Directory.Exists(candidate) && yielded.Add(candidate)) { yield return candidate; } } foreach (var directory in Directory.EnumerateDirectories(downloadPath)) { if (yielded.Add(directory)) { yield return directory; } } } private static Boolean IsMatchingContentRoot(String candidateRoot, IEnumerable selectedFilePaths, IEnumerable downloadFileNames) { foreach (var selectedFilePath in selectedFilePaths) { if (File.Exists(Path.Combine(candidateRoot, selectedFilePath))) { return true; } } foreach (var fileName in downloadFileNames) { if (File.Exists(Path.Combine(candidateRoot, fileName))) { return true; } } return false; } private static String NormalizeRelativePath(String path) { return path.Trim('/').Trim('\\').Replace('\\', Path.DirectorySeparatorChar); } private static String GetFirstPathComponent(String path) { var separatorIndex = path.IndexOfAny(['/', '\\']); return separatorIndex < 0 ? path : path[..separatorIndex]; } public async Task?> TorrentFileContents(String hash) { var torrent = await torrents.GetByHash(hash); if (torrent == null || torrent.Type != DownloadType.Torrent) { return null; } var progress = torrent.Completed.HasValue || torrent.RdStatus == TorrentStatus.Finished ? 1f : 0f; return torrent.Files .Select((file, index) => new TorrentFileItem { Index = index, Name = file.Path, Size = file.Bytes, Progress = file.Selected ? progress : 0f, Priority = file.Selected ? 1 : 0, IsSeed = false }) .ToList(); } public async Task TorrentProperties(String hash) { var savePath = settings.DefaultSavePath; var torrent = await torrents.GetByHash(hash); if (torrent == null || torrent.Type != DownloadType.Torrent) { return null; } if (!String.IsNullOrWhiteSpace(torrent.Category)) { savePath = Path.Combine(savePath, torrent.Category); } var bytesDone = torrent.RdProgress; var bytesTotal = torrent.RdSize; var speed = torrent.RdSpeed ?? 0; if (torrent.Downloads.Count > 0) { var dlStats = torrent.Downloads.Select(m => torrents.GetDownloadStats(m.DownloadId)).ToList(); bytesDone = dlStats.Sum(m => m.BytesDone); bytesTotal = dlStats.Sum(m => m.BytesTotal); speed = (Int32)(dlStats.Any() ? dlStats.Average(m => m.Speed) : 0); } var result = new TorrentProperties { AdditionDate = torrent.Added.ToUnixTimeSeconds(), Comment = "RealDebridClient ", CompletionDate = torrent.Completed?.ToUnixTimeSeconds() ?? -1, IsPrivate = false, CreatedBy = "RealDebridClient ", CreationDate = torrent.Added.ToUnixTimeSeconds(), DlLimit = -1, DlSpeed = speed, DlSpeedAvg = speed, Eta = 0, LastSeen = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), NbConnections = 0, NbConnectionsLimit = 100, Peers = 0, PeersTotal = 2, PieceSize = bytesTotal, PiecesHave = torrent.Downloads.Count(m => m.Completed.HasValue), PiecesNum = torrent.Downloads.Count, Reannounce = 0, SavePath = savePath, SeedingTime = 1, Seeds = 100, SeedsTotal = 100, ShareRatio = 9999, TimeElapsed = (Int64)(DateTimeOffset.UtcNow - torrent.Added).TotalSeconds, TotalDownloaded = bytesDone, TotalDownloadedSession = bytesDone, TotalSize = bytesTotal, TotalUploaded = bytesDone, TotalUploadedSession = bytesDone, TotalWasted = 0, UpLimit = -1, UpSpeed = speed, UpSpeedAvg = speed }; return result; } public async Task TorrentsFilePrio(String hash, IReadOnlyCollection fileIds, Int32 priority) { await torrents.UpdateFileSelection(hash, fileIds, priority > 0); } public async Task TorrentsDelete(String hash, Boolean deleteFiles) { if (deleteFiles) { logger.LogDebug("Delete {hash}, with files", hash); } else { logger.LogDebug("Delete {hash}, no files", hash); } var torrent = await torrents.GetByHash(hash); if (torrent == null || torrent.Type != DownloadType.Torrent) { return; } switch (settings.Current.Integrations.Default.FinishedAction) { case TorrentFinishedAction.RemoveAllTorrents: logger.LogDebug("Removing torrents from debrid provider and RDT-Client, no files"); await torrents.Delete(torrent.TorrentId, true, true, deleteFiles); break; case TorrentFinishedAction.RemoveRealDebrid: logger.LogDebug("Removing torrents from debrid provider, no files"); await torrents.Delete(torrent.TorrentId, false, true, deleteFiles); break; case TorrentFinishedAction.RemoveClient: logger.LogDebug("Removing torrents from client, no files"); await torrents.Delete(torrent.TorrentId, true, false, deleteFiles); break; case TorrentFinishedAction.None: logger.LogDebug("Not removing torrents or files"); break; default: logger.LogDebug($"Invalid torrent FinishedAction {torrent.FinishedAction}", torrent); break; } } public async Task TorrentsAddMagnet(String magnetLink, String? category, Int32? priority) { logger.LogDebug($"Add magnet {category}"); var torrent = new Torrent { Category = category, DownloadClient = settings.Current.DownloadClient.Client, HostDownloadAction = settings.Current.Integrations.Default.HostDownloadAction, FinishedActionDelay = settings.Current.Integrations.Default.FinishedActionDelay, DownloadAction = settings.Current.Integrations.Default.OnlyDownloadAvailableFiles ? TorrentDownloadAction.DownloadAvailableFiles : TorrentDownloadAction.DownloadAll, FinishedAction = TorrentFinishedAction.None, DownloadMinSize = settings.Current.Integrations.Default.MinFileSize, IncludeRegex = settings.Current.Integrations.Default.IncludeRegex, ExcludeRegex = settings.Current.Integrations.Default.ExcludeRegex, TorrentRetryAttempts = settings.Current.Integrations.Default.TorrentRetryAttempts, DownloadRetryAttempts = settings.Current.Integrations.Default.DownloadRetryAttempts, DeleteOnError = settings.Current.Integrations.Default.DeleteOnError, Lifetime = settings.Current.Integrations.Default.TorrentLifetime, Priority = priority ?? (settings.Current.Integrations.Default.Priority > 0 ? settings.Current.Integrations.Default.Priority : null) }; return await torrents.AddMagnetToDebridQueue(magnetLink, torrent); } public async Task TorrentsAddFile(Byte[] fileBytes, String? category, Int32? priority) { logger.LogDebug($"Add file {category}"); var torrent = new Torrent { Category = category, DownloadClient = settings.Current.DownloadClient.Client, HostDownloadAction = settings.Current.Integrations.Default.HostDownloadAction, FinishedActionDelay = settings.Current.Integrations.Default.FinishedActionDelay, DownloadAction = settings.Current.Integrations.Default.OnlyDownloadAvailableFiles ? TorrentDownloadAction.DownloadAvailableFiles : TorrentDownloadAction.DownloadAll, FinishedAction = TorrentFinishedAction.None, DownloadMinSize = settings.Current.Integrations.Default.MinFileSize, IncludeRegex = settings.Current.Integrations.Default.IncludeRegex, ExcludeRegex = settings.Current.Integrations.Default.ExcludeRegex, TorrentRetryAttempts = settings.Current.Integrations.Default.TorrentRetryAttempts, DownloadRetryAttempts = settings.Current.Integrations.Default.DownloadRetryAttempts, DeleteOnError = settings.Current.Integrations.Default.DeleteOnError, Lifetime = settings.Current.Integrations.Default.TorrentLifetime, Priority = priority ?? (settings.Current.Integrations.Default.Priority > 0 ? settings.Current.Integrations.Default.Priority : null) }; return await torrents.AddFileToDebridQueue(fileBytes, torrent); } public async Task TorrentsSetCategory(String hash, String? category) { await torrents.UpdateCategory(hash, category); } public async Task> TorrentsCategories() { var allTorrents = await torrents.Get(); var torrentsToGroup = allTorrents.Where(m => m.Type == DownloadType.Torrent && !String.IsNullOrWhiteSpace(m.Category)) .Select(m => m.Category!.ToLower()) .ToList(); var categoryList = (settings.Current.General.Categories ?? "") .Split(",", StringSplitOptions.RemoveEmptyEntries) .Distinct(StringComparer.CurrentCultureIgnoreCase) .Select(m => m.Trim()) .ToList(); torrentsToGroup.AddRange(categoryList); var results = new Dictionary(); if (torrentsToGroup.Count > 0) { results = torrentsToGroup.Distinct(StringComparer.CurrentCultureIgnoreCase) .ToDictionary(m => m, m => new TorrentCategory { Name = m, SavePath = Path.Combine(settings.DefaultSavePath, m) }); } return results; } public async Task CategoryCreate(String? category) { if (category == null) { return; } category = category.Trim(); var categoriesSetting = settings.Current.General.Categories; var categoryList = (categoriesSetting ?? "") .Split(",", StringSplitOptions.RemoveEmptyEntries) .Distinct(StringComparer.CurrentCultureIgnoreCase) .Select(m => m.Trim()) .ToList(); if (!categoryList.Contains(category)) { categoryList.Add(category); } categoriesSetting = String.Join(",", categoryList); await settings.Update("General:Categories", categoriesSetting); } public async Task CategoryRemove(String? category) { if (category == null) { return; } category = category.Trim(); var categoriesSetting = settings.Current.General.Categories; var categoryList = (categoriesSetting ?? "") .Split(",", StringSplitOptions.RemoveEmptyEntries) .Distinct(StringComparer.CurrentCultureIgnoreCase) .Select(m => m.Trim()) .ToList(); categoryList = categoryList.Where(m => m != category).ToList(); categoriesSetting = String.Join(",", categoryList); await settings.Update("General:Categories", categoriesSetting); } public async Task TorrentsTopPrio(String hash) { var torrent = await torrents.GetByHash(hash); if (torrent == null || torrent.Type != DownloadType.Torrent) { return; } await torrents.UpdatePriority(hash, 1); } public async Task TorrentPause(String hash) { var torrent = await torrents.GetByHash(hash); if (torrent == null || torrent.Type != DownloadType.Torrent) { return; } var downloadsForTorrent = await downloads.GetForTorrent(torrent.TorrentId); foreach (var download in downloadsForTorrent) { if (runnerState.ActiveDownloadClients.TryGetValue(download.DownloadId, out var downloadClient)) { await downloadClient.Pause(); } } } public async Task TorrentResume(String hash) { var torrent = await torrents.GetByHash(hash); if (torrent == null || torrent.Type != DownloadType.Torrent) { return; } var downloadsForTorrent = await downloads.GetForTorrent(torrent.TorrentId); foreach (var download in downloadsForTorrent) { if (runnerState.ActiveDownloadClients.TryGetValue(download.DownloadId, out var downloadClient)) { await downloadClient.Resume(); } } } public async Task SyncMainData() { var torrentInfo = await TorrentInfo(); var categories = await TorrentsCategories(); var activeDownloads = runnerState.ActiveDownloadClients.Sum(m => m.Value.Speed); return new() { Categories = categories, FullUpdate = true, Rid = 0, Tags = null, Trackers = new Dictionary>(), Torrents = torrentInfo.ToDictionary(m => m.Hash, m => m), ServerState = new() { DlInfoSpeed = activeDownloads, UpInfoSpeed = 0 } }; } public virtual TransferInfo TransferInfo() { var activeDownloads = runnerState.ActiveDownloadClients.Sum(m => m.Value.Speed); return new() { ConnectionStatus = "connected", DlInfoData = DownloadClient.GetTotalBytesDownloadedThisSession(), DlInfoSpeed = activeDownloads, DlRateLimit = settings.Current.DownloadClient.MaxSpeed }; } public async Task> TorrentsTrackers(String hash) { var torrent = await torrents.GetByHash(hash); if (torrent == null || torrent.Type != DownloadType.Torrent) { return []; } return [ new() { Url = $"http://{torrent.RdHost ?? torrent.ClientKind.ToString()}/**".ToLower(), Status = "Working", NumPeers = torrent.RdSeeders ?? 1, Msg = $"Fake Tracker for {torrent.ClientKind}" } ]; } }