diff --git a/.gitignore b/.gitignore index 9491a2f..4d44a9d 100644 --- a/.gitignore +++ b/.gitignore @@ -360,4 +360,5 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd +/output diff --git a/Lantean.QBTMud.Test/Lantean.QBTMud.Test.csproj b/Lantean.QBTMud.Test/Lantean.QBTMud.Test.csproj index b21e876..d3e95c3 100644 --- a/Lantean.QBTMud.Test/Lantean.QBTMud.Test.csproj +++ b/Lantean.QBTMud.Test/Lantean.QBTMud.Test.csproj @@ -10,11 +10,11 @@ - - + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Lantean.QBTMud.Test/UnitTest1.cs b/Lantean.QBTMud.Test/UnitTest1.cs index 06de721..47d2a0c 100644 --- a/Lantean.QBTMud.Test/UnitTest1.cs +++ b/Lantean.QBTMud.Test/UnitTest1.cs @@ -1,4 +1,4 @@ -using Lantean.QBitTorrentClient; +using Lantean.QBitTorrentClient; using Lantean.QBitTorrentClient.Models; using System.Linq.Expressions; using System.Text.Json; diff --git a/Lantean.QBTMud/Components/Dialogs/AddTagDialog.razor.cs b/Lantean.QBTMud/Components/Dialogs/AddTagDialog.razor.cs index fe0b548..a7f15c6 100644 --- a/Lantean.QBTMud/Components/Dialogs/AddTagDialog.razor.cs +++ b/Lantean.QBTMud/Components/Dialogs/AddTagDialog.razor.cs @@ -14,7 +14,7 @@ namespace Lantean.QBTMud.Components.Dialogs protected IDialogService DialogService { get; set; } = default!; [CascadingParameter] - IMudDialogInstance MudDialog { get; set; } = default!; + private IMudDialogInstance MudDialog { get; set; } = default!; protected HashSet Tags { get; } = []; diff --git a/Lantean.QBTMud/Components/Dialogs/AddTorrentFileDialog.razor.cs b/Lantean.QBTMud/Components/Dialogs/AddTorrentFileDialog.razor.cs index df3d257..6d0281b 100644 --- a/Lantean.QBTMud/Components/Dialogs/AddTorrentFileDialog.razor.cs +++ b/Lantean.QBTMud/Components/Dialogs/AddTorrentFileDialog.razor.cs @@ -8,7 +8,7 @@ namespace Lantean.QBTMud.Components.Dialogs public partial class AddTorrentFileDialog { [CascadingParameter] - IMudDialogInstance MudDialog { get; set; } = default!; + private IMudDialogInstance MudDialog { get; set; } = default!; protected IReadOnlyList Files { get; set; } = []; diff --git a/Lantean.QBTMud/Components/Dialogs/AddTorrentLinkDialog.razor.cs b/Lantean.QBTMud/Components/Dialogs/AddTorrentLinkDialog.razor.cs index f067f6e..24f827c 100644 --- a/Lantean.QBTMud/Components/Dialogs/AddTorrentLinkDialog.razor.cs +++ b/Lantean.QBTMud/Components/Dialogs/AddTorrentLinkDialog.razor.cs @@ -18,7 +18,7 @@ namespace Lantean.QBTMud.Components.Dialogs protected IKeyboardService KeyboardService { get; set; } = default!; [CascadingParameter] - IMudDialogInstance MudDialog { get; set; } = default!; + private IMudDialogInstance MudDialog { get; set; } = default!; [Parameter] public string? Url { get; set; } diff --git a/Lantean.QBTMud/Components/Dialogs/AddTorrentOptions.razor b/Lantean.QBTMud/Components/Dialogs/AddTorrentOptions.razor index aa2050b..c2518d0 100644 --- a/Lantean.QBTMud/Components/Dialogs/AddTorrentOptions.razor +++ b/Lantean.QBTMud/Components/Dialogs/AddTorrentOptions.razor @@ -65,4 +65,4 @@ - + \ No newline at end of file diff --git a/Lantean.QBTMud/Components/Dialogs/AddTorrentOptions.razor.cs b/Lantean.QBTMud/Components/Dialogs/AddTorrentOptions.razor.cs index 6b04504..fa298b2 100644 --- a/Lantean.QBTMud/Components/Dialogs/AddTorrentOptions.razor.cs +++ b/Lantean.QBTMud/Components/Dialogs/AddTorrentOptions.razor.cs @@ -1,7 +1,6 @@ using Lantean.QBitTorrentClient; using Lantean.QBTMud.Models; using Microsoft.AspNetCore.Components; -using MudBlazor; namespace Lantean.QBTMud.Components.Dialogs { diff --git a/Lantean.QBTMud/Components/Dialogs/AddTrackerDialog.razor.cs b/Lantean.QBTMud/Components/Dialogs/AddTrackerDialog.razor.cs index 5e38331..8eb5bf6 100644 --- a/Lantean.QBTMud/Components/Dialogs/AddTrackerDialog.razor.cs +++ b/Lantean.QBTMud/Components/Dialogs/AddTrackerDialog.razor.cs @@ -7,7 +7,7 @@ namespace Lantean.QBTMud.Components.Dialogs public partial class AddTrackerDialog { [CascadingParameter] - IMudDialogInstance MudDialog { get; set; } = default!; + private IMudDialogInstance MudDialog { get; set; } = default!; protected HashSet Trackers { get; } = []; diff --git a/Lantean.QBTMud/Components/Dialogs/CategoryPropertiesDialog.razor.cs b/Lantean.QBTMud/Components/Dialogs/CategoryPropertiesDialog.razor.cs index def40d0..00da02d 100644 --- a/Lantean.QBTMud/Components/Dialogs/CategoryPropertiesDialog.razor.cs +++ b/Lantean.QBTMud/Components/Dialogs/CategoryPropertiesDialog.razor.cs @@ -10,7 +10,7 @@ namespace Lantean.QBTMud.Components.Dialogs private string _savePath = string.Empty; [CascadingParameter] - IMudDialogInstance MudDialog { get; set; } = default!; + private IMudDialogInstance MudDialog { get; set; } = default!; [Inject] protected IApiClient ApiClient { get; set; } = default!; diff --git a/Lantean.QBTMud/Components/Dialogs/ConfirmDialog.razor.cs b/Lantean.QBTMud/Components/Dialogs/ConfirmDialog.razor.cs index ae53c5b..de383ea 100644 --- a/Lantean.QBTMud/Components/Dialogs/ConfirmDialog.razor.cs +++ b/Lantean.QBTMud/Components/Dialogs/ConfirmDialog.razor.cs @@ -7,7 +7,7 @@ namespace Lantean.QBTMud.Components.Dialogs public partial class ConfirmDialog { [CascadingParameter] - IMudDialogInstance MudDialog { get; set; } = default!; + private IMudDialogInstance MudDialog { get; set; } = default!; [Parameter] public string Content { get; set; } = default!; diff --git a/Lantean.QBTMud/Components/Dialogs/DeleteDialog.razor.cs b/Lantean.QBTMud/Components/Dialogs/DeleteDialog.razor.cs index 39b0ce3..a090073 100644 --- a/Lantean.QBTMud/Components/Dialogs/DeleteDialog.razor.cs +++ b/Lantean.QBTMud/Components/Dialogs/DeleteDialog.razor.cs @@ -7,7 +7,7 @@ namespace Lantean.QBTMud.Components.Dialogs public partial class DeleteDialog { [CascadingParameter] - IMudDialogInstance MudDialog { get; set; } = default!; + private IMudDialogInstance MudDialog { get; set; } = default!; [Parameter] public int Count { get; set; } diff --git a/Lantean.QBTMud/Components/Dialogs/ExceptionDialog.razor.cs b/Lantean.QBTMud/Components/Dialogs/ExceptionDialog.razor.cs index ab33d25..a61b67e 100644 --- a/Lantean.QBTMud/Components/Dialogs/ExceptionDialog.razor.cs +++ b/Lantean.QBTMud/Components/Dialogs/ExceptionDialog.razor.cs @@ -6,7 +6,7 @@ namespace Lantean.QBTMud.Components.Dialogs public partial class ExceptionDialog { [CascadingParameter] - IMudDialogInstance MudDialog { get; set; } = default!; + private IMudDialogInstance MudDialog { get; set; } = default!; [Parameter] public Exception? Exception { get; set; } diff --git a/Lantean.QBTMud/Components/Dialogs/FilterOptionsDialog.razor.cs b/Lantean.QBTMud/Components/Dialogs/FilterOptionsDialog.razor.cs index 5661fe0..3cc1ad7 100644 --- a/Lantean.QBTMud/Components/Dialogs/FilterOptionsDialog.razor.cs +++ b/Lantean.QBTMud/Components/Dialogs/FilterOptionsDialog.razor.cs @@ -11,7 +11,7 @@ namespace Lantean.QBTMud.Components.Dialogs private static readonly IReadOnlyList _properties = typeof(T).GetProperties(BindingFlags.Instance | BindingFlags.Public); [CascadingParameter] - IMudDialogInstance MudDialog { get; set; } = default!; + private IMudDialogInstance MudDialog { get; set; } = default!; protected IReadOnlyList Columns => _properties; diff --git a/Lantean.QBTMud/Components/Dialogs/ManageCategoriesDialog.razor.cs b/Lantean.QBTMud/Components/Dialogs/ManageCategoriesDialog.razor.cs index b40ac5a..2ff0292 100644 --- a/Lantean.QBTMud/Components/Dialogs/ManageCategoriesDialog.razor.cs +++ b/Lantean.QBTMud/Components/Dialogs/ManageCategoriesDialog.razor.cs @@ -14,7 +14,7 @@ namespace Lantean.QBTMud.Components.Dialogs protected IDialogService DialogService { get; set; } = default!; [CascadingParameter] - IMudDialogInstance MudDialog { get; set; } = default!; + private IMudDialogInstance MudDialog { get; set; } = default!; [Parameter] public IEnumerable Hashes { get; set; } = []; diff --git a/Lantean.QBTMud/Components/Dialogs/ManageTagsDialog.razor.cs b/Lantean.QBTMud/Components/Dialogs/ManageTagsDialog.razor.cs index 247ca66..bc182ce 100644 --- a/Lantean.QBTMud/Components/Dialogs/ManageTagsDialog.razor.cs +++ b/Lantean.QBTMud/Components/Dialogs/ManageTagsDialog.razor.cs @@ -14,7 +14,7 @@ namespace Lantean.QBTMud.Components.Dialogs protected IDialogService DialogService { get; set; } = default!; [CascadingParameter] - IMudDialogInstance MudDialog { get; set; } = default!; + private IMudDialogInstance MudDialog { get; set; } = default!; [Parameter] public IEnumerable Hashes { get; set; } = []; diff --git a/Lantean.QBTMud/Components/Dialogs/MultipleFieldDialog.razor.cs b/Lantean.QBTMud/Components/Dialogs/MultipleFieldDialog.razor.cs index 4507957..8f466ff 100644 --- a/Lantean.QBTMud/Components/Dialogs/MultipleFieldDialog.razor.cs +++ b/Lantean.QBTMud/Components/Dialogs/MultipleFieldDialog.razor.cs @@ -7,7 +7,7 @@ namespace Lantean.QBTMud.Components.Dialogs public partial class MultipleFieldDialog { [CascadingParameter] - IMudDialogInstance MudDialog { get; set; } = default!; + private IMudDialogInstance MudDialog { get; set; } = default!; [Parameter] public string Label { get; set; } = default!; diff --git a/Lantean.QBTMud/Components/Dialogs/NumericFieldDialog.razor.cs b/Lantean.QBTMud/Components/Dialogs/NumericFieldDialog.razor.cs index e369476..da26a06 100644 --- a/Lantean.QBTMud/Components/Dialogs/NumericFieldDialog.razor.cs +++ b/Lantean.QBTMud/Components/Dialogs/NumericFieldDialog.razor.cs @@ -8,7 +8,7 @@ namespace Lantean.QBTMud.Components.Dialogs public partial class NumericFieldDialog where T : struct, INumber { [CascadingParameter] - IMudDialogInstance MudDialog { get; set; } = default!; + private IMudDialogInstance MudDialog { get; set; } = default!; [Parameter] public string? Label { get; set; } diff --git a/Lantean.QBTMud/Components/Dialogs/RenameFilesDialog.razor.cs b/Lantean.QBTMud/Components/Dialogs/RenameFilesDialog.razor.cs index d50d966..a6267f4 100644 --- a/Lantean.QBTMud/Components/Dialogs/RenameFilesDialog.razor.cs +++ b/Lantean.QBTMud/Components/Dialogs/RenameFilesDialog.razor.cs @@ -30,7 +30,7 @@ namespace Lantean.QBTMud.Components.Dialogs protected ILocalStorageService LocalStorage { get; set; } = default!; [CascadingParameter] - IMudDialogInstance MudDialog { get; set; } = default!; + private IMudDialogInstance MudDialog { get; set; } = default!; [Parameter] public string? Hash { get; set; } @@ -426,7 +426,6 @@ namespace Lantean.QBTMud.Components.Dialogs { await LocalStorage.RemoveItemAsync(_preferencesStorageKey); } - } protected override async Task OnInitializedAsync() @@ -495,7 +494,7 @@ namespace Lantean.QBTMud.Components.Dialogs { var oldPath = renamedFile.Path + renamedFile.OriginalName; var newPath = renamedFile.Path + renamedFile.NewName; - + await ApiClient.RenameFolder(Hash, oldPath, newPath); } diff --git a/Lantean.QBTMud/Components/Dialogs/RssRulesDialog.razor.cs b/Lantean.QBTMud/Components/Dialogs/RssRulesDialog.razor.cs index 075589d..71f6863 100644 --- a/Lantean.QBTMud/Components/Dialogs/RssRulesDialog.razor.cs +++ b/Lantean.QBTMud/Components/Dialogs/RssRulesDialog.razor.cs @@ -10,7 +10,7 @@ namespace Lantean.QBTMud.Components.Dialogs private readonly List _unsavedRuleNames = []; [CascadingParameter] - IMudDialogInstance MudDialog { get; set; } = default!; + private IMudDialogInstance MudDialog { get; set; } = default!; [Inject] protected IDialogService DialogService { get; set; } = default!; diff --git a/Lantean.QBTMud/Components/Dialogs/ShareRatioDialog.razor.cs b/Lantean.QBTMud/Components/Dialogs/ShareRatioDialog.razor.cs index b8de866..1ff44fe 100644 --- a/Lantean.QBTMud/Components/Dialogs/ShareRatioDialog.razor.cs +++ b/Lantean.QBTMud/Components/Dialogs/ShareRatioDialog.razor.cs @@ -8,7 +8,7 @@ namespace Lantean.QBTMud.Components.Dialogs public partial class ShareRatioDialog { [CascadingParameter] - IMudDialogInstance MudDialog { get; set; } = default!; + private IMudDialogInstance MudDialog { get; set; } = default!; [Parameter] public string? Label { get; set; } diff --git a/Lantean.QBTMud/Components/Dialogs/SliderFieldDialog.razor.cs b/Lantean.QBTMud/Components/Dialogs/SliderFieldDialog.razor.cs index aeb3da1..9ecdcba 100644 --- a/Lantean.QBTMud/Components/Dialogs/SliderFieldDialog.razor.cs +++ b/Lantean.QBTMud/Components/Dialogs/SliderFieldDialog.razor.cs @@ -8,7 +8,7 @@ namespace Lantean.QBTMud.Components.Dialogs public partial class SliderFieldDialog where T : struct, INumber { [CascadingParameter] - IMudDialogInstance MudDialog { get; set; } = default!; + private IMudDialogInstance MudDialog { get; set; } = default!; [Parameter] public string? Label { get; set; } diff --git a/Lantean.QBTMud/Components/Dialogs/StringFieldDialog.razor.cs b/Lantean.QBTMud/Components/Dialogs/StringFieldDialog.razor.cs index fc8afb8..0ffacd0 100644 --- a/Lantean.QBTMud/Components/Dialogs/StringFieldDialog.razor.cs +++ b/Lantean.QBTMud/Components/Dialogs/StringFieldDialog.razor.cs @@ -7,7 +7,7 @@ namespace Lantean.QBTMud.Components.Dialogs public partial class StringFieldDialog { [CascadingParameter] - IMudDialogInstance MudDialog { get; set; } = default!; + private IMudDialogInstance MudDialog { get; set; } = default!; [Parameter] public string? Label { get; set; } diff --git a/Lantean.QBTMud/Components/Dialogs/SubMenuDialog.razor.cs b/Lantean.QBTMud/Components/Dialogs/SubMenuDialog.razor.cs index a2e36d4..bca93db 100644 --- a/Lantean.QBTMud/Components/Dialogs/SubMenuDialog.razor.cs +++ b/Lantean.QBTMud/Components/Dialogs/SubMenuDialog.razor.cs @@ -7,7 +7,7 @@ namespace Lantean.QBTMud.Components.Dialogs public partial class SubMenuDialog { [CascadingParameter] - IMudDialogInstance MudDialog { get; set; } = default!; + private IMudDialogInstance MudDialog { get; set; } = default!; [Parameter] public UIAction? ParentAction { get; set; } diff --git a/Lantean.QBTMud/Components/Dialogs/TorrentOptionsDialog.razor.cs b/Lantean.QBTMud/Components/Dialogs/TorrentOptionsDialog.razor.cs index 1490b36..e06fc77 100644 --- a/Lantean.QBTMud/Components/Dialogs/TorrentOptionsDialog.razor.cs +++ b/Lantean.QBTMud/Components/Dialogs/TorrentOptionsDialog.razor.cs @@ -7,7 +7,7 @@ namespace Lantean.QBTMud.Components.Dialogs public partial class TorrentOptionsDialog { [CascadingParameter] - IMudDialogInstance MudDialog { get; set; } = default!; + private IMudDialogInstance MudDialog { get; set; } = default!; [Parameter] [EditorRequired] diff --git a/Lantean.QBTMud/Components/FilesTab.razor b/Lantean.QBTMud/Components/FilesTab.razor index d466cf2..ffdffb1 100644 --- a/Lantean.QBTMud/Components/FilesTab.razor +++ b/Lantean.QBTMud/Components/FilesTab.razor @@ -1,46 +1,49 @@ - + Rename - + -
- - - - - - - Less Than 100% Availability - Less than 80% Availability - Currently Filtered Files - - - Less Than 100% Availability - Less than 80% Availability - Currently Filtered Files - - - - - - +
+
+ + + + + + + Less Than 100% Availability + Less than 80% Availability + Currently Filtered Files + + + Less Than 100% Availability + Less than 80% Availability + Currently Filtered Files + + + + + + +
+
+ +
- - @code { private RenderFragment> NameColumn { diff --git a/Lantean.QBTMud/Components/FilesTab.razor.cs b/Lantean.QBTMud/Components/FilesTab.razor.cs index 23f198f..be35377 100644 --- a/Lantean.QBTMud/Components/FilesTab.razor.cs +++ b/Lantean.QBTMud/Components/FilesTab.razor.cs @@ -20,6 +20,9 @@ namespace Lantean.QBTMud.Components private readonly CancellationTokenSource _timerCancellationToken = new(); private bool _disposedValue; + private static readonly ReadOnlyCollection EmptyContentItems = new ReadOnlyCollection(Array.Empty()); + private ReadOnlyCollection _visibleFiles = EmptyContentItems; + private bool _filesDirty = true; private List>? _filterDefinitions; private readonly Dictionary>> _columnRenderFragments = []; @@ -65,7 +68,7 @@ namespace Lantean.QBTMud.Components private DynamicTable? Table { get; set; } - private ContextMenu? ContextMenu { get; set; } + private MudMenu? ContextMenu { get; set; } public FilesTab() { @@ -102,6 +105,7 @@ namespace Lantean.QBTMud.Components if (_filterDefinitions is null) { Filters = null; + MarkFilesDirty(); return; } @@ -113,11 +117,13 @@ namespace Lantean.QBTMud.Components } Filters = filters; + MarkFilesDirty(); } protected void RemoveFilter() { Filters = null; + MarkFilesDirty(); } public async ValueTask DisposeAsync() @@ -157,6 +163,7 @@ namespace Lantean.QBTMud.Components protected void SearchTextChanged(string value) { SearchText = value; + MarkFilesDirty(); } protected Task TableDataContextMenu(TableDataContextMenuEventArgs eventArgs) @@ -178,7 +185,9 @@ namespace Lantean.QBTMud.Components return; } - await ContextMenu.OpenMenuAsync(eventArgs); + var normalizedEventArgs = eventArgs.NormalizeForContextMenu(); + + await ContextMenu.OpenMenuAsync(normalizedEventArgs); } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -197,6 +206,7 @@ namespace Lantean.QBTMud.Components { while (!_timerCancellationToken.IsCancellationRequested && await timer.WaitForNextTickAsync()) { + var hasUpdates = false; if (Active && Hash is not null) { IReadOnlyList files; @@ -213,14 +223,20 @@ namespace Lantean.QBTMud.Components if (FileList is null) { FileList = DataManager.CreateContentsList(files); + hasUpdates = true; } else { - DataManager.MergeContentsList(files, FileList); + hasUpdates = DataManager.MergeContentsList(files, FileList); } } - await InvokeAsync(StateHasChanged); + if (hasUpdates) + { + MarkFilesDirty(); + PruneSelectionIfMissing(); + await InvokeAsync(StateHasChanged); + } } } } @@ -246,6 +262,8 @@ namespace Lantean.QBTMud.Components var contents = await ApiClient.GetTorrentContents(Hash); FileList = DataManager.CreateContentsList(contents); + MarkFilesDirty(); + PruneSelectionIfMissing(); var expandedNodes = await LocalStorage.GetItemAsync>($"{_expandedNodesStorageKey}.{Hash}"); if (expandedNodes is not null) @@ -256,6 +274,8 @@ namespace Lantean.QBTMud.Components { ExpandedNodes.Clear(); } + + MarkFilesDirty(); } protected async Task PriorityValueChanged(ContentItem contentItem, Priority priority) @@ -320,11 +340,13 @@ namespace Lantean.QBTMud.Components protected void SortColumnChanged(string sortColumn) { _sortColumn = sortColumn; + MarkFilesDirty(); } protected void SortDirectionChanged(SortDirection sortDirection) { _sortDirection = sortDirection; + MarkFilesDirty(); } protected void SelectedItemChanged(ContentItem item) @@ -343,6 +365,7 @@ namespace Lantean.QBTMud.Components ExpandedNodes.Add(contentItem.Name); } + MarkFilesDirty(); await LocalStorage.SetItemAsync($"{_expandedNodesStorageKey}.{Hash}", ExpandedNodes); } @@ -368,44 +391,6 @@ namespace Lantean.QBTMud.Components return FileList!.Values.Where(f => f.Name.StartsWith(contentItem.Name + Extensions.DirectorySeparator) && !f.IsFolder); } - private IEnumerable GetChildren(ContentItem folder, int level) - { - level++; - var descendantsKey = folder.GetDescendantsKey(level); - - foreach (var item in FileList!.Values.Where(f => f.Name.StartsWith(descendantsKey) && f.Level == level).OrderByDirection(_sortDirection, GetSortSelector())) - { - if (item.IsFolder) - { - var descendants = GetChildren(item, level); - // if the filter returns some results then show folder item - if (descendants.Any()) - { - yield return item; - } - - // if the folder is not expanded - don't return children - if (!ExpandedNodes.Contains(item.Name)) - { - continue; - } - - // then show children - foreach (var descendant in descendants) - { - yield return descendant; - } - } - else - { - if (FilterContentItem(item)) - { - yield return item; - } - } - } - } - private bool FilterContentItem(ContentItem item) { if (Filters is not null) @@ -429,38 +414,130 @@ namespace Lantean.QBTMud.Components } private ReadOnlyCollection GetFiles() + { + if (!_filesDirty) + { + return _visibleFiles; + } + + _visibleFiles = BuildVisibleFiles(); + _filesDirty = false; + + return _visibleFiles; + } + + private ReadOnlyCollection BuildVisibleFiles() { if (FileList is null || FileList.Values.Count == 0) { - return new ReadOnlyCollection([]); + return EmptyContentItems; } - var maxLevel = FileList.Values.Max(f => f.Level); - // this is a flat file structure - if (maxLevel == 0) + var lookup = BuildChildrenLookup(); + if (!lookup.TryGetValue(string.Empty, out var roots)) { - return FileList.Values.Where(FilterContentItem).OrderByDirection(_sortDirection, GetSortSelector()).ToList().AsReadOnly(); + return EmptyContentItems; } - var list = new List(); + var sortSelector = GetSortSelector(); + var orderedRoots = roots.OrderByDirection(_sortDirection, sortSelector).ToList(); + var result = new List(FileList.Values.Count); - var rootItems = FileList.Values.Where(c => c.Level == 0).OrderByDirection(_sortDirection, GetSortSelector()).ToList(); - foreach (var item in rootItems) + foreach (var item in orderedRoots) { - list.Add(item); - - if (item.IsFolder && ExpandedNodes.Contains(item.Name)) + if (item.IsFolder) { - var level = 0; - var descendants = GetChildren(item, level); - foreach (var descendant in descendants) + result.Add(item); + + if (!ExpandedNodes.Contains(item.Name)) { - list.Add(descendant); + continue; + } + + var descendants = GetVisibleDescendants(item, lookup, sortSelector); + result.AddRange(descendants); + } + else + { + if (FilterContentItem(item)) + { + result.Add(item); } } } - return list.AsReadOnly(); + return new ReadOnlyCollection(result); + } + + private Dictionary> BuildChildrenLookup() + { + var lookup = new Dictionary>(FileList!.Count); + + foreach (var item in FileList!.Values) + { + var parentPath = item.Level == 0 ? string.Empty : item.Name.GetDirectoryPath(); + if (!lookup.TryGetValue(parentPath, out var children)) + { + children = []; + lookup[parentPath] = children; + } + + children.Add(item); + } + + return lookup; + } + + private List GetVisibleDescendants(ContentItem folder, Dictionary> lookup, Func sortSelector) + { + if (!lookup.TryGetValue(folder.Name, out var children)) + { + return []; + } + + var orderedChildren = children.OrderByDirection(_sortDirection, sortSelector).ToList(); + var visible = new List(); + + foreach (var child in orderedChildren) + { + if (child.IsFolder) + { + var descendants = GetVisibleDescendants(child, lookup, sortSelector); + if (descendants.Count != 0) + { + visible.Add(child); + + if (ExpandedNodes.Contains(child.Name)) + { + visible.AddRange(descendants); + } + } + } + else if (FilterContentItem(child)) + { + visible.Add(child); + } + } + + return visible; + } + + private void MarkFilesDirty() + { + _filesDirty = true; + } + + private void PruneSelectionIfMissing() + { + if (SelectedItem is not null && (FileList is null || !FileList.ContainsKey(SelectedItem.Name))) + { + SelectedItem = null; + } + + if (ContextMenuItem is not null && (FileList is null || !FileList.ContainsKey(ContextMenuItem.Name))) + { + ContextMenuItem = null; + } } protected async Task DoNotDownloadLessThan100PercentAvailability() diff --git a/Lantean.QBTMud/Components/FiltersNav.razor b/Lantean.QBTMud/Components/FiltersNav.razor index 089bf24..cdc289a 100644 --- a/Lantean.QBTMud/Components/FiltersNav.razor +++ b/Lantean.QBTMud/Components/FiltersNav.razor @@ -1,8 +1,8 @@ - + @TorrentControls(_statusType) - + - + Add category @if (IsCategoryTarget) { @@ -12,9 +12,9 @@ Remove unused categories @TorrentControls(_categoryType) - + - + Add tag @if (IsTagTarget) { @@ -23,13 +23,13 @@ Remove unused tags @TorrentControls(_tagType) - + - + Remove tracker @TorrentControls(_trackerType) - + diff --git a/Lantean.QBTMud/Components/FiltersNav.razor.cs b/Lantean.QBTMud/Components/FiltersNav.razor.cs index 090edc4..b8a7941 100644 --- a/Lantean.QBTMud/Components/FiltersNav.razor.cs +++ b/Lantean.QBTMud/Components/FiltersNav.razor.cs @@ -1,6 +1,5 @@ using Blazored.LocalStorage; using Lantean.QBitTorrentClient; -using Lantean.QBTMud.Components.UI; using Lantean.QBTMud.Helpers; using Lantean.QBTMud.Models; using Microsoft.AspNetCore.Components; @@ -69,13 +68,13 @@ namespace Lantean.QBTMud.Components protected Dictionary Statuses => GetStatuses(); - protected ContextMenu? StatusContextMenu { get; set; } + protected MudMenu? StatusContextMenu { get; set; } - protected ContextMenu? CategoryContextMenu { get; set; } + protected MudMenu? CategoryContextMenu { get; set; } - protected ContextMenu? TagContextMenu { get; set; } + protected MudMenu? TagContextMenu { get; set; } - protected ContextMenu? TrackerContextMenu { get; set; } + protected MudMenu? TrackerContextMenu { get; set; } protected string? ContextMenuStatus { get; set; } @@ -154,7 +153,9 @@ namespace Lantean.QBTMud.Components ContextMenuStatus = value; - return StatusContextMenu.OpenMenuAsync(args); + var normalizedArgs = args.NormalizeForContextMenu(); + + return StatusContextMenu.OpenMenuAsync(normalizedArgs); } protected async Task CategoryValueChanged(string value) @@ -192,7 +193,9 @@ namespace Lantean.QBTMud.Components IsCategoryTarget = value != FilterHelper.CATEGORY_ALL && value != FilterHelper.CATEGORY_UNCATEGORIZED; ContextMenuCategory = value; - return CategoryContextMenu.OpenMenuAsync(args); + var normalizedArgs = args.NormalizeForContextMenu(); + + return CategoryContextMenu.OpenMenuAsync(normalizedArgs); } protected async Task TagValueChanged(string value) @@ -230,7 +233,9 @@ namespace Lantean.QBTMud.Components IsTagTarget = value != FilterHelper.TAG_ALL && value != FilterHelper.TAG_UNTAGGED; ContextMenuTag = value; - return TagContextMenu.OpenMenuAsync(args); + var normalizedArgs = args.NormalizeForContextMenu(); + + return TagContextMenu.OpenMenuAsync(normalizedArgs); } protected async Task TrackerValueChanged(string value) @@ -267,7 +272,9 @@ namespace Lantean.QBTMud.Components ContextMenuTracker = value; - return TrackerContextMenu.OpenMenuAsync(args); + var normalizedArgs = args.NormalizeForContextMenu(); + + return TrackerContextMenu.OpenMenuAsync(normalizedArgs); } protected async Task AddCategory() diff --git a/Lantean.QBTMud/Components/GeneralTab.razor b/Lantean.QBTMud/Components/GeneralTab.razor index d3a119d..ec2501e 100644 --- a/Lantean.QBTMud/Components/GeneralTab.razor +++ b/Lantean.QBTMud/Components/GeneralTab.razor @@ -98,4 +98,4 @@ @Properties?.Comment - + \ No newline at end of file diff --git a/Lantean.QBTMud/Components/Options/AdvancedOptions.razor b/Lantean.QBTMud/Components/Options/AdvancedOptions.razor index 0bcac22..1c63590 100644 --- a/Lantean.QBTMud/Components/Options/AdvancedOptions.razor +++ b/Lantean.QBTMud/Components/Options/AdvancedOptions.razor @@ -240,4 +240,4 @@ - + \ No newline at end of file diff --git a/Lantean.QBTMud/Components/Options/BitTorrentOptions.razor b/Lantean.QBTMud/Components/Options/BitTorrentOptions.razor index 0a1e2e8..0db9bb9 100644 --- a/Lantean.QBTMud/Components/Options/BitTorrentOptions.razor +++ b/Lantean.QBTMud/Components/Options/BitTorrentOptions.razor @@ -92,7 +92,9 @@ - + diff --git a/Lantean.QBTMud/Components/Options/BitTorrentOptions.razor.cs b/Lantean.QBTMud/Components/Options/BitTorrentOptions.razor.cs index cbd2816..a2d6856 100644 --- a/Lantean.QBTMud/Components/Options/BitTorrentOptions.razor.cs +++ b/Lantean.QBTMud/Components/Options/BitTorrentOptions.razor.cs @@ -17,7 +17,7 @@ protected int SlowTorrentUlRateThreshold { get; private set; } protected int SlowTorrentInactiveTimer { get; private set; } protected bool MaxRatioEnabled { get; private set; } - protected int MaxRatio { get; private set; } + protected float MaxRatio { get; private set; } protected bool MaxSeedingTimeEnabled { get; private set; } protected int MaxSeedingTime { get; private set; } protected int MaxRatioAct { get; private set; } @@ -275,7 +275,7 @@ await PreferencesChanged.InvokeAsync(UpdatePreferences); } - protected async Task MaxRatioChanged(int value) + protected async Task MaxRatioChanged(float value) { MaxRatio = value; UpdatePreferences.MaxRatio = value; diff --git a/Lantean.QBTMud/Components/Options/DownloadsOptions.razor b/Lantean.QBTMud/Components/Options/DownloadsOptions.razor index 52ff348..044d18c 100644 --- a/Lantean.QBTMud/Components/Options/DownloadsOptions.razor +++ b/Lantean.QBTMud/Components/Options/DownloadsOptions.razor @@ -62,7 +62,7 @@ - + Manual Automatic diff --git a/Lantean.QBTMud/Components/PeersTab.razor b/Lantean.QBTMud/Components/PeersTab.razor index 9c93b51..0e2c0fe 100644 --- a/Lantean.QBTMud/Components/PeersTab.razor +++ b/Lantean.QBTMud/Components/PeersTab.razor @@ -1,24 +1,30 @@ - + Add peer @if (ContextMenuItem is not null) { Ban peer } - + - - Add peer - Ban peer - - - +
+
+ + Add peer + Ban peer + + + +
- \ No newline at end of file +
+ +
+
\ No newline at end of file diff --git a/Lantean.QBTMud/Components/PeersTab.razor.cs b/Lantean.QBTMud/Components/PeersTab.razor.cs index e6c7e01..1d710d5 100644 --- a/Lantean.QBTMud/Components/PeersTab.razor.cs +++ b/Lantean.QBTMud/Components/PeersTab.razor.cs @@ -52,7 +52,7 @@ namespace Lantean.QBTMud.Components protected Peer? SelectedItem { get; set; } - protected ContextMenu? ContextMenu { get; set; } + protected MudMenu? ContextMenu { get; set; } protected DynamicTable? Table { get; set; } @@ -153,7 +153,9 @@ namespace Lantean.QBTMud.Components return; } - await ContextMenu.ToggleMenuAsync(eventArgs); + var normalizedEventArgs = eventArgs.NormalizeForContextMenu(); + + await ContextMenu.OpenMenuAsync(normalizedEventArgs); } protected void SelectedItemChanged(Peer peer) diff --git a/Lantean.QBTMud/Components/TrackersTab.razor b/Lantean.QBTMud/Components/TrackersTab.razor index bd74c77..3b1de3a 100644 --- a/Lantean.QBTMud/Components/TrackersTab.razor +++ b/Lantean.QBTMud/Components/TrackersTab.razor @@ -1,4 +1,4 @@ - + Add trackers @if (ContextMenuItem is not null) { @@ -6,27 +6,33 @@ Remove tracker Copy tracker url } - + - - Add trackers - Edit tracker URL - Remove tracker - Copy tracker url - - - +
+
+ + Add trackers + Edit tracker URL + Remove tracker + Copy tracker url + + + +
- \ No newline at end of file +
+ +
+
\ No newline at end of file diff --git a/Lantean.QBTMud/Components/TrackersTab.razor.cs b/Lantean.QBTMud/Components/TrackersTab.razor.cs index 599ca10..2697924 100644 --- a/Lantean.QBTMud/Components/TrackersTab.razor.cs +++ b/Lantean.QBTMud/Components/TrackersTab.razor.cs @@ -52,7 +52,7 @@ namespace Lantean.QBTMud.Components protected TorrentTracker? SelectedItem { get; set; } - protected ContextMenu? ContextMenu { get; set; } + protected MudMenu? ContextMenu { get; set; } protected DynamicTable? Table { get; set; } @@ -148,7 +148,9 @@ namespace Lantean.QBTMud.Components return; } - await ContextMenu.ToggleMenuAsync(eventArgs); + var normalizedEventArgs = eventArgs.NormalizeForContextMenu(); + + await ContextMenu.OpenMenuAsync(normalizedEventArgs); } protected void SelectedItemChanged(TorrentTracker torrentTracker) diff --git a/Lantean.QBTMud/Components/UI/ContextMenu.razor b/Lantean.QBTMud/Components/UI/ContextMenu.razor deleted file mode 100644 index b278fbd..0000000 --- a/Lantean.QBTMud/Components/UI/ContextMenu.razor +++ /dev/null @@ -1,26 +0,0 @@ -@inherits MudComponentBase - - - -@* The portal has to include the cascading values inside, because it's not able to teletransport the cascade *@ - - - @if (_showChildren) - { - - @ChildContent - - } - - - - \ No newline at end of file diff --git a/Lantean.QBTMud/Components/UI/ContextMenu.razor.cs b/Lantean.QBTMud/Components/UI/ContextMenu.razor.cs deleted file mode 100644 index 2d4f592..0000000 --- a/Lantean.QBTMud/Components/UI/ContextMenu.razor.cs +++ /dev/null @@ -1,290 +0,0 @@ -using Lantean.QBTMud.Interop; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Web; -using Microsoft.JSInterop; -using MudBlazor; -using MudBlazor.Utilities; - -namespace Lantean.QBTMud.Components.UI -{ - public partial class ContextMenu : MudComponentBase - { - private bool _open; - private bool _showChildren; - private string? _popoverStyle; - private string? _id; - - private double _x; - private double _y; - private bool _isResized = false; - - private const double _diff = 64; - - private string Id - { - get - { - _id ??= Guid.NewGuid().ToString(); - - return _id; - } - } - - [Inject] - public IJSRuntime JSRuntime { get; set; } = default!; - - [Inject] - public IPopoverService PopoverService { get; set; } = default!; - - /// - /// If true, compact vertical padding will be applied to all menu items. - /// - [Parameter] - [Category(CategoryTypes.Menu.PopupAppearance)] - public bool Dense { get; set; } - - /// - /// Set to true if you want to prevent page from scrolling when the menu is open - /// - [Parameter] - [Category(CategoryTypes.Menu.PopupAppearance)] - public bool LockScroll { get; set; } - - /// - /// If true, the list menu will be same width as the parent. - /// - [Parameter] - [Category(CategoryTypes.Menu.PopupAppearance)] - public DropdownWidth RelativeWidth { get; set; } - - /// - /// Sets the max height the menu can have when open. - /// - [Parameter] - [Category(CategoryTypes.Menu.PopupAppearance)] - public int? MaxHeight { get; set; } - - /// - /// Set the anchor origin point to determine where the popover will open from. - /// - [Parameter] - [Category(CategoryTypes.Menu.PopupAppearance)] - public Origin AnchorOrigin { get; set; } = Origin.TopLeft; - - /// - /// Sets the transform origin point for the popover. - /// - [Parameter] - [Category(CategoryTypes.Menu.PopupAppearance)] - public Origin TransformOrigin { get; set; } = Origin.TopLeft; - - /// - /// If true, menu will be disabled. - /// - [Parameter] - [Category(CategoryTypes.Menu.Behavior)] - public bool Disabled { get; set; } - - /// - /// Gets or sets whether to show a ripple effect when the user clicks the button. Default is true. - /// - [Parameter] - [Category(CategoryTypes.Menu.Appearance)] - public bool Ripple { get; set; } = true; - - /// - /// Determines whether the component has a drop-shadow. Default is true - /// - [Parameter] - [Category(CategoryTypes.Menu.Appearance)] - public bool DropShadow { get; set; } = true; - - /// - /// Add menu items here - /// - [Parameter] - [Category(CategoryTypes.Menu.PopupBehavior)] - public RenderFragment? ChildContent { get; set; } - - /// - /// Fired when the menu property changes. - /// - [Parameter] - [Category(CategoryTypes.Menu.PopupBehavior)] - public EventCallback OpenChanged { get; set; } - - [Parameter] - public int AdjustmentX { get; set; } - - [Parameter] - public int AdjustmentY { get; set; } - - protected MudMenu? FakeMenu { get; set; } - - protected void FakeOpenChanged(bool value) - { - if (!value) - { - _open = false; - } - - StateHasChanged(); - } - - /// - /// Opens the menu. - /// - /// - /// The arguments of the calling mouse/pointer event. - /// - public async Task OpenMenuAsync(EventArgs args) - { - if (Disabled) - { - return; - } - - // long press on iOS triggers selection, so clear it - await JSRuntime.ClearSelection(); - - if (args is not LongPressEventArgs) - { - _showChildren = true; - } - - _open = true; - _isResized = false; - StateHasChanged(); - - var (x, y) = GetPositionFromArgs(args); - _x = x; - _y = y; - - SetPopoverStyle(x, y); - - StateHasChanged(); - - await OpenChanged.InvokeAsync(_open); - - // long press on iOS triggers selection, so clear it - await JSRuntime.ClearSelection(); - - if (args is LongPressEventArgs) - { - await Task.Delay(1000); - _showChildren = true; - } - } - - /// - /// Closes the menu. - /// - public Task CloseMenuAsync() - { - _open = false; - _popoverStyle = null; - StateHasChanged(); - - return OpenChanged.InvokeAsync(_open); - } - - private void SetPopoverStyle(double x, double y) - { - _popoverStyle = $"margin-top: {y.ToPx()}; margin-left: {x.ToPx()};"; - } - - /// - /// Toggle the visibility of the menu. - /// - public async Task ToggleMenuAsync(EventArgs args) - { - if (Disabled) - { - return; - } - - if (_open) - { - await CloseMenuAsync(); - } - else - { - await OpenMenuAsync(args); - } - } - - protected override Task OnAfterRenderAsync(bool firstRender) - { - if (!_isResized) - { - //await DeterminePosition(); - } - - return Task.CompletedTask; - } - - //private async Task DeterminePosition() - //{ - // var mainContentSize = await JSRuntime.GetInnerDimensions(".mud-main-content"); - // double? contextMenuHeight = null; - // double? contextMenuWidth = null; - - // var popoverHolder = PopoverService.ActivePopovers.FirstOrDefault(p => p.UserAttributes.ContainsKey("tracker") && (string?)p.UserAttributes["tracker"] == Id); - - // var popoverSize = await JSRuntime.GetBoundingClientRect($"#popovercontent-{popoverHolder?.Id}"); - // if (popoverSize.Height > 0) - // { - // contextMenuHeight = popoverSize.Height; - // contextMenuWidth = popoverSize.Width; - // } - // else - // { - // return; - // } - - // // the bottom position of the popover will be rendered off screen - // if (_y - _diff + contextMenuHeight.Value >= mainContentSize.Height) - // { - // // adjust the top of the context menu - // var overshoot = Math.Abs(mainContentSize.Height - (_y - _diff + contextMenuHeight.Value)); - // _y -= overshoot; - - // if (_y - _diff + contextMenuHeight >= mainContentSize.Height) - // { - // MaxHeight = (int)(mainContentSize.Height - _y + _diff); - // } - // } - - // if (_x + contextMenuWidth.Value > mainContentSize.Width) - // { - // var overshoot = Math.Abs(mainContentSize.Width - (_x + contextMenuWidth.Value)); - // _x -= overshoot; - // } - - // SetPopoverStyle(_x, _y); - // _isResized = true; - // await InvokeAsync(StateHasChanged); - //} - - private (double x, double y) GetPositionFromArgs(EventArgs eventArgs) - { - double x, y; - if (eventArgs is MouseEventArgs mouseEventArgs) - { - x = mouseEventArgs.ClientX; - y = mouseEventArgs.ClientY; - } - else if (eventArgs is LongPressEventArgs longPressEventArgs) - { - x = longPressEventArgs.ClientX; - y = longPressEventArgs.ClientY; - } - else - { - throw new NotSupportedException("Invalid eventArgs type."); - } - - return (x + AdjustmentX, y + AdjustmentY); - } - } -} \ No newline at end of file diff --git a/Lantean.QBTMud/Components/UI/CustomNavLink.razor b/Lantean.QBTMud/Components/UI/CustomNavLink.razor index 4e800d7..624aec2 100644 --- a/Lantean.QBTMud/Components/UI/CustomNavLink.razor +++ b/Lantean.QBTMud/Components/UI/CustomNavLink.razor @@ -1,5 +1,5 @@ 
-
+
@if (!string.IsNullOrEmpty(Icon)) { diff --git a/Lantean.QBTMud/Components/UI/CustomNavLink.razor.cs b/Lantean.QBTMud/Components/UI/CustomNavLink.razor.cs index 23e3bf1..4cbf21c 100644 --- a/Lantean.QBTMud/Components/UI/CustomNavLink.razor.cs +++ b/Lantean.QBTMud/Components/UI/CustomNavLink.razor.cs @@ -59,6 +59,7 @@ namespace Lantean.QBTMud.Components.UI new CssBuilder("mud-nav-link") .AddClass($"mud-nav-link-disabled", Disabled) .AddClass("active", Active) + .AddClass("unselectable", OnLongPress.HasDelegate || OnContextMenu.HasDelegate) .Build(); protected string IconClassname => diff --git a/Lantean.QBTMud/Components/UI/DynamicTable.razor.cs b/Lantean.QBTMud/Components/UI/DynamicTable.razor.cs index 063eee2..e013a44 100644 --- a/Lantean.QBTMud/Components/UI/DynamicTable.razor.cs +++ b/Lantean.QBTMud/Components/UI/DynamicTable.razor.cs @@ -81,6 +81,8 @@ namespace Lantean.QBTMud.Components.UI protected HashSet SelectedColumns { get; set; } = []; + private static readonly IReadOnlyList> EmptyColumns = Array.Empty>(); + private Dictionary _columnWidths = []; private Dictionary _columnOrder = []; @@ -89,8 +91,16 @@ namespace Lantean.QBTMud.Components.UI private SortDirection _sortDirection; + private DateTimeOffset? _suppressRowClickUntil; + private readonly Dictionary _tds = []; + private IReadOnlyList> _visibleColumns = EmptyColumns; + + private bool _columnsDirty = true; + + private IEnumerable>? _lastColumnDefinitions; + protected override async Task OnInitializedAsync() { HashSet selectedColumns; @@ -109,6 +119,13 @@ namespace Lantean.QBTMud.Components.UI SelectedColumns = selectedColumns; await SelectedColumnsChanged.InvokeAsync(SelectedColumns); } + else + { + SelectedColumns = selectedColumns; + } + + _lastColumnDefinitions = ColumnDefinitions; + MarkColumnsDirty(); string? sortColumn; SortDirection sortDirection; @@ -137,11 +154,24 @@ namespace Lantean.QBTMud.Components.UI await SortDirectionChanged.InvokeAsync(_sortDirection); } + MarkColumnsDirty(); + var storedColumnsWidths = await LocalStorage.GetItemAsync>(_columnWidthsStorageKey); if (storedColumnsWidths is not null) { _columnWidths = storedColumnsWidths; } + MarkColumnsDirty(); + } + + protected override void OnParametersSet() + { + base.OnParametersSet(); + if (!ReferenceEquals(_lastColumnDefinitions, ColumnDefinitions)) + { + _lastColumnDefinitions = ColumnDefinitions; + MarkColumnsDirty(); + } } private IEnumerable? GetOrderedItems() @@ -165,39 +195,74 @@ namespace Lantean.QBTMud.Components.UI return Items.OrderByDirection(_sortDirection, sortSelector); } - protected IEnumerable> GetColumns() + protected IReadOnlyList> GetColumns() { - var filteredColumns = ColumnDefinitions.Where(c => SelectedColumns.Contains(c.Id)).Where(ColumnFilter); - if (_columnOrder.Count == 0) + if (!_columnsDirty) { - foreach (var column in filteredColumns) - { - if (_columnWidths.TryGetValue(column.Id, out var value)) - { - column.Width = value; - } - - yield return column; - } - - yield break; + return _visibleColumns; } - var columnDictionary = filteredColumns.ToDictionary(c => c.Id); - foreach (var columnId in _columnOrder.OrderBy(c => c.Value).Select(c => c.Key)) + _visibleColumns = BuildVisibleColumns(); + _columnsDirty = false; + + return _visibleColumns; + } + + private IReadOnlyList> BuildVisibleColumns() + { + var filteredColumns = ColumnDefinitions + .Where(c => SelectedColumns.Contains(c.Id)) + .Where(ColumnFilter) + .ToList(); + + if (filteredColumns.Count == 0) { - if (!columnDictionary.TryGetValue(columnId, out var column)) + return EmptyColumns; + } + + List> orderedColumns; + if (_columnOrder.Count == 0) + { + orderedColumns = filteredColumns; + } + else + { + var orderLookup = _columnOrder.OrderBy(entry => entry.Value).ToList(); + var columnDictionary = filteredColumns.ToDictionary(c => c.Id); + orderedColumns = new List>(filteredColumns.Count); + + foreach (var (columnId, _) in orderLookup) { - continue; + if (!columnDictionary.TryGetValue(columnId, out var column)) + { + continue; + } + + orderedColumns.Add(column); } + if (orderedColumns.Count != filteredColumns.Count) + { + var existingIds = new HashSet(orderedColumns.Select(c => c.Id)); + foreach (var column in filteredColumns) + { + if (existingIds.Add(column.Id)) + { + orderedColumns.Add(column); + } + } + } + } + + foreach (var column in orderedColumns) + { if (_columnWidths.TryGetValue(column.Id, out var value)) { column.Width = value; } - - yield return column; } + + return orderedColumns; } private async Task SetSort(string columnId, SortDirection sortDirection) @@ -223,6 +288,17 @@ namespace Lantean.QBTMud.Components.UI protected async Task OnRowClickInternal(TableRowClickEventArgs eventArgs) { + if (_suppressRowClickUntil is not null) + { + if (DateTimeOffset.UtcNow <= _suppressRowClickUntil.Value) + { + _suppressRowClickUntil = null; + return; + } + + _suppressRowClickUntil = null; + } + if (eventArgs.Item is null) { return; @@ -298,6 +374,7 @@ namespace Lantean.QBTMud.Components.UI protected Task OnLongPressInternal(LongPressEventArgs eventArgs, string columnId, T item) { + _suppressRowClickUntil = DateTimeOffset.UtcNow.AddMilliseconds(500); var data = _tds[columnId]; return OnTableDataLongPress.InvokeAsync(new TableDataLongPressEventArgs(eventArgs, data, item)); } @@ -316,18 +393,21 @@ namespace Lantean.QBTMud.Components.UI SelectedColumns = result.SelectedColumns; await LocalStorage.SetItemAsync(_columnSelectionStorageKey, SelectedColumns); await SelectedColumnsChanged.InvokeAsync(SelectedColumns); + MarkColumnsDirty(); } if (!DictionaryEqual(_columnWidths, result.ColumnWidths)) { _columnWidths = result.ColumnWidths; await LocalStorage.SetItemAsync(_columnWidthsStorageKey, _columnWidths); + MarkColumnsDirty(); } if (!DictionaryEqual(_columnOrder, result.ColumnOrder)) { _columnOrder = result.ColumnOrder; await LocalStorage.SetItemAsync(_columnOrderStorageKey, _columnOrder); + MarkColumnsDirty(); } } @@ -368,17 +448,34 @@ namespace Lantean.QBTMud.Components.UI if (column.Width.HasValue) { - className = $"overflow-cell {className}"; + className = string.IsNullOrWhiteSpace(className) + ? "overflow-cell" + : $"overflow-cell {className}"; } if (OnTableDataContextMenu.HasDelegate) { - className = $"no-default-context-menu {className}"; + className = string.IsNullOrWhiteSpace(className) + ? "no-default-context-menu" + : $"no-default-context-menu {className}"; + } + + if (OnTableDataLongPress.HasDelegate) + { + className = string.IsNullOrWhiteSpace(className) + ? "unselectable" + : $"unselectable {className}"; } return className; } + private void MarkColumnsDirty() + { + _columnsDirty = true; + _visibleColumns = EmptyColumns; + } + private sealed record SortData { public SortData(string sortColumn, SortDirection sortDirection) diff --git a/Lantean.QBTMud/Components/UI/TdExtended.razor b/Lantean.QBTMud/Components/UI/TdExtended.razor index 98af236..2fee199 100644 --- a/Lantean.QBTMud/Components/UI/TdExtended.razor +++ b/Lantean.QBTMud/Components/UI/TdExtended.razor @@ -1,5 +1,5 @@ @inherits MudTd - + @ChildContent - + \ No newline at end of file diff --git a/Lantean.QBTMud/Components/WebSeedsTab.razor b/Lantean.QBTMud/Components/WebSeedsTab.razor index d685d05..75fe24c 100644 --- a/Lantean.QBTMud/Components/WebSeedsTab.razor +++ b/Lantean.QBTMud/Components/WebSeedsTab.razor @@ -1,6 +1,10 @@ - \ No newline at end of file +
+
+ +
+
\ No newline at end of file diff --git a/Lantean.QBTMud/Helpers/DisplayHelpers.cs b/Lantean.QBTMud/Helpers/DisplayHelpers.cs index c9c68fd..ebd50cf 100644 --- a/Lantean.QBTMud/Helpers/DisplayHelpers.cs +++ b/Lantean.QBTMud/Helpers/DisplayHelpers.cs @@ -19,28 +19,28 @@ namespace Lantean.QBTMud.Helpers { if (seconds is null) { - return ""; + return string.Empty; } - if (seconds == 8640000) + const long InfiniteEtaSentinelSeconds = 8_640_000; // ~100 days, used by qBittorrent for "infinite" ETA. + var value = seconds.Value; + + if (value >= long.MaxValue || value >= TimeSpan.MaxValue.TotalSeconds || value == InfiniteEtaSentinelSeconds) { return "∞"; } - if (seconds < 60) + if (value <= 0) { return "< 1m"; } - TimeSpan time; - try + var time = TimeSpan.FromSeconds(value); + if (time.TotalMinutes < 1) { - time = TimeSpan.FromSeconds(seconds.Value); - } - catch - { - return "∞"; + return "< 1m"; } + var sb = new StringBuilder(); if (prefix is not null) { diff --git a/Lantean.QBTMud/Helpers/EventArgsExtensions.cs b/Lantean.QBTMud/Helpers/EventArgsExtensions.cs new file mode 100644 index 0000000..25ff1d2 --- /dev/null +++ b/Lantean.QBTMud/Helpers/EventArgsExtensions.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Components.Web; + +namespace Lantean.QBTMud.Helpers +{ + public static class EventArgsExtensions + { + public static EventArgs NormalizeForContextMenu(this EventArgs eventArgs) + { + ArgumentNullException.ThrowIfNull(eventArgs); + + if (eventArgs is LongPressEventArgs longPressEventArgs) + { + return longPressEventArgs.ToMouseEventArgs(); + } + + return eventArgs; + } + + public static MouseEventArgs ToMouseEventArgs(this LongPressEventArgs longPressEventArgs) + { + ArgumentNullException.ThrowIfNull(longPressEventArgs); + + return new MouseEventArgs + { + Button = 2, + Buttons = 2, + ClientX = longPressEventArgs.ClientX, + ClientY = longPressEventArgs.ClientY, + OffsetX = longPressEventArgs.OffsetX, + OffsetY = longPressEventArgs.OffsetY, + PageX = longPressEventArgs.PageX, + PageY = longPressEventArgs.PageY, + ScreenX = longPressEventArgs.ScreenX, + ScreenY = longPressEventArgs.ScreenY, + Type = longPressEventArgs.Type ?? "contextmenu", + Detail = -1, + }; + } + } +} \ No newline at end of file diff --git a/Lantean.QBTMud/Helpers/FilterHelper.cs b/Lantean.QBTMud/Helpers/FilterHelper.cs index 4c9f5e2..f2e586f 100644 --- a/Lantean.QBTMud/Helpers/FilterHelper.cs +++ b/Lantean.QBTMud/Helpers/FilterHelper.cs @@ -119,34 +119,35 @@ namespace Lantean.QBTMud.Helpers switch (category) { case CATEGORY_ALL: - break; + return true; case CATEGORY_UNCATEGORIZED: if (!string.IsNullOrEmpty(torrent.Category)) { return false; } - break; + + return true; default: + if (string.IsNullOrEmpty(torrent.Category)) + { + return false; + } + if (!useSubcategories) { - if (torrent.Category != category) - { - return false; - } - else - { - if (!torrent.Category.StartsWith(category)) - { - return false; - } - } + return string.Equals(torrent.Category, category, StringComparison.Ordinal); } - break; - } - return true; + if (string.Equals(torrent.Category, category, StringComparison.Ordinal)) + { + return true; + } + + var prefix = string.Concat(category, "/"); + return torrent.Category.StartsWith(prefix, StringComparison.Ordinal); + } } public static bool FilterTag(Torrent torrent, string tag) @@ -207,7 +208,7 @@ namespace Lantean.QBTMud.Helpers break; case Status.Paused: - if (!state.Contains("paused") || !state.Contains("stopped")) + if (!state.Contains("paused") && !state.Contains("stopped")) { return false; } diff --git a/Lantean.QBTMud/Helpers/VersionHelper.cs b/Lantean.QBTMud/Helpers/VersionHelper.cs index e4a1730..4d4a247 100644 --- a/Lantean.QBTMud/Helpers/VersionHelper.cs +++ b/Lantean.QBTMud/Helpers/VersionHelper.cs @@ -1,5 +1,4 @@ - -namespace Lantean.QBTMud.Helpers +namespace Lantean.QBTMud.Helpers { internal static class VersionHelper { @@ -31,4 +30,4 @@ namespace Lantean.QBTMud.Helpers return _version.Value; } } -} +} \ No newline at end of file diff --git a/Lantean.QBTMud/Lantean.QBTMud.csproj b/Lantean.QBTMud/Lantean.QBTMud.csproj index 4ea246c..070108c 100644 --- a/Lantean.QBTMud/Lantean.QBTMud.csproj +++ b/Lantean.QBTMud/Lantean.QBTMud.csproj @@ -12,10 +12,10 @@ - - - - + + + + diff --git a/Lantean.QBTMud/Layout/DetailsLayout.razor b/Lantean.QBTMud/Layout/DetailsLayout.razor index 04410e1..f8b6479 100644 --- a/Lantean.QBTMud/Layout/DetailsLayout.razor +++ b/Lantean.QBTMud/Layout/DetailsLayout.razor @@ -1,9 +1,11 @@ @inherits LayoutComponentBase @layout LoggedInLayout - - - - - @Body - \ No newline at end of file +
+ + + + + @Body + +
\ No newline at end of file diff --git a/Lantean.QBTMud/Layout/ListLayout.razor b/Lantean.QBTMud/Layout/ListLayout.razor index 3c001ef..e88db62 100644 --- a/Lantean.QBTMud/Layout/ListLayout.razor +++ b/Lantean.QBTMud/Layout/ListLayout.razor @@ -1,11 +1,13 @@ @inherits LayoutComponentBase @layout LoggedInLayout - - - - - - @Body - - \ No newline at end of file +
+ + + + + + @Body + + +
\ No newline at end of file diff --git a/Lantean.QBTMud/Layout/LoggedInLayout.razor b/Lantean.QBTMud/Layout/LoggedInLayout.razor index 6dc39fd..9b554af 100644 --- a/Lantean.QBTMud/Layout/LoggedInLayout.razor +++ b/Lantean.QBTMud/Layout/LoggedInLayout.razor @@ -10,20 +10,53 @@ } - - - - - - - - - - - - - - @Body + + + + + + + + + + + + + + +
+ @Body + + @if (MainData?.LostConnection == true) + { + qBittorrent client is not reachable + } + + @DisplayHelpers.Size(MainData?.ServerState.FreeSpaceOnDisk, "Free space: ") + + DHT @(MainData?.ServerState.DHTNodes ?? 0) nodes + + @{ + var (icon, colour) = GetConnectionIcon(MainData?.ServerState.ConnectionStatus); + } + + + + + + + @DisplayHelpers.Size(MainData?.ServerState.DownloadInfoSpeed, null, "/s") + @DisplayHelpers.Size(MainData?.ServerState.DownloadInfoData, "(", ")") + + + + + @DisplayHelpers.Size(MainData?.ServerState.UploadInfoSpeed, null, "/s") + @DisplayHelpers.Size(MainData?.ServerState.UploadInfoData, "(", ")") + + +
+
@@ -36,34 +69,5 @@
- - @if (MainData?.LostConnection == true) - { - qBittorrent client is not reachable - } - - @DisplayHelpers.Size(MainData?.ServerState.FreeSpaceOnDisk, "Free space: ") - - DHT @(MainData?.ServerState.DHTNodes ?? 0) nodes - - @{ - var (icon, colour) = GetConnectionIcon(MainData?.ServerState.ConnectionStatus); - } - - - - - - - @DisplayHelpers.Size(MainData?.ServerState.DownloadInfoSpeed, null, "/s") - @DisplayHelpers.Size(MainData?.ServerState.DownloadInfoData, "(", ")") - - - - - @DisplayHelpers.Size(MainData?.ServerState.UploadInfoSpeed, null, "/s") - @DisplayHelpers.Size(MainData?.ServerState.UploadInfoData, "(", ")") - -
\ No newline at end of file diff --git a/Lantean.QBTMud/Layout/LoggedInLayout.razor.cs b/Lantean.QBTMud/Layout/LoggedInLayout.razor.cs index b340abe..bf9303d 100644 --- a/Lantean.QBTMud/Layout/LoggedInLayout.razor.cs +++ b/Lantean.QBTMud/Layout/LoggedInLayout.razor.cs @@ -52,22 +52,36 @@ namespace Lantean.QBTMud.Layout protected string? SearchText { get; set; } - protected IEnumerable Torrents => GetTorrents(); + protected IReadOnlyList Torrents => GetTorrents(); protected bool IsAuthenticated { get; set; } protected bool LostConnection { get; set; } - private List GetTorrents() + private IReadOnlyList _visibleTorrents = Array.Empty(); + + private bool _torrentsDirty = true; + private int _torrentsVersion; + + private IReadOnlyList GetTorrents() { + if (!_torrentsDirty) + { + return _visibleTorrents; + } + if (MainData is null) { - return []; + _visibleTorrents = Array.Empty(); + _torrentsDirty = false; + return _visibleTorrents; } var filterState = new FilterState(Category, Status, Tag, Tracker, MainData.ServerState.UseSubcategories, SearchText); + _visibleTorrents = MainData.Torrents.Values.Filter(filterState).ToList(); + _torrentsDirty = false; - return MainData.Torrents.Values.Filter(filterState).ToList(); + return _visibleTorrents; } protected override async Task OnInitializedAsync() @@ -84,6 +98,7 @@ namespace Lantean.QBTMud.Layout Version = await ApiClient.GetApplicationVersion(); var data = await ApiClient.GetMainData(_requestId); MainData = DataManager.CreateMainData(data, Version); + MarkTorrentsDirty(); _requestId = data.ResponseId; _refreshInterval = MainData.ServerState.RefreshInterval; @@ -126,32 +141,51 @@ namespace Lantean.QBTMud.Layout return; } + var shouldRender = false; + if (MainData is null || data.FullUpdate) { MainData = DataManager.CreateMainData(data, Version); + MarkTorrentsDirty(); + shouldRender = true; } else { - DataManager.MergeMainData(data, MainData); + var dataChanged = DataManager.MergeMainData(data, MainData, out var filterChanged); + if (filterChanged) + { + MarkTorrentsDirty(); + } + else if (dataChanged) + { + IncrementTorrentsVersion(); + } + shouldRender = dataChanged; } - _refreshInterval = MainData.ServerState.RefreshInterval; + if (MainData is not null) + { + _refreshInterval = MainData.ServerState.RefreshInterval; + } _requestId = data.ResponseId; - await InvokeAsync(StateHasChanged); + if (shouldRender) + { + await InvokeAsync(StateHasChanged); + } } } } } - protected EventCallback CategoryChanged => EventCallback.Factory.Create(this, category => Category = category); + protected EventCallback CategoryChanged => EventCallback.Factory.Create(this, OnCategoryChanged); - protected EventCallback StatusChanged => EventCallback.Factory.Create(this, status => Status = status); + protected EventCallback StatusChanged => EventCallback.Factory.Create(this, OnStatusChanged); - protected EventCallback TagChanged => EventCallback.Factory.Create(this, tag => Tag = tag); + protected EventCallback TagChanged => EventCallback.Factory.Create(this, OnTagChanged); - protected EventCallback TrackerChanged => EventCallback.Factory.Create(this, tracker => Tracker = tracker); + protected EventCallback TrackerChanged => EventCallback.Factory.Create(this, OnTrackerChanged); - protected EventCallback SearchTermChanged => EventCallback.Factory.Create(this, term => SearchText = term); + protected EventCallback SearchTermChanged => EventCallback.Factory.Create(this, OnSearchTermChanged); protected EventCallback SortColumnChanged => EventCallback.Factory.Create(this, columnId => SortColumn = columnId); @@ -159,12 +193,81 @@ namespace Lantean.QBTMud.Layout protected static (string, Color) GetConnectionIcon(string? status) { - if (status is null) + return status switch { - return (Icons.Material.Outlined.SignalWifiOff, Color.Warning); + "firewalled" => (Icons.Material.Outlined.SignalWifiStatusbarConnectedNoInternet4, Color.Warning), + "connected" => (Icons.Material.Outlined.SignalWifi4Bar, Color.Success), + _ => (Icons.Material.Outlined.SignalWifiOff, Color.Error), + }; + } + + private void OnCategoryChanged(string category) + { + if (Category == category) + { + return; } - return (Icons.Material.Outlined.SignalWifi4Bar, Color.Success); + Category = category; + MarkTorrentsDirty(); + } + + private void OnStatusChanged(Status status) + { + if (Status == status) + { + return; + } + + Status = status; + MarkTorrentsDirty(); + } + + private void OnTagChanged(string tag) + { + if (Tag == tag) + { + return; + } + + Tag = tag; + MarkTorrentsDirty(); + } + + private void OnTrackerChanged(string tracker) + { + if (Tracker == tracker) + { + return; + } + + Tracker = tracker; + MarkTorrentsDirty(); + } + + private void OnSearchTermChanged(string term) + { + if (SearchText == term) + { + return; + } + + SearchText = term; + MarkTorrentsDirty(); + } + + private void MarkTorrentsDirty() + { + _torrentsDirty = true; + IncrementTorrentsVersion(); + } + + private void IncrementTorrentsVersion() + { + unchecked + { + _torrentsVersion++; + } } protected virtual void Dispose(bool disposing) diff --git a/Lantean.QBTMud/Layout/OtherLayout.razor b/Lantean.QBTMud/Layout/OtherLayout.razor index 55c6356..ab3e18d 100644 --- a/Lantean.QBTMud/Layout/OtherLayout.razor +++ b/Lantean.QBTMud/Layout/OtherLayout.razor @@ -1,11 +1,13 @@ @inherits LayoutComponentBase @layout LoggedInLayout - - - - - - - @Body - \ No newline at end of file +
+ + + + + + + @Body + +
diff --git a/Lantean.QBTMud/Models/Status.cs b/Lantean.QBTMud/Models/Status.cs index 9be29be..032a42b 100644 --- a/Lantean.QBTMud/Models/Status.cs +++ b/Lantean.QBTMud/Models/Status.cs @@ -16,6 +16,5 @@ StalledDownloading, Checking, Errored, - } } \ No newline at end of file diff --git a/Lantean.QBTMud/Models/TorrentOptions.cs b/Lantean.QBTMud/Models/TorrentOptions.cs index 32d8483..0010eb7 100644 --- a/Lantean.QBTMud/Models/TorrentOptions.cs +++ b/Lantean.QBTMud/Models/TorrentOptions.cs @@ -1,6 +1,4 @@ -using Lantean.QBitTorrentClient.Models; - -namespace Lantean.QBTMud.Models +namespace Lantean.QBTMud.Models { public record TorrentOptions { diff --git a/Lantean.QBTMud/Pages/About.razor b/Lantean.QBTMud/Pages/About.razor index 03dbfb8..4c594ad 100644 --- a/Lantean.QBTMud/Pages/About.razor +++ b/Lantean.QBTMud/Pages/About.razor @@ -1,18 +1,22 @@ @page "/about" @layout OtherLayout - - @if (!DrawerOpen) - { - - - } - About - +
+
+ + @if (!DrawerOpen) + { + + + } + About + +
- - - +
+ + + - + Current maintainer @@ -108,7 +112,7 @@ - + I would first like to thank sourceforge.net for hosting qBittorrent project and for their support. I am pleased that people from all over the world are contributing to qBittorrent: Ishan Arora (India), Arnaud Demaizière (France) and Stephanos Antaris (Greece). Their help is greatly appreciated I also want to thank Στέφανος Αντάρης (santaris@csd.auth.gr) and Mirco Chinelli (infinity89@fastwebmail.it) for working on Mac OS X packaging. @@ -118,7 +122,7 @@ - + I would like to thank the people who volunteered to Circle qBittorrent.
Most of them Circled via Transifex and some of them are mentioned below:
@@ -168,7 +172,7 @@
- + The qBittorrent source code is licensed under the GNU General Public License, version 2 or (at your option) any later version (GPLv2+). However, this binary distribution is licensed under GNU General Public License, version 3 or (at your option) any later version (GPLv3+), @@ -1061,7 +1065,7 @@ - + qBittorrent was built with the following libraries: @@ -1104,4 +1108,6 @@ The free IP to Country Lite database by DB-IP is used for resolving the countries of peers. The database is licensed under the Creative Commons Attribution 4.0 International License (https://db-ip.com/) -
\ No newline at end of file + +
+
\ No newline at end of file diff --git a/Lantean.QBTMud/Pages/Blocks.razor b/Lantean.QBTMud/Pages/Blocks.razor index 6ee7f0d..f10637c 100644 --- a/Lantean.QBTMud/Pages/Blocks.razor +++ b/Lantean.QBTMud/Pages/Blocks.razor @@ -1,36 +1,41 @@ @page "/blocks" @layout OtherLayout - - @if (!DrawerOpen) - { - - - } - - Blocked IPs - +
+
+ + @if (!DrawerOpen) + { + + + } + + Blocked IPs + +
+
+ + + + + + + + + Filter + + + + + - - - - - - - - - Filter - - - - - - - \ No newline at end of file + +
+
\ No newline at end of file diff --git a/Lantean.QBTMud/Pages/CategoryManagement.razor b/Lantean.QBTMud/Pages/CategoryManagement.razor index eae455e..f26f3fd 100644 --- a/Lantean.QBTMud/Pages/CategoryManagement.razor +++ b/Lantean.QBTMud/Pages/CategoryManagement.razor @@ -1,24 +1,30 @@ @page "/categories" @layout OtherLayout - - @if (!DrawerOpen) - { - - - } - Categories - - - +
+
+ + @if (!DrawerOpen) + { + + + } + Categories + + + +
- +
+ +
+
@code { private RenderFragment> ActionsColumn diff --git a/Lantean.QBTMud/Pages/Details.razor b/Lantean.QBTMud/Pages/Details.razor index 3b899dd..f21fe64 100644 --- a/Lantean.QBTMud/Pages/Details.razor +++ b/Lantean.QBTMud/Pages/Details.razor @@ -1,41 +1,45 @@ @page "/details/{hash}" @layout DetailsLayout -
- - @if (!DrawerOpen) - { - - - } - @if (Hash is not null) - { - - } - - @Name - -
+
+
+ + @if (!DrawerOpen) + { + + + } + @if (Hash is not null) + { + + } + + @Name + +
-@if (ShowTabs) -{ - - - - - - - - - - - - - - - - - - - -} \ No newline at end of file +
+ @if (ShowTabs) + { + + + + + + + + + + + + + + + + + + + + } +
+
\ No newline at end of file diff --git a/Lantean.QBTMud/Pages/Log.razor b/Lantean.QBTMud/Pages/Log.razor index 86785d0..52f6642 100644 --- a/Lantean.QBTMud/Pages/Log.razor +++ b/Lantean.QBTMud/Pages/Log.razor @@ -1,44 +1,49 @@ @page "/log" @layout OtherLayout - - @if (!DrawerOpen) - { - - - } - - Execution Log - +
+
+ + @if (!DrawerOpen) + { + + + } + + Execution Log + +
+
+ + + + + + + + + + Normal + Info + Warning + Critical + + + + Filter + + + + + - - - - - - - - - - Normal - Info - Warning - Critical - - - - Filter - - - - - - - \ No newline at end of file + +
+
\ No newline at end of file diff --git a/Lantean.QBTMud/Pages/Options.razor b/Lantean.QBTMud/Pages/Options.razor index b3e2084..5033a8f 100644 --- a/Lantean.QBTMud/Pages/Options.razor +++ b/Lantean.QBTMud/Pages/Options.razor @@ -3,41 +3,63 @@ - - @if (!DrawerOpen) - { - - - } - Settings - - - - +
+
+ + @if (!DrawerOpen) + { + + + } + Settings + + + + +
- - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file +
+ + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+
+
\ No newline at end of file diff --git a/Lantean.QBTMud/Pages/Rss.razor b/Lantean.QBTMud/Pages/Rss.razor index 6906b4f..ea2480e 100644 --- a/Lantean.QBTMud/Pages/Rss.razor +++ b/Lantean.QBTMud/Pages/Rss.razor @@ -1,73 +1,79 @@ @page "/rss" @layout OtherLayout - - @if (!DrawerOpen) - { - - - } - RSS - - - - - - - - - - - - - - @foreach (var (key, feed) in Feeds) - { - - } - - - - @if (Articles.Count > 0) +
+
+ + @if (!DrawerOpen) { - - @foreach (var article in Articles) + + + } + RSS + + + + + + + +
+ +
+ + + + + + @foreach (var (key, feed) in Feeds) + { + + } + + + + @if (Articles.Count > 0) { - + + @foreach (var article in Articles) + { + + } + } - - } - else - { - - } - - - @if (Article is not null) - { - - - - @Article.Title - - - - Download - Open torrent URL - - - + else + { + + } + + + @if (Article is not null) + { + + + + @Article.Title + + + + Download + Open torrent URL + + + - - @Article.Date - @Article.Description - - - } - else - { - - } - - - \ No newline at end of file + + @Article.Date + @Article.Description + + + } + else + { + + } + + + +
+
\ No newline at end of file diff --git a/Lantean.QBTMud/Pages/Search.razor b/Lantean.QBTMud/Pages/Search.razor index 321c4f2..6b1af7f 100644 --- a/Lantean.QBTMud/Pages/Search.razor +++ b/Lantean.QBTMud/Pages/Search.razor @@ -1,62 +1,67 @@ @page "/search" @layout OtherLayout - - @if (!DrawerOpen) - { - - - } - - Search - +
+
+ + @if (!DrawerOpen) + { + + + } + + Search + +
+
+ + + + + + + + + + @foreach (var (value, name) in Categories) + { + @name + if (value == "all") + { + + } + } + + + + + All + @if (Plugins.Count > 0) + { + - - - - - - - - - - @foreach (var (value, name) in Categories) - { - @name - if (value == "all") - { - - } - } - - - - - All - @if (Plugins.Count > 0) - { - + } + @foreach (var (value, name) in Plugins) + { + @name + } + + + + @(_searchId is null ? "Search" : "Stop") + + + + + + - } - @foreach (var (value, name) in Plugins) - { - @name - } - - - - @(_searchId is null ? "Search" : "Stop") - - - - - - - - \ No newline at end of file + +
+
\ No newline at end of file diff --git a/Lantean.QBTMud/Pages/Statistics.razor b/Lantean.QBTMud/Pages/Statistics.razor index 3d90010..d0143d4 100644 --- a/Lantean.QBTMud/Pages/Statistics.razor +++ b/Lantean.QBTMud/Pages/Statistics.razor @@ -1,62 +1,68 @@ @page "/statistics" @layout OtherLayout - - @if (!DrawerOpen) - { - - - } - - Statistics - +
+
+ + @if (!DrawerOpen) + { + + + } + + Statistics + +
- - User statistics - - - @DisplayHelpers.Size(ServerState?.AllTimeUploaded) - - - @DisplayHelpers.Size(ServerState?.AllTimeDownloaded) - - - @DisplayHelpers.EmptyIfNull(ServerState?.GlobalRatio, format: "0.00") - - - @DisplayHelpers.Size(ServerState?.TotalWastedSession) - - - @DisplayHelpers.EmptyIfNull(ServerState?.TotalPeerConnections) - - +
+ + User statistics + + + @DisplayHelpers.Size(ServerState?.AllTimeUploaded) + + + @DisplayHelpers.Size(ServerState?.AllTimeDownloaded) + + + @DisplayHelpers.EmptyIfNull(ServerState?.GlobalRatio, format: "0.00") + + + @DisplayHelpers.Size(ServerState?.TotalWastedSession) + + + @DisplayHelpers.EmptyIfNull(ServerState?.TotalPeerConnections) + + - Cache statistics - - - @DisplayHelpers.Percentage(ServerState?.ReadCacheHits) - - - @DisplayHelpers.Size(ServerState?.TotalBuffersSize) - - + Cache statistics + + + @DisplayHelpers.Percentage(ServerState?.ReadCacheHits) + + + @DisplayHelpers.Size(ServerState?.TotalBuffersSize) + + - Performance statistics - - - @DisplayHelpers.Percentage(ServerState?.WriteCacheOverload) - - - @DisplayHelpers.Percentage(ServerState?.ReadCacheOverload) - - - @DisplayHelpers.EmptyIfNull(ServerState?.QueuedIOJobs) - - - @DisplayHelpers.EmptyIfNull(ServerState?.AverageTimeQueue, suffix: "ms") - - - @DisplayHelpers.Size(ServerState?.TotalQueuedSize) - - - + Performance statistics + + + @DisplayHelpers.Percentage(ServerState?.WriteCacheOverload) + + + @DisplayHelpers.Percentage(ServerState?.ReadCacheOverload) + + + @DisplayHelpers.EmptyIfNull(ServerState?.QueuedIOJobs) + + + @DisplayHelpers.EmptyIfNull(ServerState?.AverageTimeQueue, suffix: "ms") + + + @DisplayHelpers.Size(ServerState?.TotalQueuedSize) + + + +
+
\ No newline at end of file diff --git a/Lantean.QBTMud/Pages/TagManagement.razor b/Lantean.QBTMud/Pages/TagManagement.razor index 788ee2e..b8a3417 100644 --- a/Lantean.QBTMud/Pages/TagManagement.razor +++ b/Lantean.QBTMud/Pages/TagManagement.razor @@ -1,24 +1,30 @@ @page "/tags" @layout OtherLayout - - @if (!DrawerOpen) - { - - - } - Tags - - - +
+
+ + @if (!DrawerOpen) + { + + + } + Tags + + + +
- +
+ +
+
@code { private RenderFragment> ActionsColumn diff --git a/Lantean.QBTMud/Pages/TorrentList.razor b/Lantean.QBTMud/Pages/TorrentList.razor index 5913f47..b3cfad1 100644 --- a/Lantean.QBTMud/Pages/TorrentList.razor +++ b/Lantean.QBTMud/Pages/TorrentList.razor @@ -1,44 +1,47 @@ @page "/" @layout ListLayout - + View torrent details - + -
- - - - - - - - - - - +
+
+ + + + + + + + + + + +
+
+ + + +
- - - - @code { private static RenderFragment> ProgressBarColumn { diff --git a/Lantean.QBTMud/Pages/TorrentList.razor.cs b/Lantean.QBTMud/Pages/TorrentList.razor.cs index b4eb727..e58e41a 100644 --- a/Lantean.QBTMud/Pages/TorrentList.razor.cs +++ b/Lantean.QBTMud/Pages/TorrentList.razor.cs @@ -35,11 +35,17 @@ namespace Lantean.QBTMud.Pages public QBitTorrentClient.Models.Preferences? Preferences { get; set; } [CascadingParameter] - public IEnumerable? Torrents { get; set; } + public IReadOnlyList? Torrents { get; set; } [CascadingParameter] public MainData MainData { get; set; } = default!; + [CascadingParameter(Name = "LostConnection")] + public bool LostConnection { get; set; } + + [CascadingParameter(Name = "TorrentsVersion")] + public int TorrentsVersion { get; set; } + [CascadingParameter(Name = "SearchTermChanged")] public EventCallback SearchTermChanged { get; set; } @@ -56,13 +62,23 @@ namespace Lantean.QBTMud.Pages protected HashSet SelectedItems { get; set; } = []; - protected bool ToolbarButtonsEnabled => SelectedItems.Count > 0; + protected bool ToolbarButtonsEnabled => _toolbarButtonsEnabled; protected DynamicTable? Table { get; set; } protected Torrent? ContextMenuItem { get; set; } - protected ContextMenu? ContextMenu { get; set; } + protected MudMenu? ContextMenu { get; set; } + + private object? _lastRenderedTorrents; + private QBitTorrentClient.Models.Preferences? _lastPreferences; + private bool _lastLostConnection; + private bool _hasRendered; + private int _lastSelectionCount; + private int _lastTorrentsVersion = -1; + private bool _pendingSelectionChange; + + private bool _toolbarButtonsEnabled; protected override async Task OnAfterRenderAsync(bool firstRender) { @@ -73,9 +89,81 @@ namespace Lantean.QBTMud.Pages } } + protected override bool ShouldRender() + { + if (!_hasRendered) + { + _hasRendered = true; + _lastRenderedTorrents = Torrents; + _lastPreferences = Preferences; + _lastLostConnection = LostConnection; + _lastTorrentsVersion = TorrentsVersion; + _lastSelectionCount = SelectedItems.Count; + _toolbarButtonsEnabled = _lastSelectionCount > 0; + return true; + } + + if (_pendingSelectionChange) + { + _pendingSelectionChange = false; + _lastSelectionCount = SelectedItems.Count; + _toolbarButtonsEnabled = _lastSelectionCount > 0; + return true; + } + + if (_lastTorrentsVersion != TorrentsVersion) + { + _lastTorrentsVersion = TorrentsVersion; + _lastRenderedTorrents = Torrents; + _lastPreferences = Preferences; + _lastLostConnection = LostConnection; + _lastSelectionCount = SelectedItems.Count; + _toolbarButtonsEnabled = _lastSelectionCount > 0; + return true; + } + + if (!ReferenceEquals(_lastRenderedTorrents, Torrents)) + { + _lastRenderedTorrents = Torrents; + _lastPreferences = Preferences; + _lastLostConnection = LostConnection; + _lastSelectionCount = SelectedItems.Count; + _toolbarButtonsEnabled = _lastSelectionCount > 0; + return true; + } + + if (!ReferenceEquals(_lastPreferences, Preferences)) + { + _lastPreferences = Preferences; + _lastSelectionCount = SelectedItems.Count; + _toolbarButtonsEnabled = _lastSelectionCount > 0; + return true; + } + + if (_lastLostConnection != LostConnection) + { + _lastLostConnection = LostConnection; + _lastSelectionCount = SelectedItems.Count; + _toolbarButtonsEnabled = _lastSelectionCount > 0; + return true; + } + + if (_lastSelectionCount != SelectedItems.Count) + { + _lastSelectionCount = SelectedItems.Count; + _toolbarButtonsEnabled = _lastSelectionCount > 0; + return true; + } + + return false; + } + protected void SelectedItemsChanged(HashSet selectedItems) { SelectedItems = selectedItems; + _toolbarButtonsEnabled = SelectedItems.Count > 0; + _pendingSelectionChange = true; + InvokeAsync(StateHasChanged); } protected async Task SortDirectionChangedHandler(SortDirection sortDirection) @@ -185,7 +273,9 @@ namespace Lantean.QBTMud.Pages return; } - await ContextMenu.ToggleMenuAsync(eventArgs); + var normalizedEventArgs = eventArgs.NormalizeForContextMenu(); + + await ContextMenu.OpenMenuAsync(normalizedEventArgs); } protected IEnumerable> Columns => ColumnsDefinitions.Where(c => c.Id != "#" || Preferences?.QueueingEnabled == true); diff --git a/Lantean.QBTMud/Program.cs b/Lantean.QBTMud/Program.cs index f8064a3..55c8fdd 100644 --- a/Lantean.QBTMud/Program.cs +++ b/Lantean.QBTMud/Program.cs @@ -1,4 +1,4 @@ -using Blazored.LocalStorage; +using Blazored.LocalStorage; using Lantean.QBitTorrentClient; using Lantean.QBTMud.Services; using Microsoft.AspNetCore.Components.Web; diff --git a/Lantean.QBTMud/Services/DataManager.cs b/Lantean.QBTMud/Services/DataManager.cs index 7a5e8aa..c76e699 100644 --- a/Lantean.QBTMud/Services/DataManager.cs +++ b/Lantean.QBTMud/Services/DataManager.cs @@ -39,12 +39,19 @@ namespace Lantean.QBTMud.Services } } - var tags = new List(mainData.Tags?.Count ?? 0); + var tags = new List(); if (mainData.Tags is not null) { + var seenTags = new HashSet(StringComparer.Ordinal); foreach (var tag in mainData.Tags) { - tags.Add(tag); + var normalizedTag = NormalizeTag(tag); + if (string.IsNullOrEmpty(normalizedTag) || !seenTags.Add(normalizedTag)) + { + continue; + } + + tags.Add(normalizedTag); } } @@ -142,14 +149,24 @@ namespace Lantean.QBTMud.Services serverState.WriteCacheOverload.GetValueOrDefault()); } - public void MergeMainData(QBitTorrentClient.Models.MainData mainData, MainData torrentList) + public bool MergeMainData(QBitTorrentClient.Models.MainData mainData, MainData torrentList, out bool filterChanged) { + filterChanged = false; + var dataChanged = false; + if (mainData.CategoriesRemoved is not null) { foreach (var category in mainData.CategoriesRemoved) { - torrentList.Categories.Remove(category); - torrentList.CategoriesState.Remove(category); + if (torrentList.Categories.Remove(category)) + { + dataChanged = true; + filterChanged = true; + } + if (torrentList.CategoriesState.Remove(category)) + { + filterChanged = true; + } } } @@ -157,8 +174,17 @@ namespace Lantean.QBTMud.Services { foreach (var tag in mainData.TagsRemoved) { - torrentList.Tags.Remove(tag); - torrentList.TagState.Remove(tag); + var normalizedTag = NormalizeTag(tag); + if (string.IsNullOrEmpty(normalizedTag)) + { + continue; + } + + if (torrentList.TagState.Remove(normalizedTag)) + { + filterChanged = true; + } + torrentList.TagState.Remove(normalizedTag); } } @@ -166,8 +192,15 @@ namespace Lantean.QBTMud.Services { foreach (var tracker in mainData.TrackersRemoved) { - torrentList.Trackers.Remove(tracker); - torrentList.TrackersState.Remove(tracker); + if (torrentList.Trackers.Remove(tracker)) + { + dataChanged = true; + filterChanged = true; + } + if (torrentList.TrackersState.Remove(tracker)) + { + filterChanged = true; + } } } @@ -175,8 +208,12 @@ namespace Lantean.QBTMud.Services { foreach (var hash in mainData.TorrentsRemoved) { - RemoveTorrentFromStates(torrentList, hash); - torrentList.Torrents.Remove(hash); + if (torrentList.Torrents.Remove(hash)) + { + RemoveTorrentFromStates(torrentList, hash); + dataChanged = true; + filterChanged = true; + } } } @@ -188,10 +225,12 @@ namespace Lantean.QBTMud.Services { var newCategory = CreateCategory(category); torrentList.Categories.Add(name, newCategory); + dataChanged = true; + filterChanged = true; } - else + else if (UpdateCategory(existingCategory, category)) { - UpdateCategory(existingCategory, category); + dataChanged = true; } } } @@ -200,7 +239,22 @@ namespace Lantean.QBTMud.Services { foreach (var tag in mainData.Tags) { - torrentList.Tags.Add(tag); + var normalizedTag = NormalizeTag(tag); + if (string.IsNullOrEmpty(normalizedTag)) + { + continue; + } + + if (torrentList.Tags.Add(normalizedTag)) + { + dataChanged = true; + filterChanged = true; + } + var matchingHashes = torrentList.Torrents + .Where(pair => FilterHelper.FilterTag(pair.Value, normalizedTag)) + .Select(pair => pair.Key) + .ToHashSet(); + torrentList.TagState[normalizedTag] = matchingHashes; } } @@ -208,13 +262,16 @@ namespace Lantean.QBTMud.Services { foreach (var (url, hashes) in mainData.Trackers) { - if (!torrentList.Trackers.TryGetValue(url, out _)) + if (!torrentList.Trackers.TryGetValue(url, out var existingHashes)) { torrentList.Trackers.Add(url, hashes); + dataChanged = true; + filterChanged = true; } - else + else if (!existingHashes.SequenceEqual(hashes)) { torrentList.Trackers[url] = hashes; + dataChanged = true; } } } @@ -228,67 +285,65 @@ namespace Lantean.QBTMud.Services var newTorrent = CreateTorrent(hash, torrent); torrentList.Torrents.Add(hash, newTorrent); AddTorrentToStates(torrentList, hash, torrentList.MajorVersion); + dataChanged = true; + filterChanged = true; } else { - UpdateTorrentStates(torrentList, hash); - UpdateTorrent(existingTorrent, torrent); + var previousSnapshot = CreateSnapshot(existingTorrent); + var updateResult = UpdateTorrent(existingTorrent, torrent); + if (updateResult.FilterChanged) + { + UpdateTorrentStates(torrentList, hash, previousSnapshot, existingTorrent); + filterChanged = true; + } + if (updateResult.DataChanged) + { + dataChanged = true; + } } } } if (mainData.ServerState is not null) { - UpdateServerState(torrentList.ServerState, mainData.ServerState); + if (UpdateServerState(torrentList.ServerState, mainData.ServerState)) + { + dataChanged = true; + } } + + return dataChanged; } private static void AddTorrentToStates(MainData torrentList, string hash, int version) { - var torrent = torrentList.Torrents[hash]; + if (!torrentList.Torrents.TryGetValue(hash, out var torrent)) + { + return; + } torrentList.TagState[FilterHelper.TAG_ALL].Add(hash); - torrentList.TagState[FilterHelper.TAG_UNTAGGED].AddIfTrue(hash, FilterHelper.FilterTag(torrent, FilterHelper.TAG_UNTAGGED)); - foreach (var tag in torrentList.Tags) - { - if (!torrentList.TagState.TryGetValue(tag, out HashSet? value)) - { - value = []; - torrentList.TagState.Add(tag, value); - } - - value.AddIfTrue(hash, FilterHelper.FilterTag(torrent, tag)); - } + UpdateTagStateForAddition(torrentList, torrent, hash); torrentList.CategoriesState[FilterHelper.CATEGORY_ALL].Add(hash); - torrentList.CategoriesState[FilterHelper.CATEGORY_UNCATEGORIZED].AddIfTrue(hash, FilterHelper.FilterCategory(torrent, FilterHelper.CATEGORY_UNCATEGORIZED, torrentList.ServerState.UseSubcategories)); - foreach (var category in torrentList.Categories.Keys) - { - if (!torrentList.CategoriesState.TryGetValue(category, out HashSet? value)) - { - value = []; - torrentList.CategoriesState.Add(category, value); - } - - value.AddIfTrue(hash, FilterHelper.FilterCategory(torrent, category, torrentList.ServerState.UseSubcategories)); - } + UpdateCategoryState(torrentList, torrent, hash, previousCategory: null); foreach (var status in GetStatuses(version)) { - torrentList.StatusState[status.ToString()].AddIfTrue(hash, FilterHelper.FilterStatus(torrent, status)); + if (!torrentList.StatusState.TryGetValue(status.ToString(), out var statusSet)) + { + continue; + } + + if (FilterHelper.FilterStatus(torrent, status)) + { + statusSet.Add(hash); + } } torrentList.TrackersState[FilterHelper.TRACKER_ALL].Add(hash); - torrentList.TrackersState[FilterHelper.TRACKER_TRACKERLESS].AddIfTrue(hash, FilterHelper.FilterTracker(torrent, FilterHelper.TRACKER_TRACKERLESS)); - foreach (var tracker in torrentList.Trackers.Keys) - { - if (!torrentList.TrackersState.TryGetValue(tracker, out HashSet? value)) - { - value = []; - torrentList.TrackersState.Add(tracker, value); - } - value.AddIfTrue(hash, FilterHelper.FilterTracker(torrent, tracker)); - } + UpdateTrackerState(torrentList, torrent, hash, previousTracker: null); } private static Status[] GetStatuses(int version) @@ -310,77 +365,28 @@ namespace Lantean.QBTMud.Services return _statusArray; } - private static void UpdateTorrentStates(MainData torrentList, string hash) + private static void UpdateTorrentStates(MainData torrentList, string hash, TorrentSnapshot previousSnapshot, Torrent updatedTorrent) { - var torrent = torrentList.Torrents[hash]; - - torrentList.TagState[FilterHelper.TAG_UNTAGGED].AddIfTrueOrRemove(hash, FilterHelper.FilterTag(torrent, FilterHelper.TAG_UNTAGGED)); - foreach (var tag in torrentList.Tags) - { - if (!torrentList.TagState.TryGetValue(tag, out HashSet? value)) - { - value = []; - torrentList.TagState.Add(tag, value); - } - - value.AddIfTrueOrRemove(hash, FilterHelper.FilterTag(torrent, tag)); - } - - torrentList.CategoriesState[FilterHelper.CATEGORY_UNCATEGORIZED].AddIfTrueOrRemove(hash, FilterHelper.FilterCategory(torrent, FilterHelper.CATEGORY_UNCATEGORIZED, torrentList.ServerState.UseSubcategories)); - foreach (var category in torrentList.Categories.Keys) - { - if (!torrentList.CategoriesState.TryGetValue(category, out HashSet? value)) - { - value = []; - torrentList.CategoriesState.Add(category, value); - } - - value.AddIfTrueOrRemove(hash, FilterHelper.FilterCategory(torrent, category, torrentList.ServerState.UseSubcategories)); - } - - foreach (var status in GetStatuses(torrentList.MajorVersion)) - { - torrentList.StatusState[status.ToString()].AddIfTrueOrRemove(hash, FilterHelper.FilterStatus(torrent, status)); - } - - torrentList.TrackersState[FilterHelper.TRACKER_TRACKERLESS].AddIfTrueOrRemove(hash, FilterHelper.FilterTracker(torrent, FilterHelper.TRACKER_TRACKERLESS)); - foreach (var tracker in torrentList.Trackers.Keys) - { - if (!torrentList.TrackersState.TryGetValue(tracker, out HashSet? value)) - { - value = []; - torrentList.TrackersState.Add(tracker, value); - } - - value.AddIfTrueOrRemove(hash, FilterHelper.FilterTracker(torrent, tracker)); - } + UpdateTagStateForUpdate(torrentList, hash, previousSnapshot.Tags, updatedTorrent.Tags); + UpdateCategoryState(torrentList, updatedTorrent, hash, previousSnapshot.Category); + UpdateStatusState(torrentList, hash, previousSnapshot.State, previousSnapshot.UploadSpeed, updatedTorrent.State, updatedTorrent.UploadSpeed); + UpdateTrackerState(torrentList, updatedTorrent, hash, previousSnapshot.Tracker); } private static void RemoveTorrentFromStates(MainData torrentList, string hash) { - var torrent = torrentList.Torrents[hash]; + if (!torrentList.Torrents.TryGetValue(hash, out var torrent)) + { + return; + } + + var snapshot = CreateSnapshot(torrent); torrentList.TagState[FilterHelper.TAG_ALL].Remove(hash); - torrentList.TagState[FilterHelper.TAG_UNTAGGED].RemoveIfTrue(hash, FilterHelper.FilterTag(torrent, FilterHelper.TAG_UNTAGGED)); - foreach (var tag in torrentList.Tags) - { - if (!torrentList.TagState.TryGetValue(tag, out var tagState)) - { - continue; - } - tagState.RemoveIfTrue(hash, FilterHelper.FilterTag(torrent, tag)); - } + UpdateTagStateForRemoval(torrentList, hash, snapshot.Tags); torrentList.CategoriesState[FilterHelper.CATEGORY_ALL].Remove(hash); - torrentList.CategoriesState[FilterHelper.CATEGORY_UNCATEGORIZED].RemoveIfTrue(hash, FilterHelper.FilterCategory(torrent, FilterHelper.CATEGORY_UNCATEGORIZED, torrentList.ServerState.UseSubcategories)); - foreach (var category in torrentList.Categories.Keys) - { - if (!torrentList.CategoriesState.TryGetValue(category, out var categoryState)) - { - continue; - } - categoryState.RemoveIfTrue(hash, FilterHelper.FilterCategory(torrent, category, torrentList.ServerState.UseSubcategories)); - } + UpdateCategoryStateForRemoval(torrentList, hash, snapshot.Category); foreach (var status in GetStatuses(torrentList.MajorVersion)) { @@ -388,48 +394,172 @@ namespace Lantean.QBTMud.Services { continue; } - statusState.RemoveIfTrue(hash, FilterHelper.FilterStatus(torrent, status)); + + if (FilterHelper.FilterStatus(snapshot.State, snapshot.UploadSpeed, status)) + { + statusState.Remove(hash); + } } torrentList.TrackersState[FilterHelper.TRACKER_ALL].Remove(hash); - torrentList.TrackersState[FilterHelper.TRACKER_TRACKERLESS].RemoveIfTrue(hash, FilterHelper.FilterTracker(torrent, FilterHelper.TRACKER_TRACKERLESS)); - foreach (var tracker in torrentList.Trackers.Keys) - { - if (!torrentList.TrackersState.TryGetValue(tracker, out var trackerState)) - { - continue; - } - trackerState.RemoveIfTrue(hash, FilterHelper.FilterTracker(torrent, tracker)); - } + UpdateTrackerStateForRemoval(torrentList, hash, snapshot.Tracker); } - private static void UpdateServerState(ServerState existingServerState, QBitTorrentClient.Models.ServerState serverState) + private static bool UpdateServerState(ServerState existingServerState, QBitTorrentClient.Models.ServerState serverState) { - existingServerState.AllTimeDownloaded = serverState.AllTimeDownloaded ?? existingServerState.AllTimeDownloaded; - existingServerState.AllTimeUploaded = serverState.AllTimeUploaded ?? existingServerState.AllTimeUploaded; - existingServerState.AverageTimeQueue = serverState.AverageTimeQueue ?? existingServerState.AverageTimeQueue; - existingServerState.ConnectionStatus = serverState.ConnectionStatus ?? existingServerState.ConnectionStatus; - existingServerState.DHTNodes = serverState.DHTNodes ?? existingServerState.DHTNodes; - existingServerState.DownloadInfoData = serverState.DownloadInfoData ?? existingServerState.DownloadInfoData; - existingServerState.DownloadInfoSpeed = serverState.DownloadInfoSpeed ?? existingServerState.DownloadInfoSpeed; - existingServerState.DownloadRateLimit = serverState.DownloadRateLimit ?? existingServerState.DownloadRateLimit; - existingServerState.FreeSpaceOnDisk = serverState.FreeSpaceOnDisk ?? existingServerState.FreeSpaceOnDisk; - existingServerState.GlobalRatio = serverState.GlobalRatio ?? existingServerState.GlobalRatio; - existingServerState.QueuedIOJobs = serverState.QueuedIOJobs ?? existingServerState.QueuedIOJobs; - existingServerState.Queuing = serverState.Queuing ?? existingServerState.Queuing; - existingServerState.ReadCacheHits = serverState.ReadCacheHits ?? existingServerState.ReadCacheHits; - existingServerState.ReadCacheOverload = serverState.ReadCacheOverload ?? existingServerState.ReadCacheOverload; - existingServerState.RefreshInterval = serverState.RefreshInterval ?? existingServerState.RefreshInterval; - existingServerState.TotalBuffersSize = serverState.TotalBuffersSize ?? existingServerState.TotalBuffersSize; - existingServerState.TotalPeerConnections = serverState.TotalPeerConnections ?? existingServerState.TotalPeerConnections; - existingServerState.TotalQueuedSize = serverState.TotalQueuedSize ?? existingServerState.TotalQueuedSize; - existingServerState.TotalWastedSession = serverState.TotalWastedSession ?? existingServerState.TotalWastedSession; - existingServerState.UploadInfoData = serverState.UploadInfoData ?? existingServerState.UploadInfoData; - existingServerState.UploadInfoSpeed = serverState.UploadInfoSpeed ?? existingServerState.UploadInfoSpeed; - existingServerState.UploadRateLimit = serverState.UploadRateLimit ?? existingServerState.UploadRateLimit; - existingServerState.UseAltSpeedLimits = serverState.UseAltSpeedLimits ?? existingServerState.UseAltSpeedLimits; - existingServerState.UseSubcategories = serverState.UseSubcategories ?? existingServerState.UseSubcategories; - existingServerState.WriteCacheOverload = serverState.WriteCacheOverload ?? existingServerState.WriteCacheOverload; + var changed = false; + + if (serverState.AllTimeDownloaded.HasValue && existingServerState.AllTimeDownloaded != serverState.AllTimeDownloaded.Value) + { + existingServerState.AllTimeDownloaded = serverState.AllTimeDownloaded.Value; + changed = true; + } + + if (serverState.AllTimeUploaded.HasValue && existingServerState.AllTimeUploaded != serverState.AllTimeUploaded.Value) + { + existingServerState.AllTimeUploaded = serverState.AllTimeUploaded.Value; + changed = true; + } + + if (serverState.AverageTimeQueue.HasValue && existingServerState.AverageTimeQueue != serverState.AverageTimeQueue.Value) + { + existingServerState.AverageTimeQueue = serverState.AverageTimeQueue.Value; + changed = true; + } + + if (serverState.ConnectionStatus is not null && existingServerState.ConnectionStatus != serverState.ConnectionStatus) + { + existingServerState.ConnectionStatus = serverState.ConnectionStatus; + changed = true; + } + + if (serverState.DHTNodes.HasValue && existingServerState.DHTNodes != serverState.DHTNodes.Value) + { + existingServerState.DHTNodes = serverState.DHTNodes.Value; + changed = true; + } + + if (serverState.DownloadInfoData.HasValue && existingServerState.DownloadInfoData != serverState.DownloadInfoData.Value) + { + existingServerState.DownloadInfoData = serverState.DownloadInfoData.Value; + changed = true; + } + + if (serverState.DownloadInfoSpeed.HasValue && existingServerState.DownloadInfoSpeed != serverState.DownloadInfoSpeed.Value) + { + existingServerState.DownloadInfoSpeed = serverState.DownloadInfoSpeed.Value; + changed = true; + } + + if (serverState.DownloadRateLimit.HasValue && existingServerState.DownloadRateLimit != serverState.DownloadRateLimit.Value) + { + existingServerState.DownloadRateLimit = serverState.DownloadRateLimit.Value; + changed = true; + } + + if (serverState.FreeSpaceOnDisk.HasValue && existingServerState.FreeSpaceOnDisk != serverState.FreeSpaceOnDisk.Value) + { + existingServerState.FreeSpaceOnDisk = serverState.FreeSpaceOnDisk.Value; + changed = true; + } + + if (serverState.GlobalRatio.HasValue && existingServerState.GlobalRatio != serverState.GlobalRatio.Value) + { + existingServerState.GlobalRatio = serverState.GlobalRatio.Value; + changed = true; + } + + if (serverState.QueuedIOJobs.HasValue && existingServerState.QueuedIOJobs != serverState.QueuedIOJobs.Value) + { + existingServerState.QueuedIOJobs = serverState.QueuedIOJobs.Value; + changed = true; + } + + if (serverState.Queuing.HasValue && existingServerState.Queuing != serverState.Queuing.Value) + { + existingServerState.Queuing = serverState.Queuing.Value; + changed = true; + } + + if (serverState.ReadCacheHits.HasValue && existingServerState.ReadCacheHits != serverState.ReadCacheHits.Value) + { + existingServerState.ReadCacheHits = serverState.ReadCacheHits.Value; + changed = true; + } + + if (serverState.ReadCacheOverload.HasValue && existingServerState.ReadCacheOverload != serverState.ReadCacheOverload.Value) + { + existingServerState.ReadCacheOverload = serverState.ReadCacheOverload.Value; + changed = true; + } + + if (serverState.RefreshInterval.HasValue && existingServerState.RefreshInterval != serverState.RefreshInterval.Value) + { + existingServerState.RefreshInterval = serverState.RefreshInterval.Value; + changed = true; + } + + if (serverState.TotalBuffersSize.HasValue && existingServerState.TotalBuffersSize != serverState.TotalBuffersSize.Value) + { + existingServerState.TotalBuffersSize = serverState.TotalBuffersSize.Value; + changed = true; + } + + if (serverState.TotalPeerConnections.HasValue && existingServerState.TotalPeerConnections != serverState.TotalPeerConnections.Value) + { + existingServerState.TotalPeerConnections = serverState.TotalPeerConnections.Value; + changed = true; + } + + if (serverState.TotalQueuedSize.HasValue && existingServerState.TotalQueuedSize != serverState.TotalQueuedSize.Value) + { + existingServerState.TotalQueuedSize = serverState.TotalQueuedSize.Value; + changed = true; + } + + if (serverState.TotalWastedSession.HasValue && existingServerState.TotalWastedSession != serverState.TotalWastedSession.Value) + { + existingServerState.TotalWastedSession = serverState.TotalWastedSession.Value; + changed = true; + } + + if (serverState.UploadInfoData.HasValue && existingServerState.UploadInfoData != serverState.UploadInfoData.Value) + { + existingServerState.UploadInfoData = serverState.UploadInfoData.Value; + changed = true; + } + + if (serverState.UploadInfoSpeed.HasValue && existingServerState.UploadInfoSpeed != serverState.UploadInfoSpeed.Value) + { + existingServerState.UploadInfoSpeed = serverState.UploadInfoSpeed.Value; + changed = true; + } + + if (serverState.UploadRateLimit.HasValue && existingServerState.UploadRateLimit != serverState.UploadRateLimit.Value) + { + existingServerState.UploadRateLimit = serverState.UploadRateLimit.Value; + changed = true; + } + + if (serverState.UseAltSpeedLimits.HasValue && existingServerState.UseAltSpeedLimits != serverState.UseAltSpeedLimits.Value) + { + existingServerState.UseAltSpeedLimits = serverState.UseAltSpeedLimits.Value; + changed = true; + } + + if (serverState.UseSubcategories.HasValue && existingServerState.UseSubcategories != serverState.UseSubcategories.Value) + { + existingServerState.UseSubcategories = serverState.UseSubcategories.Value; + changed = true; + } + + if (serverState.WriteCacheOverload.HasValue && existingServerState.WriteCacheOverload != serverState.WriteCacheOverload.Value) + { + existingServerState.WriteCacheOverload = serverState.WriteCacheOverload.Value; + changed = true; + } + + return changed; } public void MergeTorrentPeers(QBitTorrentClient.Models.TorrentPeers torrentPeers, PeerList peerList) @@ -508,6 +638,12 @@ namespace Lantean.QBTMud.Services public Torrent CreateTorrent(string hash, QBitTorrentClient.Models.Torrent torrent) { + var normalizedTags = torrent.Tags? + .Select(NormalizeTag) + .Where(static tag => !string.IsNullOrEmpty(tag)) + .ToList() + ?? new List(); + return new Torrent( hash, torrent.AddedOn.GetValueOrDefault(), @@ -548,7 +684,7 @@ namespace Lantean.QBTMud.Services torrent.Size.GetValueOrDefault(), torrent.State!, torrent.SuperSeeding.GetValueOrDefault(), - torrent.Tags!, + normalizedTags, torrent.TimeActive.GetValueOrDefault(), torrent.TotalSize.GetValueOrDefault(), torrent.Tracker!, @@ -561,146 +697,873 @@ namespace Lantean.QBTMud.Services torrent.MaxInactiveSeedingTime.GetValueOrDefault()); } - private static void UpdateCategory(Category existingCategory, QBitTorrentClient.Models.Category category) + private static string NormalizeTag(string? tag) { - existingCategory.SavePath = category.SavePath ?? existingCategory.SavePath; + if (string.IsNullOrEmpty(tag)) + { + return string.Empty; + } + + var separatorIndex = tag.IndexOf('\t'); + var normalized = (separatorIndex >= 0) ? tag[..separatorIndex] : tag; + + return normalized.Trim(); } - private static void UpdateTorrent(Torrent existingTorrent, QBitTorrentClient.Models.Torrent torrent) + private static TorrentSnapshot CreateSnapshot(Torrent torrent) { - existingTorrent.AddedOn = torrent.AddedOn ?? existingTorrent.AddedOn; - existingTorrent.AmountLeft = torrent.AmountLeft ?? existingTorrent.AmountLeft; - existingTorrent.AutomaticTorrentManagement = torrent.AutomaticTorrentManagement ?? existingTorrent.AutomaticTorrentManagement; - existingTorrent.Availability = torrent.Availability ?? existingTorrent.Availability; - existingTorrent.Category = torrent.Category ?? existingTorrent.Category; - existingTorrent.Completed = torrent.Completed ?? existingTorrent.Completed; - existingTorrent.CompletionOn = torrent.CompletionOn ?? existingTorrent.CompletionOn; - existingTorrent.ContentPath = torrent.ContentPath ?? existingTorrent.ContentPath; - existingTorrent.Downloaded = torrent.Downloaded ?? existingTorrent.Downloaded; - existingTorrent.DownloadedSession = torrent.DownloadedSession ?? existingTorrent.DownloadedSession; - existingTorrent.DownloadLimit = torrent.DownloadLimit ?? existingTorrent.DownloadLimit; - existingTorrent.DownloadSpeed = torrent.DownloadSpeed ?? existingTorrent.DownloadSpeed; - existingTorrent.EstimatedTimeOfArrival = torrent.EstimatedTimeOfArrival ?? existingTorrent.EstimatedTimeOfArrival; - existingTorrent.FirstLastPiecePriority = torrent.FirstLastPiecePriority ?? existingTorrent.FirstLastPiecePriority; - existingTorrent.ForceStart = torrent.ForceStart ?? existingTorrent.ForceStart; - existingTorrent.InfoHashV1 = torrent.InfoHashV1 ?? existingTorrent.InfoHashV1; - existingTorrent.InfoHashV2 = torrent.InfoHashV2 ?? existingTorrent.InfoHashV2; - existingTorrent.LastActivity = torrent.LastActivity ?? existingTorrent.LastActivity; - existingTorrent.MagnetUri = torrent.MagnetUri ?? existingTorrent.MagnetUri; - existingTorrent.MaxRatio = torrent.MaxRatio ?? existingTorrent.MaxRatio; - existingTorrent.MaxSeedingTime = torrent.MaxSeedingTime ?? existingTorrent.MaxSeedingTime; - existingTorrent.Name = torrent.Name ?? existingTorrent.Name; - existingTorrent.NumberComplete = torrent.NumberComplete ?? existingTorrent.NumberComplete; - existingTorrent.NumberIncomplete = torrent.NumberIncomplete ?? existingTorrent.NumberIncomplete; - existingTorrent.NumberLeeches = torrent.NumberLeeches ?? existingTorrent.NumberLeeches; - existingTorrent.NumberSeeds = torrent.NumberSeeds ?? existingTorrent.NumberSeeds; - existingTorrent.Priority = torrent.Priority ?? existingTorrent.Priority; - existingTorrent.Progress = torrent.Progress ?? existingTorrent.Progress; - existingTorrent.Ratio = torrent.Ratio ?? existingTorrent.Ratio; - existingTorrent.RatioLimit = torrent.RatioLimit ?? existingTorrent.RatioLimit; - existingTorrent.SavePath = torrent.SavePath ?? existingTorrent.SavePath; - existingTorrent.SeedingTime = torrent.SeedingTime ?? existingTorrent.SeedingTime; - existingTorrent.SeedingTimeLimit = torrent.SeedingTimeLimit ?? existingTorrent.SeedingTimeLimit; - existingTorrent.SeenComplete = torrent.SeenComplete ?? existingTorrent.SeenComplete; - existingTorrent.SequentialDownload = torrent.SequentialDownload ?? existingTorrent.SequentialDownload; - existingTorrent.Size = torrent.Size ?? existingTorrent.Size; - existingTorrent.State = torrent.State ?? existingTorrent.State; - existingTorrent.SuperSeeding = torrent.SuperSeeding ?? existingTorrent.SuperSeeding; + return new TorrentSnapshot( + string.IsNullOrEmpty(torrent.Category) ? null : torrent.Category, + torrent.Tags.ToList(), + torrent.Tracker ?? string.Empty, + torrent.State ?? string.Empty, + torrent.UploadSpeed); + } + + private readonly struct TorrentSnapshot + { + public TorrentSnapshot(string? category, List tags, string tracker, string state, long uploadSpeed) + { + Category = category; + Tags = tags; + Tracker = tracker; + State = state; + UploadSpeed = uploadSpeed; + } + + public string? Category { get; } + + public IReadOnlyList Tags { get; } + + public string Tracker { get; } + + public string State { get; } + + public long UploadSpeed { get; } + } + + private static void UpdateTagStateForAddition(MainData torrentList, Torrent torrent, string hash) + { + if (torrent.Tags.Count == 0) + { + torrentList.TagState[FilterHelper.TAG_UNTAGGED].Add(hash); + return; + } + + torrentList.TagState[FilterHelper.TAG_UNTAGGED].Remove(hash); + foreach (var tag in torrent.Tags) + { + if (string.IsNullOrEmpty(tag)) + { + continue; + } + + GetOrCreateTagSet(torrentList, tag).Add(hash); + } + } + + private static void UpdateTagStateForUpdate(MainData torrentList, string hash, IReadOnlyList previousTags, IList newTags) + { + UpdateTagStateForRemoval(torrentList, hash, previousTags); + + if (newTags.Count == 0) + { + torrentList.TagState[FilterHelper.TAG_UNTAGGED].Add(hash); + return; + } + + torrentList.TagState[FilterHelper.TAG_UNTAGGED].Remove(hash); + foreach (var tag in newTags) + { + if (string.IsNullOrEmpty(tag)) + { + continue; + } + + GetOrCreateTagSet(torrentList, tag).Add(hash); + } + } + + private static void UpdateTagStateForRemoval(MainData torrentList, string hash, IReadOnlyList previousTags) + { + torrentList.TagState[FilterHelper.TAG_UNTAGGED].Remove(hash); + + foreach (var tag in previousTags) + { + if (string.IsNullOrEmpty(tag)) + { + continue; + } + + if (torrentList.TagState.TryGetValue(tag, out var set)) + { + set.Remove(hash); + } + } + } + + private static void UpdateCategoryState(MainData torrentList, Torrent updatedTorrent, string hash, string? previousCategory) + { + var useSubcategories = torrentList.ServerState.UseSubcategories; + + if (!string.IsNullOrEmpty(previousCategory)) + { + foreach (var categoryKey in EnumerateCategoryKeys(previousCategory, useSubcategories)) + { + if (torrentList.CategoriesState.TryGetValue(categoryKey, out var set)) + { + set.Remove(hash); + } + } + } + else + { + torrentList.CategoriesState[FilterHelper.CATEGORY_UNCATEGORIZED].Remove(hash); + } + + if (string.IsNullOrEmpty(updatedTorrent.Category)) + { + torrentList.CategoriesState[FilterHelper.CATEGORY_UNCATEGORIZED].Add(hash); + return; + } + + foreach (var categoryKey in EnumerateCategoryKeys(updatedTorrent.Category, useSubcategories)) + { + GetOrCreateCategorySet(torrentList, categoryKey).Add(hash); + } + } + + private static void UpdateCategoryStateForRemoval(MainData torrentList, string hash, string? previousCategory) + { + if (string.IsNullOrEmpty(previousCategory)) + { + torrentList.CategoriesState[FilterHelper.CATEGORY_UNCATEGORIZED].Remove(hash); + return; + } + + foreach (var categoryKey in EnumerateCategoryKeys(previousCategory, torrentList.ServerState.UseSubcategories)) + { + if (torrentList.CategoriesState.TryGetValue(categoryKey, out var set)) + { + set.Remove(hash); + } + } + } + + private static void UpdateStatusState(MainData torrentList, string hash, string previousState, long previousUploadSpeed, string newState, long newUploadSpeed) + { + foreach (var status in GetStatuses(torrentList.MajorVersion)) + { + if (!torrentList.StatusState.TryGetValue(status.ToString(), out var statusSet)) + { + continue; + } + + var wasMatch = FilterHelper.FilterStatus(previousState, previousUploadSpeed, status); + var isMatch = FilterHelper.FilterStatus(newState, newUploadSpeed, status); + + if (wasMatch == isMatch) + { + continue; + } + + if (wasMatch) + { + statusSet.Remove(hash); + } + + if (isMatch) + { + statusSet.Add(hash); + } + } + } + + private static void UpdateTrackerState(MainData torrentList, Torrent updatedTorrent, string hash, string? previousTracker) + { + if (!string.IsNullOrEmpty(previousTracker)) + { + if (torrentList.TrackersState.TryGetValue(previousTracker, out var oldSet)) + { + oldSet.Remove(hash); + } + } + else + { + torrentList.TrackersState[FilterHelper.TRACKER_TRACKERLESS].Remove(hash); + } + + var tracker = updatedTorrent.Tracker ?? string.Empty; + if (string.IsNullOrEmpty(tracker)) + { + torrentList.TrackersState[FilterHelper.TRACKER_TRACKERLESS].Add(hash); + return; + } + + torrentList.TrackersState[FilterHelper.TRACKER_TRACKERLESS].Remove(hash); + GetOrCreateTrackerSet(torrentList, tracker).Add(hash); + } + + private static void UpdateTrackerStateForRemoval(MainData torrentList, string hash, string? previousTracker) + { + if (string.IsNullOrEmpty(previousTracker)) + { + torrentList.TrackersState[FilterHelper.TRACKER_TRACKERLESS].Remove(hash); + return; + } + + if (torrentList.TrackersState.TryGetValue(previousTracker, out var trackerSet)) + { + trackerSet.Remove(hash); + } + } + + private static IEnumerable EnumerateCategoryKeys(string category, bool useSubcategories) + { + if (string.IsNullOrEmpty(category)) + { + yield break; + } + + yield return category; + + if (!useSubcategories) + { + yield break; + } + + var current = category; + while (true) + { + var separatorIndex = current.LastIndexOf('/'); + if (separatorIndex < 0) + { + yield break; + } + + current = current[..separatorIndex]; + yield return current; + } + } + + private static HashSet GetOrCreateTagSet(MainData torrentList, string tag) + { + if (!torrentList.TagState.TryGetValue(tag, out var set)) + { + set = new HashSet(StringComparer.Ordinal); + torrentList.TagState[tag] = set; + } + + return set; + } + + private static HashSet GetOrCreateCategorySet(MainData torrentList, string category) + { + if (!torrentList.CategoriesState.TryGetValue(category, out var set)) + { + set = new HashSet(StringComparer.Ordinal); + torrentList.CategoriesState[category] = set; + } + + return set; + } + + private static HashSet GetOrCreateTrackerSet(MainData torrentList, string tracker) + { + if (!torrentList.TrackersState.TryGetValue(tracker, out var set)) + { + set = new HashSet(StringComparer.Ordinal); + torrentList.TrackersState[tracker] = set; + } + + return set; + } + + private static bool UpdateCategory(Category existingCategory, QBitTorrentClient.Models.Category category) + { + if (category.SavePath is not null && existingCategory.SavePath != category.SavePath) + { + existingCategory.SavePath = category.SavePath; + return true; + } + + return false; + } + + private readonly struct TorrentUpdateResult + { + public TorrentUpdateResult(bool dataChanged, bool filterChanged) + { + DataChanged = dataChanged; + FilterChanged = filterChanged; + } + + public bool DataChanged { get; } + + public bool FilterChanged { get; } + } + + private static TorrentUpdateResult UpdateTorrent(Torrent existingTorrent, QBitTorrentClient.Models.Torrent torrent) + { + var dataChanged = false; + var filterChanged = false; + + if (torrent.AddedOn.HasValue && existingTorrent.AddedOn != torrent.AddedOn.Value) + { + existingTorrent.AddedOn = torrent.AddedOn.Value; + dataChanged = true; + } + + if (torrent.AmountLeft.HasValue && existingTorrent.AmountLeft != torrent.AmountLeft.Value) + { + existingTorrent.AmountLeft = torrent.AmountLeft.Value; + dataChanged = true; + } + + if (torrent.AutomaticTorrentManagement.HasValue && existingTorrent.AutomaticTorrentManagement != torrent.AutomaticTorrentManagement.Value) + { + existingTorrent.AutomaticTorrentManagement = torrent.AutomaticTorrentManagement.Value; + dataChanged = true; + } + + if (torrent.Availability.HasValue && existingTorrent.Availability != torrent.Availability.Value) + { + existingTorrent.Availability = torrent.Availability.Value; + dataChanged = true; + } + + if (torrent.Category is not null && existingTorrent.Category != torrent.Category) + { + existingTorrent.Category = torrent.Category; + dataChanged = true; + filterChanged = true; + } + + if (torrent.Completed.HasValue && existingTorrent.Completed != torrent.Completed.Value) + { + existingTorrent.Completed = torrent.Completed.Value; + dataChanged = true; + } + + if (torrent.CompletionOn.HasValue && existingTorrent.CompletionOn != torrent.CompletionOn.Value) + { + existingTorrent.CompletionOn = torrent.CompletionOn.Value; + dataChanged = true; + } + + if (torrent.ContentPath is not null && existingTorrent.ContentPath != torrent.ContentPath) + { + existingTorrent.ContentPath = torrent.ContentPath; + dataChanged = true; + } + + if (torrent.Downloaded.HasValue && existingTorrent.Downloaded != torrent.Downloaded.Value) + { + existingTorrent.Downloaded = torrent.Downloaded.Value; + dataChanged = true; + } + + if (torrent.DownloadedSession.HasValue && existingTorrent.DownloadedSession != torrent.DownloadedSession.Value) + { + existingTorrent.DownloadedSession = torrent.DownloadedSession.Value; + dataChanged = true; + } + + if (torrent.DownloadLimit.HasValue && existingTorrent.DownloadLimit != torrent.DownloadLimit.Value) + { + existingTorrent.DownloadLimit = torrent.DownloadLimit.Value; + dataChanged = true; + } + + if (torrent.DownloadSpeed.HasValue && existingTorrent.DownloadSpeed != torrent.DownloadSpeed.Value) + { + existingTorrent.DownloadSpeed = torrent.DownloadSpeed.Value; + dataChanged = true; + } + + if (torrent.EstimatedTimeOfArrival.HasValue && existingTorrent.EstimatedTimeOfArrival != torrent.EstimatedTimeOfArrival.Value) + { + existingTorrent.EstimatedTimeOfArrival = torrent.EstimatedTimeOfArrival.Value; + dataChanged = true; + } + + if (torrent.FirstLastPiecePriority.HasValue && existingTorrent.FirstLastPiecePriority != torrent.FirstLastPiecePriority.Value) + { + existingTorrent.FirstLastPiecePriority = torrent.FirstLastPiecePriority.Value; + dataChanged = true; + } + + if (torrent.ForceStart.HasValue && existingTorrent.ForceStart != torrent.ForceStart.Value) + { + existingTorrent.ForceStart = torrent.ForceStart.Value; + dataChanged = true; + } + + if (torrent.InfoHashV1 is not null && existingTorrent.InfoHashV1 != torrent.InfoHashV1) + { + existingTorrent.InfoHashV1 = torrent.InfoHashV1; + dataChanged = true; + } + + if (torrent.InfoHashV2 is not null && existingTorrent.InfoHashV2 != torrent.InfoHashV2) + { + existingTorrent.InfoHashV2 = torrent.InfoHashV2; + dataChanged = true; + } + + if (torrent.LastActivity.HasValue && existingTorrent.LastActivity != torrent.LastActivity.Value) + { + existingTorrent.LastActivity = torrent.LastActivity.Value; + dataChanged = true; + } + + if (torrent.MagnetUri is not null && existingTorrent.MagnetUri != torrent.MagnetUri) + { + existingTorrent.MagnetUri = torrent.MagnetUri; + dataChanged = true; + } + + if (torrent.MaxRatio.HasValue && existingTorrent.MaxRatio != torrent.MaxRatio.Value) + { + existingTorrent.MaxRatio = torrent.MaxRatio.Value; + dataChanged = true; + } + + if (torrent.MaxSeedingTime.HasValue && existingTorrent.MaxSeedingTime != torrent.MaxSeedingTime.Value) + { + existingTorrent.MaxSeedingTime = torrent.MaxSeedingTime.Value; + dataChanged = true; + } + + if (torrent.Name is not null && existingTorrent.Name != torrent.Name) + { + existingTorrent.Name = torrent.Name; + dataChanged = true; + filterChanged = true; + } + + if (torrent.NumberComplete.HasValue && existingTorrent.NumberComplete != torrent.NumberComplete.Value) + { + existingTorrent.NumberComplete = torrent.NumberComplete.Value; + dataChanged = true; + } + + if (torrent.NumberIncomplete.HasValue && existingTorrent.NumberIncomplete != torrent.NumberIncomplete.Value) + { + existingTorrent.NumberIncomplete = torrent.NumberIncomplete.Value; + dataChanged = true; + } + + if (torrent.NumberLeeches.HasValue && existingTorrent.NumberLeeches != torrent.NumberLeeches.Value) + { + existingTorrent.NumberLeeches = torrent.NumberLeeches.Value; + dataChanged = true; + } + + if (torrent.NumberSeeds.HasValue && existingTorrent.NumberSeeds != torrent.NumberSeeds.Value) + { + existingTorrent.NumberSeeds = torrent.NumberSeeds.Value; + dataChanged = true; + } + + if (torrent.Priority.HasValue && existingTorrent.Priority != torrent.Priority.Value) + { + existingTorrent.Priority = torrent.Priority.Value; + dataChanged = true; + } + + if (torrent.Progress.HasValue && existingTorrent.Progress != torrent.Progress.Value) + { + existingTorrent.Progress = torrent.Progress.Value; + dataChanged = true; + } + + if (torrent.Ratio.HasValue && existingTorrent.Ratio != torrent.Ratio.Value) + { + existingTorrent.Ratio = torrent.Ratio.Value; + dataChanged = true; + } + + if (torrent.RatioLimit.HasValue && existingTorrent.RatioLimit != torrent.RatioLimit.Value) + { + existingTorrent.RatioLimit = torrent.RatioLimit.Value; + dataChanged = true; + } + + if (torrent.SavePath is not null && existingTorrent.SavePath != torrent.SavePath) + { + existingTorrent.SavePath = torrent.SavePath; + dataChanged = true; + } + + if (torrent.SeedingTime.HasValue && existingTorrent.SeedingTime != torrent.SeedingTime.Value) + { + existingTorrent.SeedingTime = torrent.SeedingTime.Value; + dataChanged = true; + } + + if (torrent.SeedingTimeLimit.HasValue && existingTorrent.SeedingTimeLimit != torrent.SeedingTimeLimit.Value) + { + existingTorrent.SeedingTimeLimit = torrent.SeedingTimeLimit.Value; + dataChanged = true; + } + + if (torrent.SeenComplete.HasValue && existingTorrent.SeenComplete != torrent.SeenComplete.Value) + { + existingTorrent.SeenComplete = torrent.SeenComplete.Value; + dataChanged = true; + } + + if (torrent.SequentialDownload.HasValue && existingTorrent.SequentialDownload != torrent.SequentialDownload.Value) + { + existingTorrent.SequentialDownload = torrent.SequentialDownload.Value; + dataChanged = true; + } + + if (torrent.Size.HasValue && existingTorrent.Size != torrent.Size.Value) + { + existingTorrent.Size = torrent.Size.Value; + dataChanged = true; + } + + if (torrent.State is not null && existingTorrent.State != torrent.State) + { + existingTorrent.State = torrent.State; + dataChanged = true; + filterChanged = true; + } + + if (torrent.SuperSeeding.HasValue && existingTorrent.SuperSeeding != torrent.SuperSeeding.Value) + { + existingTorrent.SuperSeeding = torrent.SuperSeeding.Value; + dataChanged = true; + } + if (torrent.Tags is not null) { - existingTorrent.Tags.Clear(); - existingTorrent.Tags.AddRange(torrent.Tags); + var normalizedTags = torrent.Tags.Select(NormalizeTag) + .Where(static tag => !string.IsNullOrEmpty(tag)) + .ToList(); + + if (!existingTorrent.Tags.SequenceEqual(normalizedTags)) + { + existingTorrent.Tags.Clear(); + existingTorrent.Tags.AddRange(normalizedTags); + dataChanged = true; + filterChanged = true; + } } - existingTorrent.TimeActive = torrent.TimeActive ?? existingTorrent.TimeActive; - existingTorrent.TotalSize = torrent.TotalSize ?? existingTorrent.TotalSize; - existingTorrent.Tracker = torrent.Tracker ?? existingTorrent.Tracker; - existingTorrent.UploadLimit = torrent.UploadLimit ?? existingTorrent.UploadLimit; - existingTorrent.Uploaded = torrent.Uploaded ?? existingTorrent.Uploaded; - existingTorrent.UploadedSession = torrent.UploadedSession ?? existingTorrent.UploadedSession; - existingTorrent.UploadSpeed = torrent.UploadSpeed ?? existingTorrent.UploadSpeed; - existingTorrent.Reannounce = torrent.Reannounce ?? existingTorrent.Reannounce; - existingTorrent.InactiveSeedingTimeLimit = torrent.InactiveSeedingTimeLimit ?? existingTorrent.InactiveSeedingTimeLimit; - existingTorrent.MaxInactiveSeedingTime = torrent.MaxInactiveSeedingTime ?? existingTorrent.MaxInactiveSeedingTime; + + if (torrent.TimeActive.HasValue && existingTorrent.TimeActive != torrent.TimeActive.Value) + { + existingTorrent.TimeActive = torrent.TimeActive.Value; + dataChanged = true; + } + + if (torrent.TotalSize.HasValue && existingTorrent.TotalSize != torrent.TotalSize.Value) + { + existingTorrent.TotalSize = torrent.TotalSize.Value; + dataChanged = true; + } + + if (torrent.Tracker is not null && existingTorrent.Tracker != torrent.Tracker) + { + existingTorrent.Tracker = torrent.Tracker; + dataChanged = true; + filterChanged = true; + } + + if (torrent.UploadLimit.HasValue && existingTorrent.UploadLimit != torrent.UploadLimit.Value) + { + existingTorrent.UploadLimit = torrent.UploadLimit.Value; + dataChanged = true; + } + + if (torrent.Uploaded.HasValue && existingTorrent.Uploaded != torrent.Uploaded.Value) + { + existingTorrent.Uploaded = torrent.Uploaded.Value; + dataChanged = true; + } + + if (torrent.UploadedSession.HasValue && existingTorrent.UploadedSession != torrent.UploadedSession.Value) + { + existingTorrent.UploadedSession = torrent.UploadedSession.Value; + dataChanged = true; + } + + var previousUploadSpeed = existingTorrent.UploadSpeed; + if (torrent.UploadSpeed.HasValue && previousUploadSpeed != torrent.UploadSpeed.Value) + { + existingTorrent.UploadSpeed = torrent.UploadSpeed.Value; + dataChanged = true; + if ((previousUploadSpeed > 0) != (torrent.UploadSpeed.Value > 0)) + { + filterChanged = true; + } + } + + if (torrent.Reannounce.HasValue && existingTorrent.Reannounce != torrent.Reannounce.Value) + { + existingTorrent.Reannounce = torrent.Reannounce.Value; + dataChanged = true; + } + + if (torrent.InactiveSeedingTimeLimit.HasValue && existingTorrent.InactiveSeedingTimeLimit != torrent.InactiveSeedingTimeLimit.Value) + { + existingTorrent.InactiveSeedingTimeLimit = torrent.InactiveSeedingTimeLimit.Value; + dataChanged = true; + } + + if (torrent.MaxInactiveSeedingTime.HasValue && existingTorrent.MaxInactiveSeedingTime != torrent.MaxInactiveSeedingTime.Value) + { + existingTorrent.MaxInactiveSeedingTime = torrent.MaxInactiveSeedingTime.Value; + dataChanged = true; + } + + return new TorrentUpdateResult(dataChanged, filterChanged); } public Dictionary CreateContentsList(IReadOnlyList files) { - var contents = new Dictionary(); + return BuildContentsTree(files); + } + + private static Dictionary BuildContentsTree(IReadOnlyList files) + { + var result = new Dictionary(); if (files.Count == 0) { - return contents; + return result; } var folderIndex = files.Min(f => f.Index) - 1; + var nodes = new Dictionary(files.Count * 2); + var root = new ContentTreeNode(null, null); foreach (var file in files) { - if (!file.Name.Contains(Extensions.DirectorySeparator)) + var parent = root; + string? parentPath = parent.Item?.Name; + + var segments = file.Name.Split(Extensions.DirectorySeparator); + var directoriesLength = segments.Length - 1; + + for (var i = 0; i < directoriesLength; i++) { - contents.Add(file.Name, new ContentItem(file.Name, file.Name, file.Index, (Priority)(int)file.Priority, file.Progress, file.Size, file.Availability)); - } - else - { - var nameAndPath = file.Name.Split(Extensions.DirectorySeparator); - var paths = nameAndPath[..^1]; - for (var i = 0; i < paths.Length; i++) + var folderName = segments[i]; + if (folderName == ".unwanted") { - var directoryName = paths[i]; - var directoryPath = string.Join(Extensions.DirectorySeparator, paths[0..(i + 1)]); - if (!contents.ContainsKey(directoryPath)) - { - contents.Add(directoryPath, new ContentItem(directoryPath, directoryName, folderIndex--, Priority.Normal, 0, 0, 0, true, i)); - } + continue; } - var displayName = nameAndPath[^1]; + var folderPath = string.IsNullOrEmpty(parentPath) + ? folderName + : string.Concat(parentPath, Extensions.DirectorySeparator, folderName); - contents.Add(file.Name, new ContentItem(file.Name, displayName, file.Index, (Priority)(int)file.Priority, file.Progress, file.Size, file.Availability, false, paths.Length)); + if (!nodes.TryGetValue(folderPath, out var folderNode)) + { + var level = (parent.Item?.Level ?? -1) + 1; + var folderItem = new ContentItem(folderPath, folderName, folderIndex--, Priority.Normal, 0, 0, 0, true, level); + folderNode = new ContentTreeNode(folderItem, parent); + nodes[folderPath] = folderNode; + parent.Children[folderPath] = folderNode; + } + + parent = folderNode; + parentPath = parent.Item!.Name; } + + var displayName = segments[^1]; + var fileLevel = (parent.Item?.Level ?? -1) + 1; + var fileItem = new ContentItem(file.Name, displayName, file.Index, (Priority)(int)file.Priority, file.Progress, file.Size, file.Availability, false, fileLevel); + var fileNode = new ContentTreeNode(fileItem, parent); + nodes[file.Name] = fileNode; + parent.Children[fileItem.Name] = fileNode; } - var directories = contents.Where(c => c.Value.IsFolder).OrderByDescending(c => c.Value.Level); + var folders = nodes.Values + .Where(n => n.Item is not null && n.Item.IsFolder) + .OrderByDescending(n => n.Item!.Level) + .ToList(); - foreach (var directory in directories) + foreach (var folder in folders) { - var key = directory.Key; - var level = directory.Value.Level; - var filesContents = contents.Where(c => c.Value.Name.StartsWith(key + Extensions.DirectorySeparator) && !c.Value.IsFolder).ToList(); - var directoriesContents = contents.Where(c => c.Value.Name.StartsWith(key + Extensions.DirectorySeparator) && c.Value.IsFolder && c.Value.Level == level + 1).ToList(); - var allContents = filesContents.Concat(directoriesContents); - var priorities = allContents.Select(d => d.Value.Priority).Distinct(); - var downloadingContents = allContents.Where(c => c.Value.Priority != Priority.DoNotDownload && !c.Value.IsFolder).ToList(); - - long size = 0; - float availability = 0; - long downloaded = 0; - float progress = 0; - if (downloadingContents.Count != 0) + var folderItem = folder.Item!; + if (folder.Children.Count == 0) { - size = downloadingContents.Sum(c => c.Value.Size); - availability = downloadingContents.Average(c => c.Value.Availability); - downloaded = downloadingContents.Sum(c => c.Value.Downloaded); - progress = (float)downloaded / size; + folderItem.Size = 0; + folderItem.Progress = 0; + folderItem.Availability = 0; + folderItem.Priority = Priority.Normal; + continue; } - if (!contents.TryGetValue(key, out var dir)) + long sizeSum = 0; + double progressSum = 0; + double availabilitySum = 0; + var firstChild = true; + var aggregatedPriority = Priority.Normal; + + foreach (var child in folder.Children.Values) + { + var childItem = child.Item!; + sizeSum += childItem.Size; + + if (firstChild) + { + aggregatedPriority = childItem.Priority; + firstChild = false; + } + else if (aggregatedPriority != childItem.Priority) + { + aggregatedPriority = Priority.Mixed; + } + + if (childItem.Priority != Priority.DoNotDownload) + { + progressSum += childItem.Progress * childItem.Size; + availabilitySum += childItem.Availability * childItem.Size; + } + } + + folderItem.Size = sizeSum; + folderItem.Progress = sizeSum > 0 ? (float)(progressSum / sizeSum) : 0; + folderItem.Availability = sizeSum > 0 ? (float)(availabilitySum / sizeSum) : 0; + folderItem.Priority = firstChild ? Priority.Normal : aggregatedPriority; + } + + foreach (var node in nodes.Values) + { + if (node.Item is null) { continue; } - dir.Availability = availability; - dir.Size = size; - dir.Progress = progress; - if (priorities.Count() == 1) + + result[node.Item.Name] = node.Item; + } + + return result; + } + + private static bool UpdateContentItem(ContentItem destination, ContentItem source) + { + const float floatTolerance = 0.0001f; + var changed = false; + + if (destination.Priority != source.Priority) + { + destination.Priority = source.Priority; + changed = true; + } + + if (System.Math.Abs(destination.Progress - source.Progress) > floatTolerance) + { + destination.Progress = source.Progress; + changed = true; + } + + if (destination.Size != source.Size) + { + destination.Size = source.Size; + changed = true; + } + + if (System.Math.Abs(destination.Availability - source.Availability) > floatTolerance) + { + destination.Availability = source.Availability; + changed = true; + } + + return changed; + } + + private struct DirectoryAccumulator + { + public long TotalSize { get; private set; } + + private long _activeSize; + private double _progressSum; + private double _availabilitySum; + private Priority? _priority; + private bool _mixedPriority; + + public void Add(Priority priority, float progress, long size, float availability) + { + TotalSize += size; + + if (priority != Priority.DoNotDownload) { - dir.Priority = priorities.First(); + _activeSize += size; + _progressSum += progress * size; + _availabilitySum += availability * size; } - else + + if (!_priority.HasValue) { - dir.Priority = Priority.Mixed; + _priority = priority; + } + else if (_priority.Value != priority) + { + _mixedPriority = true; } } - return contents; + public Priority ResolvePriority() + { + if (_mixedPriority) + { + return Priority.Mixed; + } + + return _priority ?? Priority.Normal; + } + + public float ResolveProgress() + { + if (_activeSize == 0 || TotalSize == 0) + { + return 0f; + } + + var value = _progressSum / _activeSize; + if (value < 0) + { + return 0f; + } + + if (value > 1) + { + return 1f; + } + + return (float)value; + } + + public float ResolveAvailability() + { + if (_activeSize == 0 || TotalSize == 0) + { + return 0f; + } + + return (float)(_availabilitySum / _activeSize); + } + } + + private sealed class ContentTreeNode + { + public ContentTreeNode(ContentItem? item, ContentTreeNode? parent) + { + Item = item; + Parent = parent; + Children = new Dictionary(); + } + + public ContentItem? Item { get; } + + public ContentTreeNode? Parent { get; } + + public Dictionary Children { get; } } public QBitTorrentClient.Models.UpdatePreferences MergePreferences(QBitTorrentClient.Models.UpdatePreferences? original, QBitTorrentClient.Models.UpdatePreferences changed) @@ -1122,24 +1985,120 @@ namespace Lantean.QBTMud.Services return original; } - public void MergeContentsList(IReadOnlyList files, Dictionary contents) + public bool MergeContentsList(IReadOnlyList files, Dictionary contents) { - var contentsList = CreateContentsList(files); - - foreach (var (key, value) in contentsList) + if (files.Count == 0) { - if (contents.TryGetValue(key, out var content)) + if (contents.Count == 0) { - content.Availability = value.Availability; - content.Priority = value.Priority; - content.Progress = value.Progress; - content.Size = value.Size; + return false; + } + + contents.Clear(); + return true; + } + + var hasChanges = false; + var seenPaths = new HashSet(files.Count * 2); + var directoryAccumulators = new Dictionary(); + + var minExistingIndex = contents.Count == 0 + ? int.MaxValue + : contents.Values.Min(c => c.Index); + var minFileIndex = files.Min(f => f.Index); + var nextFolderIndex = System.Math.Min(minExistingIndex, minFileIndex) - 1; + + foreach (var file in files) + { + var priority = (Priority)(int)file.Priority; + var pathSegments = file.Name.Split(Extensions.DirectorySeparator); + var level = pathSegments.Length - 1; + var displayName = pathSegments[^1]; + var filePath = file.Name; + seenPaths.Add(filePath); + + if (contents.TryGetValue(filePath, out var existingFile)) + { + var updatedFile = new ContentItem(filePath, displayName, file.Index, priority, file.Progress, file.Size, file.Availability, false, level); + if (UpdateContentItem(existingFile, updatedFile)) + { + hasChanges = true; + } } else { - contents[key] = value; + var newFile = new ContentItem(filePath, displayName, file.Index, priority, file.Progress, file.Size, file.Availability, false, level); + contents[filePath] = newFile; + hasChanges = true; + } + + string directoryPath = string.Empty; + for (var i = 0; i < level; i++) + { + var segment = pathSegments[i]; + if (segment == ".unwanted") + { + continue; + } + + directoryPath = string.IsNullOrEmpty(directoryPath) + ? segment + : string.Concat(directoryPath, Extensions.DirectorySeparator, segment); + + seenPaths.Add(directoryPath); + + if (!contents.TryGetValue(directoryPath, out var directoryItem)) + { + var newDirectory = new ContentItem(directoryPath, segment, nextFolderIndex--, Priority.Normal, 0, 0, 0, true, i); + contents[directoryPath] = newDirectory; + hasChanges = true; + } + + if (!directoryAccumulators.TryGetValue(directoryPath, out var accumulator)) + { + accumulator = new DirectoryAccumulator(); + } + + accumulator.Add(priority, file.Progress, file.Size, file.Availability); + directoryAccumulators[directoryPath] = accumulator; } } + + var keysToRemove = contents.Keys.Where(key => !seenPaths.Contains(key)).ToList(); + if (keysToRemove.Count != 0) + { + hasChanges = true; + foreach (var key in keysToRemove) + { + contents.Remove(key); + } + } + + foreach (var (directoryPath, accumulator) in directoryAccumulators) + { + if (!contents.TryGetValue(directoryPath, out var directoryItem)) + { + continue; + } + + var updatedDirectory = new ContentItem( + directoryPath, + directoryItem.DisplayName, + directoryItem.Index, + accumulator.ResolvePriority(), + accumulator.ResolveProgress(), + accumulator.TotalSize, + accumulator.ResolveAvailability(), + true, + directoryItem.Level); + + if (UpdateContentItem(directoryItem, updatedDirectory)) + { + hasChanges = true; + } + } + + return hasChanges; } public RssList CreateRssList(IReadOnlyDictionary rssItems) diff --git a/Lantean.QBTMud/Services/IDataManager.cs b/Lantean.QBTMud/Services/IDataManager.cs index 0463ec6..1267f32 100644 --- a/Lantean.QBTMud/Services/IDataManager.cs +++ b/Lantean.QBTMud/Services/IDataManager.cs @@ -8,7 +8,7 @@ namespace Lantean.QBTMud.Services Torrent CreateTorrent(string hash, QBitTorrentClient.Models.Torrent torrent); - void MergeMainData(QBitTorrentClient.Models.MainData mainData, MainData torrentList); + bool MergeMainData(QBitTorrentClient.Models.MainData mainData, MainData torrentList, out bool filterChanged); PeerList CreatePeerList(QBitTorrentClient.Models.TorrentPeers torrentPeers); @@ -16,7 +16,7 @@ namespace Lantean.QBTMud.Services Dictionary CreateContentsList(IReadOnlyList files); - void MergeContentsList(IReadOnlyList files, Dictionary contents); + bool MergeContentsList(IReadOnlyList files, Dictionary contents); QBitTorrentClient.Models.UpdatePreferences MergePreferences(QBitTorrentClient.Models.UpdatePreferences? original, QBitTorrentClient.Models.UpdatePreferences changed); diff --git a/Lantean.QBTMud/wwwroot/css/app.css b/Lantean.QBTMud/wwwroot/css/app.css index c4ec6e8..89b7c01 100644 --- a/Lantean.QBTMud/wwwroot/css/app.css +++ b/Lantean.QBTMud/wwwroot/css/app.css @@ -65,15 +65,11 @@ code { } .mud-appbar.mud-appbar-fixed-bottom { - height: 35px; -} - -.mud-main-content { - padding-bottom: 35px; + height: calc(var(--app-status-bar-height) + env(safe-area-inset-bottom, 0px)); } .mud-drawer-fixed.mud-drawer-mini.mud-drawer-clipped-always, .mud-drawer-fixed.mud-drawer-persistent:not(.mud-drawer-clipped-never), .mud-drawer-fixed.mud-drawer-responsive.mud-drawer-clipped-always, .mud-drawer-fixed.mud-drawer-temporary.mud-drawer-clipped-always { - height: calc(100% - var(--mud-appbar-height) - 35px); + height: calc(100% - var(--mud-appbar-height) - (var(--app-status-bar-height) + env(safe-area-inset-bottom, 0px))); } .w-100 { @@ -154,25 +150,91 @@ code { margin-right: 5px; } -.torrent-list .mud-table-container { - height: calc(100vh - 160px); +/*. Layout helpers */ +.content-panel { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; } -.file-list .mud-table-container { - height: calc(100vh - 245px); +.content-panel__toolbar { + flex: 0 0 auto; } -.details-list .mud-table-container { - height: calc(100vh - 200px); +.content-panel__toolbar--scroll { + overflow-x: auto; + white-space: nowrap; } -.details-tab-contents { - height: calc(100vh - 200px); +.content-panel__body { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.content-panel__container { + flex: 1 1 auto; + display: flex; + flex-direction: column; + min-height: 0; +} + +.content-panel__table { + flex: 1 1 auto; + display: flex; + flex-direction: column; + min-height: 0; +} + +.content-panel__table .mud-table-container { + flex: 1 1 auto; + height: 100%; +} + +.content-panel__body > .mud-tabs { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + padding-top: 0; + margin-top: 0; +} + + .content-panel__body > .mud-tabs .mud-tabs-tabbar { + margin-bottom: 0; + } + + .content-panel__body > .mud-tabs .mud-tabs-panels { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + padding-top: 0; + margin-top: -1px; + border-top: none; + } + +.content-panel__body .mud-tabs .mud-tabs-panels .mud-tab-panel { overflow: auto; } +.torrent-list .mud-table-container, +.file-list .mud-table-container, +.details-list .mud-table-container, .search-list .mud-table-container { - height: calc(100vh - 260px); + height: 100%; +} + +.details-tab-contents, +.options-tab-contents, +.rss-contents { + flex: 1 1 auto; + min-height: 0; + overflow: auto; } tr.log-normal td { @@ -220,10 +282,6 @@ td .folder-button { padding: 6px 16px 6px 16px !important; } -.rss-contents { - height: calc(100vh - 149px); -} - @keyframes spin { 0% { transform: rotate(0deg); @@ -255,4 +313,117 @@ td .folder-button { .mud-popover .mud-divider:last-child { display: none; -} \ No newline at end of file +} +:root { + --app-viewport-height: 100vh; + --app-status-bar-height: 35px; +} + +@supports (height: 100svh) { + :root { + --app-viewport-height: 100svh; + } +} + +@supports ((height: 100dvh) and (not (height: 100svh))) { + :root { + --app-viewport-height: 100dvh; + } +} + +html, +body { + height: var(--app-viewport-height); + min-height: var(--app-viewport-height); +} + +body { + margin: 0; + overflow: hidden; + overscroll-behavior: none; +} + +#app, +.mud-layout { + height: 100%; + min-height: 100%; +} + +.app-shell { + display: flex; + flex-direction: column; + height: var(--app-viewport-height); + min-height: var(--app-viewport-height); + overflow: hidden; +} + +.app-shell__body { + display: flex; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.app-shell__sidebar { + flex: 0 0 auto; +} + +.app-shell__main { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + padding: var(--mud-appbar-height) 0 calc(var(--app-status-bar-height) + env(safe-area-inset-bottom, 0px)); + box-sizing: border-box; +} + +.app-shell__status-bar.mud-appbar { + flex: 0 0 calc(var(--app-status-bar-height) + env(safe-area-inset-bottom, 0px)); + height: calc(var(--app-status-bar-height) + env(safe-area-inset-bottom, 0px)); + width: 100%; + background-color: var(--mud-palette-dark-lighten); + align-items: center; + justify-content: flex-start; + box-sizing: border-box; +} + +.app-shell__status-bar .mud-toolbar { + width: 100%; + height: 100%; + padding-bottom: env(safe-area-inset-bottom, 0px); + background-color: inherit; + box-sizing: border-box; +} + +@supports (-webkit-touch-callout: none) { + :root { + --app-viewport-height: -webkit-fill-available; + } + + html, + body { + height: -webkit-fill-available; + min-height: -webkit-fill-available; + } + + .app-shell { + height: -webkit-fill-available; + min-height: -webkit-fill-available; + } +} + + +/* Tab bar gap fix */ +.content-panel__body > .mud-tabs .mud-tabs-tabbar { + margin-bottom: 0; + padding-bottom: 0; + border-bottom-width: 0; +} + +.content-panel__body > .mud-tabs .mud-tabs-tabbar .mud-tabs-wrapper { + margin-bottom: -1px; +} +.content-panel__body > .mud-tabs .mud-tabs-tabbar .mud-tabs-slider { + bottom: 0; +} diff --git a/Lantean.QBTMud/wwwroot/index.html b/Lantean.QBTMud/wwwroot/index.html index 148a5a1..c984f42 100644 --- a/Lantean.QBTMud/wwwroot/index.html +++ b/Lantean.QBTMud/wwwroot/index.html @@ -37,4 +37,4 @@ - + \ No newline at end of file diff --git a/Lantean.QBTMud/wwwroot/js/longpress.js b/Lantean.QBTMud/wwwroot/js/longpress.js index b30e59e..92fca10 100644 --- a/Lantean.QBTMud/wwwroot/js/longpress.js +++ b/Lantean.QBTMud/wwwroot/js/longpress.js @@ -5,4 +5,4 @@ // * @author John Doherty // * @license MIT // */ -!function (e, t) { "use strict"; var n = null, a = "PointerEvent" in e || e.navigator && "msPointerEnabled" in e.navigator, i = "ontouchstart" in e || navigator.MaxTouchPoints > 0 || navigator.msMaxTouchPoints > 0, o = a ? "pointerdown" : i ? "touchstart" : "mousedown", r = a ? "pointerup" : i ? "touchend" : "mouseup", m = a ? "pointermove" : i ? "touchmove" : "mousemove", u = a ? "pointerleave" : i ? "touchleave" : "mouseleave", s = 0, c = 0, l = 10, v = 10; function f(e) { p(), e = function (e) { if (void 0 !== e.changedTouches) return e.changedTouches[0]; return e }(e), this.dispatchEvent(new CustomEvent("longpress", { bubbles: !0, cancelable: !0, detail: { clientX: e.clientX, clientY: e.clientY, offsetX: e.offsetX, offsetY: e.offsetY, pageX: e.pageX, pageY: e.pageY }, clientX: e.clientX, clientY: e.clientY, offsetX: e.offsetX, offsetY: e.offsetY, pageX: e.pageX, pageY: e.pageY, screenX: e.screenX, screenY: e.screenY })) || t.addEventListener("click", function e(n) { t.removeEventListener("click", e, !0), function (e) { e.stopImmediatePropagation(), e.preventDefault(), e.stopPropagation() }(n) }, !0) } function d(a) { p(a); var i = a.target, o = parseInt(function (e, n, a) { for (; e && e !== t.documentElement;) { var i = e.getAttribute(n); if (i) return i; e = e.parentNode } return a }(i, "data-long-press-delay", "400"), 10); n = function (t, n) { if (!(e.requestAnimationFrame || e.webkitRequestAnimationFrame || e.mozRequestAnimationFrame && e.mozCancelRequestAnimationFrame || e.oRequestAnimationFrame || e.msRequestAnimationFrame)) return e.setTimeout(t, n); var a = (new Date).getTime(), i = {}, o = function () { (new Date).getTime() - a >= n ? t.call() : i.value = requestAnimFrame(o) }; return i.value = requestAnimFrame(o), i }(f.bind(i, a), o) } function p(t) { var a; (a = n) && (e.cancelAnimationFrame ? e.cancelAnimationFrame(a.value) : e.webkitCancelAnimationFrame ? e.webkitCancelAnimationFrame(a.value) : e.webkitCancelRequestAnimationFrame ? e.webkitCancelRequestAnimationFrame(a.value) : e.mozCancelRequestAnimationFrame ? e.mozCancelRequestAnimationFrame(a.value) : e.oCancelRequestAnimationFrame ? e.oCancelRequestAnimationFrame(a.value) : e.msCancelRequestAnimationFrame ? e.msCancelRequestAnimationFrame(a.value) : clearTimeout(a)), n = null } "function" != typeof e.CustomEvent && (e.CustomEvent = function (e, n) { n = n || { bubbles: !1, cancelable: !1, detail: void 0 }; var a = t.createEvent("CustomEvent"); return a.initCustomEvent(e, n.bubbles, n.cancelable, n.detail), a }, e.CustomEvent.prototype = e.Event.prototype), e.requestAnimFrame = e.requestAnimationFrame || e.webkitRequestAnimationFrame || e.mozRequestAnimationFrame || e.oRequestAnimationFrame || e.msRequestAnimationFrame || function (t) { e.setTimeout(t, 1e3 / 60) }, t.addEventListener(r, p, !0), t.addEventListener(u, p, !0), t.addEventListener(m, function (e) { var t = Math.abs(s - e.clientX), n = Math.abs(c - e.clientY); (t >= l || n >= v) && p() }, !0), t.addEventListener("wheel", p, !0), t.addEventListener("scroll", p, !0), t.addEventListener(o, function (e) { s = e.clientX, c = e.clientY, d(e) }, !0) }(window, document); \ No newline at end of file +!function (e, t) { "use strict"; var n = null, a = "PointerEvent" in e || e.navigator && "msPointerEnabled" in e.navigator, i = "ontouchstart" in e || navigator.MaxTouchPoints > 0 || navigator.msMaxTouchPoints > 0, o = a ? "pointerdown" : i ? "touchstart" : "mousedown", r = a ? "pointerup" : i ? "touchend" : "mouseup", m = a ? "pointermove" : i ? "touchmove" : "mousemove", u = a ? "pointerleave" : i ? "touchleave" : "mouseleave", s = 0, c = 0, l = 10, v = 10; function f(e) { p(), e = function (e) { if (void 0 !== e.changedTouches) return e.changedTouches[0]; return e }(e); var n = new CustomEvent("longpress", { bubbles: !0, cancelable: !0, detail: { clientX: e.clientX, clientY: e.clientY, offsetX: e.offsetX, offsetY: e.offsetY, pageX: e.pageX, pageY: e.pageY }, clientX: e.clientX, clientY: e.clientY, offsetX: e.offsetX, offsetY: e.offsetY, pageX: e.pageX, pageY: e.pageY, screenX: e.screenX, screenY: e.screenY }); n.__longPress = !0, this.dispatchEvent(n) || t.addEventListener("click", function e(n) { t.removeEventListener("click", e, !0), function (e) { e.stopImmediatePropagation(), e.preventDefault(), e.stopPropagation() }(n) }, !0) } function d(a) { p(a); var i = a.target, o = parseInt(function (e, n, a) { for (; e && e !== t.documentElement;) { var i = e.getAttribute(n); if (i) return i; e = e.parentNode } return a }(i, "data-long-press-delay", "400"), 10); n = function (t, n) { if (!(e.requestAnimationFrame || e.webkitRequestAnimationFrame || e.mozRequestAnimationFrame && e.mozCancelRequestAnimationFrame || e.oRequestAnimationFrame || e.msRequestAnimationFrame)) return e.setTimeout(t, n); var a = (new Date).getTime(), i = {}, o = function () { (new Date).getTime() - a >= n ? t.call() : i.value = requestAnimFrame(o) }; return i.value = requestAnimFrame(o), i }(f.bind(i, a), o) } function p(t) { var a; (a = n) && (e.cancelAnimationFrame ? e.cancelAnimationFrame(a.value) : e.webkitCancelAnimationFrame ? e.webkitCancelAnimationFrame(a.value) : e.webkitCancelRequestAnimationFrame ? e.webkitCancelRequestAnimationFrame(a.value) : e.mozCancelRequestAnimationFrame ? e.mozCancelRequestAnimationFrame(a.value) : e.oCancelRequestAnimationFrame ? e.oCancelRequestAnimationFrame(a.value) : e.msCancelRequestAnimationFrame ? e.msCancelRequestAnimationFrame(a.value) : clearTimeout(a)), n = null } "function" != typeof e.CustomEvent && (e.CustomEvent = function (e, n) { n = n || { bubbles: !1, cancelable: !1, detail: void 0 }; var a = t.createEvent("CustomEvent"); return a.initCustomEvent(e, n.bubbles, n.cancelable, n.detail), a }, e.CustomEvent.prototype = e.Event.prototype), e.requestAnimFrame = e.requestAnimationFrame || e.webkitRequestAnimationFrame || e.mozRequestAnimationFrame || e.oRequestAnimationFrame || e.msRequestAnimationFrame || function (t) { e.setTimeout(t, 1e3 / 60) }, t.addEventListener(r, p, !0), t.addEventListener(u, p, !0), t.addEventListener(m, function (e) { var t = Math.abs(s - e.clientX), n = Math.abs(c - e.clientY); (t >= l || n >= v) && p() }, !0), t.addEventListener("wheel", p, !0), t.addEventListener("scroll", p, !0), t.addEventListener(o, function (e) { s = e.clientX, c = e.clientY, d(e) }, !0) }(window, document); \ No newline at end of file diff --git a/Lantean.QBitTorrentClient/Converters/SaveLocationJsonConverter.cs b/Lantean.QBitTorrentClient/Converters/SaveLocationJsonConverter.cs index a49c9e8..3164bad 100644 --- a/Lantean.QBitTorrentClient/Converters/SaveLocationJsonConverter.cs +++ b/Lantean.QBitTorrentClient/Converters/SaveLocationJsonConverter.cs @@ -27,7 +27,7 @@ namespace Lantean.QBitTorrentClient.Converters { writer.WriteNumberValue(0); } - else if (value.IsDefaltFolder) + else if (value.IsDefaultFolder) { writer.WriteNumberValue(1); } diff --git a/Lantean.QBitTorrentClient/Models/AddTorrentParams.cs b/Lantean.QBitTorrentClient/Models/AddTorrentParams.cs index 577d586..ed29a5b 100644 --- a/Lantean.QBitTorrentClient/Models/AddTorrentParams.cs +++ b/Lantean.QBitTorrentClient/Models/AddTorrentParams.cs @@ -24,13 +24,13 @@ public bool? UseDownloadPath { get; set; } public string? Category { get; set; } - + public IEnumerable? Tags { get; set; } public string? RenameTorrent { get; set; } public long? UploadLimit { get; set; } - + public long? DownloadLimit { get; set; } public float? RatioLimit { get; set; } diff --git a/Lantean.QBitTorrentClient/Models/Preferences.cs b/Lantean.QBitTorrentClient/Models/Preferences.cs index 860ff36..555c477 100644 --- a/Lantean.QBitTorrentClient/Models/Preferences.cs +++ b/Lantean.QBitTorrentClient/Models/Preferences.cs @@ -112,7 +112,7 @@ namespace Lantean.QBitTorrentClient.Models int maxConnecPerTorrent, int maxInactiveSeedingTime, bool maxInactiveSeedingTimeEnabled, - int maxRatio, + float maxRatio, int maxRatioAct, bool maxRatioEnabled, int maxSeedingTime, @@ -745,7 +745,7 @@ namespace Lantean.QBitTorrentClient.Models public bool MaxInactiveSeedingTimeEnabled { get; } [JsonPropertyName("max_ratio")] - public int MaxRatio { get; } + public float MaxRatio { get; } [JsonPropertyName("max_ratio_act")] public int MaxRatioAct { get; } diff --git a/Lantean.QBitTorrentClient/Models/SaveLocation.cs b/Lantean.QBitTorrentClient/Models/SaveLocation.cs index 9565e51..ca3da4b 100644 --- a/Lantean.QBitTorrentClient/Models/SaveLocation.cs +++ b/Lantean.QBitTorrentClient/Models/SaveLocation.cs @@ -4,7 +4,7 @@ { public bool IsWatchedFolder { get; set; } - public bool IsDefaltFolder { get; set; } + public bool IsDefaultFolder { get; set; } public string? SavePath { get; set; } @@ -23,7 +23,7 @@ { return new SaveLocation { - IsDefaltFolder = true + IsDefaultFolder = true }; } } @@ -40,7 +40,7 @@ { return new SaveLocation { - IsDefaltFolder = true + IsDefaultFolder = true }; } else @@ -61,7 +61,7 @@ { return 0; } - else if (IsDefaltFolder) + else if (IsDefaultFolder) { return 1; } diff --git a/Lantean.QBitTorrentClient/Models/TorrentProperties.cs b/Lantean.QBitTorrentClient/Models/TorrentProperties.cs index 7453e5b..8a00221 100644 --- a/Lantean.QBitTorrentClient/Models/TorrentProperties.cs +++ b/Lantean.QBitTorrentClient/Models/TorrentProperties.cs @@ -14,7 +14,7 @@ namespace Lantean.QBitTorrentClient.Models long downloadLimit, long downloadSpeed, long downloadSpeedAverage, - int estimatedTimeOfArrival, + long estimatedTimeOfArrival, long lastSeen, int connections, int connectionsLimit, @@ -104,7 +104,7 @@ namespace Lantean.QBitTorrentClient.Models public long DownloadSpeedAverage { get; } [JsonPropertyName("eta")] - public int EstimatedTimeOfArrival { get; } + public long EstimatedTimeOfArrival { get; } [JsonPropertyName("last_seen")] public long LastSeen { get; } diff --git a/Lantean.QBitTorrentClient/Models/UpdatePreferences.cs b/Lantean.QBitTorrentClient/Models/UpdatePreferences.cs index a6ffef0..9423d28 100644 --- a/Lantean.QBitTorrentClient/Models/UpdatePreferences.cs +++ b/Lantean.QBitTorrentClient/Models/UpdatePreferences.cs @@ -323,7 +323,7 @@ namespace Lantean.QBitTorrentClient.Models public bool? MaxInactiveSeedingTimeEnabled { get; set; } [JsonPropertyName("max_ratio")] - public int? MaxRatio { get; set; } + public float? MaxRatio { get; set; } [JsonPropertyName("max_ratio_act")] public int? MaxRatioAct { get; set; } diff --git a/global.json b/global.json new file mode 100644 index 0000000..263c7a1 --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "sdk": { + "version": "9.0.306" + } +} \ No newline at end of file diff --git a/readme.md b/readme.md index ef09366..cbdd967 100644 --- a/readme.md +++ b/readme.md @@ -68,11 +68,13 @@ cd qbtmud dotnet restore ``` -### 3. Build the Application +### 3. Build and Publish the Application ```sh -dotnet build --configuration Release +dotnet publish --configuration Release ``` +This will output the Web UI files to `Lantean.QBTMud\bin\Release\net9.0\publish\wwwroot`. + ### 4. Configure qBittorrent to Use qbtmud Follow the same steps as in the **Installation** section to set qbtmud as your WebUI.