Add new torrent filters

This commit is contained in:
ahjephson
2025-10-27 11:29:41 +00:00
parent 498420bf23
commit 4666cb0b36
11 changed files with 265 additions and 40 deletions

View File

@@ -1,9 +1,9 @@
# Upgrade to qBittorrent WebUI v5 UI Alignment Plan
## Torrent List Filtering
- **Regex toggle & field selector**: Introduce the regex checkbox and the "Filter by" (Name/Save path) select found in v5. Update `FilterState`/`LoggedInLayout` to carry both values, wire them to `TorrentList`s toolbar, and validate invalid patterns gracefully.
- **Filter helper parity**: Rework `FilterHelper.ContainsAllTerms/FilterTerms` to mirror `window.qBittorrent.Misc.containsAllTerms` (evaluate every term, respect `+`/`-` prefixes). Ensure filtering applies to the selected field, not just the torrent name.
- **New status buckets**: Add `Running` and `Moving` to `Status` enum, update `FilterHelper.FilterStatus`, `DisplayHelpers`, and `FiltersNav` so counts/icons match upstream.
## ~~Torrent List Filtering~~
- ~~**Regex toggle & field selector**: Introduce the regex checkbox and the "Filter by" (Name/Save path) select found in v5. Update `FilterState`/`LoggedInLayout` to carry both values, wire them to `TorrentList`s toolbar, and validate invalid patterns gracefully.~~
- ~~**Filter helper parity**: Rework `FilterHelper.ContainsAllTerms/FilterTerms` to mirror `window.qBittorrent.Misc.containsAllTerms` (evaluate every term, respect `+`/`-` prefixes). Ensure filtering applies to the selected field, not just the torrent name.~~
- ~~**New status buckets**: Add `Running` and `Moving` to `Status` enum, update `FilterHelper.FilterStatus`, `DisplayHelpers`, and `FiltersNav` so counts/icons match upstream.~~
## ~~Tracker Filters~~
- ~~**Special buckets**: Extend `FilterHelper`/`DataManager` to create sets for "Announce error", "Error", "Warning", and "Trackerless" in addition to "All". Store the required flags on the UI `Torrent` model (`HasTrackerError`, `HasTrackerWarning`, `HasOtherAnnounceError`, `TrackersCount`, etc.).~~
@@ -11,7 +11,7 @@
## ~~Torrent Data Model & Columns~~
- ~~**Model sync**: Bring `Lantean.QBTMud.Models.Torrent` into parity with v5 (`Popularity`, `DownloadPath`, `RootPath`, `InfoHashV1/2`, `IsPrivate`, share-limit action fields, tracker flags, etc.) and map them in `DataManager.CreateTorrent`.~~
- ~~**Column set alignment**: Match the v5 table defaults—add missing columns (Popularity, Reannounce in, Info hashes, Download path, Private, etc.), fix "Ratio Limit" to display `RatioLimit`, and ensure column ordering/enabled state mirrors `DynamicTable.TorrentsTable`.~~
- ~~**Column set alignment**: Match the v5 table defaults—add missing columns (`Popularity`, `Reannounce` in, `Info` hashes, `Download path`, `Private`, etc.), fix "Ratio Limit" to display `RatioLimit`, and ensure column ordering/enabled state mirrors `DynamicTable.TorrentsTable`.~~
- ~~**Helper updates**: Extend `DisplayHelpers` to format the new fields (popularity, private flag, info hashes, error state icons).~~
## Actions & Dialogs

View File

@@ -22,7 +22,7 @@
<MudIconButton Icon="@Icons.Material.Outlined.FilterList" OnClick="ShowFilterDialog" title="Filter" />
<MudIconButton Icon="@Icons.Material.Outlined.FilterListOff" OnClick="RemoveFilter" title="Remove Filter" />
<MudSpacer />
<MudTextField T="string" Value="SearchText" ValueChanged="SearchTextChanged" Immediate="true" DebounceInterval="500" Placeholder="Filter file list" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0"></MudTextField>
<MudTextField @ref="SearchInput" T="string" Value="SearchText" ValueChanged="SearchTextChanged" Immediate="true" DebounceInterval="500" Placeholder="Filter file list" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0"></MudTextField>
</MudToolBar>
</div>
<div class="content-panel__body">
@@ -93,4 +93,4 @@
};
}
}
}
}

View File

@@ -70,6 +70,8 @@ namespace Lantean.QBTMud.Components
private MudMenu? ContextMenu { get; set; }
private MudTextField<string>? SearchInput { get; set; }
public FilesTab()
{
_columnRenderFragments.Add("Name", NameColumn);
@@ -235,7 +237,11 @@ namespace Lantean.QBTMud.Components
{
MarkFilesDirty();
PruneSelectionIfMissing();
await InvokeAsync(StateHasChanged);
await InvokeAsync(() =>
{
SyncSearchTextFromInput();
StateHasChanged();
});
}
}
}
@@ -415,6 +421,11 @@ namespace Lantean.QBTMud.Components
private ReadOnlyCollection<ContentItem> GetFiles()
{
if (SyncSearchTextFromInput())
{
_filesDirty = true;
}
if (!_filesDirty)
{
return _visibleFiles;
@@ -522,6 +533,23 @@ namespace Lantean.QBTMud.Components
return visible;
}
private bool SyncSearchTextFromInput()
{
if (SearchInput is null)
{
return false;
}
var currentValue = SearchInput.Value;
if (string.Equals(SearchText, currentValue, StringComparison.Ordinal))
{
return false;
}
SearchText = currentValue;
return true;
}
private void MarkFilesDirty()
{
_filesDirty = true;
@@ -629,4 +657,4 @@ namespace Lantean.QBTMud.Components
ColumnDefinitionHelper.CreateColumnDefinition<ContentItem>("Availability", c => c.Availability, c => c.Availability.ToString("0.00")),
];
}
}
}

View File

@@ -1,4 +1,6 @@
using Lantean.QBTMud.Models;
using System;
using System.Text.RegularExpressions;
using Lantean.QBTMud.Models;
namespace Lantean.QBTMud.Helpers
{
@@ -20,7 +22,7 @@ namespace Lantean.QBTMud.Helpers
.Where(t => FilterTag(t, filterState.Tag))
.Where(t => FilterCategory(t, filterState.Category, filterState.UseSubcategories))
.Where(t => FilterTracker(t, filterState.Tracker))
.Where(t => FilterTerms(t.Name, filterState.Terms));
.Where(t => FilterTerms(t, filterState));
}
public static HashSet<string> ToHashesHashSet(this IEnumerable<Torrent> torrents)
@@ -60,46 +62,111 @@ namespace Lantean.QBTMud.Helpers
}
}
public static bool ContainsAllTerms(string text, IEnumerable<string> terms)
public static bool ContainsAllTerms(string text, IEnumerable<string> terms, bool useRegex)
{
return terms.Any(t =>
{
var term = t;
var isTermRequired = term[0] == '+';
var isTermExcluded = term[0] == '-';
var target = text ?? string.Empty;
if (isTermRequired || isTermExcluded)
foreach (var rawTerm in terms)
{
if (string.IsNullOrEmpty(rawTerm))
{
continue;
}
var term = rawTerm;
var isExclude = false;
if (term[0] == '+' || term[0] == '-')
{
isExclude = term[0] == '-';
if (term.Length == 1)
{
return true;
continue;
}
term = term[1..];
}
var textContainsTerm = text.Contains(term, StringComparison.OrdinalIgnoreCase);
return isTermExcluded ? !textContainsTerm : textContainsTerm;
});
if (string.IsNullOrEmpty(term))
{
continue;
}
if (isExclude)
{
if (MatchesTerm(target, term, useRegex))
{
return false;
}
continue;
}
if (!MatchesTerm(target, term, useRegex))
{
return false;
}
}
return true;
}
public static bool FilterTerms(string field, string? terms)
{
if (terms is null || terms == "")
return FilterTerms(field, terms, useRegex: false, isRegexValid: true);
}
public static bool FilterTerms(string field, string? terms, bool useRegex, bool isRegexValid)
{
if (string.IsNullOrWhiteSpace(terms))
{
return true;
}
return ContainsAllTerms(field, terms.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
if (useRegex && !isRegexValid)
{
return true;
}
var value = field ?? string.Empty;
var tokens = terms.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return ContainsAllTerms(value, tokens, useRegex);
}
public static bool FilterTerms(Torrent torrent, FilterState filterState)
{
return FilterTerms(GetFilterFieldValue(torrent, filterState.FilterField), filterState.Terms, filterState.UseRegex, filterState.IsRegexValid);
}
public static bool FilterTerms(Torrent torrent, string? terms)
{
if (terms is null || terms == "")
return FilterTerms(torrent.Name, terms, useRegex: false, isRegexValid: true);
}
private static string GetFilterFieldValue(Torrent torrent, TorrentFilterField field)
{
return field switch
{
return true;
TorrentFilterField.SavePath => torrent.SavePath ?? string.Empty,
_ => torrent.Name ?? string.Empty,
};
}
private static bool MatchesTerm(string text, string term, bool useRegex)
{
if (!useRegex)
{
return text.Contains(term, StringComparison.OrdinalIgnoreCase);
}
return ContainsAllTerms(torrent.Name, terms.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
try
{
return Regex.IsMatch(text, term, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
}
catch (ArgumentException)
{
return false;
}
}
public static bool FilterTracker(Torrent torrent, string tracker)

View File

@@ -21,6 +21,6 @@ namespace Lantean.QBTMud.Layout
public EventCallback<string> TrackerChanged { get; set; }
[CascadingParameter(Name = "SearchTermChanged")]
public EventCallback<string> SearchTermChanged { get; set; }
public EventCallback<FilterSearchState> SearchTermChanged { get; set; }
}
}
}

View File

@@ -52,6 +52,12 @@ namespace Lantean.QBTMud.Layout
protected string? SearchText { get; set; }
protected TorrentFilterField SearchField { get; set; } = TorrentFilterField.Name;
protected bool UseRegexSearch { get; set; }
protected bool IsRegexValid { get; set; } = true;
protected IReadOnlyList<Torrent> Torrents => GetTorrents();
protected bool IsAuthenticated { get; set; }
@@ -77,7 +83,16 @@ namespace Lantean.QBTMud.Layout
return _visibleTorrents;
}
var filterState = new FilterState(Category, Status, Tag, Tracker, MainData.ServerState.UseSubcategories, SearchText);
var filterState = new FilterState(
Category,
Status,
Tag,
Tracker,
MainData.ServerState.UseSubcategories,
SearchText,
SearchField,
UseRegexSearch,
IsRegexValid);
_visibleTorrents = MainData.Torrents.Values.Filter(filterState).ToList();
_torrentsDirty = false;
@@ -185,7 +200,7 @@ namespace Lantean.QBTMud.Layout
protected EventCallback<string> TrackerChanged => EventCallback.Factory.Create<string>(this, OnTrackerChanged);
protected EventCallback<string> SearchTermChanged => EventCallback.Factory.Create<string>(this, OnSearchTermChanged);
protected EventCallback<FilterSearchState> SearchTermChanged => EventCallback.Factory.Create<FilterSearchState>(this, OnSearchTermChanged);
protected EventCallback<string> SortColumnChanged => EventCallback.Factory.Create<string>(this, columnId => SortColumn = columnId);
@@ -271,14 +286,23 @@ namespace Lantean.QBTMud.Layout
MarkTorrentsDirty();
}
private void OnSearchTermChanged(string term)
private void OnSearchTermChanged(FilterSearchState state)
{
if (SearchText == term)
var hasChanges =
SearchText != state.Text ||
SearchField != state.Field ||
UseRegexSearch != state.UseRegex ||
IsRegexValid != state.IsRegexValid;
if (!hasChanges)
{
return;
}
SearchText = term;
SearchText = state.Text;
SearchField = state.Field;
UseRegexSearch = state.UseRegex;
IsRegexValid = state.IsRegexValid;
MarkTorrentsDirty();
}

View File

@@ -0,0 +1,21 @@
namespace Lantean.QBTMud.Models
{
public readonly struct FilterSearchState
{
public FilterSearchState(string? text, TorrentFilterField field, bool useRegex, bool isRegexValid)
{
Text = text;
Field = field;
UseRegex = useRegex;
IsRegexValid = isRegexValid;
}
public string? Text { get; }
public TorrentFilterField Field { get; }
public bool UseRegex { get; }
public bool IsRegexValid { get; }
}
}

View File

@@ -2,7 +2,16 @@
{
public readonly struct FilterState
{
public FilterState(string category, Status status, string tag, string tracker, bool useSubcategories, string? terms)
public FilterState(
string category,
Status status,
string tag,
string tracker,
bool useSubcategories,
string? terms,
TorrentFilterField filterField,
bool useRegex,
bool isRegexValid)
{
Category = category;
Status = status;
@@ -10,6 +19,9 @@
Tracker = tracker;
UseSubcategories = useSubcategories;
Terms = terms;
FilterField = filterField;
UseRegex = useRegex;
IsRegexValid = isRegexValid;
}
public string Category { get; } = "all";
@@ -18,5 +30,8 @@
public string Tracker { get; } = "all";
public bool UseSubcategories { get; }
public string? Terms { get; }
public TorrentFilterField FilterField { get; } = TorrentFilterField.Name;
public bool UseRegex { get; }
public bool IsRegexValid { get; } = true;
}
}
}

View File

@@ -0,0 +1,8 @@
namespace Lantean.QBTMud.Models
{
public enum TorrentFilterField
{
Name = 0,
SavePath = 1
}
}

View File

@@ -1,5 +1,6 @@
@page "/"
@layout ListLayout
@using Lantean.QBTMud.Models
<MudMenu @ref="ContextMenu" Dense="true" RelativeWidth="DropdownWidth.Ignore" PositionAtCursor="true" ListClass="unselectable" PopoverClass="unselectable">
<MudMenuItem Icon="@Icons.Material.Outlined.Info" IconColor="Color.Inherit" OnClick="ShowTorrentContextMenu">View torrent details</MudMenuItem>
@@ -18,7 +19,12 @@
<MudIconButton Icon="@Icons.Material.Outlined.Info" Color="Color.Inherit" Disabled="@(!ToolbarButtonsEnabled)" OnClick="ShowTorrentToolbar" title="View torrent details" />
<MudIconButton Icon="@Icons.Material.Outlined.ViewColumn" Color="Color.Inherit" OnClick="ColumnOptions" title="Choose Columns" />
<MudSpacer />
<MudTextField Value="SearchText" TextChanged="SearchTextChanged" Immediate="true" DebounceInterval="1000" Placeholder="Filter torrent list" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0"></MudTextField>
<MudSelect T="TorrentFilterField" @bind-Value="SearchField" @bind-Value:after="OnSearchFieldChanged" Dense="true" Class="mt-0 mr-2 filter-field-select" AnchorOrigin="Origin.BottomRight" TransformOrigin="Origin.TopRight" Variant="Variant.Outlined">
<MudSelectItem Value="TorrentFilterField.Name">Name</MudSelectItem>
<MudSelectItem Value="TorrentFilterField.SavePath">Save path</MudSelectItem>
</MudSelect>
<MudCheckBox T="bool" @bind-Value="UseRegex" @bind-Value:after="OnUseRegexChanged" Label="Regex" Class="mt-0 mr-2" />
<MudTextField Value="SearchText" TextChanged="SearchTextChanged" Immediate="true" DebounceInterval="1000" Placeholder="Filter torrent list" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0" Error="@(UseRegex && !IsRegexValid)" ErrorText="@SearchErrorText"></MudTextField>
</MudToolBar>
</div>
<div class="content-panel__body">
@@ -69,4 +75,4 @@
};
}
}
}
}

View File

@@ -6,6 +6,7 @@ using Lantean.QBTMud.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using MudBlazor;
using System.Text.RegularExpressions;
namespace Lantean.QBTMud.Pages
{
@@ -47,7 +48,7 @@ namespace Lantean.QBTMud.Pages
public int TorrentsVersion { get; set; }
[CascadingParameter(Name = "SearchTermChanged")]
public EventCallback<string> SearchTermChanged { get; set; }
public EventCallback<FilterSearchState> SearchTermChanged { get; set; }
[CascadingParameter(Name = "SortColumnChanged")]
public EventCallback<string> SortColumnChanged { get; set; }
@@ -60,6 +61,14 @@ namespace Lantean.QBTMud.Pages
protected string? SearchText { get; set; }
protected TorrentFilterField SearchField { get; set; } = TorrentFilterField.Name;
protected bool UseRegex { get; set; }
protected bool IsRegexValid { get; set; } = true;
protected string? SearchErrorText { get; set; }
protected HashSet<Torrent> SelectedItems { get; set; } = [];
protected bool ToolbarButtonsEnabled => _toolbarButtonsEnabled;
@@ -179,7 +188,19 @@ namespace Lantean.QBTMud.Pages
protected async Task SearchTextChanged(string text)
{
SearchText = text;
await SearchTermChanged.InvokeAsync(SearchText);
ValidateRegex();
await PublishSearchStateAsync();
}
protected async Task OnSearchFieldChanged()
{
await PublishSearchStateAsync();
}
protected async Task OnUseRegexChanged()
{
ValidateRegex();
await PublishSearchStateAsync();
}
protected async Task AddTorrentFile()
@@ -220,6 +241,41 @@ namespace Lantean.QBTMud.Pages
return [(ContextMenuItem is null ? "fake" : ContextMenuItem.Hash)];
}
private async Task PublishSearchStateAsync()
{
var state = new FilterSearchState(SearchText, SearchField, UseRegex, IsRegexValid);
await SearchTermChanged.InvokeAsync(state);
}
private void ValidateRegex()
{
if (!UseRegex)
{
IsRegexValid = true;
SearchErrorText = null;
return;
}
if (string.IsNullOrWhiteSpace(SearchText))
{
IsRegexValid = true;
SearchErrorText = null;
return;
}
try
{
_ = new Regex(SearchText, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
IsRegexValid = true;
SearchErrorText = null;
}
catch (ArgumentException)
{
IsRegexValid = false;
SearchErrorText = "Invalid regular expression";
}
}
public async Task ColumnOptions()
{
if (Table is null)