21 Commits

Author SHA1 Message Date
ahjephson
4666cb0b36 Add new torrent filters 2025-10-27 11:29:41 +00:00
ahjephson
498420bf23 Add new torrent states 2025-10-27 10:48:53 +00:00
ahjephson
9d2ba7c57b Polish search UI 2025-10-26 16:38:08 +00:00
ahjephson
2e843b1191 Add updated search 2025-10-25 21:05:04 +01:00
ahjephson
eb13b83548 Add search plugin management 2025-10-25 14:09:30 +01:00
ahjephson
e9daca6568 Move projects to correct folder. Add AGENTS.md files to assist with code generation. 2025-10-25 13:31:34 +01:00
ahjephson
075ea9f855 Remove invalid async style from tests and quick code format 2025-10-23 17:05:45 +01:00
ahjephson
d01204a703 Split datamanager into separate classes. 2025-10-23 16:51:58 +01:00
ahjephson
ab1c594b07 Add unit tests for QBitTorrentClient 2025-10-23 16:03:05 +01:00
ahjephson
6a5d8b2610 Don't use async methods 2025-10-23 15:57:34 +01:00
ahjephson
b8412bb232 Add new v5 properties to AddTorrent 2025-10-22 14:05:13 +01:00
ahjephson
e64a13c7c9 Add ShareRatio to include ShareLimitAction 2025-10-22 12:42:30 +01:00
ahjephson
e4ea79a8ed Update planning doc 2025-10-22 11:41:06 +01:00
ahjephson
0976b72411 Add new v5 torrent properties 2025-10-22 11:39:04 +01:00
ahjephson
965fbcd010 Add copy comments/content path 2025-10-22 10:55:34 +01:00
ahjephson
3d0dbde9f4 Add v5 only preferences 2025-10-22 10:40:14 +01:00
ahjephson
5b4fbde7b2 Removed paused state. 2025-10-22 08:29:43 +01:00
ahjephson
0db0ad4374 Remove other v4 logic 2025-10-21 14:20:25 +01:00
ahjephson
c390d83e4d Update to use v5 api only. 2025-10-21 13:38:50 +01:00
ahjephson
8dd29c238d Update client to use net v5 apis 2025-10-21 13:12:38 +01:00
ahjephson
fca17edfd1 Merge tag '1.2.0' into develop
1.2.0
2025-10-20 20:56:10 +01:00
338 changed files with 14440 additions and 2019 deletions

60
AGENTS.md Normal file
View File

@@ -0,0 +1,60 @@
# AGENTS.md (root)
> Scope: This file gives high-level context and guardrails for the repository root. Deeper folders may add their own `AGENTS.md` files which take precedence for their subtrees (e.g., `./src/AGENTS.md`, `./test/AGENTS.md`).
## Project overview
- qbtmud is a drop-in replacement for qBittorrent's default WebUI, aiming for full feature parity with a modern UI.
- Primary goals: parity with the default WebUI, excellent UX, reliability, and easy installation.
- Non-goals: diverging from qBittorrent semantics without explicit design approval.
## Repository layout
- Solution: `Lantean.QBTMud.sln`
- Projects:
- `Lantean.QBTMud` — Web UI host and published assets.
- `Lantean.QBitTorrentClient` — client library for qBittorrent Web API.
- `Lantean.QBTMud.Test` — unit tests.
- `Lantean.QBitTorrentClient.Test` — unit tests.
- Config/conventions: `.editorconfig`, `.gitattributes`, `nuget.config`, `global.json` (SDK pin).
## Build, test, publish
- Prerequisites: .NET 9 SDK (use the version pinned by `global.json` if present).
- Restore & build:
- `dotnet restore`
- `dotnet build --configuration Release`
- Run tests:
- `dotnet test`
- Publish Web UI:
- `dotnet publish Lantean.QBTMud -c Release`
- Output (static assets): `Lantean.QBTMud/bin/Release/net9.0/publish/wwwroot/`
## Coding and test standards
- Source code rules and generation constraints live in `./src/AGENTS.md` (authoritative for code style, design, docs).
- Unit test rules live in `./test/AGENTS.md` (authoritative for test structure, naming, mocks, coverage).
- If rules conflict, the deeper file (closer to the change) wins; otherwise, follow both.
## How to work in this repo (for agents)
1. Read this file, then the relevant folder `AGENTS.md` (e.g., `src` or `test`).
2. Before modifying code:
- Confirm SDK target, nullable context, analyzers, and editorconfig rules.
- Keep public surface consistent; do not break qBittorrent Web API expectations without approval.
3. When generating code:
- Follow `./src/AGENTS.md` exactly (naming, formatting, docs, DI, async, security).
- Prefer minimal, maintainable changes; avoid churn to unrelated files.
4. When writing tests:
- Follow `./test/AGENTS.md` exactly (class/method naming, `_target`, mocks, coverage).
5. Before opening a PR:
- Build succeeds, tests are green.
- Public XML docs added/updated.
- Changelog notes in the PR description (what changed, why, risks, testing).
## PR and review checklist
- [ ] Change is scoped and well-justified; no unrelated edits.
- [ ] Code adheres to `./src/AGENTS.md` standards.
- [ ] Tests adhere to `./test/AGENTS.md` and achieve required coverage.
- [ ] No secrets, tokens, or user-specific paths committed.
- [ ] Builds with the pinned SDK; `dotnet restore`, `build`, `test`, and `publish` succeed.
- [ ] Error messages and logs are clear and actionable.
## Communication & assumptions
- Do not guess. If any requirement, API contract, or behavior is unclear, ask for clarification.
- Prefer concise diffs and explicit rationale in commit messages and PR descriptions.

View File

@@ -1,13 +1,13 @@
 
Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17 # Visual Studio Version 18
VisualStudioVersion = 17.8.34511.84 VisualStudioVersion = 18.0.11121.172 d18.0
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lantean.QBTMud.Test", "Lantean.QBTMud.Test\Lantean.QBTMud.Test.csproj", "{715E075C-1D86-4A7F-BC72-E1E24A294F17}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lantean.QBTMud.Test", "test\Lantean.QBTMud.Test\Lantean.QBTMud.Test.csproj", "{715E075C-1D86-4A7F-BC72-E1E24A294F17}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lantean.QBitTorrentClient", "Lantean.QBitTorrentClient\Lantean.QBitTorrentClient.csproj", "{F0DDAB07-0D6C-40C7-AFE6-08FF2C4CC7E7}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lantean.QBitTorrentClient", "src\Lantean.QBitTorrentClient\Lantean.QBitTorrentClient.csproj", "{F0DDAB07-0D6C-40C7-AFE6-08FF2C4CC7E7}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lantean.QBTMud", "Lantean.QBTMud\Lantean.QBTMud.csproj", "{83BC76CC-D51B-42AF-A6EE-FA400C300098}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lantean.QBTMud", "src\Lantean.QBTMud\Lantean.QBTMud.csproj", "{83BC76CC-D51B-42AF-A6EE-FA400C300098}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{1BF1A631-87D7-4039-A701-88C5E0234B63}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{1BF1A631-87D7-4039-A701-88C5E0234B63}"
ProjectSection(SolutionItems) = preProject ProjectSection(SolutionItems) = preProject
@@ -15,6 +15,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
readme.md = readme.md readme.md = readme.md
EndProjectSection EndProjectSection
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lantean.QBitTorrentClient.Test", "test\Lantean.QBitTorrentClient.Test\Lantean.QBitTorrentClient.Test.csproj", "{796E865C-7AA6-4BD9-B12F-394801199A75}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Test", "Test", "{344EAF42-5D2B-4F56-8B28-1F3158A37E0A}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -33,10 +37,18 @@ Global
{83BC76CC-D51B-42AF-A6EE-FA400C300098}.Debug|Any CPU.Build.0 = Debug|Any CPU {83BC76CC-D51B-42AF-A6EE-FA400C300098}.Debug|Any CPU.Build.0 = Debug|Any CPU
{83BC76CC-D51B-42AF-A6EE-FA400C300098}.Release|Any CPU.ActiveCfg = Release|Any CPU {83BC76CC-D51B-42AF-A6EE-FA400C300098}.Release|Any CPU.ActiveCfg = Release|Any CPU
{83BC76CC-D51B-42AF-A6EE-FA400C300098}.Release|Any CPU.Build.0 = Release|Any CPU {83BC76CC-D51B-42AF-A6EE-FA400C300098}.Release|Any CPU.Build.0 = Release|Any CPU
{796E865C-7AA6-4BD9-B12F-394801199A75}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{796E865C-7AA6-4BD9-B12F-394801199A75}.Debug|Any CPU.Build.0 = Debug|Any CPU
{796E865C-7AA6-4BD9-B12F-394801199A75}.Release|Any CPU.ActiveCfg = Release|Any CPU
{796E865C-7AA6-4BD9-B12F-394801199A75}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{715E075C-1D86-4A7F-BC72-E1E24A294F17} = {344EAF42-5D2B-4F56-8B28-1F3158A37E0A}
{796E865C-7AA6-4BD9-B12F-394801199A75} = {344EAF42-5D2B-4F56-8B28-1F3158A37E0A}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {82E46DB7-956A-4971-BB18-1F20650EC1A4} SolutionGuid = {82E46DB7-956A-4971-BB18-1F20650EC1A4}
EndGlobalSection EndGlobalSection

View File

@@ -1,68 +0,0 @@
<MudGrid>
<MudItem xs="12">
<MudSwitch Label="Additional Options" @bind-Value="Expanded" LabelPlacement="Placement.End" />
</MudItem>
</MudGrid>
<MudCollapse Expanded="Expanded">
<MudGrid>
<MudItem xs="12">
<MudSelect Label="Torrent Management Mode" @bind-Value="TorrentManagementMode" Variant="Variant.Outlined">
<MudSelectItem Value="false">Manual</MudSelectItem>
<MudSelectItem Value="true">Automatic</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12">
<MudTextField Label="Save files to location" @bind-Value="SavePath" Variant="Variant.Outlined"></MudTextField>
</MudItem>
@if (ShowCookieOption)
{
<MudItem xs="12">
<MudTextField Label="Cookie" @bind-Value="Cookie" Variant="Variant.Outlined"></MudTextField>
</MudItem>
}
<MudItem xs="12">
<MudTextField Label="Rename" @bind-Value="RenameTorrent" Variant="Variant.Outlined"></MudTextField>
</MudItem>
<MudItem xs="12">
<MudSelect Label="Category" @bind-Value="Category" Variant="Variant.Outlined">
@foreach (var category in Categories)
{
<MudSelectItem Value="category">@category</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12">
<FieldSwitch Label="Start torrent" @bind-Value="StartTorrent" />
</MudItem>
<MudItem xs="12">
<FieldSwitch Label="Add to top of queue" @bind-Value="AddToTopOfQueue" />
</MudItem>
<MudItem xs="12">
<MudSelect Label="Stop condition" @bind-Value="StopCondition" Variant="Variant.Outlined">
<MudSelectItem Value="@("None")">None</MudSelectItem>
<MudSelectItem Value="@("MetadataReceived")">Metadata received</MudSelectItem>
<MudSelectItem Value="@("FilesChecked")">Files checked</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12">
<FieldSwitch Label="Skip hash check" @bind-Value="SkipHashCheck" />
</MudItem>
<MudSelect Label="Content layout" @bind-Value="ContentLayout" Variant="Variant.Outlined">
<MudSelectItem Value="@("Original")">Original</MudSelectItem>
<MudSelectItem Value="@("Subfolder")">Create subfolder</MudSelectItem>
<MudSelectItem Value="@("NoSubfolder")">Don't create subfolder'</MudSelectItem>
</MudSelect>
<MudItem xs="12">
<FieldSwitch Label="Download in sequentual order" @bind-Value="DownloadInSequentialOrder" />
</MudItem>
<MudItem xs="12">
<FieldSwitch Label="Download first and last pieces first" @bind-Value="DownloadFirstAndLastPiecesFirst" />
</MudItem>
<MudItem xs="12">
<MudNumericField Label="Limit download rate" @bind-Value="DownloadLimit" Variant="Variant.Outlined" Min="0" />
</MudItem>
<MudItem xs="12">
<MudNumericField Label="Limit upload rate" @bind-Value="UploadLimit" Variant="Variant.Outlined" Min="0" />
</MudItem>
</MudGrid>
</MudCollapse>

View File

@@ -1,81 +0,0 @@
using Lantean.QBitTorrentClient;
using Lantean.QBTMud.Models;
using Microsoft.AspNetCore.Components;
namespace Lantean.QBTMud.Components.Dialogs
{
public partial class AddTorrentOptions
{
[Inject]
protected IApiClient ApiClient { get; set; } = default!;
[Parameter]
public bool ShowCookieOption { get; set; }
protected bool Expanded { get; set; }
protected bool TorrentManagementMode { get; set; }
protected string SavePath { get; set; } = default!;
protected string? Cookie { get; set; }
protected string? RenameTorrent { get; set; }
protected IEnumerable<string> Categories { get; set; } = [];
protected string? Category { get; set; }
protected bool StartTorrent { get; set; } = true;
protected bool AddToTopOfQueue { get; set; } = true;
protected string StopCondition { get; set; } = "None";
protected bool SkipHashCheck { get; set; } = false;
protected string ContentLayout { get; set; } = "Original";
protected bool DownloadInSequentialOrder { get; set; } = false;
protected bool DownloadFirstAndLastPiecesFirst { get; set; } = false;
protected long DownloadLimit { get; set; }
protected long UploadLimit { get; set; }
protected override async Task OnInitializedAsync()
{
var categories = await ApiClient.GetAllCategories();
Categories = categories.Select(c => c.Key).ToList();
var preferences = await ApiClient.GetApplicationPreferences();
TorrentManagementMode = preferences.AutoTmmEnabled;
SavePath = preferences.SavePath;
StartTorrent = !preferences.StartPausedEnabled;
AddToTopOfQueue = preferences.AddToTopOfQueue;
StopCondition = preferences.TorrentStopCondition;
ContentLayout = preferences.TorrentContentLayout;
}
public TorrentOptions GetTorrentOptions()
{
return new TorrentOptions(
TorrentManagementMode,
SavePath,
Cookie,
RenameTorrent,
Category,
StartTorrent,
AddToTopOfQueue,
StopCondition,
SkipHashCheck,
ContentLayout,
DownloadInSequentialOrder,
DownloadFirstAndLastPiecesFirst,
DownloadLimit,
UploadLimit);
}
}
}

View File

@@ -1,33 +0,0 @@
namespace Lantean.QBTMud.Helpers
{
internal static class VersionHelper
{
private static int? _version;
private const int _defaultVersion = 5;
public static int DefaultVersion => _defaultVersion;
public static int GetMajorVersion(string? version)
{
if (_version is not null)
{
return _version.Value;
}
if (string.IsNullOrEmpty(version))
{
return _defaultVersion;
}
if (!Version.TryParse(version?.Replace("v", ""), out var theVersion))
{
return _defaultVersion;
}
_version = theVersion.Major;
return _version.Value;
}
}
}

View File

@@ -1,26 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<CompressionEnabled>false</CompressionEnabled>
<LangVersion>12</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Blazored.LocalStorage" Version="4.5.0" />
<PackageReference Include="ByteSize" Version="2.1.2" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.10" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.10" />
<PackageReference Include="MudBlazor" Version="8.13.0" />
<PackageReference Include="MudBlazor.ThemeManager" Version="3.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Lantean.QBitTorrentClient\Lantean.QBitTorrentClient.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,40 +0,0 @@
@inherits LayoutComponentBase
<CascadingValue Value="DrawerOpen" Name="DrawerOpen">
<EnhancedErrorBoundary @ref="ErrorBoundary" OnClear="Cleared">
<MudThemeProvider @ref="MudThemeProvider" @bind-IsDarkMode="IsDarkMode" Theme="Theme" />
<MudDialogProvider CloseOnEscapeKey="true" />
<MudSnackbarProvider />
<MudPopoverProvider />
<PageTitle>qBittorrent Web UI</PageTitle>
<MudLayout>
<MudAppBar>
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="ToggleDrawer" />
<MudText Typo="Typo.h5" Class="ml-3">qBittorrent Web UI</MudText>
<MudSpacer />
@if (ErrorBoundary?.Errors.Count > 0)
{
<MudBadge Content="@(ErrorBoundary?.Errors.Count ?? 0)" Color="Color.Error" Overlap="true" Class="mr-2">
<MudIconButton Icon="@Icons.Material.Filled.Error" Color="Color.Default" OnClick="ToggleErrorDrawer" />
</MudBadge>
}
<MudSwitch T="bool" Label="Dark Mode" LabelPlacement="Placement.End" Value="IsDarkMode" ValueChanged="DarkModeChanged" Class="pl-3" />
<Menu @ref="Menu" />
</MudAppBar>
<MudDrawer @bind-Open="ErrorDrawerOpen" ClipMode="DrawerClipMode.Docked" Elevation="2" Anchor="Anchor.Right">
<ErrorDisplay ErrorBoundary="ErrorBoundary" />
</MudDrawer>
<CascadingValue Value="Theme">
<CascadingValue Value="IsDarkMode" Name="IsDarkMode">
<CascadingValue Value="Menu">
<CascadingValue Value="DrawerOpen" Name="DrawerOpen">
@Body
</CascadingValue>
</CascadingValue>
</CascadingValue>
</CascadingValue>
</MudLayout>
</EnhancedErrorBoundary>
</CascadingValue>

View File

@@ -1,22 +0,0 @@
namespace Lantean.QBTMud.Models
{
public readonly struct FilterState
{
public FilterState(string category, Status status, string tag, string tracker, bool useSubcategories, string? terms)
{
Category = category;
Status = status;
Tag = tag;
Tracker = tracker;
UseSubcategories = useSubcategories;
Terms = terms;
}
public string Category { get; } = "all";
public Status Status { get; } = Status.All;
public string Tag { get; } = "all";
public string Tracker { get; } = "all";
public bool UseSubcategories { get; }
public string? Terms { get; }
}
}

View File

@@ -1,11 +0,0 @@
namespace Lantean.QBTMud.Models
{
public class SearchForm
{
public string? SearchText { get; set; }
public string SelectedPlugin { get; set; } = "all";
public string SelectedCategory { get; set; } = "all";
}
}

View File

@@ -1,67 +0,0 @@
@page "/search"
@layout OtherLayout
<div class="content-panel">
<div class="content-panel__toolbar">
<MudToolBar Gutters="false" Dense="true">
@if (!DrawerOpen)
{
<MudIconButton Icon="@Icons.Material.Outlined.NavigateBefore" OnClick="NavigateBack" title="Back to torrent list" />
<MudDivider Vertical="true" />
}
<MudDivider Vertical="true" />
<MudText Class="pl-5 no-wrap">Search</MudText>
</MudToolBar>
</div>
<div class="content-panel__body">
<MudCard Elevation="1" Class="ml-4 mr-4 mb-4">
<MudCardContent>
<EditForm Model="Model" OnValidSubmit="DoSearch">
<MudGrid>
<MudItem xs="12" md="4">
<MudTextField T="string" Label="Criteria" @bind-Value="Model.SearchText" Variant="Variant.Outlined" />
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" Label="Categories" @bind-Value="Model.SelectedCategory" Variant="Variant.Outlined">
@foreach (var (value, name) in Categories)
{
<MudSelectItem Value="value">@name</MudSelectItem>
if (value == "all")
{
<MudDivider />
}
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" Label="Plugins" @bind-Value="Model.SelectedPlugin" Variant="Variant.Outlined">
<MudSelectItem Value="@("all")">All</MudSelectItem>
@if (Plugins.Count > 0)
{
<MudDivider />
}
@foreach (var (value, name) in Plugins)
{
<MudSelectItem Value="value">@name</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="2">
<MudButton ButtonType="ButtonType.Submit" FullWidth="true" Color="Color.Primary" EndIcon="@Icons.Material.Filled.Search" Variant="Variant.Filled" Class="mt-6">@(_searchId is null ? "Search" : "Stop")</MudButton>
</MudItem>
</MudGrid>
</EditForm>
</MudCardContent>
</MudCard>
<DynamicTable @ref="Table"
T="Lantean.QBitTorrentClient.Models.SearchResult"
ColumnDefinitions="Columns"
Items="Results"
MultiSelection="false"
SelectOnRowClick="false"
Class="search-list content-panel__table" />
</div>
</div>

View File

@@ -1,185 +0,0 @@
using Lantean.QBitTorrentClient;
using Lantean.QBTMud.Components.UI;
using Lantean.QBTMud.Helpers;
using Lantean.QBTMud.Models;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
using MudBlazor;
namespace Lantean.QBTMud.Pages
{
public partial class Search : IDisposable
{
private IReadOnlyList<QBitTorrentClient.Models.SearchPlugin>? _plugins;
private int? _searchId;
private bool _disposedValue;
private readonly CancellationTokenSource _timerCancellationToken = new();
private readonly int _refreshInterval = 1500;
private QBitTorrentClient.Models.SearchResults? _searchResults;
[Inject]
protected IApiClient ApiClient { get; set; } = default!;
[Inject]
protected IDialogService DialogService { get; set; } = default!;
[Inject]
protected NavigationManager NavigationManager { get; set; } = default!;
[CascadingParameter]
public MainData? MainData { get; set; }
[CascadingParameter(Name = "DrawerOpen")]
public bool DrawerOpen { get; set; }
[Parameter]
public string? Hash { get; set; }
protected SearchForm Model { get; set; } = new SearchForm();
protected Dictionary<string, string> Plugins => _plugins is null ? [] : _plugins.ToDictionary(a => a.Name, a => a.FullName);
protected Dictionary<string, string> Categories => GetCategories(Model.SelectedPlugin);
protected IEnumerable<QBitTorrentClient.Models.SearchResult>? Results => _searchResults?.Results;
protected DynamicTable<QBitTorrentClient.Models.SearchResult>? Table { get; set; }
protected override async Task OnInitializedAsync()
{
_plugins = await ApiClient.GetSearchPlugins();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
using (var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(_refreshInterval)))
{
while (!_timerCancellationToken.IsCancellationRequested && await timer.WaitForNextTickAsync())
{
if (_searchId is not null)
{
try
{
_searchResults = await ApiClient.GetSearchResults(_searchId.Value);
if (_searchResults.Status == "Stopped")
{
await ApiClient.DeleteSearch(_searchId.Value);
_searchId = null;
}
}
catch (HttpRequestException)
{
if (MainData is not null)
{
MainData.LostConnection = true;
}
_searchId = null;
}
await InvokeAsync(StateHasChanged);
}
}
}
}
}
protected void NavigateBack()
{
NavigationManager.NavigateTo("/");
}
private Dictionary<string, string> GetCategories(string plugin)
{
if (_plugins is null)
{
return [];
}
if (plugin == "all")
{
return _plugins.SelectMany(i => i.SupportedCategories).Distinct().ToDictionary(a => a.Id, a => a.Name);
}
var pluginItem = _plugins.FirstOrDefault(p => p.Name == plugin);
if (pluginItem is null)
{
return [];
}
return pluginItem.SupportedCategories.ToDictionary(a => a.Id, a => a.Name);
}
protected async Task DoSearch(EditContext editContext)
{
if (_searchId is null)
{
if (string.IsNullOrEmpty(Model.SearchText))
{
return;
}
_searchResults = null;
_searchId = await ApiClient.StartSearch(Model.SearchText, [Model.SelectedPlugin], Model.SelectedCategory);
}
else
{
try
{
var status = await ApiClient.GetSearchStatus(_searchId.Value);
if (status is not null)
{
if (status.Status == "Running")
{
await ApiClient.StopSearch(_searchId.Value);
}
await ApiClient.DeleteSearch(_searchId.Value);
_searchId = null;
}
}
catch (HttpRequestException exception) when (exception.StatusCode == System.Net.HttpStatusCode.NotFound)
{
_searchId = null;
}
}
}
protected IEnumerable<ColumnDefinition<QBitTorrentClient.Models.SearchResult>> Columns => ColumnsDefinitions;
public static List<ColumnDefinition<QBitTorrentClient.Models.SearchResult>> ColumnsDefinitions { get; } =
[
new ColumnDefinition<QBitTorrentClient.Models.SearchResult>("Name", l => l.FileName),
new ColumnDefinition<QBitTorrentClient.Models.SearchResult>("Size", l => @DisplayHelpers.Size(l.FileSize)),
new ColumnDefinition<QBitTorrentClient.Models.SearchResult>("Seeders", l => l.Seeders),
new ColumnDefinition<QBitTorrentClient.Models.SearchResult>("Leechers", l => l.Leechers),
new ColumnDefinition<QBitTorrentClient.Models.SearchResult>("Search engine", l => l.SiteUrl),
];
protected virtual void Dispose(bool disposing)
{
if (!_disposedValue)
{
if (disposing)
{
_timerCancellationToken.Cancel();
_timerCancellationToken.Dispose();
}
_disposedValue = true;
}
}
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

View File

@@ -1,81 +0,0 @@
namespace Lantean.QBitTorrentClient.Models
{
public class SaveLocation
{
public bool IsWatchedFolder { get; set; }
public bool IsDefaultFolder { get; set; }
public string? SavePath { get; set; }
public static SaveLocation Create(object? value)
{
if (value is int intValue)
{
if (intValue == 0)
{
return new SaveLocation
{
IsWatchedFolder = true
};
}
else if (intValue == 1)
{
return new SaveLocation
{
IsDefaultFolder = true
};
}
}
else if (value is string stringValue)
{
if (stringValue == "0")
{
return new SaveLocation
{
IsWatchedFolder = true
};
}
else if (stringValue == "1")
{
return new SaveLocation
{
IsDefaultFolder = true
};
}
else
{
return new SaveLocation
{
SavePath = stringValue
};
}
}
throw new ArgumentOutOfRangeException(nameof(value));
}
public object ToValue()
{
if (IsWatchedFolder)
{
return 0;
}
else if (IsDefaultFolder)
{
return 1;
}
else if (SavePath is not null)
{
return SavePath;
}
throw new InvalidOperationException("Invalid value.");
}
public override string? ToString()
{
return ToValue().ToString();
}
}
}

View File

@@ -1,264 +0,0 @@
using Lantean.QBitTorrentClient.Converters;
using System.Text.Json.Serialization;
namespace Lantean.QBitTorrentClient.Models
{
public record Torrent
{
[JsonConstructor]
public Torrent(
long? addedOn,
long? amountLeft,
bool? automaticTorrentManagement,
float? availability,
string? category,
long? completed,
long? completionOn,
string? contentPath,
long? downloadLimit,
long? downloadSpeed,
long? downloaded,
long? downloadedSession,
long? estimatedTimeOfArrival,
bool? firstLastPiecePriority,
bool? forceStart,
string hash,
string? infoHashV1,
string? infoHashV2,
long? lastActivity,
string? magnetUri,
float? maxRatio,
int? maxSeedingTime,
string? name,
int? numberComplete,
int? numberIncomplete,
int? numberLeeches,
int? numberSeeds,
int? priority,
float? progress,
float? ratio,
float? ratioLimit,
string? savePath,
long? seedingTime,
int? seedingTimeLimit,
long? seenComplete,
bool? sequentialDownload,
long? size,
string? state,
bool? superSeeding,
IReadOnlyList<string>? tags,
int? timeActive,
long? totalSize,
string? tracker,
long? uploadLimit,
long? uploaded,
long? uploadedSession,
long? uploadSpeed,
long? reannounce,
float? inactiveSeedingTimeLimit,
float? maxInactiveSeedingTime)
{
AddedOn = addedOn;
AmountLeft = amountLeft;
AutomaticTorrentManagement = automaticTorrentManagement;
Availability = availability;
Category = category;
Completed = completed;
CompletionOn = completionOn;
ContentPath = contentPath;
DownloadLimit = downloadLimit;
DownloadSpeed = downloadSpeed;
Downloaded = downloaded;
DownloadedSession = downloadedSession;
EstimatedTimeOfArrival = estimatedTimeOfArrival;
FirstLastPiecePriority = firstLastPiecePriority;
ForceStart = forceStart;
Hash = hash;
InfoHashV1 = infoHashV1;
InfoHashV2 = infoHashV2;
LastActivity = lastActivity;
MagnetUri = magnetUri;
MaxRatio = maxRatio;
MaxSeedingTime = maxSeedingTime;
Name = name;
NumberComplete = numberComplete;
NumberIncomplete = numberIncomplete;
NumberLeeches = numberLeeches;
NumberSeeds = numberSeeds;
Priority = priority;
Progress = progress;
Ratio = ratio;
RatioLimit = ratioLimit;
SavePath = savePath;
SeedingTime = seedingTime;
SeedingTimeLimit = seedingTimeLimit;
SeenComplete = seenComplete;
SequentialDownload = sequentialDownload;
Size = size;
State = state;
SuperSeeding = superSeeding;
Tags = tags ?? [];
TimeActive = timeActive;
TotalSize = totalSize;
Tracker = tracker;
UploadLimit = uploadLimit;
Uploaded = uploaded;
UploadedSession = uploadedSession;
UploadSpeed = uploadSpeed;
Reannounce = reannounce;
InactiveSeedingTimeLimit = inactiveSeedingTimeLimit;
MaxInactiveSeedingTime = maxInactiveSeedingTime;
}
[JsonPropertyName("added_on")]
public long? AddedOn { get; }
[JsonPropertyName("amount_left")]
public long? AmountLeft { get; }
[JsonPropertyName("auto_tmm")]
public bool? AutomaticTorrentManagement { get; }
[JsonPropertyName("availability")]
public float? Availability { get; }
[JsonPropertyName("category")]
public string? Category { get; }
[JsonPropertyName("completed")]
public long? Completed { get; }
[JsonPropertyName("completion_on")]
public long? CompletionOn { get; }
[JsonPropertyName("content_path")]
public string? ContentPath { get; }
[JsonPropertyName("dl_limit")]
public long? DownloadLimit { get; }
[JsonPropertyName("dlspeed")]
public long? DownloadSpeed { get; }
[JsonPropertyName("downloaded")]
public long? Downloaded { get; }
[JsonPropertyName("downloaded_session")]
public long? DownloadedSession { get; }
[JsonPropertyName("eta")]
public long? EstimatedTimeOfArrival { get; }
[JsonPropertyName("f_l_piece_prio")]
public bool? FirstLastPiecePriority { get; }
[JsonPropertyName("force_start")]
public bool? ForceStart { get; }
[JsonPropertyName("hash")]
public string Hash { get; }
[JsonPropertyName("infohash_v1")]
public string? InfoHashV1 { get; }
[JsonPropertyName("infohash_v2")]
public string? InfoHashV2 { get; }
[JsonPropertyName("last_activity")]
public long? LastActivity { get; }
[JsonPropertyName("magnet_uri")]
public string? MagnetUri { get; }
[JsonPropertyName("max_ratio")]
public float? MaxRatio { get; }
[JsonPropertyName("max_seeding_time")]
public int? MaxSeedingTime { get; }
[JsonPropertyName("name")]
public string? Name { get; }
[JsonPropertyName("num_complete")]
public int? NumberComplete { get; }
[JsonPropertyName("num_incomplete")]
public int? NumberIncomplete { get; }
[JsonPropertyName("num_leechs")]
public int? NumberLeeches { get; }
[JsonPropertyName("num_seeds")]
public int? NumberSeeds { get; }
[JsonPropertyName("priority")]
public int? Priority { get; }
[JsonPropertyName("progress")]
public float? Progress { get; }
[JsonPropertyName("ratio")]
public float? Ratio { get; }
[JsonPropertyName("ratio_limit")]
public float? RatioLimit { get; }
[JsonPropertyName("save_path")]
public string? SavePath { get; }
[JsonPropertyName("seeding_time")]
public long? SeedingTime { get; }
[JsonPropertyName("seeding_time_limit")]
public int? SeedingTimeLimit { get; }
[JsonPropertyName("seen_complete")]
public long? SeenComplete { get; }
[JsonPropertyName("seq_dl")]
public bool? SequentialDownload { get; }
[JsonPropertyName("size")]
public long? Size { get; }
[JsonPropertyName("state")]
public string? State { get; }
[JsonPropertyName("super_seeding")]
public bool? SuperSeeding { get; }
[JsonPropertyName("tags")]
[JsonConverter(typeof(CommaSeparatedJsonConverter))]
public IReadOnlyList<string>? Tags { get; }
[JsonPropertyName("time_active")]
public int? TimeActive { get; }
[JsonPropertyName("total_size")]
public long? TotalSize { get; }
[JsonPropertyName("tracker")]
public string? Tracker { get; }
[JsonPropertyName("up_limit")]
public long? UploadLimit { get; }
[JsonPropertyName("uploaded")]
public long? Uploaded { get; }
[JsonPropertyName("uploaded_session")]
public long? UploadedSession { get; }
[JsonPropertyName("upspeed")]
public long? UploadSpeed { get; }
[JsonPropertyName("reannounce")]
public long? Reannounce { get; }
[JsonPropertyName("inactive_seeding_time_limit")]
public float? InactiveSeedingTimeLimit { get; }
[JsonPropertyName("max_inactive_seeding_time")]
public float? MaxInactiveSeedingTime { get; }
}
}

View File

@@ -1,52 +0,0 @@
using System.Text.Json.Serialization;
namespace Lantean.QBitTorrentClient.Models
{
public record TorrentTracker
{
[JsonConstructor]
public TorrentTracker(
string url,
TrackerStatus status,
int tier,
int peers,
int seeds,
int leeches,
int downloads,
string message)
{
Url = url;
Status = status;
Tier = tier;
Peers = peers;
Seeds = seeds;
Leeches = leeches;
Downloads = downloads;
Message = message;
}
[JsonPropertyName("url")]
public string Url { get; }
[JsonPropertyName("status")]
public TrackerStatus Status { get; }
[JsonPropertyName("tier")]
public int Tier { get; }
[JsonPropertyName("num_peers")]
public int Peers { get; }
[JsonPropertyName("num_seeds")]
public int Seeds { get; }
[JsonPropertyName("num_leeches")]
public int Leeches { get; }
[JsonPropertyName("num_downloaded")]
public int Downloads { get; }
[JsonPropertyName("msg")]
public string Message { get; }
}
}

77
Search-Implementation.md Normal file
View File

@@ -0,0 +1,77 @@
# Search Feature Parity Plan
## Objectives
- Bring the `qbt-mud` search experience to functional parity with the qBittorrent v5 WebUI, including multi-job handling, advanced result filtering, and plugin management.
- Reuse existing infrastructure (MudBlazor tables, dialogs, API client) wherever possible while filling the missing pieces.
- Deliver the work in incremental, testable slices that keep existing search behaviour stable until parity is achieved.
## Current Implementation Snapshot
- `Lantean.QBTMud/Pages/Search.razor` renders a simple form that runs a single search against one plugin and displays results in a static `DynamicTable` without row actions.
- `Lantean.QBTMud/Pages/Search.razor.cs` only stores the last `_searchId`, polls `search/results` until the job stops, and discards previous jobs.
- `SearchResult` models lack `EngineName`/`PublishedOn` data and the UI never exposes description/download links.
- There is no UI for plugin enable/disable/install/update, nor support for saved search filters (min seeds/size, search-in scope).
- The API client already exposes all `search/*` endpoints, but the UI consumes only a subset.
## Gap Analysis vs qBittorrent v5
- **Search job lifecycle**: v5 keeps a tab per job, shows statuses from `search/status`, supports restarting/completing jobs, and allows switching between them. `qbt-mud` supports only one ephemeral job.
- **Form inputs & filters**: v5 enables searching across *enabled* plugins, selecting multiple specific plugins, and provides client-side filters (search-in scope, seeds range, size range). Current form offers only single-plugin + category.
- **Results table**: v5 streams batched results (limit/offset), displays engine/site/pub date, exposes context menu actions (download, open description, copy data), and shows visible/total result counts. Current table is read-only with limited columns.
- **Search plugin management**: v5 surfaces enabled state, version, URL, manual install (local/URL), uninstall, enable/disable toggles, and update-all. There is no corresponding UI in `qbt-mud`.
- **State persistence**: v5 stores granular filter preferences locally. `qbt-mud` has no saved state for search filters or column selection specific to search.
- **Accessibility/UX**: parity requires toolbar buttons (stop/refresh/close job), empty-state messaging (no plugins/searches), and error surfaces when API calls fail.
## Implementation Plan
### 1. Search Job Lifecycle & State Management
- Introduce a dedicated view-model (e.g., `SearchJobViewModel`) to track pattern, selected plugins, category, status, totals, timestamps, and accumulated results. Locate in `Lantean.QBTMud/Models`.
- Expand `Search.razor.cs` to maintain a collection of jobs keyed by id, using `GetSearchesStatus()` for periodic synchronization and `GetSearchResults(id, limit, offset)` to stream additional rows.
- Replace the single `_searchId`/`_searchResults` fields with job-centric state, and schedule polling via `PeriodicTimer` per active job. Ensure timers dispose cleanly on navigation/dispose.
- Update the razor markup to display job tabs or a side list (matching MudBlazors `MudTabs` or `MudList`) allowing context menu actions (refresh, close, close all). Mirror qBittorrent behaviour where “Stop” cancels a running job and “Search” starts a new one without clearing previous jobs.
- Reflect job status icons/text (Running, Stopped, Aborted, Error) and total result counts in the UI; surface API failures through existing toast/dialog mechanisms.
- Files impacted: `Lantean.QBTMud/Pages/Search.razor`, `Lantean.QBTMud/Pages/Search.razor.cs`, `Lantean.QBTMud/Models/SearchForm.cs`, new `Lantean.QBTMud/Models/SearchJobViewModel.cs`.
### 2. Search Form & Filters
- Extend `SearchForm` with multi-select plugin selection (`ICollection<string> SelectedPlugins`), a special “Enabled plugins” option, search-in scope, and optional min/max seeds & size filters (with units). Persist defaults via `ILocalStorageService`.
- Update the form markup to use `MudSelect` with `MultiSelection="true"` and chips for selected plugins, add numeric inputs for seeds/size with validation, and wire a text filter box for client-side result filtering.
- Implement client-side filtering in `Search.razor.cs` by applying the configured filters to each jobs accumulated results before binding them to `DynamicTable` (similar to qBittorrents `search.js` behaviour). Consider extracting a helper (`SearchFilterHelper`) for readability.
- Include UI affordances for empty states (no plugins installed, no searches yet) and a “Manage plugins…” button that opens the plugin dialog.
- Files impacted: `Lantean.QBTMud/Pages/Search.razor`, `Lantean.QBTMud/Pages/Search.razor.cs`, `Lantean.QBTMud/Models/SearchForm.cs`, new helper under `Lantean.QBTMud/Helpers/SearchFilterHelper.cs`.
### 3. Search Results Table & Row Actions
- Expand the column definitions to match v5 (`Name`, `Size`, `Seeders`, `Leechers`, `Engine`, `Site`, `Published`, optional `Actions`). Update `ColumnsDefinitions` in `Search.razor.cs` and ensure `DynamicTable` can render links/buttons inside rows.
- Add a row-action menu leveraging `DynamicTable`s `OnTableDataContextMenu` to provide “Download”, “Open description”, “Copy → Name/Download link/Description URL” options. Implement the handlers in the code-behind, reusing `DialogHelper.InvokeAddTorrentLinkDialog` and clipboard utilities.
- Track and display the visible vs total result counts per job (using `SearchResults.Total` + post-filter counts) and surface in the UI header.
- Support incremental result loading by requesting in batches (e.g., 200-500 items) with offset; append to the jobs result list and trigger table refresh without re-fetching the full dataset.
- Files impacted: `Lantean.QBTMud/Pages/Search.razor`, `Lantean.QBTMud/Pages/Search.razor.cs`, possibly `Lantean.QBTMud/Components/UI/DynamicTable.razor.cs` (if new hooks required), clipboard utilities in `Lantean.QBTMud/Helpers`.
### 4. Search Plugin Management Experience
- Create a dialog (e.g., `SearchPluginsDialog.razor` + `.razor.cs`) presenting the plugin list with columns for enabled, name, version, URL, and last update. Include actions to enable/disable (batch), uninstall, install from URL/path, and update all.
- Wire the dialog into the search page “Manage plugins…” button and optionally from settings. Ensure optimistic UI updates after each command with error fallback.
- Provide basic validation for install sources (URL/local path) and progress feedback (loading spinner, success/fail toasts).
- Files impacted: new component under `Lantean.QBTMud/Components/Dialogs/SearchPluginsDialog.*`, updates to `Lantean.QBTMud/Helpers/DialogHelper.cs` (shortcut methods), and `Lantean.QBTMud/Pages/Search.razor` for invocation.
### 5. Client & Model Updates
- Update `Lantean.QBitTorrentClient/Models/SearchResult.cs` to include `EngineName`, `SiteUrl` (already), and `PublishedOn` (`pubDate`) properties with appropriate JSON bindings. Adjust constructors and equality semantics accordingly.
- Audit `Lantean.QBTMud` consumers for the new properties and update them to display `EngineName` instead of reusing `SiteUrl` for plugin name.
- Validate whether `SearchStatus` needs extra fields (e.g., `Plugin`) in v5 API responses; extend the model if necessary and adapt `ApiClientSearchTests` fixtures.
- Ensure `StartSearch` can accept “enabled” and multi-plugin input. Update `DoSearch` to send either `["enabled"]` or the selected plugin names without wrapping them in an array when empty. Handle cases where no plugin is selected gracefully.
- Files impacted: `Lantean.QBitTorrentClient/Models/SearchResult.cs`, `Lantean.QBitTorrentClient/Models/SearchStatus.cs` (if needed), `Lantean.QBitTorrentClient/ApiClient.cs`, `Lantean.QBitTorrentClient.Test/ApiClientSearchTests.cs`, downstream mapping code in `Lantean.QBTMud`.
### 6. Testing & Validation
- Unit tests: extend `ApiClientSearchTests` to cover the new `SearchResult` fields and multi-plugin payload logic. Add tests for the filter helper to ensure parity with v5 behaviour (min/max seeds/size, pattern matching, search-in scope).
- Component/integration tests: create bUnit tests for the search page covering (a) job creation and stop flow, (b) filtering behaviour, and (c) context menu actions invoking expected API calls or helper methods.
- Manual QA checklist: verify multi-job tabs, plugin install/uninstall flows, incremental result loading, download actions, and resilience to API failures (404, timeouts). Include mobile viewport sanity checks for responsive layout.
## Assumptions & Open Questions
- qBittorrent v5 continues to expose `engineName` and `pubDate` fields; confirm with a sample response before implementing.
- Determine whether search results should persist across sessions (v5 clears on reload); initial plan assumes in-memory only.
- Confirm availability of clipboard services within existing helper infrastructure or add a consistent abstraction.
- Check whether MudBlazor can render high-density tab headers akin to v5; if not, consider a vertical `MudNavMenu` for job selection.
## Suggested Sequencing
- Stage 1: Model/client updates + unit tests (ensures data shapes are correct).
- Stage 2: Search page refactor to multi-job architecture (retain basic table).
- Stage 3: Layer in advanced filters and result actions.
- Stage 4: Add plugin management dialog and wiring.
- Stage 5: Polish UX (counts, empty states, toasts) and execute full QA pass.

99
Unit-Testing-Plan.md Normal file
View File

@@ -0,0 +1,99 @@
# bUnit Coverage Expansion Plan
## Objectives
- Establish a modern component testing stack for `Lantean.QBTMud` using bUnit + xUnit so critical UI flows can be validated without manual regression.
- Provide structured guidance for converting the existing placeholder tests into meaningful component coverage, prioritising high-value pages and shared UI primitives.
- Ensure the plan dovetails with the broader qBittorrent v5 alignment work (e.g., new search experience, torrent actions, dialogs).
## Current Test Landscape
- `Lantean.QBTMud.Test` is already configured as an xUnit project but contains only experimental/unit scaffolding (`UnitTest1.cs`). No component tests run today.
- No bUnit, MudBlazor test services, or HTTP abstractions are wired into the test project; dependency injection for components (e.g., `IApiClient`, `ILocalStorageService`, `IDialogService`) is unmocked.
- CI expectations for UI regression coverage are unclear; codifying a baseline will help future contributors.
## HighLevel Strategy
1. **Lay the foundation**: add bUnit/MudBlazor testing dependencies, create reusable test context helpers, and introduce typed doubles for frequently injected services.
2. **Cover critical views first**: prioritise pages and components with complex state or upcoming rewrites (Search page, Torrent list, dialogs).
3. **Expand outward**: incrementally add tests for navigation/layout wrappers, filter components, and shared dialogs as new features land.
4. **Integrate with CI**: ensure `dotnet test` executes component tests locally and on pipelines, with fixtures structured for parallel execution.
5. **Adopt guardrails**: document patterns and required assertions so new components ship with tests by default.
## Implementation Steps
### 1. Test Project Setup
- Update `Lantean.QBTMud.Test.csproj`:
- Add packages: `bunit`, `bunit.xunit`, `Bunit.Moq`, `MudBlazor.Services`, `Microsoft.Extensions.DependencyInjection`, `Moq`, and `AwesomeAssertions`.
- Enable nullable warnings consistency by mirroring app project settings.
- Create a `TestImports.cs` file with global usings for bUnit, MudBlazor, Moq/NSubstitute, and the app namespaces to reduce boilerplate.
- Replace existing placeholder tests with a `SmokeTests` folder reserved for minimal sanity checks.
### 2. Shared Test Infrastructure
- Introduce `ComponentTestContext : TestContext` (or extension methods) under `Lantean.QBTMud.Test/Infrastructure` to centralise DI setup:
- Register MudBlazor services (`Services.AddMudServices()`), NavigationManager (FakeNav), configuration, and logging stubs.
- Provide helper `AddApiClientMock`, `AddLocalStorageMock`, etc., returning strongly typed mocks or substitutes.
- Ensure deterministic `ILocalStorageService` by using `Blazored.LocalStorage`s in-memory implementation or a bespoke stub.
- Expose utility methods (`RenderComponentWithServices<TComponent>(Action<IServiceCollection>?)`) so tests can override specific dependencies.
- Add snapshot helpers for table row extraction, dialog inspection, and event dispatch (e.g., clicking buttons, submitting forms).
### 3. Search Page Coverage (High Priority)
- Create `Search` test suite (aligning with Search-Implementation plan):
- **Form rendering**: assert initial state (default plugin/category selection, button text) and dynamic behaviour (Stop label once a job starts).
- **Search lifecycle**: mock `IApiClient` to return synthetic plugin lists, search IDs, and results. Validate that `StartSearch` is called with expected payloads and that subsequent renders display fetched rows.
- **Job management UI**: when multi-job support ships, verify tab/list rendering, status icons, and ability to stop/delete jobs on user interaction.
- **Client-side filters**: stub job results and assert that seeds/size/search-scope filters adjust the rendered rows and visible totals.
- **Context menu actions**: simulate row right-click and ensure download/copy handlers invoke the right helper methods (`DialogHelper`, clipboard service).
### 4. Torrent List & Filters
- Cover `Pages/TorrentList.razor` with focus on:
- Toolbar state (search box debounce, filter chips, action menus).
- Interaction with cascaded `MainData` and `SearchTermChanged` callbacks.
- Row selection + bulk action context menus (mock API calls via injected services).
- Add tests for `Components/FiltersNav.razor` verifying bucket counts, selection, and tracker/category pipes once filter logic is upgraded.
- Validate `FilterHelper` behaviours via dedicated unit tests if not already covered (regex toggle, field selection, status buckets).
### 5. Dialog & Action Components
- For each Mud dialog (e.g., `AddTorrentFileDialog`, `ColumnOptionsDialog`, upcoming `SearchPluginsDialog`):
- Render inside a `DialogService` test host, populate parameters, trigger submission, and assert returned `DialogResult` data.
- Mock `IApiClient` interactions (upload torrent, enable plugin). Ensure failures surface error UI (snackbar/toasts) when applicable.
- Test `DialogHelper` extension methods by invoking them within the test context and verifying underlying service calls.
### 6. Layout & Navigation
- Test `Layout/LoggedInLayout.razor` and `Layout/ListLayout.razor` for:
- Drawer toggling logic, search cascades, and navigation events (`NavigationManager.NavigateTo`).
- Correct propagation of `CascadingValue`s to child components using a stub child that records received values.
- Ensure top-level routes (e.g., `/`, `/search`, `/settings`) render expected components via `Router` tests or minimal `App.razor` integration tests.
### 7. Regression Harness & Tooling
- Configure `dotnet test` to run with `--filter "FullyQualifiedName~Lantean.QBTMud"` to focus on component tests during local workflows; optionally add a separate github action job for UI tests.
- Implement deterministic snapshot helpers (HTML normalisation) only if comparisons are stable; otherwise rely on semantic assertions (CSS class presence, text, event invocation).
- Document new testing conventions in `CONTRIBUTING.md` or a dedicated `docs/testing.md` entry (how to add bUnit tests, service registration patterns, use of mocks).
## Component Prioritisation Checklist
1. **Critical flows**: Search page, Torrent list, Add torrent dialogs, Share ratio dialog.
2. **High churn components**: Filters, status navigation, upcoming tracker changes.
3. **Shared UI primitives**: `DynamicTable`, `FieldSwitch`, `SortLabel`—ensure core behaviours (sorting, column selection, local storage state) are verified.
4. **Error states**: offline mode (`MainData.LostConnection`), failed API calls, and empty lists (no torrents, no plugins).
## Testing Utilities to Build
- `ApiClientMockBuilder`: fluent helper returning mocks with queued responses for search/torrent operations.
- `LocalStorageInMemory`: simple implementation capturing set/get, supporting assertions on persisted keys (column selections, search filters).
- `EventDispatcher`: wraps `IRenderedComponent<T>` to simplify firing click/submit/change events on MudBlazor controls (abstracts CSS selectors).
- `DialogHostDriver`: orchestrates rendering a dialog and extracting returned data without duplicating boilerplate.
## Deliverables & Milestones
1. **Sprint 1**: project setup, base infrastructure, smoke test rendering of home/search pages.
2. **Sprint 2**: full coverage for Search page (form, lifecycle, filters) with mocked API flows.
3. **Sprint 3**: torrent list + filters + column options dialog tests; measure coverage delta.
4. **Sprint 4**: dialogs/actions (add torrent, share ratio, plugin management), plus regression fixtures for navigation layouts.
5. **Ongoing**: integrate with CI, enforce new component tests as part of definition of done.
## Open Questions / Assumptions
- Determine preferred mocking framework (current packages include AwesomeAssertions; decide whether to standardise on Moq or NSubstitute).
- Confirm availability of clipboard/browser APIs within test environment; may need to wrap them for deterministic testing.
- Decide on snapshot vs semantic assertions for DynamicTable output—HTML may be verbose; consider helper methods to parse table rows into POCOs before asserting.
- Validate whether UI tests must run under multiple cultures/themes; if so, extend test context to toggle `MudTheme` or culture info.
## Next Steps
- Review and align on tooling choices (Moq vs NSubstitute, FluentAssertions adoption).
- Implement Step 12 in a feature branch, replacing placeholder tests with the shared infrastructure and a first Search page smoke test.
- Iterate on the checklist as new UI work (Search parity, tracker filters) lands to keep tests in lockstep with features.

25
Upgrade-To-v5-Planning.md Normal file
View File

@@ -0,0 +1,25 @@
# 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.~~
## ~~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.).~~
- ~~**Tracker grouping & removal**: When grouping trackers by host in `FiltersNav`, retain original URL entries so removal can target the right string. Replace the placeholder "Remove tracker" action with a real implementation and disable it for synthetic buckets.~~
## ~~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`.~~
- ~~**Helper updates**: Extend `DisplayHelpers` to format the new fields (popularity, private flag, info hashes, error state icons).~~
## Actions & Dialogs
- ~~**Copy submenu**: Add "Copy comment" and "Copy content path" to the copy submenu in `TorrentActions`, keeping clipboard behaviour identical to v5.~~
- ~~**Share ratio dialog**: Update `ShareRatioDialog`, `ShareRatio/ShareRatioMax`, and `DialogHelper.InvokeShareRatioDialog` to surface `ShareLimitAction`, fix the `MaxInactiveSeedingTime` mapping, and call `SetTorrentShareLimit` with the action.~~
## ~~Add-Torrent Flow~~
- ~~Mirror the v5 add-torrent pane: add controls for incomplete save path, tags, auto-start, queue position, share-limit action, etc., in `AddTorrentOptions.razor`, and wire the new fields into the submission object.~~
## ~~Preferences & Local Settings~~
- ~~Introduce new v5 toggles such as "Display full tracker URL" in `AdvancedOptions`, persist them via the preferences service, and respect the setting in the tracker column rendering.~~

79
src/AGENTS.md Normal file
View File

@@ -0,0 +1,79 @@
# Code Generation Rules
## Expectations
- Code must be technically precise, unambiguous, and avoid bad practices.
- Keep code consistent with the coding standards below.
- Follow Microsoft's official best practices for C#, Razor, and SQL.
- Adhere to SOLID and DRY principles.
- Avoid security vulnerabilities and common pitfalls.
- Write clean, self-documenting, readable code; use inline comments only where needed.
- Always include XML documentation on public methods with `<summary>`, `<param>`, and `<returns>` tags.
- Structure error and exception messages clearly, with correct grammar and punctuation.
- Design thoughtfully with proper async usage, memory safety, and dependency injection.
- Prioritize maintainability, testability, and scalability.
## Coding Standards
### Naming
- Use PascalCase for classes, records, structs, methods, properties, and public fields.
- Use _camelCase for private fields and private constants.
- Use PascalCase for public constants.
- Use camelCase for local variables and methods.
- Interfaces must begin with `I`.
### Formatting
- Braces on a new line and never omitted.
- Use blank lines where appropriate to improve readability.
- Expression-bodied members are allowed only for get-only properties; methods must use block bodies.
- Member order:
1. Constants
2. Static properties/fields
3. Private fields
4. Private properties
5. Public fields
6. Public properties
7. Constructor
8. Public instance methods
9. Private instance methods
10. Public static methods
11. Private static methods
### Coding Practices
- Use `var` wherever possible unless it harms clarity.
- Enable and properly use nullable reference types.
- Always specify access modifiers, even when the default applies.
- Use `async` only when needed; append `Async` only if a synchronous counterpart exists.
- Prefer LINQ for simple operations; use loops for complex logic.
- Do not use exceptions for flow control.
### Design
- Use constructor injection only, unless absolutely necessary (for example, in Blazor).
- Static methods and classes are acceptable when appropriate.
- Avoid partial classes in user code unless generated.
- Use `record` for data-only objects.
- Extension methods are permitted and should follow standard naming conventions.
### Documentation
- XML documentation comments are required on all public methods:
- Include `<summary>`, `<param>` (if applicable), and `<returns>` (when needed).
- Use inline comments sparingly and only to explain complex or non-obvious logic.
- Place attributes one per line.
- Only one type per file; the file name must match the type.
- Exception: multiple generic variants of the same type may share a file if small and strongly related.
## Enforcement
- Generate C# code that follows these standards exactly.
- If existing code does not follow these rules, call it out explicitly before proceeding.
## Pre-flight checklist (agents must confirm)
- [ ] Standards here are applied to all generated code.
- [ ] Nullable reference types are enabled and used correctly.
- [ ] Public methods include XML docs with proper tags.
- [ ] Braces are never omitted; no expression-bodied methods (except get-only properties).
- [ ] Async usage is justified; `Async` suffix only when a sync counterpart exists.
- [ ] Member order matches the specified list.
- [ ] Access modifiers are explicit everywhere.
- [ ] LINQ used for simple ops; loops for complex logic.
- [ ] No exceptions are used for flow control.
- [ ] Design follows DI, SOLID, DRY; security pitfalls avoided.
- [ ] Any conflicts with existing code are reported for clarification.

View File

@@ -0,0 +1,121 @@
@using Lantean.QBitTorrentClient.Models
<MudGrid>
<MudItem xs="12">
<MudSwitch Label="Additional Options" @bind-Value="Expanded" LabelPlacement="Placement.End" />
</MudItem>
</MudGrid>
<MudCollapse Expanded="Expanded">
<MudGrid Class="mt-2">
<MudItem xs="12">
<MudSelect T="bool" Label="Torrent management mode" Value="@TorrentManagementMode" ValueChanged="@SetTorrentManagementMode" Variant="Variant.Outlined">
<MudSelectItem Value="@false">Manual</MudSelectItem>
<MudSelectItem Value="@true">Automatic</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6">
<MudTextField T="string" Label="Save files to location" Value="@SavePath" ValueChanged="@SavePathChanged" Variant="Variant.Outlined" Disabled="@TorrentManagementMode" />
</MudItem>
<MudItem xs="12" sm="6">
<FieldSwitch Label="Use incomplete save path" Value="@UseDownloadPath" ValueChanged="@SetUseDownloadPath" Disabled="@TorrentManagementMode" />
</MudItem>
<MudItem xs="12">
<MudTextField T="string" Label="Incomplete save path" Value="@DownloadPath" ValueChanged="@DownloadPathChanged" Variant="Variant.Outlined" Disabled="@DownloadPathDisabled" />
</MudItem>
@if (ShowCookieOption)
{
<MudItem xs="12">
<MudTextField Label="Cookie" @bind-Value="Cookie" Variant="Variant.Outlined" />
</MudItem>
}
<MudItem xs="12">
<MudTextField Label="Rename" @bind-Value="RenameTorrent" Variant="Variant.Outlined" />
</MudItem>
<MudItem xs="12">
<MudSelect T="string" Label="Category" Value="@Category" ValueChanged="@CategoryChanged" Variant="Variant.Outlined" Clearable="true">
<MudSelectItem Value="@string.Empty">None</MudSelectItem>
@foreach (var category in CategoryOptions)
{
<MudSelectItem Value="@category.Name">@category.Name</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12">
<MudSelect T="string" Label="Tags" Variant="Variant.Outlined" MultiSelection="true" SelectedValues="@SelectedTags" SelectedValuesChanged="@SelectedTagsChanged" Disabled="@(AvailableTags.Count == 0)">
@foreach (var tag in AvailableTags)
{
<MudSelectItem Value="@tag">@tag</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12">
<FieldSwitch Label="Start torrent" @bind-Value="StartTorrent" />
</MudItem>
<MudItem xs="12">
<FieldSwitch Label="Add to top of queue" @bind-Value="AddToTopOfQueue" />
</MudItem>
<MudItem xs="12">
<MudSelect T="string" Label="Stop condition" Value="@StopCondition" ValueChanged="@StopConditionChanged" Variant="Variant.Outlined">
<MudSelectItem Value="@("None")">None</MudSelectItem>
<MudSelectItem Value="@("MetadataReceived")">Metadata received</MudSelectItem>
<MudSelectItem Value="@("FilesChecked")">Files checked</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12">
<FieldSwitch Label="Skip hash check" @bind-Value="SkipHashCheck" />
</MudItem>
<MudItem xs="12">
<MudSelect T="string" Label="Content layout" Value="@ContentLayout" ValueChanged="@ContentLayoutChanged" Variant="Variant.Outlined">
<MudSelectItem Value="@("Original")">Original</MudSelectItem>
<MudSelectItem Value="@("Subfolder")">Create subfolder</MudSelectItem>
<MudSelectItem Value="@("NoSubfolder")">Don't create subfolder</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12">
<FieldSwitch Label="Download in sequential order" @bind-Value="DownloadInSequentialOrder" />
</MudItem>
<MudItem xs="12">
<FieldSwitch Label="Download first and last pieces first" @bind-Value="DownloadFirstAndLastPiecesFirst" />
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField Label="Limit download rate" @bind-Value="DownloadLimit" Variant="Variant.Outlined" Min="0" />
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField Label="Limit upload rate" @bind-Value="UploadLimit" Variant="Variant.Outlined" Min="0" />
</MudItem>
<MudItem xs="12">
<MudSelect T="ShareLimitMode" Label="Share limit preset" Value="@SelectedShareLimitMode" ValueChanged="@ShareLimitModeChanged" Variant="Variant.Outlined">
<MudSelectItem Value="@ShareLimitMode.Global">Use global share limit</MudSelectItem>
<MudSelectItem Value="@ShareLimitMode.NoLimit">Set no share limit</MudSelectItem>
<MudSelectItem Value="@ShareLimitMode.Custom">Set custom share limit</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="4">
<FieldSwitch Label="Ratio" Value="@RatioLimitEnabled" ValueChanged="@RatioLimitEnabledChanged" Disabled="@(!IsCustomShareLimit)" />
</MudItem>
<MudItem xs="12" sm="8">
<MudNumericField T="float" Label="Ratio limit" Value="@RatioLimit" ValueChanged="@RatioLimitChanged" Disabled="@(!RatioLimitEnabled || !IsCustomShareLimit)" Min="0" Step="0.1f" Format="F2" Variant="Variant.Outlined" />
</MudItem>
<MudItem xs="12" sm="4">
<FieldSwitch Label="Total minutes" Value="@SeedingTimeLimitEnabled" ValueChanged="@SeedingTimeLimitEnabledChanged" Disabled="@(!IsCustomShareLimit)" />
</MudItem>
<MudItem xs="12" sm="8">
<MudNumericField T="int" Label="Total minutes" Value="@SeedingTimeLimit" ValueChanged="@SeedingTimeLimitChanged" Disabled="@(!SeedingTimeLimitEnabled || !IsCustomShareLimit)" Min="1" Variant="Variant.Outlined" />
</MudItem>
<MudItem xs="12" sm="4">
<FieldSwitch Label="Inactive minutes" Value="@InactiveSeedingTimeLimitEnabled" ValueChanged="@InactiveSeedingTimeLimitEnabledChanged" Disabled="@(!IsCustomShareLimit)" />
</MudItem>
<MudItem xs="12" sm="8">
<MudNumericField T="int" Label="Inactive minutes" Value="@InactiveSeedingTimeLimit" ValueChanged="@InactiveSeedingTimeLimitChanged" Disabled="@(!InactiveSeedingTimeLimitEnabled || !IsCustomShareLimit)" Min="1" Variant="Variant.Outlined" />
</MudItem>
<MudItem xs="12">
<MudSelect T="ShareLimitAction" Label="Action when limit is reached" Value="@SelectedShareLimitAction" ValueChanged="@ShareLimitActionChanged" Disabled="@(!IsCustomShareLimit)" Variant="Variant.Outlined">
<MudSelectItem Value="@ShareLimitAction.Default">Default</MudSelectItem>
<MudSelectItem Value="@ShareLimitAction.Stop">Stop torrent</MudSelectItem>
<MudSelectItem Value="@ShareLimitAction.Remove">Remove torrent</MudSelectItem>
<MudSelectItem Value="@ShareLimitAction.RemoveWithContent">Remove torrent and data</MudSelectItem>
<MudSelectItem Value="@ShareLimitAction.EnableSuperSeeding">Enable super seeding</MudSelectItem>
</MudSelect>
</MudItem>
</MudGrid>
</MudCollapse>

View File

@@ -0,0 +1,440 @@
using Lantean.QBitTorrentClient;
using Lantean.QBitTorrentClient.Models;
using Lantean.QBTMud.Models;
using Microsoft.AspNetCore.Components;
namespace Lantean.QBTMud.Components.Dialogs
{
public partial class AddTorrentOptions
{
private readonly List<CategoryOption> _categoryOptions = new();
private readonly Dictionary<string, CategoryOption> _categoryLookup = new(StringComparer.Ordinal);
private string _manualSavePath = string.Empty;
private bool _manualUseDownloadPath;
private string _manualDownloadPath = string.Empty;
private string _defaultSavePath = string.Empty;
private string _defaultDownloadPath = string.Empty;
private bool _defaultDownloadPathEnabled;
[Inject]
protected IApiClient ApiClient { get; set; } = default!;
[Parameter]
public bool ShowCookieOption { get; set; }
protected bool Expanded { get; set; }
protected bool TorrentManagementMode { get; set; }
protected string SavePath { get; set; } = string.Empty;
protected string DownloadPath { get; set; } = string.Empty;
protected bool UseDownloadPath { get; set; }
protected bool DownloadPathDisabled => TorrentManagementMode || !UseDownloadPath;
protected string? Cookie { get; set; }
protected string? RenameTorrent { get; set; }
protected IReadOnlyList<CategoryOption> CategoryOptions => _categoryOptions;
protected string? Category { get; set; } = string.Empty;
protected List<string> AvailableTags { get; private set; } = [];
protected HashSet<string> SelectedTags { get; private set; } = new(StringComparer.Ordinal);
protected bool StartTorrent { get; set; } = true;
protected bool AddToTopOfQueue { get; set; } = true;
protected string StopCondition { get; set; } = "None";
protected bool SkipHashCheck { get; set; }
protected string ContentLayout { get; set; } = "Original";
protected bool DownloadInSequentialOrder { get; set; }
protected bool DownloadFirstAndLastPiecesFirst { get; set; }
protected long DownloadLimit { get; set; }
protected long UploadLimit { get; set; }
protected ShareLimitMode SelectedShareLimitMode { get; set; } = ShareLimitMode.Global;
protected bool RatioLimitEnabled { get; set; }
protected float RatioLimit { get; set; } = 1.0f;
protected bool SeedingTimeLimitEnabled { get; set; }
protected int SeedingTimeLimit { get; set; } = 1440;
protected bool InactiveSeedingTimeLimitEnabled { get; set; }
protected int InactiveSeedingTimeLimit { get; set; } = 1440;
protected ShareLimitAction SelectedShareLimitAction { get; set; } = ShareLimitAction.Default;
protected bool IsCustomShareLimit => SelectedShareLimitMode == ShareLimitMode.Custom;
protected override async Task OnInitializedAsync()
{
var categories = await ApiClient.GetAllCategories();
foreach (var (name, value) in categories.OrderBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
{
var option = new CategoryOption(name, value.SavePath, value.DownloadPath);
_categoryOptions.Add(option);
_categoryLookup[name] = option;
}
var tags = await ApiClient.GetAllTags();
AvailableTags = tags.OrderBy(t => t, StringComparer.OrdinalIgnoreCase).ToList();
var preferences = await ApiClient.GetApplicationPreferences();
TorrentManagementMode = preferences.AutoTmmEnabled;
_defaultSavePath = preferences.SavePath ?? string.Empty;
_manualSavePath = _defaultSavePath;
SavePath = _defaultSavePath;
_defaultDownloadPath = preferences.TempPath ?? string.Empty;
_defaultDownloadPathEnabled = preferences.TempPathEnabled;
_manualDownloadPath = _defaultDownloadPath;
_manualUseDownloadPath = preferences.TempPathEnabled;
UseDownloadPath = _manualUseDownloadPath;
DownloadPath = UseDownloadPath ? _manualDownloadPath : string.Empty;
StartTorrent = !preferences.AddStoppedEnabled;
AddToTopOfQueue = preferences.AddToTopOfQueue;
StopCondition = preferences.TorrentStopCondition;
ContentLayout = preferences.TorrentContentLayout;
RatioLimitEnabled = preferences.MaxRatioEnabled;
RatioLimit = preferences.MaxRatio;
SeedingTimeLimitEnabled = preferences.MaxSeedingTimeEnabled;
if (preferences.MaxSeedingTimeEnabled)
{
SeedingTimeLimit = preferences.MaxSeedingTime;
}
InactiveSeedingTimeLimitEnabled = preferences.MaxInactiveSeedingTimeEnabled;
if (preferences.MaxInactiveSeedingTimeEnabled)
{
InactiveSeedingTimeLimit = preferences.MaxInactiveSeedingTime;
}
SelectedShareLimitAction = MapShareLimitAction(preferences.MaxRatioAct);
if (TorrentManagementMode)
{
ApplyAutomaticPaths();
}
}
protected void SetTorrentManagementMode(bool value)
{
if (TorrentManagementMode == value)
{
return;
}
TorrentManagementMode = value;
if (TorrentManagementMode)
{
ApplyAutomaticPaths();
}
else
{
RestoreManualPaths();
}
}
protected void SavePathChanged(string value)
{
SavePath = value;
if (!TorrentManagementMode)
{
_manualSavePath = value;
}
}
protected void SetUseDownloadPath(bool value)
{
if (TorrentManagementMode)
{
return;
}
_manualUseDownloadPath = value;
UseDownloadPath = value;
if (value)
{
if (string.IsNullOrWhiteSpace(_manualDownloadPath))
{
_manualDownloadPath = string.IsNullOrWhiteSpace(_defaultDownloadPath) ? string.Empty : _defaultDownloadPath;
}
DownloadPath = _manualDownloadPath;
}
else
{
_manualDownloadPath = DownloadPath;
DownloadPath = string.Empty;
}
}
protected void DownloadPathChanged(string value)
{
DownloadPath = value;
if (!TorrentManagementMode && UseDownloadPath)
{
_manualDownloadPath = value;
}
}
protected void CategoryChanged(string? value)
{
Category = string.IsNullOrWhiteSpace(value) ? null : value;
if (TorrentManagementMode)
{
ApplyAutomaticPaths();
}
}
protected void SelectedTagsChanged(IEnumerable<string> tags)
{
SelectedTags = tags is null
? new HashSet<string>(StringComparer.Ordinal)
: new HashSet<string>(tags, StringComparer.Ordinal);
}
protected void StopConditionChanged(string value)
{
StopCondition = value;
}
protected void ContentLayoutChanged(string value)
{
ContentLayout = value;
}
protected void ShareLimitModeChanged(ShareLimitMode mode)
{
SelectedShareLimitMode = mode;
if (mode != ShareLimitMode.Custom)
{
RatioLimitEnabled = false;
SeedingTimeLimitEnabled = false;
InactiveSeedingTimeLimitEnabled = false;
SelectedShareLimitAction = ShareLimitAction.Default;
}
}
protected void RatioLimitEnabledChanged(bool value)
{
RatioLimitEnabled = value;
}
protected void RatioLimitChanged(float value)
{
RatioLimit = value;
}
protected void SeedingTimeLimitEnabledChanged(bool value)
{
SeedingTimeLimitEnabled = value;
}
protected void SeedingTimeLimitChanged(int value)
{
SeedingTimeLimit = value;
}
protected void InactiveSeedingTimeLimitEnabledChanged(bool value)
{
InactiveSeedingTimeLimitEnabled = value;
}
protected void InactiveSeedingTimeLimitChanged(int value)
{
InactiveSeedingTimeLimit = value;
}
protected void ShareLimitActionChanged(ShareLimitAction value)
{
SelectedShareLimitAction = value;
}
public TorrentOptions GetTorrentOptions()
{
var options = new TorrentOptions(
TorrentManagementMode,
_manualSavePath,
Cookie,
RenameTorrent,
string.IsNullOrWhiteSpace(Category) ? null : Category,
StartTorrent,
AddToTopOfQueue,
StopCondition,
SkipHashCheck,
ContentLayout,
DownloadInSequentialOrder,
DownloadFirstAndLastPiecesFirst,
DownloadLimit,
UploadLimit);
options.UseDownloadPath = TorrentManagementMode ? null : UseDownloadPath;
options.DownloadPath = (!TorrentManagementMode && UseDownloadPath) ? DownloadPath : null;
options.Tags = SelectedTags.Count > 0 ? SelectedTags.ToArray() : null;
switch (SelectedShareLimitMode)
{
case ShareLimitMode.Global:
options.RatioLimit = Limits.GlobalLimit;
options.SeedingTimeLimit = Limits.GlobalLimit;
options.InactiveSeedingTimeLimit = Limits.GlobalLimit;
options.ShareLimitAction = ShareLimitAction.Default.ToString();
break;
case ShareLimitMode.NoLimit:
options.RatioLimit = Limits.NoLimit;
options.SeedingTimeLimit = Limits.NoLimit;
options.InactiveSeedingTimeLimit = Limits.NoLimit;
options.ShareLimitAction = ShareLimitAction.Default.ToString();
break;
case ShareLimitMode.Custom:
options.RatioLimit = RatioLimitEnabled ? RatioLimit : Limits.NoLimit;
options.SeedingTimeLimit = SeedingTimeLimitEnabled ? SeedingTimeLimit : Limits.NoLimit;
options.InactiveSeedingTimeLimit = InactiveSeedingTimeLimitEnabled ? InactiveSeedingTimeLimit : Limits.NoLimit;
options.ShareLimitAction = SelectedShareLimitAction.ToString();
break;
}
return options;
}
private void ApplyAutomaticPaths()
{
SavePath = ResolveAutomaticSavePath();
var (enabled, path) = ResolveAutomaticDownloadPath();
UseDownloadPath = enabled;
DownloadPath = enabled ? path ?? string.Empty : string.Empty;
}
private void RestoreManualPaths()
{
SavePath = _manualSavePath;
UseDownloadPath = _manualUseDownloadPath;
DownloadPath = _manualUseDownloadPath ? _manualDownloadPath : string.Empty;
}
private string ResolveAutomaticSavePath()
{
var category = GetSelectedCategory();
if (category is null)
{
return _defaultSavePath;
}
if (!string.IsNullOrWhiteSpace(category.SavePath))
{
return category.SavePath!;
}
if (!string.IsNullOrWhiteSpace(_defaultSavePath) && !string.IsNullOrWhiteSpace(category.Name))
{
return Path.Combine(_defaultSavePath, category.Name);
}
return _defaultSavePath;
}
private (bool Enabled, string? Path) ResolveAutomaticDownloadPath()
{
var category = GetSelectedCategory();
if (category is null)
{
if (!_defaultDownloadPathEnabled)
{
return (false, string.Empty);
}
return (true, _defaultDownloadPath);
}
if (category.DownloadPath is null)
{
if (!_defaultDownloadPathEnabled)
{
return (false, string.Empty);
}
return (true, ComposeDefaultDownloadPath(category.Name));
}
if (!category.DownloadPath.Enabled)
{
return (false, string.Empty);
}
if (!string.IsNullOrWhiteSpace(category.DownloadPath.Path))
{
return (true, category.DownloadPath.Path);
}
return (true, ComposeDefaultDownloadPath(category.Name));
}
private string ComposeDefaultDownloadPath(string categoryName)
{
if (string.IsNullOrWhiteSpace(_defaultDownloadPath))
{
return string.Empty;
}
if (string.IsNullOrWhiteSpace(categoryName))
{
return _defaultDownloadPath;
}
return Path.Combine(_defaultDownloadPath, categoryName);
}
private CategoryOption? GetSelectedCategory()
{
if (string.IsNullOrWhiteSpace(Category))
{
return null;
}
return _categoryLookup.TryGetValue(Category, out var option) ? option : null;
}
private static ShareLimitAction MapShareLimitAction(int preferenceValue)
{
return preferenceValue switch
{
0 => ShareLimitAction.Stop,
1 => ShareLimitAction.Remove,
2 => ShareLimitAction.RemoveWithContent,
3 => ShareLimitAction.EnableSuperSeeding,
_ => ShareLimitAction.Default
};
}
protected enum ShareLimitMode
{
Global,
NoLimit,
Custom
}
protected sealed record CategoryOption(string Name, string? SavePath, DownloadPathOption? DownloadPath);
}
}

View File

@@ -24,7 +24,7 @@ namespace Lantean.QBTMud.Components.Dialogs
protected IApiClient ApiClient { get; set; } = default!; protected IApiClient ApiClient { get; set; } = default!;
[Inject] [Inject]
protected IDataManager DataManager { get; set; } = default!; protected ITorrentDataManager DataManager { get; set; } = default!;
[Inject] [Inject]
protected ILocalStorageService LocalStorage { get; set; } = default!; protected ILocalStorageService LocalStorage { get; set; } = default!;

View File

@@ -53,7 +53,7 @@
<MudNumericField T="int" Label="Ignore Subsequent Matches for (0 to Disable)" Value="IgnoreDays" ValueChanged="IgnoreDaysChanged" Disabled="@(SelectedRuleName is null)" Variant="Variant.Outlined" /> <MudNumericField T="int" Label="Ignore Subsequent Matches for (0 to Disable)" Value="IgnoreDays" ValueChanged="IgnoreDaysChanged" Disabled="@(SelectedRuleName is null)" Variant="Variant.Outlined" />
</MudItem> </MudItem>
<MudItem xs="12"> <MudItem xs="12">
<MudSelect T="string" Label="Add paused" Value="AddPaused" ValueChanged="AddPausedChanged" Disabled="@(SelectedRuleName is null)" Variant="Variant.Outlined"> <MudSelect T="string" Label="Add stopped" Value="AddStopped" ValueChanged="AddStoppedChanged" Disabled="@(SelectedRuleName is null)" Variant="Variant.Outlined">
<MudSelectItem Value="@("default")">Use global settings</MudSelectItem> <MudSelectItem Value="@("default")">Use global settings</MudSelectItem>
<MudSelectItem Value="@("always")">Always</MudSelectItem> <MudSelectItem Value="@("always")">Always</MudSelectItem>
<MudSelectItem Value="@("never")">Never</MudSelectItem> <MudSelectItem Value="@("never")">Never</MudSelectItem>

View File

@@ -114,11 +114,11 @@ namespace Lantean.QBTMud.Components.Dialogs
SelectedRule.IgnoreDays = value; SelectedRule.IgnoreDays = value;
} }
protected string? AddPaused { get; set; } protected string? AddStopped { get; set; }
protected void AddPausedChanged(string value) protected void AddStoppedChanged(string value)
{ {
AddPaused = value; AddStopped = value;
switch (value) switch (value)
{ {
case "default": case "default":
@@ -273,15 +273,15 @@ namespace Lantean.QBTMud.Components.Dialogs
switch (SelectedRule.TorrentParams.Stopped) switch (SelectedRule.TorrentParams.Stopped)
{ {
case null: case null:
AddPaused = "default"; AddStopped = "default";
break; break;
case true: case true:
AddPaused = "always"; AddStopped = "always";
break; break;
case false: case false:
AddPaused = "never"; AddStopped = "never";
break; break;
} }

View File

@@ -0,0 +1,126 @@
<MudDialog Class="search-plugins-dialog">
<DialogContent>
@if (IsBusy)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-4" />
}
<MudGrid Class="mb-4" Spacing="2">
<MudItem xs="12" md="6">
<MudPaper Class="pa-4">
<MudText Typo="Typo.subtitle2" Class="mb-2">Install from URL</MudText>
<MudTextField @bind-Value="InstallUrl"
Placeholder="https://example.com/plugin.zip"
Variant="Variant.Outlined"
Disabled="OperationInProgress"
Immediate="true" />
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
Disabled="OperationInProgress || string.IsNullOrWhiteSpace(InstallUrl)"
OnClick="InstallFromUrl"
Class="mt-3"
StartIcon="@Icons.Material.Filled.CloudDownload">Install</MudButton>
</MudPaper>
</MudItem>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4">
<MudText Typo="Typo.subtitle2" Class="mb-2">Install from server path</MudText>
<MudTextField @bind-Value="InstallLocalPath"
Placeholder="/home/qbt/plugins/my-plugin.py"
Variant="Variant.Outlined"
Disabled="OperationInProgress"
Immediate="true" />
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
Disabled="OperationInProgress || string.IsNullOrWhiteSpace(InstallLocalPath)"
OnClick="InstallFromPath"
Class="mt-3"
StartIcon="@Icons.Material.Filled.UploadFile">Install</MudButton>
</MudPaper>
</MudItem>
</MudGrid>
<MudPaper Class="pa-3 mb-3">
<MudStack Row="true" Spacing="2">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Disabled="!HasSelection || OperationInProgress"
StartIcon="@Icons.Material.Filled.ToggleOn"
OnClick="EnableSelected">Enable</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Default"
Disabled="!HasSelection || OperationInProgress"
StartIcon="@Icons.Material.Filled.ToggleOff"
OnClick="DisableSelected">Disable</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Error"
Disabled="!HasSelection || OperationInProgress"
StartIcon="@Icons.Material.Filled.Delete"
OnClick="UninstallSelected">Uninstall</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Info"
Disabled="OperationInProgress || Plugins.Count == 0"
StartIcon="@Icons.Material.Filled.Update"
OnClick="UpdateAll">Update all</MudButton>
<MudSpacer />
<MudTooltip Text="Refresh plugins">
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Color="Color.Default"
Disabled="OperationInProgress"
OnClick="RefreshPlugins" />
</MudTooltip>
</MudStack>
</MudPaper>
<MudTable T="Lantean.QBitTorrentClient.Models.SearchPlugin"
Items="Plugins"
Hover="true"
Bordered="true"
Dense="true">
<HeaderContent>
<MudTh>Select</MudTh>
<MudTh>Enabled</MudTh>
<MudTh>Name</MudTh>
<MudTh>Identifier</MudTh>
<MudTh>Version</MudTh>
<MudTh>Last update</MudTh>
<MudTh>Source</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Select">
<MudIconButton Icon="@GetSelectionIcon(context)"
Color="@GetSelectionColor(context)"
Disabled="OperationInProgress"
OnClick="@(() => ToggleSelection(context))" />
</MudTd>
<MudTd DataLabel="Enabled">
<MudIconButton Icon="@GetEnabledIcon(context)"
Color="@GetEnabledColor(context)"
Disabled="OperationInProgress"
OnClick="@(() => TogglePlugin(context, !context.Enabled))" />
</MudTd>
<MudTd DataLabel="Name">
<MudText Typo="Typo.body2">@context.FullName</MudText>
</MudTd>
<MudTd DataLabel="Identifier">
<MudChip T="string" Color="Color.Secondary" Variant="Variant.Outlined" Size="Size.Small">@context.Name</MudChip>
</MudTd>
<MudTd DataLabel="Version">@context.Version</MudTd>
<MudTd DataLabel="Last update">@GetLastUpdatedText(context)</MudTd>
<MudTd DataLabel="Source">
@if (string.IsNullOrWhiteSpace(context.Url))
{
<MudText Typo="Typo.caption">Not provided</MudText>
}
else
{
<MudLink Href="@context.Url" Target="_blank">@context.Url</MudLink>
}
</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.subtitle2" Class="pa-4">No plugins installed.</MudText>
</NoRecordsContent>
</MudTable>
</DialogContent>
<DialogActions>
<MudButton Variant="Variant.Text" Color="Color.Primary" OnClick="CloseDialog">Close</MudButton>
</DialogActions>
</MudDialog>

View File

@@ -0,0 +1,241 @@
using System.Linq;
using System.Net.Http;
using Lantean.QBitTorrentClient;
using Lantean.QBitTorrentClient.Models;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace Lantean.QBTMud.Components.Dialogs
{
public partial class SearchPluginsDialog
{
[Inject]
protected IApiClient ApiClient { get; set; } = default!;
[Inject]
protected ISnackbar Snackbar { get; set; } = default!;
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = default!;
protected List<SearchPlugin> Plugins { get; set; } = [];
protected HashSet<string> SelectedPluginNames { get; set; } = [];
protected string? InstallUrl { get; set; }
protected string? InstallLocalPath { get; set; }
protected bool OperationInProgress { get; set; }
private bool _hasChanges;
private bool _loading;
protected bool IsBusy => _loading || OperationInProgress;
protected bool HasSelection => SelectedPluginNames.Count > 0;
protected override async Task OnInitializedAsync()
{
await LoadPlugins();
}
private async Task LoadPlugins()
{
_loading = true;
try
{
var response = await ApiClient.GetSearchPlugins();
Plugins = response is null ? [] : response.ToList();
SelectedPluginNames = [];
}
catch (Exception exception)
{
Snackbar.Add($"Failed to load search plugins: {exception.Message}", Severity.Error);
}
finally
{
_loading = false;
await InvokeAsync(StateHasChanged);
}
}
protected async Task InstallFromUrl()
{
var source = InstallUrl?.Trim();
if (string.IsNullOrEmpty(source))
{
return;
}
var success = await RunOperation(() => ApiClient.InstallSearchPlugins(source), "Plugin install queued.", true);
if (success)
{
InstallUrl = string.Empty;
}
}
protected async Task InstallFromPath()
{
var source = InstallLocalPath?.Trim();
if (string.IsNullOrEmpty(source))
{
return;
}
var success = await RunOperation(() => ApiClient.InstallSearchPlugins(source), "Plugin install queued.", true);
if (success)
{
InstallLocalPath = string.Empty;
}
}
protected async Task EnableSelected()
{
if (!HasSelection)
{
return;
}
var names = SelectedPluginNames.ToArray();
await RunOperation(() => ApiClient.EnableSearchPlugins(names), $"Enabled {names.Length} plugin(s).");
}
protected async Task DisableSelected()
{
if (!HasSelection)
{
return;
}
var names = SelectedPluginNames.ToArray();
await RunOperation(() => ApiClient.DisableSearchPlugins(names), $"Disabled {names.Length} plugin(s).");
}
protected async Task UninstallSelected()
{
if (!HasSelection)
{
return;
}
var names = SelectedPluginNames.ToArray();
await RunOperation(() => ApiClient.UninstallSearchPlugins(names), $"Removed {names.Length} plugin(s).");
}
protected async Task UpdateAll()
{
if (Plugins.Count == 0)
{
return;
}
await RunOperation(() => ApiClient.UpdateSearchPlugins(), "Plugin update queued.");
}
protected async Task TogglePlugin(SearchPlugin plugin, bool enable)
{
if (OperationInProgress)
{
return;
}
var previous = plugin.Enabled;
plugin.Enabled = enable;
var success = await RunOperation(
enable
? () => ApiClient.EnableSearchPlugins(plugin.Name)
: () => ApiClient.DisableSearchPlugins(plugin.Name),
enable ? $"Enabled {plugin.FullName}." : $"Disabled {plugin.FullName}.",
false);
if (!success)
{
plugin.Enabled = previous;
}
}
protected async Task RefreshPlugins()
{
await LoadPlugins();
}
protected bool IsSelected(SearchPlugin plugin)
{
return SelectedPluginNames.Contains(plugin.Name);
}
protected void ToggleSelection(SearchPlugin plugin)
{
if (!SelectedPluginNames.Add(plugin.Name))
{
SelectedPluginNames.Remove(plugin.Name);
}
StateHasChanged();
}
protected string GetSelectionIcon(SearchPlugin plugin)
{
return IsSelected(plugin) ? Icons.Material.Filled.CheckBox : Icons.Material.Outlined.CheckBoxOutlineBlank;
}
protected Color GetSelectionColor(SearchPlugin plugin)
{
return IsSelected(plugin) ? Color.Primary : Color.Default;
}
protected string GetEnabledIcon(SearchPlugin plugin)
{
return plugin.Enabled ? Icons.Material.Filled.ToggleOn : Icons.Material.Outlined.ToggleOff;
}
protected Color GetEnabledColor(SearchPlugin plugin)
{
return plugin.Enabled ? Color.Success : Color.Default;
}
protected string GetLastUpdatedText(SearchPlugin plugin)
{
// qBittorrent's search/plugins API does not expose the last update timestamp,
// so we display a placeholder until the client model is extended.
return "Not available";
}
protected void CloseDialog()
{
MudDialog.Close(DialogResult.Ok(_hasChanges));
}
private async Task<bool> RunOperation(Func<Task> operation, string successMessage, bool refresh = true)
{
OperationInProgress = true;
try
{
await operation();
Snackbar.Add(successMessage, Severity.Success);
_hasChanges = true;
if (refresh)
{
await LoadPlugins();
}
return true;
}
catch (HttpRequestException exception)
{
Snackbar.Add($"Search plugin operation failed: {exception.Message}", Severity.Error);
}
catch (InvalidOperationException exception)
{
Snackbar.Add($"Search plugin operation failed: {exception.Message}", Severity.Error);
}
finally
{
OperationInProgress = false;
}
return false;
}
}
}

View File

@@ -1,4 +1,5 @@
@inherits SubmittableDialog @inherits SubmittableDialog
@using Lantean.QBitTorrentClient.Models
<MudDialog> <MudDialog>
<DialogContent> <DialogContent>
@@ -34,6 +35,15 @@
<MudItem xs="9"> <MudItem xs="9">
<MudNumericField T="int" Value="InactiveMinutes" ValueChanged="InactiveMinutesChanged" Disabled="@(!(CustomEnabled && InactiveMinutesEnabled))" Min="1" Max="1024000" Variant="Variant.Outlined" Adornment="Adornment.End" AdornmentText="minutes" /> <MudNumericField T="int" Value="InactiveMinutes" ValueChanged="InactiveMinutesChanged" Disabled="@(!(CustomEnabled && InactiveMinutesEnabled))" Min="1" Max="1024000" Variant="Variant.Outlined" Adornment="Adornment.End" AdornmentText="minutes" />
</MudItem> </MudItem>
<MudItem xs="12">
<MudSelect T="ShareLimitAction" Label="Action when limit is reached" Value="SelectedShareLimitAction" ValueChanged="ShareLimitActionChanged" Disabled="@(!CustomEnabled)" Variant="Variant.Outlined">
<MudSelectItem Value="ShareLimitAction.Default">Default</MudSelectItem>
<MudSelectItem Value="ShareLimitAction.Stop">Stop torrent</MudSelectItem>
<MudSelectItem Value="ShareLimitAction.Remove">Remove torrent</MudSelectItem>
<MudSelectItem Value="ShareLimitAction.RemoveWithContent">Remove torrent and data</MudSelectItem>
<MudSelectItem Value="ShareLimitAction.EnableSuperSeeding">Enable super seeding</MudSelectItem>
</MudSelect>
</MudItem>
</MudGrid> </MudGrid>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>

View File

@@ -1,4 +1,7 @@
using Lantean.QBitTorrentClient; using System;
using System.Collections.Generic;
using Lantean.QBitTorrentClient;
using Lantean.QBitTorrentClient.Models;
using Lantean.QBTMud.Models; using Lantean.QBTMud.Models;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using MudBlazor; using MudBlazor;
@@ -16,6 +19,9 @@ namespace Lantean.QBTMud.Components.Dialogs
[Parameter] [Parameter]
public ShareRatioMax? Value { get; set; } public ShareRatioMax? Value { get; set; }
[Parameter]
public ShareRatioMax? CurrentValue { get; set; }
[Parameter] [Parameter]
public bool Disabled { get; set; } public bool Disabled { get; set; }
@@ -33,6 +39,8 @@ namespace Lantean.QBTMud.Components.Dialogs
protected int InactiveMinutes { get; set; } protected int InactiveMinutes { get; set; }
protected ShareLimitAction SelectedShareLimitAction { get; set; } = ShareLimitAction.Default;
protected bool CustomEnabled => ShareRatioType == 0; protected bool CustomEnabled => ShareRatioType == 0;
protected void RatioEnabledChanged(bool value) protected void RatioEnabledChanged(bool value)
@@ -65,40 +73,75 @@ namespace Lantean.QBTMud.Components.Dialogs
InactiveMinutes = value; InactiveMinutes = value;
} }
protected void ShareLimitActionChanged(ShareLimitAction value)
{
SelectedShareLimitAction = value;
}
protected override void OnParametersSet() protected override void OnParametersSet()
{ {
if (Value is null || Value.RatioLimit == Limits.GlobalLimit && Value.SeedingTimeLimit == Limits.GlobalLimit && Value.InactiveSeedingTimeLimit == Limits.GlobalLimit) RatioEnabled = false;
TotalMinutesEnabled = false;
InactiveMinutesEnabled = false;
var baseline = Value ?? CurrentValue;
SelectedShareLimitAction = baseline?.ShareLimitAction ?? ShareLimitAction.Default;
if (baseline is null || baseline.RatioLimit == Limits.GlobalLimit && baseline.SeedingTimeLimit == Limits.GlobalLimit && baseline.InactiveSeedingTimeLimit == Limits.GlobalLimit)
{ {
ShareRatioType = Limits.GlobalLimit; ShareRatioType = Limits.GlobalLimit;
return;
} }
else if (Value.MaxRatio == Limits.NoLimit && Value.MaxSeedingTime == Limits.NoLimit && Value.MaxInactiveSeedingTime == Limits.NoLimit)
if (baseline.MaxRatio == Limits.NoLimit && baseline.MaxSeedingTime == Limits.NoLimit && baseline.MaxInactiveSeedingTime == Limits.NoLimit)
{ {
ShareRatioType = Limits.NoLimit; ShareRatioType = Limits.NoLimit;
return;
}
ShareRatioType = 0;
if (baseline.RatioLimit >= 0)
{
RatioEnabled = true;
Ratio = baseline.RatioLimit;
} }
else else
{ {
ShareRatioType = 0; Ratio = 0;
if (Value.RatioLimit >= 0) }
{
RatioEnabled = true; if (baseline.SeedingTimeLimit >= 0)
Ratio = Value.RatioLimit; {
} TotalMinutesEnabled = true;
if (Value.SeedingTimeLimit >= 0) TotalMinutes = (int)baseline.SeedingTimeLimit;
{ }
TotalMinutesEnabled = true; else
TotalMinutes = (int)Value.SeedingTimeLimit; {
} TotalMinutes = 0;
if (Value.InactiveSeedingTimeLimit >= 0) }
{
InactiveMinutesEnabled = true; if (baseline.InactiveSeedingTimeLimit >= 0)
InactiveMinutes = (int)Value.InactiveSeedingTimeLimit; {
} InactiveMinutesEnabled = true;
InactiveMinutes = (int)baseline.InactiveSeedingTimeLimit;
}
else
{
InactiveMinutes = 0;
} }
} }
protected void ShareRatioTypeChanged(int value) protected void ShareRatioTypeChanged(int value)
{ {
ShareRatioType = value; ShareRatioType = value;
if (!CustomEnabled)
{
RatioEnabled = false;
TotalMinutesEnabled = false;
InactiveMinutesEnabled = false;
SelectedShareLimitAction = ShareLimitAction.Default;
}
} }
protected void Cancel() protected void Cancel()
@@ -112,16 +155,19 @@ namespace Lantean.QBTMud.Components.Dialogs
if (ShareRatioType == Limits.GlobalLimit) if (ShareRatioType == Limits.GlobalLimit)
{ {
result.RatioLimit = result.SeedingTimeLimit = result.InactiveSeedingTimeLimit = Limits.GlobalLimit; result.RatioLimit = result.SeedingTimeLimit = result.InactiveSeedingTimeLimit = Limits.GlobalLimit;
result.ShareLimitAction = ShareLimitAction.Default;
} }
else if (ShareRatioType == Limits.NoLimit) else if (ShareRatioType == Limits.NoLimit)
{ {
result.RatioLimit = result.SeedingTimeLimit = result.InactiveSeedingTimeLimit = Limits.NoLimit; result.RatioLimit = result.SeedingTimeLimit = result.InactiveSeedingTimeLimit = Limits.NoLimit;
result.ShareLimitAction = ShareLimitAction.Default;
} }
else else
{ {
result.RatioLimit = RatioEnabled ? Ratio : Limits.NoLimit; result.RatioLimit = RatioEnabled ? Ratio : Limits.NoLimit;
result.SeedingTimeLimit = TotalMinutesEnabled ? TotalMinutes : Limits.NoLimit; result.SeedingTimeLimit = TotalMinutesEnabled ? TotalMinutes : Limits.NoLimit;
result.InactiveSeedingTimeLimit = InactiveMinutesEnabled ? InactiveMinutes : Limits.NoLimit; result.InactiveSeedingTimeLimit = InactiveMinutesEnabled ? InactiveMinutes : Limits.NoLimit;
result.ShareLimitAction = SelectedShareLimitAction;
} }
MudDialog.Close(DialogResult.Ok(result)); MudDialog.Close(DialogResult.Ok(result));
} }

View File

@@ -22,7 +22,7 @@
<MudIconButton Icon="@Icons.Material.Outlined.FilterList" OnClick="ShowFilterDialog" title="Filter" /> <MudIconButton Icon="@Icons.Material.Outlined.FilterList" OnClick="ShowFilterDialog" title="Filter" />
<MudIconButton Icon="@Icons.Material.Outlined.FilterListOff" OnClick="RemoveFilter" title="Remove Filter" /> <MudIconButton Icon="@Icons.Material.Outlined.FilterListOff" OnClick="RemoveFilter" title="Remove Filter" />
<MudSpacer /> <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> </MudToolBar>
</div> </div>
<div class="content-panel__body"> <div class="content-panel__body">

View File

@@ -50,7 +50,7 @@ namespace Lantean.QBTMud.Components
protected ILocalStorageService LocalStorage { get; set; } = default!; protected ILocalStorageService LocalStorage { get; set; } = default!;
[Inject] [Inject]
protected IDataManager DataManager { get; set; } = default!; protected ITorrentDataManager DataManager { get; set; } = default!;
protected HashSet<string> ExpandedNodes { get; set; } = []; protected HashSet<string> ExpandedNodes { get; set; } = [];
@@ -70,6 +70,8 @@ namespace Lantean.QBTMud.Components
private MudMenu? ContextMenu { get; set; } private MudMenu? ContextMenu { get; set; }
private MudTextField<string>? SearchInput { get; set; }
public FilesTab() public FilesTab()
{ {
_columnRenderFragments.Add("Name", NameColumn); _columnRenderFragments.Add("Name", NameColumn);
@@ -235,7 +237,11 @@ namespace Lantean.QBTMud.Components
{ {
MarkFilesDirty(); MarkFilesDirty();
PruneSelectionIfMissing(); PruneSelectionIfMissing();
await InvokeAsync(StateHasChanged); await InvokeAsync(() =>
{
SyncSearchTextFromInput();
StateHasChanged();
});
} }
} }
} }
@@ -415,6 +421,11 @@ namespace Lantean.QBTMud.Components
private ReadOnlyCollection<ContentItem> GetFiles() private ReadOnlyCollection<ContentItem> GetFiles()
{ {
if (SyncSearchTextFromInput())
{
_filesDirty = true;
}
if (!_filesDirty) if (!_filesDirty)
{ {
return _visibleFiles; return _visibleFiles;
@@ -522,6 +533,23 @@ namespace Lantean.QBTMud.Components
return visible; 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() private void MarkFilesDirty()
{ {
_filesDirty = true; _filesDirty = true;

View File

@@ -26,7 +26,7 @@
</MudMenu> </MudMenu>
<MudMenu @ref="TrackerContextMenu" Dense="true" PositionAtCursor="true" ListClass="unselectable" PopoverClass="unselectable"> <MudMenu @ref="TrackerContextMenu" Dense="true" PositionAtCursor="true" ListClass="unselectable" PopoverClass="unselectable">
<MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="RemoveUnusedCategories">Remove tracker</MudMenuItem> <MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" Disabled="@(!CanRemoveTracker)" OnClick="RemoveTracker">Remove tracker</MudMenuItem>
<MudDivider /> <MudDivider />
@TorrentControls(_trackerType) @TorrentControls(_trackerType)
</MudMenu> </MudMenu>
@@ -52,9 +52,9 @@
} }
</MudNavGroup> </MudNavGroup>
<MudNavGroup Title="Trackers" @bind-Expanded="_trackersExpanded"> <MudNavGroup Title="Trackers" @bind-Expanded="_trackersExpanded">
@foreach (var (tracker, count) in Trackers) @foreach (var tracker in Trackers)
{ {
<CustomNavLink Class="filter-menu-item" Active="@(Tracker == tracker)" Icon="@Icons.Material.Filled.PinDrop" IconColor="Color.Info" OnClick="@(e => TrackerValueChanged(tracker))" OnContextMenu="@(e => TrackerOnContextMenu(e, tracker))" OnLongPress="@(e => TrackerOnLongPress(e, tracker))">@($"{tracker} ({count})")</CustomNavLink> <CustomNavLink Class="filter-menu-item" Active="@(Tracker == tracker.Key)" Icon="@Icons.Material.Filled.PinDrop" IconColor="Color.Info" OnClick="@(e => TrackerValueChanged(tracker.Key))" OnContextMenu="@(e => TrackerOnContextMenu(e, tracker.Key))" OnLongPress="@(e => TrackerOnLongPress(e, tracker.Key))">@($"{tracker.DisplayName} ({tracker.Count})")</CustomNavLink>
} }
</MudNavGroup> </MudNavGroup>
</MudNavMenu> </MudNavMenu>
@@ -65,8 +65,8 @@
{ {
return __builder => return __builder =>
{ {
<MudMenuItem Icon="@Icons.Material.Filled.PlayArrow" IconColor="Color.Success" OnClick="@(e => ResumeTorrents(type))">Resume torrents</MudMenuItem> <MudMenuItem Icon="@Icons.Material.Filled.PlayArrow" IconColor="Color.Success" OnClick="@(e => StartTorrents(type))">Start torrents</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Pause" IconColor="Color.Warning" OnClick="@(e => PauseTorrents(type))">Pause torrents</MudMenuItem> <MudMenuItem Icon="@Icons.Material.Filled.Stop" IconColor="Color.Warning" OnClick="@(e => StopTorrents(type))">Stop torrents</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="@(e => RemoveTorrents(type))">Remove torrents</MudMenuItem> <MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="@(e => RemoveTorrents(type))">Remove torrents</MudMenuItem>
}; };
} }

View File

@@ -5,6 +5,9 @@ using Lantean.QBTMud.Models;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.Web;
using MudBlazor; using MudBlazor;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Lantean.QBTMud.Components namespace Lantean.QBTMud.Components
{ {
@@ -24,6 +27,7 @@ namespace Lantean.QBTMud.Components
private bool _categoriesExpanded = true; private bool _categoriesExpanded = true;
private bool _tagsExpanded = true; private bool _tagsExpanded = true;
private bool _trackersExpanded = true; private bool _trackersExpanded = true;
private readonly Dictionary<string, TrackerFilterItem> _trackerItems = new(StringComparer.Ordinal);
protected string Status { get; set; } = Models.Status.All.ToString(); protected string Status { get; set; } = Models.Status.All.ToString();
@@ -64,7 +68,7 @@ namespace Lantean.QBTMud.Components
protected Dictionary<string, int> Categories => GetCategories(); protected Dictionary<string, int> Categories => GetCategories();
protected Dictionary<string, int> Trackers => GetTrackers(); private IReadOnlyList<TrackerFilterItem> Trackers => GetTrackers();
protected Dictionary<string, int> Statuses => GetStatuses(); protected Dictionary<string, int> Statuses => GetStatuses();
@@ -88,6 +92,11 @@ namespace Lantean.QBTMud.Components
protected string? ContextMenuTracker { get; set; } protected string? ContextMenuTracker { get; set; }
private bool CanRemoveTracker => ContextMenuTracker is not null
&& _trackerItems.TryGetValue(ContextMenuTracker, out var trackerItem)
&& !trackerItem.IsSynthetic
&& trackerItem.Urls.Count > 0;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var status = await LocalStorage.GetItemAsStringAsync(_statusSelectionStorageKey); var status = await LocalStorage.GetItemAsStringAsync(_statusSelectionStorageKey);
@@ -314,6 +323,32 @@ namespace Lantean.QBTMud.Components
} }
} }
private async Task RemoveTracker()
{
if (ContextMenuTracker is null)
{
return;
}
if (!_trackerItems.TryGetValue(ContextMenuTracker, out var trackerItem) || trackerItem.IsSynthetic)
{
return;
}
if (trackerItem.Urls.Count == 0)
{
return;
}
var hashes = GetAffectedTorrentHashes(_trackerType);
if (hashes.Count == 0)
{
return;
}
await ApiClient.RemoveTrackers(trackerItem.Urls, hashes: hashes.ToArray());
}
protected async Task AddTag() protected async Task AddTag()
{ {
if (ContextMenuTag is null) if (ContextMenuTag is null)
@@ -352,25 +387,25 @@ namespace Lantean.QBTMud.Components
} }
} }
protected async Task ResumeTorrents(string type) protected async Task StartTorrents(string type)
{ {
var torrents = GetAffectedTorrentHashes(type); var torrents = GetAffectedTorrentHashes(type);
await ApiClient.ResumeTorrents(torrents); await ApiClient.StartTorrents(hashes: torrents.ToArray());
} }
protected async Task PauseTorrents(string type) protected async Task StopTorrents(string type)
{ {
var torrents = GetAffectedTorrentHashes(type); var torrents = GetAffectedTorrentHashes(type);
await ApiClient.PauseTorrents(torrents); await ApiClient.StopTorrents(hashes: torrents.ToArray());
} }
protected async Task RemoveTorrents(string type) protected async Task RemoveTorrents(string type)
{ {
var torrents = GetAffectedTorrentHashes(type); var torrents = GetAffectedTorrentHashes(type);
await DialogService.InvokeDeleteTorrentDialog(ApiClient, [.. torrents]); await DialogService.InvokeDeleteTorrentDialog(ApiClient, Preferences?.ConfirmTorrentDeletion == true, [.. torrents]);
} }
private Dictionary<string, int> GetTags() private Dictionary<string, int> GetTags()
@@ -393,17 +428,59 @@ namespace Lantean.QBTMud.Components
return MainData.CategoriesState.ToDictionary(d => d.Key, d => d.Value.Count); return MainData.CategoriesState.ToDictionary(d => d.Key, d => d.Value.Count);
} }
private Dictionary<string, int> GetTrackers() private IReadOnlyList<TrackerFilterItem> GetTrackers()
{ {
if (MainData is null) if (MainData is null)
{ {
return []; _trackerItems.Clear();
return Array.Empty<TrackerFilterItem>();
} }
return MainData.TrackersState var items = new List<TrackerFilterItem>();
.GroupBy(d => GetHostName(d.Key)) _trackerItems.Clear();
.Select(l => new KeyValuePair<string, int>(GetHostName(l.First().Key), l.Sum(i => i.Value.Count)))
.ToDictionary(d => d.Key, d => d.Value); AppendSpecialTrackerItem(FilterHelper.TRACKER_ALL, items);
AppendSpecialTrackerItem(FilterHelper.TRACKER_TRACKERLESS, items);
AppendSpecialTrackerItem(FilterHelper.TRACKER_ERROR, items);
AppendSpecialTrackerItem(FilterHelper.TRACKER_WARNING, items);
AppendSpecialTrackerItem(FilterHelper.TRACKER_ANNOUNCE_ERROR, items);
if (MainData.Trackers.Count > 0)
{
var hostGroups = new Dictionary<string, TrackerHostGroup>(StringComparer.OrdinalIgnoreCase);
foreach (var (url, hashes) in MainData.Trackers)
{
var host = GetHostName(url);
if (!hostGroups.TryGetValue(host, out var group))
{
group = new TrackerHostGroup(host);
hostGroups[host] = group;
}
if (hashes is not null)
{
foreach (var hash in hashes)
{
if (MainData.Torrents.ContainsKey(hash))
{
group.Hashes.Add(hash);
}
}
}
group.Urls.Add(url);
}
foreach (var group in hostGroups.Values.OrderBy(g => g.Host, StringComparer.OrdinalIgnoreCase))
{
var urls = group.Urls.Distinct(StringComparer.Ordinal).ToArray();
var item = new TrackerFilterItem(group.Host, group.Host, group.Hashes.Count, false, urls);
items.Add(item);
_trackerItems[item.Key] = item;
}
}
return items;
} }
private Dictionary<string, int> GetStatuses() private Dictionary<string, int> GetStatuses()
@@ -464,6 +541,37 @@ namespace Lantean.QBTMud.Components
} }
} }
private void AppendSpecialTrackerItem(string key, List<TrackerFilterItem> items)
{
if (MainData is null)
{
return;
}
var count = MainData.TrackersState.TryGetValue(key, out var set) ? set.Count : 0;
var item = new TrackerFilterItem(key, key, count, true, Array.Empty<string>());
items.Add(item);
_trackerItems[key] = item;
}
private sealed class TrackerHostGroup
{
public TrackerHostGroup(string host)
{
Host = host;
Hashes = new HashSet<string>(StringComparer.Ordinal);
Urls = new List<string>();
}
public string Host { get; }
public HashSet<string> Hashes { get; }
public List<string> Urls { get; }
}
private sealed record TrackerFilterItem(string Key, string DisplayName, int Count, bool IsSynthetic, IReadOnlyList<string> Urls);
private static string GetHostName(string tracker) private static string GetHostName(string tracker)
{ {
try try

View File

@@ -26,7 +26,7 @@ namespace Lantean.QBTMud.Components
protected IApiClient ApiClient { get; set; } = default!; protected IApiClient ApiClient { get; set; } = default!;
[Inject] [Inject]
protected IDataManager DataManager { get; set; } = default!; protected ITorrentDataManager DataManager { get; set; } = default!;
protected IReadOnlyList<PieceState> Pieces { get; set; } = []; protected IReadOnlyList<PieceState> Pieces { get; set; } = [];

View File

@@ -68,6 +68,21 @@
</MudCardContent> </MudCardContent>
</MudCard> </MudCard>
<MudCard Elevation="1" Class="ml-4 mr-4 mb-4">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.subtitle2">Confirmation</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent Class="pt-0">
<MudGrid>
<MudItem xs="12">
<FieldSwitch Label="Confirm torrent recheck" Value="ConfirmTorrentRecheck" ValueChanged="ConfirmTorrentRecheckChanged" />
</MudItem>
</MudGrid>
</MudCardContent>
</MudCard>
<MudCard Elevation="1" Class="ml-4 mr-4 mb-4 mt-4"> <MudCard Elevation="1" Class="ml-4 mr-4 mb-4 mt-4">
<MudCardHeader> <MudCardHeader>
<CardHeaderContent> <CardHeaderContent>

View File

@@ -16,6 +16,8 @@ namespace Lantean.QBTMud.Components.Options
protected int SaveResumeDataInterval { get; private set; } protected int SaveResumeDataInterval { get; private set; }
protected int TorrentFileSizeLimit { get; private set; } protected int TorrentFileSizeLimit { get; private set; }
protected bool RecheckCompletedTorrents { get; private set; } protected bool RecheckCompletedTorrents { get; private set; }
protected bool ConfirmTorrentRecheck { get; private set; }
protected string? AppInstanceName { get; private set; } protected string? AppInstanceName { get; private set; }
protected int RefreshInterval { get; private set; } protected int RefreshInterval { get; private set; }
protected bool ResolvePeerCountries { get; private set; } protected bool ResolvePeerCountries { get; private set; }
@@ -97,6 +99,7 @@ namespace Lantean.QBTMud.Components.Options
SaveResumeDataInterval = Preferences.SaveResumeDataInterval; SaveResumeDataInterval = Preferences.SaveResumeDataInterval;
TorrentFileSizeLimit = Preferences.TorrentFileSizeLimit / 1024 / 1024; TorrentFileSizeLimit = Preferences.TorrentFileSizeLimit / 1024 / 1024;
RecheckCompletedTorrents = Preferences.RecheckCompletedTorrents; RecheckCompletedTorrents = Preferences.RecheckCompletedTorrents;
ConfirmTorrentRecheck = Preferences.ConfirmTorrentRecheck;
AppInstanceName = Preferences.AppInstanceName; AppInstanceName = Preferences.AppInstanceName;
RefreshInterval = Preferences.RefreshInterval; RefreshInterval = Preferences.RefreshInterval;
ResolvePeerCountries = Preferences.ResolvePeerCountries; ResolvePeerCountries = Preferences.ResolvePeerCountries;
@@ -209,6 +212,13 @@ namespace Lantean.QBTMud.Components.Options
await PreferencesChanged.InvokeAsync(UpdatePreferences); await PreferencesChanged.InvokeAsync(UpdatePreferences);
} }
protected async Task ConfirmTorrentRecheckChanged(bool value)
{
ConfirmTorrentRecheck = value;
UpdatePreferences.ConfirmTorrentRecheck = value;
await PreferencesChanged.InvokeAsync(UpdatePreferences);
}
protected async Task AppInstanceNameChanged(string value) protected async Task AppInstanceNameChanged(string value)
{ {
AppInstanceName = value; AppInstanceName = value;

View File

@@ -17,6 +17,24 @@
</MudCardContent> </MudCardContent>
</MudCard> </MudCard>
<MudCard Elevation="1" Class="ml-4 mr-4 mb-4">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.subtitle2">Transfer List</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent Class="pt-0">
<MudGrid>
<MudItem xs="12">
<FieldSwitch Label="Confirm when deleting torrents" Value="ConfirmTorrentDeletion" ValueChanged="ConfirmTorrentDeletionChanged" />
</MudItem>
<MudItem xs="12">
<FieldSwitch Label="Show external IP in status bar" Value="StatusBarExternalIp" ValueChanged="StatusBarExternalIpChanged" />
</MudItem>
</MudGrid>
</MudCardContent>
</MudCard>
<MudCard Elevation="1" Class="ml-4 mr-4 mb-4"> <MudCard Elevation="1" Class="ml-4 mr-4 mb-4">
<MudCardHeader> <MudCardHeader>
<CardHeaderContent> <CardHeaderContent>

View File

@@ -4,6 +4,10 @@ namespace Lantean.QBTMud.Components.Options
{ {
public partial class BehaviourOptions : Options public partial class BehaviourOptions : Options
{ {
protected bool ConfirmTorrentDeletion { get; set; }
protected bool StatusBarExternalIp { get; set; }
protected bool FileLogEnabled { get; set; } protected bool FileLogEnabled { get; set; }
protected string? FileLogPath { get; set; } protected string? FileLogPath { get; set; }
@@ -27,6 +31,8 @@ namespace Lantean.QBTMud.Components.Options
return false; return false;
} }
ConfirmTorrentDeletion = Preferences.ConfirmTorrentDeletion;
StatusBarExternalIp = Preferences.StatusBarExternalIp;
FileLogEnabled = Preferences.FileLogEnabled; FileLogEnabled = Preferences.FileLogEnabled;
FileLogPath = Preferences.FileLogPath; FileLogPath = Preferences.FileLogPath;
FileLogBackupEnabled = Preferences.FileLogBackupEnabled; FileLogBackupEnabled = Preferences.FileLogBackupEnabled;
@@ -39,6 +45,20 @@ namespace Lantean.QBTMud.Components.Options
return true; return true;
} }
protected async Task ConfirmTorrentDeletionChanged(bool value)
{
ConfirmTorrentDeletion = value;
UpdatePreferences.ConfirmTorrentDeletion = value;
await PreferencesChanged.InvokeAsync(UpdatePreferences);
}
protected async Task StatusBarExternalIpChanged(bool value)
{
StatusBarExternalIp = value;
UpdatePreferences.StatusBarExternalIp = value;
await PreferencesChanged.InvokeAsync(UpdatePreferences);
}
protected async Task FileLogEnabledChanged(bool value) protected async Task FileLogEnabledChanged(bool value)
{ {
FileLogEnabled = value; FileLogEnabled = value;

View File

@@ -19,7 +19,7 @@
<FieldSwitch Label="Add to top of queue" Value="AddToTopOfQueue" ValueChanged="AddToTopOfQueueChanged" /> <FieldSwitch Label="Add to top of queue" Value="AddToTopOfQueue" ValueChanged="AddToTopOfQueueChanged" />
</MudItem> </MudItem>
<MudItem xs="12"> <MudItem xs="12">
<FieldSwitch Label="Do not start the download automatically" Value="StartPausedEnabled" ValueChanged="StartPausedEnabledChanged" /> <FieldSwitch Label="Do not start the download automatically" Value="AddStoppedEnabled" ValueChanged="AddStoppedEnabledChanged" />
</MudItem> </MudItem>
<MudItem xs="12"> <MudItem xs="12">
<MudSelect T="string" Label="Torrent stop condition" Value="TorrentStopCondition" ValueChanged="TorrentStopConditionChanged" Variant="Variant.Outlined"> <MudSelect T="string" Label="Torrent stop condition" Value="TorrentStopCondition" ValueChanged="TorrentStopConditionChanged" Variant="Variant.Outlined">

View File

@@ -6,7 +6,7 @@ namespace Lantean.QBTMud.Components.Options
{ {
protected string? TorrentContentLayout { get; set; } protected string? TorrentContentLayout { get; set; }
protected bool AddToTopOfQueue { get; set; } protected bool AddToTopOfQueue { get; set; }
protected bool StartPausedEnabled { get; set; } protected bool AddStoppedEnabled { get; set; }
protected string? TorrentStopCondition { get; set; } protected string? TorrentStopCondition { get; set; }
protected bool AutoDeleteMode { get; set; } protected bool AutoDeleteMode { get; set; }
protected bool PreallocateAll { get; set; } protected bool PreallocateAll { get; set; }
@@ -51,7 +51,7 @@ namespace Lantean.QBTMud.Components.Options
// when adding a torrent // when adding a torrent
TorrentContentLayout = Preferences.TorrentContentLayout; TorrentContentLayout = Preferences.TorrentContentLayout;
AddToTopOfQueue = Preferences.AddToTopOfQueue; AddToTopOfQueue = Preferences.AddToTopOfQueue;
StartPausedEnabled = Preferences.StartPausedEnabled; AddStoppedEnabled = Preferences.AddStoppedEnabled;
TorrentStopCondition = Preferences.TorrentStopCondition; TorrentStopCondition = Preferences.TorrentStopCondition;
AutoDeleteMode = Preferences.AutoDeleteMode == 1; AutoDeleteMode = Preferences.AutoDeleteMode == 1;
PreallocateAll = Preferences.PreallocateAll; PreallocateAll = Preferences.PreallocateAll;
@@ -116,10 +116,10 @@ namespace Lantean.QBTMud.Components.Options
await PreferencesChanged.InvokeAsync(UpdatePreferences); await PreferencesChanged.InvokeAsync(UpdatePreferences);
} }
protected async Task StartPausedEnabledChanged(bool value) protected async Task AddStoppedEnabledChanged(bool value)
{ {
StartPausedEnabled = value; AddStoppedEnabled = value;
UpdatePreferences.StartPausedEnabled = value; UpdatePreferences.AddStoppedEnabled = value;
await PreferencesChanged.InvokeAsync(UpdatePreferences); await PreferencesChanged.InvokeAsync(UpdatePreferences);
} }

View File

@@ -40,7 +40,7 @@ namespace Lantean.QBTMud.Components
protected IApiClient ApiClient { get; set; } = default!; protected IApiClient ApiClient { get; set; } = default!;
[Inject] [Inject]
protected IDataManager DataManager { get; set; } = default!; protected IPeerDataManager PeerDataManager { get; set; } = default!;
protected PeerList? PeerList { get; set; } protected PeerList? PeerList { get; set; }
@@ -78,11 +78,11 @@ namespace Lantean.QBTMud.Components
var peers = await ApiClient.GetTorrentPeersData(Hash, _requestId); var peers = await ApiClient.GetTorrentPeersData(Hash, _requestId);
if (PeerList is null || peers.FullUpdate) if (PeerList is null || peers.FullUpdate)
{ {
PeerList = DataManager.CreatePeerList(peers); PeerList = PeerDataManager.CreatePeerList(peers);
} }
else else
{ {
DataManager.MergeTorrentPeers(peers, PeerList); PeerDataManager.MergeTorrentPeers(peers, PeerList);
} }
_requestId = peers.RequestId; _requestId = peers.RequestId;
@@ -200,11 +200,11 @@ namespace Lantean.QBTMud.Components
} }
if (PeerList is null || peers.FullUpdate) if (PeerList is null || peers.FullUpdate)
{ {
PeerList = DataManager.CreatePeerList(peers); PeerList = PeerDataManager.CreatePeerList(peers);
} }
else else
{ {
DataManager.MergeTorrentPeers(peers, PeerList); PeerDataManager.MergeTorrentPeers(peers, PeerList);
} }
_requestId = peers.RequestId; _requestId = peers.RequestId;

Some files were not shown because too many files have changed in this diff Show More