mirror of
				https://github.com/lantean-code/qbtmud.git
				synced 2025-10-31 03:53:36 +00:00 
			
		
		
		
	Compare commits
	
		
			89 Commits
		
	
	
		
			v0.1.0
			...
			feature/v5
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 0fc3a5c39f | ||
|  | 7e79d237c7 | ||
|  | 960ca26ae3 | ||
|  | 25108a95c8 | ||
|  | 709d6a1a4e | ||
|  | 3ec8d189c6 | ||
|  | 11bc184cda | ||
|  | 78a357337b | ||
|  | 5c28ec3a23 | ||
|  | 6a14df189c | ||
|  | fcb6dc436a | ||
|  | 21cd5e65be | ||
|  | 4666cb0b36 | ||
|  | 498420bf23 | ||
|  | 9d2ba7c57b | ||
|  | 2e843b1191 | ||
|  | eb13b83548 | ||
|  | e9daca6568 | ||
|  | 075ea9f855 | ||
|  | d01204a703 | ||
|  | ab1c594b07 | ||
|  | 6a5d8b2610 | ||
|  | b8412bb232 | ||
|  | e64a13c7c9 | ||
|  | e4ea79a8ed | ||
|  | 0976b72411 | ||
|  | 965fbcd010 | ||
|  | 3d0dbde9f4 | ||
|  | 5b4fbde7b2 | ||
|  | 0db0ad4374 | ||
|  | c390d83e4d | ||
|  | 8dd29c238d | ||
|  | fca17edfd1 | ||
|  | d8535fa262 | ||
|  | 1c6bfed6ee | ||
|  | 281caf8026 | ||
|  | ff905e7cac | ||
|  | cb80dd0d6b | ||
|  | 9113fb90ee | ||
|  | d8b4e932d1 | ||
|  | 3d0d211d10 | ||
|  | 7db4f2f78d | ||
|  | 1f606b4449 | ||
|  | 88d66b4887 | ||
|  | 2ad7be1073 | ||
|  | 300e81345c | ||
|  | 9d8d84168e | ||
|  | bb66b97f45 | ||
|  | 4824037ba7 | ||
|  | 1f9b631a36 | ||
|  | 2c744cd972 | ||
|  | b02bb7cfae | ||
|  | e4dac8556e | ||
|  | a9a8a4eba8 | ||
|  | bb524450f0 | ||
|  | d4ac79af00 | ||
|  | 7370d73c59 | ||
|  | 8796cc0f24 | ||
|  | b24ae440d4 | ||
|  | bb90ce5216 | ||
|  | 4eaa46b2b3 | ||
|  | 1cf9f97187 | ||
|  | 4f9129fd46 | ||
|  | 9a9d2c2ee2 | ||
|  | 736bc46745 | ||
|  | 23ae19c4c7 | ||
|  | 603470eb30 | ||
|  | 27c2406340 | ||
|  | 4578dcc11f | ||
|  | 3215fa3936 | ||
|  | 78e62f31d0 | ||
|  | e23842fcb0 | ||
|  | 411c7f87cc | ||
|  | 4098f8f5a9 | ||
|  | 12f81c5978 | ||
|  | 717738d720 | ||
|  | 885c34c8cf | ||
|  | ef3c68a6aa | ||
|  | a29e64fc1b | ||
|  | e55955c75e | ||
|  | aa80396862 | ||
|  | 30ced3293c | ||
|  | c54f73a517 | ||
|  | bad509e40f | ||
|  | 6a0796ef20 | ||
|  | dc4b515763 | ||
|  | 938702a7b3 | ||
|  | 6ca1c6edd4 | ||
|  | 24eb5cf5e9 | 
							
								
								
									
										82
									
								
								.github/workflows/dotnet.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										82
									
								
								.github/workflows/dotnet.yml
									
									
									
									
										vendored
									
									
								
							| @@ -4,6 +4,7 @@ on: | ||||
|   push: | ||||
|     branches: | ||||
|       - master | ||||
|   workflow_dispatch: | ||||
|   pull_request: | ||||
|     branches: | ||||
|       - '**' | ||||
| @@ -21,20 +22,44 @@ jobs: | ||||
|       - name: Setup .NET | ||||
|         uses: actions/setup-dotnet@v4 | ||||
|         with: | ||||
|           dotnet-version: '8.0.x' | ||||
|           dotnet-version: '9.0.x' | ||||
|  | ||||
|       - name: Install GitVersion | ||||
|         uses: gittools/actions/gitversion/setup@v3.0.0 | ||||
|         with: | ||||
|           versionSpec: '6.x' | ||||
|           versionSpec: '6.0.0' | ||||
|  | ||||
|       - name: Determine Version | ||||
|         id: gitversion | ||||
|         run: | | ||||
|           VERSION=$(dotnet gitversion /output json /showvariable FullSemVer) | ||||
|           SAFE_VERSION=$(echo "$VERSION" | sed -E 's/[^A-Za-z0-9._+-]+/-/g') | ||||
|           echo "VERSION=$VERSION" >> $GITHUB_ENV | ||||
|           echo "VERSION_SAFE=$SAFE_VERSION" >> $GITHUB_ENV | ||||
|         shell: bash | ||||
|  | ||||
|       - name: Set Release Channel | ||||
|         id: release_channel | ||||
|         shell: bash | ||||
|         run: | | ||||
|           if [ "${GITHUB_REF}" = "refs/heads/master" ]; then | ||||
|             echo "channel=stable" >> $GITHUB_OUTPUT | ||||
|             echo "prerelease=false" >> $GITHUB_OUTPUT | ||||
|             echo "label=Release" >> $GITHUB_OUTPUT | ||||
|           elif [ "${GITHUB_REF}" = "refs/heads/develop" ]; then | ||||
|             echo "channel=beta" >> $GITHUB_OUTPUT | ||||
|             echo "prerelease=true" >> $GITHUB_OUTPUT | ||||
|             echo "label=Beta" >> $GITHUB_OUTPUT | ||||
|           elif [[ "${GITHUB_REF}" == refs/heads/feature/* ]]; then | ||||
|             echo "channel=alpha" >> $GITHUB_OUTPUT | ||||
|             echo "prerelease=true" >> $GITHUB_OUTPUT | ||||
|             echo "label=Alpha" >> $GITHUB_OUTPUT | ||||
|           else | ||||
|             echo "channel=none" >> $GITHUB_OUTPUT | ||||
|             echo "prerelease=false" >> $GITHUB_OUTPUT | ||||
|             echo "label=" >> $GITHUB_OUTPUT | ||||
|           fi | ||||
|  | ||||
|       - name: Restore dependencies | ||||
|         run: dotnet restore | ||||
|  | ||||
| @@ -44,46 +69,63 @@ jobs: | ||||
|       - name: Run Tests | ||||
|         run: dotnet test --no-build --configuration Release | ||||
|  | ||||
|       - name: Publish (only on master) | ||||
|         if: github.ref == 'refs/heads/master' | ||||
|         run: dotnet publish Lantean.QBTMud/Lantean.QBTMud.csproj -c Release -o output | ||||
|       - name: Publish | ||||
|         if: steps.release_channel.outputs.channel != 'none' | ||||
|         run: dotnet publish src/Lantean.QBTMud/Lantean.QBTMud.csproj -c Release -o output | ||||
|  | ||||
|       - name: Prepare Release ZIP | ||||
|         if: github.ref == 'refs/heads/master' | ||||
|         if: steps.release_channel.outputs.channel != 'none' | ||||
|         run: | | ||||
|           cd output | ||||
|           mv wwwroot public | ||||
|           zip -r "../qbt-mud-v${{ env.VERSION }}.zip" public | ||||
|           zip -r "../qbt-mud-v${{ env.VERSION_SAFE }}.zip" public | ||||
|         shell: bash | ||||
|  | ||||
|       - name: Check if Tag Exists | ||||
|         id: check_tag | ||||
|       - name: Resolve Release Tag | ||||
|         if: steps.release_channel.outputs.channel != 'none' | ||||
|         id: resolve_tag | ||||
|         shell: bash | ||||
|         run: | | ||||
|           if git rev-parse "v${{ env.VERSION }}" >/dev/null 2>&1; then | ||||
|             echo "TAG_EXISTS=true" >> $GITHUB_ENV | ||||
|           if git rev-parse "${VERSION}" >/dev/null 2>&1; then | ||||
|             echo "tag=${VERSION}" >> $GITHUB_OUTPUT | ||||
|             echo "exists=true" >> $GITHUB_OUTPUT | ||||
|             echo "Using existing tag '${VERSION}'" | ||||
|           elif git rev-parse "v${VERSION}" >/dev/null 2>&1; then | ||||
|             echo "tag=v${VERSION}" >> $GITHUB_OUTPUT | ||||
|             echo "exists=true" >> $GITHUB_OUTPUT | ||||
|             echo "Using existing tag 'v${VERSION}'" | ||||
|           else | ||||
|             echo "TAG_EXISTS=false" >> $GITHUB_ENV | ||||
|             echo "tag=${VERSION}" >> $GITHUB_OUTPUT | ||||
|             echo "exists=false" >> $GITHUB_OUTPUT | ||||
|             echo "::warning::No matching git tag found for '${VERSION}' or 'v${VERSION}'." | ||||
|           fi | ||||
|  | ||||
|       - name: Ensure Release Tag Exists | ||||
|         if: steps.release_channel.outputs.channel == 'stable' && steps.resolve_tag.outputs.exists != 'true' | ||||
|         shell: bash | ||||
|         run: | | ||||
|           echo "::error::Expected an existing git tag '${VERSION}' (or 'v${VERSION}') before creating a release." | ||||
|           exit 1 | ||||
|  | ||||
|       - name: Create GitHub Release | ||||
|         if: github.ref == 'refs/heads/master' && env.TAG_EXISTS == 'false' | ||||
|         if: steps.release_channel.outputs.channel != 'none' && (steps.resolve_tag.outputs.exists == 'true' || steps.release_channel.outputs.channel != 'stable') | ||||
|         id: create_release | ||||
|         uses: actions/create-release@v1 | ||||
|         with: | ||||
|           tag_name: v${{ env.VERSION }} | ||||
|           release_name: Release v${{ env.VERSION }} | ||||
|           draft: false | ||||
|           prerelease: false | ||||
|           tag_name: ${{ steps.resolve_tag.outputs.tag }} | ||||
|           release_name: ${{ steps.release_channel.outputs.label }} ${{ steps.resolve_tag.outputs.tag }} | ||||
|           draft: ${{ steps.release_channel.outputs.channel != 'alpha' }} | ||||
|           prerelease: ${{ steps.release_channel.outputs.prerelease }} | ||||
|         env: | ||||
|           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||||
|  | ||||
|       - name: Upload Release Asset | ||||
|         if: github.ref == 'refs/heads/master' && env.TAG_EXISTS == 'false' | ||||
|         if: steps.release_channel.outputs.channel != 'none' && (steps.resolve_tag.outputs.exists == 'true' || steps.release_channel.outputs.channel != 'stable') | ||||
|         uses: actions/upload-release-asset@v1 | ||||
|         with: | ||||
|           upload_url: ${{ steps.create_release.outputs.upload_url }} | ||||
|           asset_path: qbt-mud-v${{ env.VERSION }}.zip | ||||
|           asset_name: qbt-mud-v${{ env.VERSION }}.zip | ||||
|           asset_path: qbt-mud-v${{ env.VERSION_SAFE }}.zip | ||||
|           asset_name: qbt-mud-v${{ env.VERSION_SAFE }}.zip | ||||
|           asset_content_type: application/zip | ||||
|         env: | ||||
|           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||||
|   | ||||
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -360,4 +360,5 @@ MigrationBackup/ | ||||
| .ionide/ | ||||
|  | ||||
| # Fody - auto-generated XML schema | ||||
| FodyWeavers.xsd | ||||
| FodyWeavers.xsd | ||||
| /output | ||||
|   | ||||
							
								
								
									
										60
									
								
								AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								AGENTS.md
									
									
									
									
									
										Normal 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. | ||||
| @@ -1,3 +0,0 @@ | ||||
| namespace Lantean.QBTMud.Test | ||||
| { | ||||
| } | ||||
| @@ -1,6 +0,0 @@ | ||||
| namespace Lantean.QBTMud.Test | ||||
| { | ||||
|     internal class Tests | ||||
|     { | ||||
|     } | ||||
| } | ||||
| @@ -1,67 +0,0 @@ | ||||
| using Lantean.QBitTorrentClient; | ||||
| using Lantean.QBitTorrentClient.Models; | ||||
| using System.Linq.Expressions; | ||||
| using System.Text.Json; | ||||
| using Xunit.Abstractions; | ||||
|  | ||||
| namespace Lantean.QBTMud.Test | ||||
| { | ||||
|     public class UnitTest1 | ||||
|     { | ||||
|         private readonly ITestOutputHelper _testOutputHelper; | ||||
|  | ||||
|         public UnitTest1(ITestOutputHelper testOutputHelper) | ||||
|         { | ||||
|             _testOutputHelper = testOutputHelper; | ||||
|         } | ||||
|  | ||||
|         [Fact] | ||||
|         public void Test() | ||||
|         { | ||||
|             Test2(a => a.Name); | ||||
|         } | ||||
|  | ||||
|         private void Test2(Expression<Func<TestClass, object>> expr) | ||||
|         { | ||||
|             var body = expr.Body; | ||||
|         } | ||||
|  | ||||
|         [Fact] | ||||
|         public void Create() | ||||
|         { | ||||
|             var propInfo = typeof(TestClass).GetProperty("Name")!; | ||||
|  | ||||
|             ParameterExpression expression = Expression.Parameter(typeof(TestClass), "a"); | ||||
|             var propertyExpression = Expression.Property(expression, "Value"); | ||||
|  | ||||
|             var convertExpression = Expression.Convert(propertyExpression, typeof(object)); | ||||
|  | ||||
|             var l = Expression.Lambda<Func<TestClass, object>>(convertExpression, expression); | ||||
|  | ||||
|             Expression<Func<TestClass, object>> expr2 = a => a.Name; | ||||
|  | ||||
|             var x = l.Compile(); | ||||
|             var res = (long)x(new TestClass { Name = "Name", Value = 12 }); | ||||
|             Assert.Equal(12, res); | ||||
|             expr2.Compile(); | ||||
|         } | ||||
|  | ||||
|         [Fact] | ||||
|         public void ScanDir() | ||||
|         { | ||||
|             //Dictionary<string, string> | ||||
|             var json = "{\r\n\t\"/this/is/path\": 1,\r\n\t\"/this/other\": 0,\r\n\t\"/home\": \"/path\"\r\n}"; | ||||
|  | ||||
|             var obj = JsonSerializer.Deserialize<Dictionary<string, SaveLocation>>(json, SerializerOptions.Options); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public class TestClass | ||||
|     { | ||||
|         public string Name { get; set; } | ||||
|  | ||||
|         public string Description { get; set; } | ||||
|  | ||||
|         public long Value { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -1,20 +1,25 @@ | ||||
|  | ||||
| Microsoft Visual Studio Solution File, Format Version 12.00 | ||||
| # Visual Studio Version 17 | ||||
| VisualStudioVersion = 17.8.34511.84 | ||||
| # Visual Studio Version 18 | ||||
| VisualStudioVersion = 18.0.11121.172 d18.0 | ||||
| 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 | ||||
| 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 | ||||
| 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 | ||||
| Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{1BF1A631-87D7-4039-A701-88C5E0234B63}" | ||||
| 	ProjectSection(SolutionItems) = preProject | ||||
| 		.editorconfig = .editorconfig | ||||
| 		readme.md = readme.md | ||||
| 		AGENTS.md = AGENTS.md | ||||
| 	EndProjectSection | ||||
| 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 | ||||
| 	GlobalSection(SolutionConfigurationPlatforms) = preSolution | ||||
| 		Debug|Any CPU = Debug|Any CPU | ||||
| @@ -33,10 +38,18 @@ Global | ||||
| 		{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.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 | ||||
| 	GlobalSection(SolutionProperties) = preSolution | ||||
| 		HideSolutionNode = FALSE | ||||
| 	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 | ||||
| 		SolutionGuid = {82E46DB7-956A-4971-BB18-1F20650EC1A4} | ||||
| 	EndGlobalSection | ||||
|   | ||||
| @@ -1,24 +0,0 @@ | ||||
| @inherits SubmittableDialog | ||||
|  | ||||
| <MudDialog> | ||||
|     <DialogContent> | ||||
|         <MudGrid> | ||||
|             <MudItem xs="12"> | ||||
|                 <MudFileUpload T="IReadOnlyList<IBrowserFile>" FilesChanged="UploadFiles" Accept=".torrent" MaximumFileCount="50" > | ||||
|                     <ActivatorContent> | ||||
|                         <MudButton Variant="Variant.Filled" | ||||
|                                    Color="Color.Primary" | ||||
|                                    StartIcon="@Icons.Material.Filled.CloudUpload"> | ||||
|                             Choose files | ||||
|                         </MudButton> | ||||
|                     </ActivatorContent> | ||||
|                 </MudFileUpload> | ||||
|             </MudItem> | ||||
|         </MudGrid> | ||||
|         <AddTorrentOptions @ref="TorrentOptions" ShowCookieOption="true" /> | ||||
|     </DialogContent> | ||||
|     <DialogActions> | ||||
|         <MudButton OnClick="Cancel">Close</MudButton> | ||||
|         <MudButton Color="Color.Primary" OnClick="Submit">Upload Torrents</MudButton> | ||||
|     </DialogActions> | ||||
| </MudDialog> | ||||
| @@ -1,68 +0,0 @@ | ||||
| <MudGrid> | ||||
|     <MudItem xs="12"> | ||||
|         <MudSwitch Label="Additional Options" @bind-Value="Expanded" LabelPosition="LabelPosition.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> | ||||
| @@ -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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,91 +0,0 @@ | ||||
| <ContextMenu @ref="ContextMenu" Dense="true"> | ||||
|     <MudMenuItem Icon="@Icons.Material.Filled.DriveFileRenameOutline" OnClick="RenameFileContextMenu">Rename</MudMenuItem> | ||||
| </ContextMenu> | ||||
|  | ||||
| <MudToolBar Gutters="false" Dense="true"> | ||||
|     <MudIconButton Icon="@Icons.Material.Filled.DriveFileRenameOutline" OnClick="RenameFileToolbar" title="Rename" /> | ||||
|     <MudDivider Vertical="true" /> | ||||
|     <MudIconButton Icon="@Icons.Material.Outlined.ViewColumn" Color="Color.Inherit" OnClick="ColumnOptions" title="Choose Columns" /> | ||||
|     <MudDivider Vertical="true" /> | ||||
|     <MudMenu Icon="@Icons.Material.Outlined.FileDownloadOff" Label="Do Not Download" AnchorOrigin="Origin.BottomLeft" TransformOrigin="Origin.TopLeft" title="Do Not Download"> | ||||
|         <MudMenuItem OnClick="DoNotDownloadLessThan100PercentAvailability">Less Than 100% Availability</MudMenuItem> | ||||
|         <MudMenuItem OnClick="DoNotDownloadLessThan80PercentAvailability">Less than 80% Availability</MudMenuItem> | ||||
|         <MudMenuItem OnClick="DoNotDownloadCurrentlyFilteredFiles">Currently Filtered Files</MudMenuItem> | ||||
|     </MudMenu> | ||||
|     <MudMenu Icon="@Icons.Material.Outlined.FileDownload" Label="Normal Priority" AnchorOrigin="Origin.BottomLeft" TransformOrigin="Origin.TopLeft" title="Download"> | ||||
|         <MudMenuItem OnClick="NormalPriorityLessThan100PercentAvailability">Less Than 100% Availability</MudMenuItem> | ||||
|         <MudMenuItem OnClick="NormalPriorityLessThan80PercentAvailability">Less than 80% Availability</MudMenuItem> | ||||
|         <MudMenuItem OnClick="NormalPriorityCurrentlyFilteredFiles">Currently Filtered Files</MudMenuItem> | ||||
|     </MudMenu> | ||||
|     <MudIconButton Icon="@Icons.Material.Outlined.FilterList" OnClick="ShowFilterDialog" title="Filter" /> | ||||
|     <MudIconButton Icon="@Icons.Material.Outlined.FilterListOff" OnClick="RemoveFilter" title="Remove Filter" /> | ||||
|     <MudSpacer /> | ||||
|     <MudTextField T="string" Value="SearchText" ValueChanged="SearchTextChanged" Immediate="true" DebounceInterval="500" Placeholder="Filter file list" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0"></MudTextField> | ||||
| </MudToolBar> | ||||
|  | ||||
| <DynamicTable | ||||
|     @ref="Table" | ||||
|     T="ContentItem"  | ||||
|     ColumnDefinitions="Columns"  | ||||
|     Items="Files"  | ||||
|     MultiSelection="false" | ||||
|     SelectOnRowClick="true" | ||||
|     PreSorted="true" | ||||
|     SelectedItemChanged="SelectedItemChanged" | ||||
|     SortColumnChanged="SortColumnChanged" | ||||
|     SortDirectionChanged="SortDirectionChanged" | ||||
|     OnTableDataContextMenu="TableDataContextMenu" | ||||
|     OnTableDataLongPress="TableDataLongPress" | ||||
|     Class="file-list" | ||||
| /> | ||||
|  | ||||
| @code { | ||||
|     private RenderFragment<RowContext<ContentItem>> NameColumn | ||||
|     { | ||||
|         get | ||||
|         { | ||||
|             return context => __builder =>  | ||||
|             { | ||||
|                 <div style="@($"margin-left: {(context.Data.Level * 14) + (context.Data.Level >= 1 ? 16 : 0)}px")"> | ||||
|                     @if (context.Data.IsFolder) | ||||
|                     { | ||||
|                         <MudIconButton Class="folder-button" Edge="Edge.Start" ButtonType="ButtonType.Button" Icon="@(ExpandedNodes.Contains(context.Data.Name) ? Icons.Material.Filled.KeyboardArrowDown : Icons.Material.Filled.KeyboardArrowRight)" OnClick="@(c => ToggleNode(context.Data))"></MudIconButton> | ||||
|                         <MudIcon Icon="@Icons.Material.Filled.Folder" Class="pt-0" Style="margin-right: 4px; position: relative; top: 7px; margin-left: -15px" /> | ||||
|                     } | ||||
|                     @context.Data.DisplayName | ||||
|                 </div>; | ||||
|             }; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private RenderFragment<RowContext<ContentItem>> PriorityColumn | ||||
|     { | ||||
|         get | ||||
|         { | ||||
|             return context => __builder => | ||||
|             { | ||||
|                 <MudSelect T="Priority" Dense="true" Value="@context.Data.Priority" ValueChanged="@(priority => PriorityValueChanged(context.Data, priority))" Class="mt-0"> | ||||
|                     <MudSelectItem T="Priority" Value="Priority.DoNotDownload">Do not download</MudSelectItem> | ||||
|                     <MudSelectItem T="Priority" Value="Priority.Normal">Normal</MudSelectItem> | ||||
|                     <MudSelectItem T="Priority" Value="Priority.High">High</MudSelectItem> | ||||
|                     <MudSelectItem T="Priority" Value="Priority.Maximum">Maximum</MudSelectItem> | ||||
|                 </MudSelect> | ||||
|             }; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static RenderFragment<RowContext<ContentItem>> ProgressBarColumn | ||||
|     { | ||||
|         get | ||||
|         { | ||||
|             return context => __builder => | ||||
|             { | ||||
|                 var value = (float?)context.GetValue(); | ||||
|                 var color = value < 1 ? Color.Success : Color.Info; | ||||
|                 <MudProgressLinear title="Progress" Color="@color" Value="@((value ?? 0) * 100)" Class="progress-expand" Size="Size.Large"> | ||||
|                     @DisplayHelpers.Percentage(value) | ||||
|                 </MudProgressLinear>; | ||||
|             }; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,24 +0,0 @@ | ||||
| <ContextMenu @ref="ContextMenu" Dense="true"> | ||||
|     <MudMenuItem Icon="@Icons.Material.Filled.AddCircle" IconColor="Color.Info" OnClick="AddPeer">Add peer</MudMenuItem> | ||||
|     @if (ContextMenuItem is not null) | ||||
|     { | ||||
|         <MudMenuItem Icon="@Icons.Material.Filled.DisabledByDefault" IconColor="Color.Info" OnClick="BanPeerContextMenu">Ban peer</MudMenuItem> | ||||
|     } | ||||
| </ContextMenu> | ||||
|  | ||||
| <MudToolBar Gutters="false" Dense="true"> | ||||
|     <MudIconButton Icon="@Icons.Material.Filled.AddCircle" Color="Color.Info" OnClick="AddPeer">Add peer</MudIconButton> | ||||
|     <MudIconButton Icon="@Icons.Material.Filled.DisabledByDefault" Color="Color.Error" OnClick="BanPeerToolbar" Disabled="@(SelectedItem is null)">Ban peer</MudIconButton> | ||||
|     <MudDivider Vertical="true" /> | ||||
|     <MudIconButton Icon="@Icons.Material.Outlined.ViewColumn" Color="Color.Inherit" OnClick="ColumnOptions" title="Choose Columns" /> | ||||
| </MudToolBar> | ||||
|  | ||||
| <DynamicTable T="Peer" | ||||
|               ColumnDefinitions="Columns" | ||||
|               Items="Peers" | ||||
|               MultiSelection="false" | ||||
|               SelectOnRowClick="true" | ||||
|               OnTableDataLongPress="TableDataLongPress" | ||||
|               OnTableDataContextMenu="TableDataContextMenu" | ||||
|               SelectedItemChanged="SelectedItemChanged" | ||||
|               Class="details-list" /> | ||||
| @@ -1,32 +0,0 @@ | ||||
| <ContextMenu @ref="ContextMenu" Dense="true"> | ||||
|     <MudMenuItem Icon="@Icons.Material.Filled.AddCircle" IconColor="Color.Info" OnClick="AddTracker">Add trackers</MudMenuItem> | ||||
|     @if (ContextMenuItem is not null) | ||||
|     { | ||||
|         <MudMenuItem Icon="@Icons.Material.Filled.Edit" IconColor="Color.Info" OnClick="EditTrackerToolbar">Edit tracker URL</MudMenuItem> | ||||
|         <MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="RemoveTrackerContextMenu">Remove tracker</MudMenuItem> | ||||
|         <MudMenuItem Icon="@Icons.Material.Filled.FolderCopy" IconColor="Color.Info" OnClick="CopyTrackerUrlContextMenu">Copy tracker url</MudMenuItem> | ||||
|     } | ||||
| </ContextMenu> | ||||
|  | ||||
| <MudToolBar Gutters="false" Dense="true"> | ||||
|     <MudIconButton Icon="@Icons.Material.Filled.AddCircle" Color="Color.Info" OnClick="AddTracker">Add trackers</MudIconButton> | ||||
|     <MudIconButton Icon="@Icons.Material.Filled.Edit" Color="Color.Info" OnClick="EditTrackerToolbar" Disabled="@(SelectedItem is null)">Edit tracker URL</MudIconButton> | ||||
|     <MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="RemoveTrackerToolbar" Disabled="@(SelectedItem is null)">Remove tracker</MudIconButton> | ||||
|     <MudIconButton Icon="@Icons.Material.Filled.FolderCopy" Color="Color.Info" OnClick="CopyTrackerUrlToolbar" Disabled="@(SelectedItem is null)">Copy tracker url</MudIconButton> | ||||
|     <MudDivider Vertical="true" /> | ||||
|     <MudIconButton Icon="@Icons.Material.Outlined.ViewColumn" Color="Color.Inherit" OnClick="ColumnOptions" title="Choose Columns" /> | ||||
| </MudToolBar> | ||||
|  | ||||
| <DynamicTable @ref="Table" | ||||
|               T="Lantean.QBitTorrentClient.Models.TorrentTracker" | ||||
|               ColumnDefinitions="Columns" | ||||
|               Items="Trackers" | ||||
|               MultiSelection="false" | ||||
|               SelectOnRowClick="false" | ||||
|               PreSorted="true" | ||||
|               SortDirectionChanged="SortDirectionChanged" | ||||
|               SortColumnChanged="SortColumnChanged" | ||||
|               OnTableDataLongPress="TableDataLongPress" | ||||
|               OnTableDataContextMenu="TableDataContextMenu" | ||||
|               SelectedItemChanged="SelectedItemChanged" | ||||
|               Class="file-list" /> | ||||
| @@ -1,28 +0,0 @@ | ||||
| @inherits MudComponentBase | ||||
|  | ||||
| <MudMenu @ref="FakeMenu" Style="display: none" OpenChanged="FakeOpenChanged"></MudMenu> | ||||
|  | ||||
| @* The portal has to include the cascading values inside, because it's not able to teletransport the cascade *@ | ||||
| <MudPopover tracker="@Id" | ||||
|             Open="@_open" | ||||
|             Class="unselectable" | ||||
|             MaxHeight="@MaxHeight" | ||||
|             AnchorOrigin="@AnchorOrigin" | ||||
|             TransformOrigin="TransformOrigin" | ||||
|             RelativeWidth="@FullWidth" | ||||
|             OverflowBehavior="OverflowBehavior.FlipAlways" | ||||
|             Style="@_popoverStyle" | ||||
|             @ontouchend:preventDefault> | ||||
|     <CascadingValue Value="@(FakeMenu)"> | ||||
|         @if (_showChildren) | ||||
|         { | ||||
|             <MudList T="object" | ||||
|             Class="unselectable" | ||||
|                  Dense="@Dense"> | ||||
|                 @ChildContent | ||||
|         </MudList> | ||||
|         } | ||||
|     </CascadingValue> | ||||
| </MudPopover> | ||||
|  | ||||
| <MudOverlay Visible="@(_open)" LockScroll="@LockScroll" AutoClose="true" OnClosed="@CloseMenuAsync" /> | ||||
| @@ -1,294 +0,0 @@ | ||||
| using Lantean.QBTMud.Interop; | ||||
| using Microsoft.AspNetCore.Components; | ||||
| using Microsoft.AspNetCore.Components.Web; | ||||
| using Microsoft.JSInterop; | ||||
| using MudBlazor; | ||||
| using MudBlazor.Utilities; | ||||
|  | ||||
| namespace Lantean.QBTMud.Components.UI | ||||
| { | ||||
|     // This is a very hacky approach but works for now. | ||||
|     // This needs to inherit from MudMenu because MudMenuItem needs a MudMenu passed to it to control the close of the menu when an item is clicked. | ||||
|     // MudPopover isn't ideal for this because that is designed to be used relative to an activator which in these cases it isn't. | ||||
|     // Ideally this should be changed to use something like the way the DialogService works. | ||||
|  | ||||
|     // Or - rework this to have a hidden MudMenu and hook into the OpenChanged event to monitor when the MudMenuItem closes it. | ||||
|     public partial class ContextMenu : MudComponentBase | ||||
|     { | ||||
|         private bool _open; | ||||
|         private bool _showChildren; | ||||
|         private string? _popoverStyle; | ||||
|         private string? _id; | ||||
|  | ||||
|         private double _x; | ||||
|         private double _y; | ||||
|         private bool _isResized = false; | ||||
|  | ||||
|         private const double _diff = 64; | ||||
|  | ||||
|         private string Id | ||||
|         { | ||||
|             get | ||||
|             { | ||||
|                 _id ??= Guid.NewGuid().ToString(); | ||||
|  | ||||
|                 return _id; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         [Inject] | ||||
|         public IJSRuntime JSRuntime { get; set; } = default!; | ||||
|  | ||||
|         [Inject] | ||||
|         public IPopoverService PopoverService { get; set; } = default!; | ||||
|  | ||||
|         /// <summary> | ||||
|         /// If true, compact vertical padding will be applied to all menu items. | ||||
|         /// </summary> | ||||
|         [Parameter] | ||||
|         [Category(CategoryTypes.Menu.PopupAppearance)] | ||||
|         public bool Dense { get; set; } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Set to true if you want to prevent page from scrolling when the menu is open | ||||
|         /// </summary> | ||||
|         [Parameter] | ||||
|         [Category(CategoryTypes.Menu.PopupAppearance)] | ||||
|         public bool LockScroll { get; set; } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// If true, the list menu will be same width as the parent. | ||||
|         /// </summary> | ||||
|         [Parameter] | ||||
|         [Category(CategoryTypes.Menu.PopupAppearance)] | ||||
|         public bool FullWidth { get; set; } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Sets the max height the menu can have when open. | ||||
|         /// </summary> | ||||
|         [Parameter] | ||||
|         [Category(CategoryTypes.Menu.PopupAppearance)] | ||||
|         public int? MaxHeight { get; set; } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Set the anchor origin point to determine where the popover will open from. | ||||
|         /// </summary> | ||||
|         [Parameter] | ||||
|         [Category(CategoryTypes.Menu.PopupAppearance)] | ||||
|         public Origin AnchorOrigin { get; set; } = Origin.TopLeft; | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Sets the transform origin point for the popover. | ||||
|         /// </summary> | ||||
|         [Parameter] | ||||
|         [Category(CategoryTypes.Menu.PopupAppearance)] | ||||
|         public Origin TransformOrigin { get; set; } = Origin.TopLeft; | ||||
|  | ||||
|         /// <summary> | ||||
|         /// If true, menu will be disabled. | ||||
|         /// </summary> | ||||
|         [Parameter] | ||||
|         [Category(CategoryTypes.Menu.Behavior)] | ||||
|         public bool Disabled { get; set; } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Gets or sets whether to show a ripple effect when the user clicks the button. Default is true. | ||||
|         /// </summary> | ||||
|         [Parameter] | ||||
|         [Category(CategoryTypes.Menu.Appearance)] | ||||
|         public bool Ripple { get; set; } = true; | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Determines whether the component has a drop-shadow. Default is true | ||||
|         /// </summary> | ||||
|         [Parameter] | ||||
|         [Category(CategoryTypes.Menu.Appearance)] | ||||
|         public bool DropShadow { get; set; } = true; | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Add menu items here | ||||
|         /// </summary> | ||||
|         [Parameter] | ||||
|         [Category(CategoryTypes.Menu.PopupBehavior)] | ||||
|         public RenderFragment? ChildContent { get; set; } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Fired when the menu <see cref="Open"/> property changes. | ||||
|         /// </summary> | ||||
|         [Parameter] | ||||
|         [Category(CategoryTypes.Menu.PopupBehavior)] | ||||
|         public EventCallback<bool> OpenChanged { get; set; } | ||||
|  | ||||
|         [Parameter] | ||||
|         public int AdjustmentX { get; set; } | ||||
|  | ||||
|         [Parameter] | ||||
|         public int AdjustmentY { get; set; } | ||||
|  | ||||
|         protected MudMenu? FakeMenu { get; set; } | ||||
|  | ||||
|         protected void FakeOpenChanged(bool value) | ||||
|         { | ||||
|             if (!value) | ||||
|             { | ||||
|                 _open = false; | ||||
|             } | ||||
|  | ||||
|             StateHasChanged(); | ||||
|         } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Opens the menu. | ||||
|         /// </summary> | ||||
|         /// <param name="args"> | ||||
|         /// The arguments of the calling mouse/pointer event. | ||||
|         /// </param> | ||||
|         public async Task OpenMenuAsync(EventArgs args) | ||||
|         { | ||||
|             if (Disabled) | ||||
|             { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             // long press on iOS triggers selection, so clear it | ||||
|             await JSRuntime.ClearSelection(); | ||||
|  | ||||
|             if (args is not LongPressEventArgs) | ||||
|             { | ||||
|                 _showChildren = true; | ||||
|             } | ||||
|  | ||||
|             _open = true; | ||||
|             _isResized = false; | ||||
|             StateHasChanged(); | ||||
|  | ||||
|             var (x, y) = GetPositionFromArgs(args); | ||||
|             _x = x; | ||||
|             _y = y; | ||||
|  | ||||
|             SetPopoverStyle(x, y); | ||||
|  | ||||
|             StateHasChanged(); | ||||
|  | ||||
|             await OpenChanged.InvokeAsync(_open); | ||||
|  | ||||
|             // long press on iOS triggers selection, so clear it | ||||
|             await JSRuntime.ClearSelection(); | ||||
|  | ||||
|             if (args is LongPressEventArgs) | ||||
|             { | ||||
|                 await Task.Delay(1000); | ||||
|                 _showChildren = true; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Closes the menu. | ||||
|         /// </summary> | ||||
|         public Task CloseMenuAsync() | ||||
|         { | ||||
|             _open = false; | ||||
|             _popoverStyle = null; | ||||
|             StateHasChanged(); | ||||
|  | ||||
|             return OpenChanged.InvokeAsync(_open); | ||||
|         } | ||||
|  | ||||
|         private void SetPopoverStyle(double x, double y) | ||||
|         { | ||||
|             _popoverStyle = $"margin-top: {y.ToPx()}; margin-left: {x.ToPx()};"; | ||||
|         } | ||||
|  | ||||
|         /// <summary> | ||||
|         /// Toggle the visibility of the menu. | ||||
|         /// </summary> | ||||
|         public async Task ToggleMenuAsync(EventArgs args) | ||||
|         { | ||||
|             if (Disabled) | ||||
|             { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             if (_open) | ||||
|             { | ||||
|                 await CloseMenuAsync(); | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 await OpenMenuAsync(args); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         protected override async Task OnAfterRenderAsync(bool firstRender) | ||||
|         { | ||||
|             if (!_isResized) | ||||
|             { | ||||
|                 await DeterminePosition(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private async Task DeterminePosition() | ||||
|         { | ||||
|             var mainContentSize = await JSRuntime.GetInnerDimensions(".mud-main-content"); | ||||
|             double? contextMenuHeight = null; | ||||
|             double? contextMenuWidth = null; | ||||
|  | ||||
|             var popoverHolder = PopoverService.ActivePopovers.FirstOrDefault(p => p.UserAttributes.ContainsKey("tracker") && (string?)p.UserAttributes["tracker"] == Id); | ||||
|  | ||||
|             var popoverSize = await JSRuntime.GetBoundingClientRect($"#popovercontent-{popoverHolder?.Id}"); | ||||
|             if (popoverSize.Height > 0) | ||||
|             { | ||||
|                 contextMenuHeight = popoverSize.Height; | ||||
|                 contextMenuWidth = popoverSize.Width; | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             // the bottom position of the popover will be rendered off screen | ||||
|             if (_y - _diff + contextMenuHeight.Value >= mainContentSize.Height) | ||||
|             { | ||||
|                 // adjust the top of the context menu | ||||
|                 var overshoot = Math.Abs(mainContentSize.Height - (_y - _diff + contextMenuHeight.Value)); | ||||
|                 _y -= overshoot; | ||||
|  | ||||
|                 if (_y - _diff + contextMenuHeight >= mainContentSize.Height) | ||||
|                 { | ||||
|                     MaxHeight = (int)(mainContentSize.Height - _y + _diff); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             if (_x + contextMenuWidth.Value > mainContentSize.Width) | ||||
|             { | ||||
|                 var overshoot = Math.Abs(mainContentSize.Width - (_x + contextMenuWidth.Value)); | ||||
|                 _x -= overshoot; | ||||
|             } | ||||
|  | ||||
|             SetPopoverStyle(_x, _y); | ||||
|             _isResized = true; | ||||
|             await InvokeAsync(StateHasChanged); | ||||
|         } | ||||
|  | ||||
|         private (double x, double y) GetPositionFromArgs(EventArgs eventArgs) | ||||
|         { | ||||
|             double x, y; | ||||
|             if (eventArgs is MouseEventArgs mouseEventArgs) | ||||
|             { | ||||
|                 x = mouseEventArgs.ClientX; | ||||
|                 y = mouseEventArgs.ClientY; | ||||
|             } | ||||
|             else if (eventArgs is LongPressEventArgs longPressEventArgs) | ||||
|             { | ||||
|                 x = longPressEventArgs.ClientX; | ||||
|                 y = longPressEventArgs.ClientY; | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 throw new NotSupportedException("Invalid eventArgs type."); | ||||
|             } | ||||
|  | ||||
|             return (x + AdjustmentX, y + AdjustmentY); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,6 +0,0 @@ | ||||
| <DynamicTable T="Lantean.QBitTorrentClient.Models.WebSeed" | ||||
|               ColumnDefinitions="Columns" | ||||
|               Items="WebSeeds" | ||||
|               MultiSelection="false" | ||||
|               SelectOnRowClick="false" | ||||
|               Class="details-list" /> | ||||
| @@ -1,28 +0,0 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly"> | ||||
|  | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net8.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="8.0.10" /> | ||||
| 	<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.10" PrivateAssets="all" /> | ||||
| 	<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" /> | ||||
| 	<PackageReference Include="MudBlazor" Version="7.15.0" /> | ||||
| 	<PackageReference Include="MudBlazor.ThemeManager" Version="2.1.0" /> | ||||
|     <!-- added to fix vuln in dependency --> | ||||
| 	<PackageReference Include="System.Text.Json" Version="8.0.5" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\Lantean.QBitTorrentClient\Lantean.QBitTorrentClient.csproj" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
| </Project> | ||||
| @@ -1,9 +0,0 @@ | ||||
| @inherits LayoutComponentBase | ||||
| @layout LoggedInLayout | ||||
|  | ||||
| <MudDrawer Open="DrawerOpen" ClipMode="DrawerClipMode.Always" Elevation="2" Overlay="false"> | ||||
|     <TorrentsListNav Torrents="Torrents" SelectedTorrent="@SelectedTorrent" SortDirection="SortDirection" SortColumn="@SortColumn" /> | ||||
| </MudDrawer> | ||||
| <MudMainContent> | ||||
|     @Body | ||||
| </MudMainContent> | ||||
| @@ -1,11 +0,0 @@ | ||||
| @inherits LayoutComponentBase | ||||
| @layout LoggedInLayout | ||||
|  | ||||
| <MudDrawer Open="DrawerOpen" ClipMode="DrawerClipMode.Always" Elevation="2" Overlay="false"> | ||||
|     <FiltersNav CategoryChanged="CategoryChanged" StatusChanged="StatusChanged" TagChanged="TagChanged" TrackerChanged="TrackerChanged"  /> | ||||
| </MudDrawer> | ||||
| <MudMainContent> | ||||
|     <CascadingValue Value="SearchTermChanged" Name="SearchTermChanged"> | ||||
|     @Body | ||||
|     </CascadingValue> | ||||
| </MudMainContent> | ||||
| @@ -1,69 +0,0 @@ | ||||
| @inherits LayoutComponentBase | ||||
| @layout MainLayout | ||||
|  | ||||
| <PageTitle>qBittorrent @Version Web UI</PageTitle> | ||||
|  | ||||
| @if (!IsAuthenticated) | ||||
| { | ||||
|     <MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-7" Style="width: 100%; height: 30px" /> | ||||
|     return; | ||||
| } | ||||
|  | ||||
| <CascadingValue Value="Torrents"> | ||||
|     <CascadingValue Value="MainData"> | ||||
|         <CascadingValue Value="Preferences"> | ||||
|             <CascadingValue Value="SortColumnChanged" Name="SortColumnChanged"> | ||||
|                 <CascadingValue Value="SortColumn" Name="SortColumn"> | ||||
|                     <CascadingValue Value="SortDirectionChanged" Name="SortDirectionChanged"> | ||||
|                         <CascadingValue Value="SortDirection" Name="SortDirection"> | ||||
|                             <CascadingValue Value="CategoryChanged" Name="CategoryChanged"> | ||||
|                                 <CascadingValue Value="StatusChanged" Name="StatusChanged"> | ||||
|                                     <CascadingValue Value="TagChanged" Name="TagChanged"> | ||||
|                                         <CascadingValue Value="TrackerChanged" Name="TrackerChanged"> | ||||
|                                             <CascadingValue Value="SearchTermChanged" Name="SearchTermChanged"> | ||||
|                                                 <CascadingValue Value="@(MainData?.LostConnection ?? false)" Name="LostConnection"> | ||||
|                                                     <CascadingValue Value="Version" Name="Version"> | ||||
|                                                         @Body | ||||
|                                                     </CascadingValue> | ||||
|                                                 </CascadingValue> | ||||
|                                             </CascadingValue> | ||||
|                                         </CascadingValue> | ||||
|                                     </CascadingValue> | ||||
|                                 </CascadingValue> | ||||
|                             </CascadingValue> | ||||
|                         </CascadingValue> | ||||
|                     </CascadingValue> | ||||
|                 </CascadingValue> | ||||
|             </CascadingValue> | ||||
|         </CascadingValue> | ||||
|         <MudAppBar Bottom="true" Fixed="true" Elevation="0" Dense="true" Style="background-color: var(--mud-palette-dark-lighten);"> | ||||
|             @if (MainData?.LostConnection == true) | ||||
|             { | ||||
|                 <MudText Class="mx-2 mb-1" Color="Color.Error">qBittorrent client is not reachable</MudText> | ||||
|             } | ||||
|             <MudSpacer /> | ||||
|             <MudText Class="mx-2 mb-1">@DisplayHelpers.Size(MainData?.ServerState.FreeSpaceOnDisk, "Free space: ")</MudText> | ||||
|             <MudDivider Vertical="true" /> | ||||
|             <MudText Class="mx-2 mb-1">DHT @(MainData?.ServerState.DHTNodes ?? 0) nodes</MudText> | ||||
|             <MudDivider Vertical="true" /> | ||||
|             @{ | ||||
|                 var (icon, colour) = GetConnectionIcon(MainData?.ServerState.ConnectionStatus); | ||||
|             } | ||||
|             <MudIcon Class="mx-1 mb-1" Icon="@icon" Color="@colour" Title="MainData?.ServerState.ConnectionStatus" /> | ||||
|             <MudDivider Vertical="true" /> | ||||
|             <MudIcon Class="mx-1 mb-1" Icon="@Icons.Material.Outlined.Speed" Color="@((MainData?.ServerState.UseAltSpeedLimits ?? false) ? Color.Error : Color.Success)" /> | ||||
|             <MudDivider Vertical="true" /> | ||||
|             <MudIcon Class="ml-1 mb-1" Icon="@Icons.Material.Filled.KeyboardDoubleArrowDown" Color="Color.Success" /> | ||||
|             <MudText Class="mr-1 mb-1"> | ||||
|                 @DisplayHelpers.Size(MainData?.ServerState.DownloadInfoSpeed, null, "/s") | ||||
|                 @DisplayHelpers.Size(MainData?.ServerState.DownloadInfoData, "(", ")") | ||||
|             </MudText> | ||||
|             <MudDivider Vertical="true" /> | ||||
|             <MudIcon Class="ml-1 mb-1" Icon="@Icons.Material.Filled.KeyboardDoubleArrowUp" Color="Color.Info" /> | ||||
|             <MudText Class="mr-1 mb-1"> | ||||
|                 @DisplayHelpers.Size(MainData?.ServerState.UploadInfoSpeed, null, "/s") | ||||
|                 @DisplayHelpers.Size(MainData?.ServerState.UploadInfoData, "(", ")") | ||||
|             </MudText> | ||||
|         </MudAppBar> | ||||
|     </CascadingValue> | ||||
| </CascadingValue> | ||||
| @@ -1,191 +0,0 @@ | ||||
| using Lantean.QBitTorrentClient; | ||||
| using Lantean.QBTMud.Components; | ||||
| using Lantean.QBTMud.Helpers; | ||||
| using Lantean.QBTMud.Models; | ||||
| using Lantean.QBTMud.Services; | ||||
| using Microsoft.AspNetCore.Components; | ||||
| using MudBlazor; | ||||
|  | ||||
| namespace Lantean.QBTMud.Layout | ||||
| { | ||||
|     public partial class LoggedInLayout : IDisposable | ||||
|     { | ||||
|         private readonly bool _refreshEnabled = true; | ||||
|  | ||||
|         private int _requestId = 0; | ||||
|         private bool _disposedValue; | ||||
|         private readonly CancellationTokenSource _timerCancellationToken = new(); | ||||
|         private int _refreshInterval = 1500; | ||||
|  | ||||
|         [Inject] | ||||
|         protected IApiClient ApiClient { get; set; } = default!; | ||||
|  | ||||
|         [Inject] | ||||
|         protected IDataManager DataManager { get; set; } = default!; | ||||
|  | ||||
|         [Inject] | ||||
|         protected NavigationManager NavigationManager { get; set; } = default!; | ||||
|  | ||||
|         [CascadingParameter(Name = "DrawerOpen")] | ||||
|         public bool DrawerOpen { get; set; } | ||||
|  | ||||
|         [CascadingParameter] | ||||
|         public Menu? Menu { get; set; } | ||||
|  | ||||
|         protected MainData? MainData { get; set; } | ||||
|  | ||||
|         protected string Category { get; set; } = FilterHelper.CATEGORY_ALL; | ||||
|  | ||||
|         protected string Tag { get; set; } = FilterHelper.TAG_ALL; | ||||
|  | ||||
|         protected string Tracker { get; set; } = FilterHelper.TRACKER_ALL; | ||||
|  | ||||
|         protected Status Status { get; set; } = Status.All; | ||||
|  | ||||
|         protected QBitTorrentClient.Models.Preferences? Preferences { get; set; } | ||||
|  | ||||
|         protected string? SortColumn { get; set; } | ||||
|  | ||||
|         protected SortDirection SortDirection { get; set; } | ||||
|  | ||||
|         protected string Version { get; set; } = ""; | ||||
|  | ||||
|         protected string? SearchText { get; set; } | ||||
|  | ||||
|         protected IEnumerable<Torrent> Torrents => GetTorrents(); | ||||
|  | ||||
|         protected bool IsAuthenticated { get; set; } | ||||
|  | ||||
|         protected bool LostConnection { get; set; } | ||||
|  | ||||
|         private List<Torrent> GetTorrents() | ||||
|         { | ||||
|             if (MainData is null) | ||||
|             { | ||||
|                 return []; | ||||
|             } | ||||
|  | ||||
|             var filterState = new FilterState(Category, Status, Tag, Tracker, MainData.ServerState.UseSubcategories, SearchText); | ||||
|  | ||||
|             return MainData.Torrents.Values.Filter(filterState).ToList(); | ||||
|         } | ||||
|  | ||||
|         protected override async Task OnInitializedAsync() | ||||
|         { | ||||
|             if (!await ApiClient.CheckAuthState()) | ||||
|             { | ||||
|                 NavigationManager.NavigateTo("/login"); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             await InvokeAsync(StateHasChanged); | ||||
|  | ||||
|             Preferences = await ApiClient.GetApplicationPreferences(); | ||||
|             Version = await ApiClient.GetApplicationVersion(); | ||||
|             var data = await ApiClient.GetMainData(_requestId); | ||||
|             MainData = DataManager.CreateMainData(data); | ||||
|  | ||||
|             _requestId = data.ResponseId; | ||||
|             _refreshInterval = MainData.ServerState.RefreshInterval; | ||||
|  | ||||
|             IsAuthenticated = true; | ||||
|  | ||||
|             Menu?.ShowMenu(Preferences); | ||||
|         } | ||||
|  | ||||
|         protected override async Task OnAfterRenderAsync(bool firstRender) | ||||
|         { | ||||
|             if (!_refreshEnabled) | ||||
|             { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             if (firstRender) | ||||
|             { | ||||
|                 using (var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(_refreshInterval))) | ||||
|                 { | ||||
|                     while (!_timerCancellationToken.IsCancellationRequested && await timer.WaitForNextTickAsync()) | ||||
|                     { | ||||
|                         if (!IsAuthenticated) | ||||
|                         { | ||||
|                             return; | ||||
|                         } | ||||
|                         QBitTorrentClient.Models.MainData data; | ||||
|                         try | ||||
|                         { | ||||
|                             data = await ApiClient.GetMainData(_requestId); | ||||
|                         } | ||||
|                         catch (HttpRequestException) | ||||
|                         { | ||||
|                             if (MainData is not null) | ||||
|                             { | ||||
|                                 MainData.LostConnection = true; | ||||
|                             } | ||||
|                             _timerCancellationToken.CancelIfNotDisposed(); | ||||
|                             await InvokeAsync(StateHasChanged); | ||||
|                             return; | ||||
|                         } | ||||
|  | ||||
|                         if (MainData is null || data.FullUpdate) | ||||
|                         { | ||||
|                             MainData = DataManager.CreateMainData(data); | ||||
|                         } | ||||
|                         else | ||||
|                         { | ||||
|                             DataManager.MergeMainData(data, MainData); | ||||
|                         } | ||||
|  | ||||
|                         _refreshInterval = MainData.ServerState.RefreshInterval; | ||||
|                         _requestId = data.ResponseId; | ||||
|                         await InvokeAsync(StateHasChanged); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         protected EventCallback<string> CategoryChanged => EventCallback.Factory.Create<string>(this, category => Category = category); | ||||
|  | ||||
|         protected EventCallback<Status> StatusChanged => EventCallback.Factory.Create<Status>(this, status => Status = status); | ||||
|  | ||||
|         protected EventCallback<string> TagChanged => EventCallback.Factory.Create<string>(this, tag => Tag = tag); | ||||
|  | ||||
|         protected EventCallback<string> TrackerChanged => EventCallback.Factory.Create<string>(this, tracker => Tracker = tracker); | ||||
|  | ||||
|         protected EventCallback<string> SearchTermChanged => EventCallback.Factory.Create<string>(this, term => SearchText = term); | ||||
|  | ||||
|         protected EventCallback<string> SortColumnChanged => EventCallback.Factory.Create<string>(this, columnId => SortColumn = columnId); | ||||
|  | ||||
|         protected EventCallback<SortDirection> SortDirectionChanged => EventCallback.Factory.Create<SortDirection>(this, sortDirection => SortDirection = sortDirection); | ||||
|  | ||||
|         protected static (string, Color) GetConnectionIcon(string? status) | ||||
|         { | ||||
|             if (status is null) | ||||
|             { | ||||
|                 return (Icons.Material.Outlined.SignalWifiOff, Color.Warning); | ||||
|             } | ||||
|  | ||||
|             return (Icons.Material.Outlined.SignalWifi4Bar, Color.Success); | ||||
|         } | ||||
|  | ||||
|         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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,38 +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" LabelPosition="LabelPosition.End" Value="IsDarkMode" ValueChanged="DarkModeChanged" Class="pl-3" /> | ||||
|                 <Menu @ref="Menu" /> | ||||
|             </MudAppBar> | ||||
|             <MudDrawer 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"> | ||||
|                         @Body | ||||
|                     </CascadingValue> | ||||
|                 </CascadingValue> | ||||
|             </CascadingValue> | ||||
|         </MudLayout> | ||||
|     </EnhancedErrorBoundary> | ||||
| </CascadingValue> | ||||
| @@ -1,11 +0,0 @@ | ||||
| @inherits LayoutComponentBase | ||||
| @layout LoggedInLayout | ||||
|  | ||||
| <MudDrawer Open="DrawerOpen" ClipMode="DrawerClipMode.Always" Elevation="2" Overlay="false"> | ||||
|     <MudNavMenu> | ||||
|         <ApplicationActions IsMenu="false" Preferences="Preferences" /> | ||||
|     </MudNavMenu> | ||||
| </MudDrawer> | ||||
| <MudMainContent> | ||||
|     @Body | ||||
| </MudMainContent> | ||||
| @@ -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; } | ||||
|     } | ||||
| } | ||||
| @@ -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"; | ||||
|     } | ||||
| } | ||||
| @@ -1,36 +0,0 @@ | ||||
| @page "/blocks" | ||||
| @layout OtherLayout | ||||
|  | ||||
| <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">Blocked IPs</MudText> | ||||
| </MudToolBar> | ||||
|  | ||||
| <MudCard Elevation="1" Class="ml-4 mr-4 mb-4"> | ||||
|     <MudCardContent> | ||||
|         <EditForm Model="Model" OnSubmit="Submit"> | ||||
|             <MudGrid> | ||||
|                 <MudItem md="10"> | ||||
|                     <MudTextField T="string" Label="Criteria" @bind-Value="Model.Criteria" Variant="Variant.Outlined" /> | ||||
|                 </MudItem> | ||||
|                 <MudItem md="2"> | ||||
|                     <MudButton ButtonType="ButtonType.Submit" FullWidth="true" Color="Color.Primary" EndIcon="@Icons.Material.Filled.Search" Variant="Variant.Filled" Class="mt-6">Filter</MudButton> | ||||
|                 </MudItem> | ||||
|             </MudGrid> | ||||
|         </EditForm> | ||||
|     </MudCardContent> | ||||
| </MudCard> | ||||
|  | ||||
| <DynamicTable @ref="Table" | ||||
|               T="Lantean.QBitTorrentClient.Models.PeerLog" | ||||
|               ColumnDefinitions="Columns" | ||||
|               Items="Results" | ||||
|               MultiSelection="false" | ||||
|               SelectOnRowClick="false" | ||||
|               RowClassFunc="RowClass" | ||||
|               Class="search-list" /> | ||||
| @@ -1,39 +0,0 @@ | ||||
| @page "/categories" | ||||
| @layout OtherLayout | ||||
|  | ||||
| <MudToolBar Gutters="false" Dense="true"> | ||||
|     @if (!DrawerOpen) | ||||
|     { | ||||
|         <MudIconButton Icon="@Icons.Material.Outlined.NavigateBefore" OnClick="NavigateBack" title="Back to torrent list" /> | ||||
|         <MudDivider Vertical="true" /> | ||||
|     } | ||||
|     <MudText Class="px-5 no-wrap">Categories</MudText> | ||||
|     <MudDivider Vertical="true" /> | ||||
|     <MudIconButton Icon="@Icons.Material.Filled.PlaylistAdd" OnClick="AddCategory" title="Add Category" /> | ||||
| </MudToolBar> | ||||
|  | ||||
| <DynamicTable @ref="Table" | ||||
|               T="Category" | ||||
|               ColumnDefinitions="Columns" | ||||
|               Items="Results" | ||||
|               MultiSelection="false" | ||||
|               SelectOnRowClick="false" | ||||
|               Class="details-list" /> | ||||
|  | ||||
| @code { | ||||
|     private RenderFragment<RowContext<Category>> ActionsColumn | ||||
|     { | ||||
|         get | ||||
|         { | ||||
|             return context => __builder => | ||||
|             { | ||||
|                 var value = (Category?)context.GetValue(); | ||||
|                 <MudButtonGroup> | ||||
|                     <MudIconButton Icon="@Icons.Material.Filled.Edit" Color="Color.Warning" OnClick="@(e => EditCategory(value?.Name))" /> | ||||
|                     <MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="@(e => DeleteCategory(value?.Name))" /> | ||||
|                 </MudButtonGroup> | ||||
|                 ; | ||||
|             }; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,39 +0,0 @@ | ||||
| @page "/details/{hash}" | ||||
| @layout DetailsLayout | ||||
|  | ||||
| <MudToolBar Gutters="false" Dense="true"> | ||||
|     @if (!DrawerOpen) | ||||
|     { | ||||
|         <MudIconButton Icon="@Icons.Material.Outlined.NavigateBefore" OnClick="NavigateBack" title="Back to torrent list" /> | ||||
|         <MudDivider Vertical="true" /> | ||||
|     } | ||||
|     @if (Hash is not null) | ||||
|     { | ||||
|         <TorrentActions RenderType="RenderType.InitialIconsOnly" Hashes="@([Hash])" Torrents="MainData.Torrents" Preferences="Preferences" /> | ||||
|     } | ||||
|     <MudDivider Vertical="true" /> | ||||
|     <MudText Class="pl-5 no-wrap">@Name</MudText> | ||||
| </MudToolBar> | ||||
|  | ||||
| @if (ShowTabs) | ||||
| { | ||||
|     <CascadingValue Value="RefreshInterval" Name="RefreshInterval"> | ||||
|         <MudTabs Elevation="2" ApplyEffectsToContainer="true" @bind-ActivePanelIndex="ActiveTab" KeepPanelsAlive="true" Border="true"> | ||||
|             <MudTabPanel Text="General"> | ||||
|                 <GeneralTab Hash="@Hash" Active="@(ActiveTab == 0)" /> | ||||
|             </MudTabPanel> | ||||
|             <MudTabPanel Text="Trackers"> | ||||
|                 <TrackersTab Hash="@Hash" Active="@(ActiveTab == 1)" /> | ||||
|             </MudTabPanel> | ||||
|             <MudTabPanel Text="Peers"> | ||||
|                 <PeersTab Hash="@Hash" Active="@(ActiveTab == 2)" /> | ||||
|             </MudTabPanel> | ||||
|             <MudTabPanel Text="HTTP Sources"> | ||||
|                 <WebSeedsTab Hash="@Hash" Active="@(ActiveTab == 3)" /> | ||||
|             </MudTabPanel> | ||||
|             <MudTabPanel Text="Content"> | ||||
|                 <FilesTab Hash="@Hash" Active="@(ActiveTab == 4)" /> | ||||
|             </MudTabPanel> | ||||
|         </MudTabs> | ||||
|     </CascadingValue> | ||||
| } | ||||
| @@ -1,44 +0,0 @@ | ||||
| @page "/log" | ||||
| @layout OtherLayout | ||||
|  | ||||
| <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">Execution Log</MudText> | ||||
| </MudToolBar> | ||||
|  | ||||
| <MudCard Elevation="1" Class="ml-4 mr-4 mb-4"> | ||||
|     <MudCardContent> | ||||
|         <EditForm Model="Model" OnSubmit="Submit"> | ||||
|             <MudGrid> | ||||
|                 <MudItem md="7"> | ||||
|                     <MudTextField T="string" Label="Criteria" @bind-Value="Model.Criteria" Variant="Variant.Outlined" /> | ||||
|                 </MudItem> | ||||
|                 <MudItem md="3"> | ||||
|                     <MudSelect @ref="CategoryMudSelect" T="string" Label="Categories" SelectedValues="Model.SelectedTypes" SelectedValuesChanged="SelectedValuesChanged" Variant="Variant.Outlined" MultiSelection="true" MultiSelectionTextFunc="GenerateSelectedText" SelectAll="true"> | ||||
|                         <MudSelectItem Value="@("Normal")">Normal</MudSelectItem> | ||||
|                         <MudSelectItem Value="@("Info")">Info</MudSelectItem> | ||||
|                         <MudSelectItem Value="@("Warning")">Warning</MudSelectItem> | ||||
|                         <MudSelectItem Value="@("Critical")">Critical</MudSelectItem> | ||||
|                     </MudSelect> | ||||
|                 </MudItem> | ||||
|                 <MudItem md="2"> | ||||
|                     <MudButton ButtonType="ButtonType.Submit" FullWidth="true" Color="Color.Primary" EndIcon="@Icons.Material.Filled.Search" Variant="Variant.Filled" Class="mt-6">Filter</MudButton> | ||||
|                 </MudItem> | ||||
|             </MudGrid> | ||||
|         </EditForm> | ||||
|     </MudCardContent> | ||||
| </MudCard> | ||||
|  | ||||
| <DynamicTable @ref="Table" | ||||
|               T="Lantean.QBitTorrentClient.Models.Log" | ||||
|               ColumnDefinitions="Columns" | ||||
|               Items="Results" | ||||
|               MultiSelection="false" | ||||
|               SelectOnRowClick="false" | ||||
|               RowClassFunc="RowClass" | ||||
|               Class="search-list" /> | ||||
| @@ -1,43 +0,0 @@ | ||||
| @page "/settings" | ||||
| @layout OtherLayout | ||||
|  | ||||
| <NavigationLock ConfirmExternalNavigation="@(UpdatePreferences is not null)" OnBeforeInternalNavigation="ValidateExit" /> | ||||
|  | ||||
| <MudToolBar Gutters="false" Dense="true"> | ||||
|     @if (!DrawerOpen) | ||||
|     { | ||||
|         <MudIconButton Icon="@Icons.Material.Outlined.NavigateBefore" OnClick="NavigateBack" /> | ||||
|         <MudDivider Vertical="true" /> | ||||
|     } | ||||
|     <MudText Class="px-5 no-wrap">Settings</MudText> | ||||
|     <MudDivider Vertical="true" /> | ||||
|     <MudIconButton Icon="@Icons.Material.Outlined.Save" OnClick="Save" Disabled="@(LostConnection || UpdatePreferences is null)" /> | ||||
|     <MudIconButton Icon="@Icons.Material.Outlined.Undo" OnClick="Undo" Disabled="@(LostConnection || UpdatePreferences is null)" /> | ||||
| </MudToolBar> | ||||
|  | ||||
| <MudTabs Elevation="2" ApplyEffectsToContainer="true" @bind-ActivePanelIndex="ActiveTab" Border="true"> | ||||
|     <MudTabPanel Text="Behaviour"> | ||||
|         <BehaviourOptions @ref="BehaviourOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" /> | ||||
|     </MudTabPanel> | ||||
|     <MudTabPanel Text="Downloads"> | ||||
|         <DownloadsOptions @ref="DownloadsOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" /> | ||||
|     </MudTabPanel> | ||||
|     <MudTabPanel Text="Connection"> | ||||
|         <ConnectionOptions @ref="ConnectionOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" /> | ||||
|     </MudTabPanel> | ||||
|     <MudTabPanel Text="Speed"> | ||||
|         <SpeedOptions @ref="SpeedOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" /> | ||||
|     </MudTabPanel> | ||||
|     <MudTabPanel Text="BitTorrent"> | ||||
|         <BitTorrentOptions @ref="BitTorrentOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" /> | ||||
|     </MudTabPanel> | ||||
|     <MudTabPanel Text="RSS"> | ||||
|         <RSSOptions @ref="RSSOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" /> | ||||
|     </MudTabPanel> | ||||
|     <MudTabPanel Text="Web UI"> | ||||
|         <WebUIOptions @ref="WebUIOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" /> | ||||
|     </MudTabPanel> | ||||
|     <MudTabPanel Text="Advanced"> | ||||
|         <AdvancedOptions @ref="AdvancedOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" /> | ||||
|     </MudTabPanel> | ||||
| </MudTabs> | ||||
| @@ -1,73 +0,0 @@ | ||||
| @page "/rss" | ||||
| @layout OtherLayout | ||||
|  | ||||
| <MudToolBar Gutters="false" Dense="true"> | ||||
|     @if (!DrawerOpen) | ||||
|     { | ||||
|         <MudIconButton Icon="@Icons.Material.Outlined.NavigateBefore" OnClick="NavigateBack" title="Back to torrent list" /> | ||||
|         <MudDivider Vertical="true" /> | ||||
|     } | ||||
|     <MudText Class="px-5 no-wrap">RSS</MudText> | ||||
|     <MudDivider Vertical="true" /> | ||||
|     <MudIconButton Icon="@Icons.Material.Outlined.Subscriptions" OnClick="NewSubscription" title="New subscription" /> | ||||
|     <MudIconButton Icon="@Icons.Material.Outlined.MarkEmailRead" OnClick="MarkAsRead" Disabled="@(SelectedFeed is null)" title="Mark items read" /> | ||||
|     <MudIconButton Icon="@Icons.Material.Outlined.Update" OnClick="UpdateAll" title="Update all" /> | ||||
|     <MudDivider Vertical="true" /> | ||||
|     <MudIconButton Icon="@Icons.Material.Outlined.DownloadForOffline" OnClick="EditDownloadRules" title="Edit auto downloading rules" /> | ||||
| </MudToolBar> | ||||
|  | ||||
| <MudContainer MaxWidth="MaxWidth.ExtraExtraLarge"> | ||||
|     <MudGrid Class="rss-contents"> | ||||
|         <MudItem xs="4" Style="height: 100%"> | ||||
|             <MudList T="string" SelectionMode="SelectionMode.SingleSelection" SelectedValue="SelectedFeed" SelectedValueChanged="SelectedFeedChanged" Dense> | ||||
|                 <MudListItem Icon="@Icons.Material.Filled.MarkEmailUnread" Text="@($"Unread ({UnreadCount})")" Value="@("unread")" /> | ||||
|                 @foreach (var (key, feed) in Feeds) | ||||
|                 { | ||||
|                     <MudListItem Icon="@(feed.IsLoading ? Icons.Material.Filled.Sync : Icons.Material.Filled.Wifi)" Class="@(feed.IsLoading ? "spin-animation" : "")" Text="@($"{feed.Title} ({feed.UnreadCount})")" Value="@key" /> | ||||
|                 } | ||||
|             </MudList> | ||||
|         </MudItem> | ||||
|         <MudItem xs="4" Style="height: 100%; overflow: auto"> | ||||
|             @if (Articles.Count > 0) | ||||
|             { | ||||
|                 <MudList T="string" SelectionMode="SelectionMode.SingleSelection" SelectedValue="SelectedArticle" SelectedValueChanged="SelectedArticleChanged" Dense> | ||||
|                     @foreach (var article in Articles) | ||||
|                     { | ||||
|                         <MudListItem Text="@article.Title" Value="article.Id" Icon="@Icons.Material.Filled.Check" IconColor="@(article.IsRead ? Color.Success : Color.Transparent)" /> | ||||
|                     } | ||||
|                 </MudList> | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 <MudSkeleton SkeletonType="SkeletonType.Rectangle" Height="100%" Animation="Animation.False" Width="100%" /> | ||||
|             } | ||||
|         </MudItem> | ||||
|         <MudItem xs="4" Style="height: 100%"> | ||||
|             @if (Article is not null) | ||||
|             { | ||||
|                 <MudCard> | ||||
|                     <MudCardHeader> | ||||
|                         <CardHeaderContent> | ||||
|                             <MudText Typo="Typo.h6" Style="overflow-wrap: anywhere">@Article.Title</MudText> | ||||
|                         </CardHeaderContent> | ||||
|                         <CardHeaderActions> | ||||
|                             <MudMenu Icon="@Icons.Material.Filled.MoreVert" Dense> | ||||
|                                 <MudMenuItem Icon="@Icons.Material.Filled.Download" OnClick="c => DownloadItem(Article.TorrentURL)" title="Download">Download</MudMenuItem> | ||||
|                                 <MudMenuItem Icon="@Icons.Material.Filled.Link" Href="@Article.TorrentURL" Target="@Article.TorrentURL" title="Download">Open torrent URL</MudMenuItem> | ||||
|                             </MudMenu> | ||||
|                         </CardHeaderActions> | ||||
|                     </MudCardHeader> | ||||
|  | ||||
|                     <MudCardContent> | ||||
|                         <MudText Typo="Typo.subtitle2">@Article.Date</MudText> | ||||
|                         <MudText Typo="Typo.body1">@Article.Description</MudText> | ||||
|                     </MudCardContent> | ||||
|                 </MudCard> | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 <MudSkeleton SkeletonType="SkeletonType.Rectangle" Height="100%" Animation="Animation.False" Width="100%" /> | ||||
|             } | ||||
|         </MudItem> | ||||
|     </MudGrid> | ||||
| </MudContainer> | ||||
| @@ -1,58 +0,0 @@ | ||||
| @page "/search" | ||||
| @layout OtherLayout | ||||
|  | ||||
| <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> | ||||
|  | ||||
| <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="2" 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="2" md="3"> | ||||
|                     <MudSelect T="string" Label="Plugins" @bind-Value="Model.SelectedPlugin" Variant="Variant.Outlined"> | ||||
|                         <MudSelectItem Value="@("all")">All</MudSelectItem> | ||||
|                         <MudDivider /> | ||||
|                         @foreach (var (value, name) in Plugins) | ||||
|                         { | ||||
|                             <MudSelectItem Value="value">@name</MudSelectItem> | ||||
|                         } | ||||
|                     </MudSelect> | ||||
|                 </MudItem> | ||||
|                 <MudItem xs="2" 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" /> | ||||
| @@ -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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,62 +0,0 @@ | ||||
| @page "/statistics" | ||||
| @layout OtherLayout | ||||
|  | ||||
| <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">Statistics</MudText> | ||||
| </MudToolBar> | ||||
|  | ||||
| <MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="details-tab-contents"> | ||||
|     <MudText Typo="Typo.subtitle2" Class="pt-6">User statistics</MudText> | ||||
|     <MudGrid> | ||||
|         <MudItem xs="12"> | ||||
|             <MudField Label="All-time uploaded">@DisplayHelpers.Size(ServerState?.AllTimeUploaded)</MudField> | ||||
|         </MudItem> | ||||
|         <MudItem xs="12"> | ||||
|             <MudField Label="All-time downloaded">@DisplayHelpers.Size(ServerState?.AllTimeDownloaded)</MudField> | ||||
|         </MudItem> | ||||
|         <MudItem xs="12"> | ||||
|             <MudField Label="All-time share ratio">@DisplayHelpers.EmptyIfNull(ServerState?.GlobalRatio, format: "0.00")</MudField> | ||||
|         </MudItem> | ||||
|         <MudItem xs="12"> | ||||
|             <MudField Label="Session waste">@DisplayHelpers.Size(ServerState?.TotalWastedSession)</MudField> | ||||
|         </MudItem> | ||||
|         <MudItem xs="12"> | ||||
|             <MudField Label="Connected peers">@DisplayHelpers.EmptyIfNull(ServerState?.TotalPeerConnections)</MudField> | ||||
|         </MudItem> | ||||
|     </MudGrid> | ||||
|  | ||||
|     <MudText Typo="Typo.subtitle2" Class="pt-6">Cache statistics</MudText> | ||||
|     <MudGrid> | ||||
|         <MudItem xs="12"> | ||||
|             <MudField Label="Read cache hits">@DisplayHelpers.Percentage(ServerState?.ReadCacheHits)</MudField> | ||||
|         </MudItem> | ||||
|         <MudItem xs="12"> | ||||
|             <MudField Label="Total buffer size">@DisplayHelpers.Size(ServerState?.TotalBuffersSize)</MudField> | ||||
|         </MudItem> | ||||
|     </MudGrid> | ||||
|  | ||||
|     <MudText Typo="Typo.subtitle2" Class="pt-6">Performance statistics</MudText> | ||||
|     <MudGrid> | ||||
|         <MudItem xs="12"> | ||||
|             <MudField Label="Write cache overload">@DisplayHelpers.Percentage(ServerState?.WriteCacheOverload)</MudField> | ||||
|         </MudItem> | ||||
|         <MudItem xs="12"> | ||||
|             <MudField Label="Read cache overload">@DisplayHelpers.Percentage(ServerState?.ReadCacheOverload)</MudField> | ||||
|         </MudItem> | ||||
|         <MudItem xs="12"> | ||||
|             <MudField Label="Queued I/O jobs">@DisplayHelpers.EmptyIfNull(ServerState?.QueuedIOJobs)</MudField> | ||||
|         </MudItem> | ||||
|         <MudItem xs="12"> | ||||
|             <MudField Label="Average time in queue">@DisplayHelpers.EmptyIfNull(ServerState?.AverageTimeQueue, suffix: "ms")</MudField> | ||||
|         </MudItem> | ||||
|         <MudItem xs="12"> | ||||
|             <MudField Label="Total queued size">@DisplayHelpers.Size(ServerState?.TotalQueuedSize)</MudField> | ||||
|         </MudItem> | ||||
|     </MudGrid> | ||||
| </MudContainer> | ||||
| @@ -1,38 +0,0 @@ | ||||
| @page "/tags" | ||||
| @layout OtherLayout | ||||
|  | ||||
| <MudToolBar Gutters="false" Dense="true"> | ||||
|     @if (!DrawerOpen) | ||||
|     { | ||||
|         <MudIconButton Icon="@Icons.Material.Outlined.NavigateBefore" OnClick="NavigateBack" title="Back to torrent list" /> | ||||
|         <MudDivider Vertical="true" /> | ||||
|     } | ||||
|     <MudText Class="px-5 no-wrap">Tags</MudText> | ||||
|     <MudDivider Vertical="true" /> | ||||
|     <MudIconButton Icon="@Icons.Material.Filled.NewLabel" OnClick="AddTag" title="Add Tag" /> | ||||
| </MudToolBar> | ||||
|  | ||||
| <DynamicTable @ref="Table" | ||||
|               T="string" | ||||
|               ColumnDefinitions="Columns" | ||||
|               Items="Results" | ||||
|               MultiSelection="false" | ||||
|               SelectOnRowClick="false" | ||||
|               Class="details-list" /> | ||||
|  | ||||
| @code { | ||||
|     private RenderFragment<RowContext<string>> ActionsColumn | ||||
|     { | ||||
|         get | ||||
|         { | ||||
|             return context => __builder => | ||||
|             { | ||||
|                 var value = (string?)context.GetValue(); | ||||
|                 <MudButtonGroup> | ||||
|                     <MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="@(e => DeleteTag(value))" /> | ||||
|                 </MudButtonGroup> | ||||
|                 ; | ||||
|             }; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,67 +0,0 @@ | ||||
| @page "/" | ||||
| @layout ListLayout | ||||
|  | ||||
| <ContextMenu @ref="ContextMenu" Dense="true" AdjustmentX="@(DrawerOpen ? -235 : 0)"> | ||||
|     <MudMenuItem Icon="@Icons.Material.Outlined.Info" IconColor="Color.Inherit" OnClick="ShowTorrentContextMenu">View torrent details</MudMenuItem> | ||||
|     <MudDivider /> | ||||
|     <TorrentActions RenderType="RenderType.MenuItems" Hashes="GetContextMenuTargetHashes()" PrimaryHash="@(ContextMenuItem?.Hash)" Torrents="MainData.Torrents" Preferences="Preferences" /> | ||||
| </ContextMenu> | ||||
|  | ||||
| <MudToolBar Gutters="false" Dense="true"> | ||||
|     <MudIconButton Icon="@Icons.Material.Outlined.AddLink" OnClick="AddTorrentLink" title="Add torrent link" /> | ||||
|     <MudIconButton Icon="@Icons.Material.Outlined.AddCircle" OnClick="AddTorrentFile" title="Add torrent file" /> | ||||
|     <MudDivider Vertical="true" /> | ||||
|     <TorrentActions RenderType="RenderType.InitialIconsOnly" Hashes="GetSelectedTorrentsHashes()" Torrents="MainData.Torrents" Preferences="Preferences" /> | ||||
|     <MudDivider Vertical="true" /> | ||||
|     <MudIconButton Icon="@Icons.Material.Outlined.Info" Color="Color.Inherit" Disabled="@(!ToolbarButtonsEnabled)" OnClick="ShowTorrentToolbar" title="View torrent details" /> | ||||
|     <MudIconButton Icon="@Icons.Material.Outlined.ViewColumn" Color="Color.Inherit" OnClick="ColumnOptions" title="Choose Columns" /> | ||||
|     <MudSpacer /> | ||||
|     <MudTextField Value="SearchText" TextChanged="SearchTextChanged" Immediate="true" DebounceInterval="1000" Placeholder="Filter torrent list" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0"></MudTextField> | ||||
| </MudToolBar> | ||||
|  | ||||
| <MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="ma-0 pa-0"> | ||||
|     <DynamicTable | ||||
|         @ref="Table" | ||||
|         T="Torrent"  | ||||
|         Class="torrent-list" | ||||
|         ColumnDefinitions="Columns"  | ||||
|         Items="Torrents"  | ||||
|         OnRowClick="RowClick"  | ||||
|         MultiSelection="true" | ||||
|         SelectOnRowClick="true" | ||||
|         SelectedItemsChanged="SelectedItemsChanged" | ||||
|         SortColumnChanged="SortColumnChangedHandler" | ||||
|         SortDirectionChanged="SortDirectionChangedHandler" | ||||
|         OnTableDataContextMenu="TableDataContextMenu" | ||||
|         OnTableDataLongPress="TableDataLongPress" | ||||
|     /> | ||||
| </MudContainer> | ||||
|  | ||||
| @code { | ||||
|     private static RenderFragment<RowContext<Torrent>> ProgressBarColumn | ||||
|     { | ||||
|         get | ||||
|         { | ||||
|             return context => __builder => | ||||
|             { | ||||
|                 var value = (float?)context.GetValue(); | ||||
|                 var color = value < 1 ? Color.Success : Color.Info; | ||||
|                 <MudProgressLinear title="Progress" Color="@color" Value="@((value ?? 0) * 100)" Class="progress-expand" Size="Size.Large"> | ||||
|                     @DisplayHelpers.Percentage(value) | ||||
|                 </MudProgressLinear>; | ||||
|             }; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static RenderFragment<RowContext<Torrent>> IconColumn | ||||
|     { | ||||
|         get | ||||
|         { | ||||
|             return context => __builder => | ||||
|             { | ||||
|                 var (icon, color) = DisplayHelpers.GetStateIcon((string?)context.GetValue()); | ||||
|                 <MudIcon Icon="@icon" Color="@color" /> | ||||
|             }; | ||||
|         } | ||||
|     } | ||||
| } | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,25 +0,0 @@ | ||||
| using Lantean.QBTMud.Models; | ||||
|  | ||||
| namespace Lantean.QBTMud.Services | ||||
| { | ||||
|     public interface IDataManager | ||||
|     { | ||||
|         MainData CreateMainData(QBitTorrentClient.Models.MainData mainData); | ||||
|  | ||||
|         Torrent CreateTorrent(string hash, QBitTorrentClient.Models.Torrent torrent); | ||||
|  | ||||
|         void MergeMainData(QBitTorrentClient.Models.MainData mainData, MainData torrentList); | ||||
|  | ||||
|         PeerList CreatePeerList(QBitTorrentClient.Models.TorrentPeers torrentPeers); | ||||
|  | ||||
|         void MergeTorrentPeers(QBitTorrentClient.Models.TorrentPeers torrentPeers, PeerList peerList); | ||||
|  | ||||
|         Dictionary<string, ContentItem> CreateContentsList(IReadOnlyList<QBitTorrentClient.Models.FileData> files); | ||||
|  | ||||
|         void MergeContentsList(IReadOnlyList<QBitTorrentClient.Models.FileData> files, Dictionary<string, ContentItem> contents); | ||||
|  | ||||
|         QBitTorrentClient.Models.UpdatePreferences MergePreferences(QBitTorrentClient.Models.UpdatePreferences? original, QBitTorrentClient.Models.UpdatePreferences changed); | ||||
|  | ||||
|         RssList CreateRssList(IReadOnlyDictionary<string, QBitTorrentClient.Models.RssItem> rssItems); | ||||
|     } | ||||
| } | ||||
| @@ -1,8 +0,0 @@ | ||||
| ///*! | ||||
| // * long-press-event - v2.4.6 | ||||
| // * Pure JavaScript long-press-event | ||||
| // * https://github.com/john-doherty/long-press-event | ||||
| // * @author John Doherty <www.johndoherty.info> | ||||
| // * @license MIT | ||||
| // */ | ||||
| !function (e, t) { "use strict"; var n = null, a = "PointerEvent" in e || e.navigator && "msPointerEnabled" in e.navigator, i = "ontouchstart" in e || navigator.MaxTouchPoints > 0 || navigator.msMaxTouchPoints > 0, o = a ? "pointerdown" : i ? "touchstart" : "mousedown", r = a ? "pointerup" : i ? "touchend" : "mouseup", m = a ? "pointermove" : i ? "touchmove" : "mousemove", u = a ? "pointerleave" : i ? "touchleave" : "mouseleave", s = 0, c = 0, l = 10, v = 10; function f(e) { p(), e = function (e) { if (void 0 !== e.changedTouches) return e.changedTouches[0]; return e }(e), this.dispatchEvent(new CustomEvent("longpress", { bubbles: !0, cancelable: !0, detail: { clientX: e.clientX, clientY: e.clientY, offsetX: e.offsetX, offsetY: e.offsetY, pageX: e.pageX, pageY: e.pageY }, clientX: e.clientX, clientY: e.clientY, offsetX: e.offsetX, offsetY: e.offsetY, pageX: e.pageX, pageY: e.pageY, screenX: e.screenX, screenY: e.screenY })) || t.addEventListener("click", function e(n) { t.removeEventListener("click", e, !0), function (e) { e.stopImmediatePropagation(), e.preventDefault(), e.stopPropagation() }(n) }, !0) } function d(a) { p(a); var i = a.target, o = parseInt(function (e, n, a) { for (; e && e !== t.documentElement;) { var i = e.getAttribute(n); if (i) return i; e = e.parentNode } return a }(i, "data-long-press-delay", "400"), 10); n = function (t, n) { if (!(e.requestAnimationFrame || e.webkitRequestAnimationFrame || e.mozRequestAnimationFrame && e.mozCancelRequestAnimationFrame || e.oRequestAnimationFrame || e.msRequestAnimationFrame)) return e.setTimeout(t, n); var a = (new Date).getTime(), i = {}, o = function () { (new Date).getTime() - a >= n ? t.call() : i.value = requestAnimFrame(o) }; return i.value = requestAnimFrame(o), i }(f.bind(i, a), o) } function p(t) { var a; (a = n) && (e.cancelAnimationFrame ? e.cancelAnimationFrame(a.value) : e.webkitCancelAnimationFrame ? e.webkitCancelAnimationFrame(a.value) : e.webkitCancelRequestAnimationFrame ? e.webkitCancelRequestAnimationFrame(a.value) : e.mozCancelRequestAnimationFrame ? e.mozCancelRequestAnimationFrame(a.value) : e.oCancelRequestAnimationFrame ? e.oCancelRequestAnimationFrame(a.value) : e.msCancelRequestAnimationFrame ? e.msCancelRequestAnimationFrame(a.value) : clearTimeout(a)), n = null } "function" != typeof e.CustomEvent && (e.CustomEvent = function (e, n) { n = n || { bubbles: !1, cancelable: !1, detail: void 0 }; var a = t.createEvent("CustomEvent"); return a.initCustomEvent(e, n.bubbles, n.cancelable, n.detail), a }, e.CustomEvent.prototype = e.Event.prototype), e.requestAnimFrame = e.requestAnimationFrame || e.webkitRequestAnimationFrame || e.mozRequestAnimationFrame || e.oRequestAnimationFrame || e.msRequestAnimationFrame || function (t) { e.setTimeout(t, 1e3 / 60) }, t.addEventListener(r, p, !0), t.addEventListener(u, p, !0), t.addEventListener(m, function (e) { var t = Math.abs(s - e.clientX), n = Math.abs(c - e.clientY); (t >= l || n >= v) && p() }, !0), t.addEventListener("wheel", p, !0), t.addEventListener("scroll", p, !0), t.addEventListener(o, function (e) { s = e.clientX, c = e.clientY, d(e) }, !0) }(window, document); | ||||
| @@ -1,10 +0,0 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|  | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net8.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
| 	<TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|  | ||||
| </Project> | ||||
| @@ -1,81 +0,0 @@ | ||||
| namespace Lantean.QBitTorrentClient.Models | ||||
| { | ||||
|     public class SaveLocation | ||||
|     { | ||||
|         public bool IsWatchedFolder { get; set; } | ||||
|  | ||||
|         public bool IsDefaltFolder { 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 | ||||
|                     { | ||||
|                         IsDefaltFolder = true | ||||
|                     }; | ||||
|                 } | ||||
|             } | ||||
|             else if (value is string stringValue) | ||||
|             { | ||||
|                 if (stringValue == "0") | ||||
|                 { | ||||
|                     return new SaveLocation | ||||
|                     { | ||||
|                         IsWatchedFolder = true | ||||
|                     }; | ||||
|                 } | ||||
|                 else if (stringValue == "1") | ||||
|                 { | ||||
|                     return new SaveLocation | ||||
|                     { | ||||
|                         IsDefaltFolder = true | ||||
|                     }; | ||||
|                 } | ||||
|                 else | ||||
|                 { | ||||
|                     return new SaveLocation | ||||
|                     { | ||||
|                         SavePath = stringValue | ||||
|                     }; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             throw new ArgumentOutOfRangeException(nameof(value)); | ||||
|         } | ||||
|  | ||||
|         public object ToValue() | ||||
|         { | ||||
|             if (IsWatchedFolder) | ||||
|             { | ||||
|                 return 0; | ||||
|             } | ||||
|             else if (IsDefaltFolder) | ||||
|             { | ||||
|                 return 1; | ||||
|             } | ||||
|             else if (SavePath is not null) | ||||
|             { | ||||
|                 return SavePath; | ||||
|             } | ||||
|  | ||||
|             throw new InvalidOperationException("Invalid value."); | ||||
|         } | ||||
|  | ||||
|         public override string? ToString() | ||||
|         { | ||||
|             return ToValue().ToString(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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; } | ||||
|     } | ||||
| } | ||||
| @@ -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
									
								
							
							
						
						
									
										77
									
								
								Search-Implementation.md
									
									
									
									
									
										Normal 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 MudBlazor’s `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 job’s accumulated results before binding them to `DynamicTable` (similar to qBittorrent’s `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 job’s 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
									
								
							
							
						
						
									
										99
									
								
								Unit-Testing-Plan.md
									
									
									
									
									
										Normal 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. | ||||
|  | ||||
| ## High‑Level 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 1–2 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
									
								
							
							
						
						
									
										25
									
								
								Upgrade-To-v5-Planning.md
									
									
									
									
									
										Normal 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.~~ | ||||
							
								
								
									
										5
									
								
								global.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								global.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| { | ||||
|   "sdk": { | ||||
|     "version": "9.0.306" | ||||
|   } | ||||
| } | ||||
							
								
								
									
										11
									
								
								nuget.config
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								nuget.config
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| <configuration> | ||||
|   <packageSources> | ||||
|     <!-- Define package sources here --> | ||||
|   </packageSources> | ||||
|   <packageSourceMapping> | ||||
|     <!-- Optional source mapping --> | ||||
|   </packageSourceMapping> | ||||
|   <packageVersionOverride> | ||||
|     <package id="FluentAssertions" allowedVersions="[7.0.0,8.0.0)" /> | ||||
|   </packageVersionOverride> | ||||
| </configuration> | ||||
							
								
								
									
										94
									
								
								readme.md
									
									
									
									
									
								
							
							
						
						
									
										94
									
								
								readme.md
									
									
									
									
									
								
							| @@ -1,14 +1,84 @@ | ||||
| # qbt-mud | ||||
| # qbtmud | ||||
|  | ||||
| ## To-Do | ||||
| qbtmud is a drop-in replacement for qBittorrent's default WebUI, implementing all of its functionality with a modern and user-friendly interface. | ||||
|  | ||||
| - Rename multiple files dialog | ||||
| - ~~RSS feeds and dialogs~~ | ||||
| - ~~About~~ | ||||
| - ~~Context menu for files list/trackers list/peers list~~ | ||||
| - ~~Tag management page~~ | ||||
| - ~~Category management page~~ | ||||
| - ~~Update all tables to use DynamicTable~~ | ||||
|   - ~~Log~~ | ||||
|   - ~~Blocks~~ | ||||
|   - ~~Search~~ | ||||
| ## Features | ||||
|  | ||||
| qbtmud replicates all core features of the qBittorrent WebUI, including: | ||||
|  | ||||
| - **Torrent Management** – Add, remove, and control torrents. | ||||
| - **Tracker Control** – View and manage trackers. | ||||
| - **Peer Management** – Monitor and manage peers connected to torrents. | ||||
| - **File Prioritization** – Select and prioritize specific files within a torrent. | ||||
| - **Speed Limits** – Set global and per-torrent speed limits. | ||||
| - **RSS Integration** – Subscribe to RSS feeds for automated torrent downloads. | ||||
| - **Search Functionality** – Integrated torrent search. | ||||
| - **Sequential Downloading** – Download files in order for media streaming. | ||||
| - **Super Seeding Mode** – Efficiently distribute torrents as an initial seeder. | ||||
| - **IP Filtering** – Improve security by filtering specific IP addresses. | ||||
| - **IPv6 Support** – Full support for IPv6 networks. | ||||
| - **Bandwidth Scheduler** – Schedule bandwidth limits. | ||||
| - **WebUI Access** – Remotely manage torrents through the WebUI. | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| For a detailed explanation of these features, refer to the [qBittorrent Options Guide](https://github.com/qbittorrent/qBittorrent/wiki/Explanation-of-Options-in-qBittorrent). | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## Installation | ||||
|  | ||||
| To install qbtmud without building from source: | ||||
|  | ||||
| ### 1. Download the Latest Release | ||||
| - Go to the [qbtmud Releases](https://github.com/lantean-code/qbtmud/releases) page. | ||||
| - Download the latest release archive for your operating system. | ||||
|  | ||||
| ### 2. Extract the Archive | ||||
| - Extract the contents of the downloaded archive to a directory of your choice. | ||||
|  | ||||
| ### 3. Configure qBittorrent to Use qbtmud | ||||
| - Open qBittorrent and navigate to `Tools` > `Options` > `Web UI`. | ||||
| - Enable the option **"Use alternative WebUI"**. | ||||
| - Set the **"Root Folder"** to the directory where you extracted qbtmud. | ||||
| - Click **OK** to save the settings. | ||||
|  | ||||
| ### 4. Access qbtmud | ||||
| - Open your web browser and go to `http://localhost:8080` (or the port configured in qBittorrent). | ||||
|  | ||||
| For more detailed instructions, refer to the [Alternate WebUI Usage Guide](https://github.com/qbittorrent/qBittorrent/wiki/Alternate-WebUI-usage). | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## Building from Source | ||||
|  | ||||
| To build qbtmud from source, you need to have the **.NET 9.0 SDK** installed on your system. | ||||
|  | ||||
| ### 1. Clone the Repository | ||||
| ```sh | ||||
| git clone https://github.com/lantean-code/qbtmud.git | ||||
| cd qbtmud | ||||
| ``` | ||||
|  | ||||
| ### 2. Restore Dependencies | ||||
| ```sh | ||||
| dotnet restore | ||||
| ``` | ||||
|  | ||||
| ### 3. Build and Publish the Application | ||||
| ```sh | ||||
| dotnet publish --configuration Release | ||||
| ``` | ||||
|  | ||||
| This will output the Web UI files to `Lantean.QBTMud\bin\Release\net9.0\publish\wwwroot`. | ||||
|  | ||||
| ### 4. Configure qBittorrent to Use qbtmud | ||||
| Follow the same steps as in the **Installation** section to set qbtmud as your WebUI. | ||||
|  | ||||
| ### 5. Run qbtmud | ||||
| Navigate to the directory containing the built files and run the application using the appropriate command for your OS. | ||||
|  | ||||
| By following these steps, you can set up qbtmud to manage your qBittorrent server with an improved web interface, offering better functionality and usability. | ||||
|   | ||||
							
								
								
									
										79
									
								
								src/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								src/AGENTS.md
									
									
									
									
									
										Normal 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. | ||||
| @@ -8,7 +8,7 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|     public partial class AddPeerDialog | ||||
|     { | ||||
|         [CascadingParameter] | ||||
|         public MudDialogInstance MudDialog { get; set; } = default!; | ||||
|         public IMudDialogInstance MudDialog { get; set; } = default!; | ||||
| 
 | ||||
|         protected HashSet<PeerId> Peers { get; } = []; | ||||
| 
 | ||||
| @@ -14,7 +14,7 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|         protected IDialogService DialogService { get; set; } = default!; | ||||
| 
 | ||||
|         [CascadingParameter] | ||||
|         public MudDialogInstance MudDialog { get; set; } = default!; | ||||
|         private IMudDialogInstance MudDialog { get; set; } = default!; | ||||
| 
 | ||||
|         protected HashSet<string> Tags { get; } = []; | ||||
| 
 | ||||
| @@ -0,0 +1,52 @@ | ||||
| @inherits SubmittableDialog | ||||
|  | ||||
| <MudDialog> | ||||
|     <DialogContent> | ||||
|         <MudGrid> | ||||
|             <MudItem xs="12"> | ||||
|                 <MudFileUpload T="IReadOnlyList<IBrowserFile>" FilesChanged="UploadFiles" Accept=".torrent" MaximumFileCount="50" > | ||||
|                     <ActivatorContent> | ||||
|                         <MudButton Variant="Variant.Filled" | ||||
|                                    Color="Color.Primary" | ||||
|                                    StartIcon="@Icons.Material.Filled.CloudUpload"> | ||||
|                             Choose files | ||||
|                         </MudButton> | ||||
|                     </ActivatorContent> | ||||
|                 </MudFileUpload> | ||||
|             </MudItem> | ||||
|             <MudItem xs="12"> | ||||
|                 @if (Files?.Any() == true) | ||||
|                 { | ||||
|                     <MudCard Elevation="3" Class="mb-3"> | ||||
|                         <MudCardHeader Class="pb-0"> | ||||
|                             Selected Torrent Files (@Files.Count) | ||||
|                         </MudCardHeader> | ||||
|                         <MudCardContent> | ||||
|                             <MudList T="string"> | ||||
|                                 @foreach (var file in Files) | ||||
|                                 { | ||||
|                                     <MudListItem> | ||||
|                                         <div class="w-100 d-flex align-center"> | ||||
|                                             <span>@file.Name</span> | ||||
|                                             <MudSpacer /> | ||||
|                                             <MudIconButton Icon="@Icons.Material.Filled.Delete" | ||||
|                                                            Color="Color.Error" | ||||
|                                                            Variant="Variant.Text" | ||||
|                                                            Size="Size.Small" | ||||
|                                                            OnClick="@(() => Remove(file))" /> | ||||
|                                         </div> | ||||
|                                     </MudListItem> | ||||
|                                 } | ||||
|                             </MudList> | ||||
|                         </MudCardContent> | ||||
|                     </MudCard> | ||||
|                 } | ||||
|             </MudItem> | ||||
|         </MudGrid> | ||||
|         <AddTorrentOptions @ref="TorrentOptions" ShowCookieOption="true" /> | ||||
|     </DialogContent> | ||||
|     <DialogActions> | ||||
|         <MudButton OnClick="Cancel">Close</MudButton> | ||||
|         <MudButton Color="Color.Primary" OnClick="Submit">Upload Torrents</MudButton> | ||||
|     </DialogActions> | ||||
| </MudDialog> | ||||
| @@ -8,15 +8,15 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|     public partial class AddTorrentFileDialog | ||||
|     { | ||||
|         [CascadingParameter] | ||||
|         public MudDialogInstance MudDialog { get; set; } = default!; | ||||
|         private IMudDialogInstance MudDialog { get; set; } = default!; | ||||
| 
 | ||||
|         protected IReadOnlyList<IBrowserFile> Files { get; set; } = []; | ||||
|         private List<IBrowserFile> Files { get; set; } = []; | ||||
| 
 | ||||
|         protected AddTorrentOptions TorrentOptions { get; set; } = default!; | ||||
| 
 | ||||
|         protected void UploadFiles(IReadOnlyList<IBrowserFile> files) | ||||
|         { | ||||
|             Files = files; | ||||
|             Files = files.ToList(); | ||||
|         } | ||||
| 
 | ||||
|         protected void Cancel() | ||||
| @@ -30,6 +30,11 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|             MudDialog.Close(DialogResult.Ok(options)); | ||||
|         } | ||||
| 
 | ||||
|         protected void Remove(IBrowserFile file) | ||||
|         { | ||||
|             Files.Remove(file); | ||||
|         } | ||||
| 
 | ||||
|         protected override Task Submit(KeyboardEvent keyboardEvent) | ||||
|         { | ||||
|             Submit(); | ||||
| @@ -18,7 +18,7 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|         protected IKeyboardService KeyboardService { get; set; } = default!; | ||||
| 
 | ||||
|         [CascadingParameter] | ||||
|         public MudDialogInstance MudDialog { get; set; } = default!; | ||||
|         private IMudDialogInstance MudDialog { get; set; } = default!; | ||||
| 
 | ||||
|         [Parameter] | ||||
|         public string? Url { get; set; } | ||||
							
								
								
									
										121
									
								
								src/Lantean.QBTMud/Components/Dialogs/AddTorrentOptions.razor
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								src/Lantean.QBTMud/Components/Dialogs/AddTorrentOptions.razor
									
									
									
									
									
										Normal 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> | ||||
							
								
								
									
										440
									
								
								src/Lantean.QBTMud/Components/Dialogs/AddTorrentOptions.razor.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										440
									
								
								src/Lantean.QBTMud/Components/Dialogs/AddTorrentOptions.razor.cs
									
									
									
									
									
										Normal 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); | ||||
|     } | ||||
| } | ||||
| @@ -7,7 +7,7 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|     public partial class AddTrackerDialog | ||||
|     { | ||||
|         [CascadingParameter] | ||||
|         public MudDialogInstance MudDialog { get; set; } = default!; | ||||
|         private IMudDialogInstance MudDialog { get; set; } = default!; | ||||
| 
 | ||||
|         protected HashSet<string> Trackers { get; } = []; | ||||
| 
 | ||||
| @@ -10,7 +10,7 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|         private string _savePath = string.Empty; | ||||
| 
 | ||||
|         [CascadingParameter] | ||||
|         public MudDialogInstance MudDialog { get; set; } = default!; | ||||
|         private IMudDialogInstance MudDialog { get; set; } = default!; | ||||
| 
 | ||||
|         [Inject] | ||||
|         protected IApiClient ApiClient { get; set; } = default!; | ||||
| @@ -5,12 +5,13 @@ | ||||
|     <DialogContent> | ||||
|         <MudCard Class="w-100" Elevation="0"> | ||||
|             <MudGrid> | ||||
|                 @for (var i = 0; i < Columns.Count; i++) | ||||
|                 @for (var i = 0; i < OrderedColumns.Length; i++) | ||||
|                 { | ||||
|                     var column = Columns[i]; | ||||
|                     var item = OrderedColumns[i]; | ||||
|                     var column = Columns.First(c => c.Id == item); | ||||
|                     var index = i; | ||||
|                     <MudItem xs="7"> | ||||
|                             <MudCheckBox T="bool" ValueChanged="@(c => SetSelected(c, column.Id))" Label="@column.Header" LabelPosition="LabelPosition.End" Value="@(SelectedColumnsInternal.Contains(column.Id))" /> | ||||
|                         <MudCheckBox T="bool" ValueChanged="@(c => SetSelected(c, column.Id))" Label="@column.Header" LabelPlacement="Placement.End" Value="@(SelectedColumnsInternal.Contains(column.Id))" /> | ||||
|                     </MudItem> | ||||
|                     <MudItem xs="3"> | ||||
|                         <MudTextField T="string" Value="@(GetValue(column.Width, column.Id))" ValueChanged="@(c => SetWidth(c, column.Id))" Label="Width" Variant="Variant.Text" HelperText="px" Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Outlined.WidthNormal" OnAdornmentClick="@(c => SetWidth("auto", column.Id))" /> | ||||
| @@ -7,7 +7,7 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|     public partial class ColumnOptionsDialog<T> | ||||
|     { | ||||
|         [CascadingParameter] | ||||
|         public MudDialogInstance MudDialog { get; set; } = default!; | ||||
|         private IMudDialogInstance MudDialog { get; set; } = default!; | ||||
| 
 | ||||
|         [Parameter] | ||||
|         [EditorRequired] | ||||
| @@ -20,10 +20,15 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|         [Parameter] | ||||
|         public Dictionary<string, int?> Widths { get; set; } = []; | ||||
| 
 | ||||
|         [Parameter] | ||||
|         public Dictionary<string, int> Order { get; set; } = []; | ||||
| 
 | ||||
|         protected HashSet<string> SelectedColumnsInternal { get; set; } = []; | ||||
| 
 | ||||
|         protected Dictionary<string, int?> WidthsInternal { get; set; } = []; | ||||
| 
 | ||||
|         protected Dictionary<string, int> OrderInternal { get; set; } = []; | ||||
| 
 | ||||
|         protected override void OnParametersSet() | ||||
|         { | ||||
|             if (SelectedColumnsInternal.Count == 0) | ||||
| @@ -51,6 +56,25 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|                     WidthsInternal[width.Key] = width.Value; | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             if (OrderInternal.Count == 0) | ||||
|             { | ||||
|                 if (Order.Count == 0) | ||||
|                 { | ||||
|                     for (int i = 0; i < Columns.Count; i++) | ||||
|                     { | ||||
|                         var column = Columns[i]; | ||||
|                         OrderInternal.Add(column.Id, i); | ||||
|                     } | ||||
|                 } | ||||
|                 else | ||||
|                 { | ||||
|                     foreach (var order in Order) | ||||
|                     { | ||||
|                         OrderInternal[order.Key] = order.Value; | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         protected void SetSelected(bool selected, string id) | ||||
| @@ -101,7 +125,15 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             (Columns[index], Columns[index - 1]) = (Columns[index - 1], Columns[index]); | ||||
|             var currentId = OrderInternal.FirstOrDefault(o => o.Value == index).Key; | ||||
|             var otherId = OrderInternal.FirstOrDefault(o => o.Value == index - 1).Key; | ||||
| 
 | ||||
|             OrderInternal[otherId] = index; | ||||
|             OrderInternal[currentId] = index - 1; | ||||
| 
 | ||||
|             //(Columns[index], Columns[index - 1]) = (Columns[index - 1], Columns[index]); | ||||
| 
 | ||||
|             StateHasChanged(); | ||||
|         } | ||||
| 
 | ||||
|         protected void MoveDown(int index) | ||||
| @@ -111,7 +143,15 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             (Columns[index], Columns[index + 1]) = (Columns[index + 1], Columns[index]); | ||||
|             var currentId = OrderInternal.FirstOrDefault(o => o.Value == index).Key; | ||||
|             var otherId = OrderInternal.FirstOrDefault(o => o.Value == index + 1).Key; | ||||
| 
 | ||||
|             OrderInternal[otherId] = index; | ||||
|             OrderInternal[currentId] = index + 1; | ||||
| 
 | ||||
|             //(Columns[index], Columns[index + 1]) = (Columns[index + 1], Columns[index]); | ||||
| 
 | ||||
|             StateHasChanged(); | ||||
|         } | ||||
| 
 | ||||
|         protected string GetValue(int? value, string columnId) | ||||
| @@ -134,6 +174,13 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|             return value.Value.ToString(); | ||||
|         } | ||||
| 
 | ||||
|         private string[] OrderedColumns => GetOrderedColumns(); | ||||
| 
 | ||||
|         private string[] GetOrderedColumns() | ||||
|         { | ||||
|             return OrderInternal.OrderBy(x => x.Value).Select(x => x.Key).ToArray(); | ||||
|         } | ||||
| 
 | ||||
|         protected void Cancel() | ||||
|         { | ||||
|             MudDialog.Cancel(); | ||||
| @@ -141,7 +188,7 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
| 
 | ||||
|         protected void Submit() | ||||
|         { | ||||
|             MudDialog.Close(DialogResult.Ok((SelectedColumnsInternal, WidthsInternal))); | ||||
|             MudDialog.Close(DialogResult.Ok((SelectedColumnsInternal, WidthsInternal, OrderInternal))); | ||||
|         } | ||||
| 
 | ||||
|         protected override Task Submit(KeyboardEvent keyboardEvent) | ||||
| @@ -7,7 +7,7 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|     public partial class ConfirmDialog | ||||
|     { | ||||
|         [CascadingParameter] | ||||
|         public MudDialogInstance MudDialog { get; set; } = default!; | ||||
|         private IMudDialogInstance MudDialog { get; set; } = default!; | ||||
| 
 | ||||
|         [Parameter] | ||||
|         public string Content { get; set; } = default!; | ||||
| @@ -6,7 +6,7 @@ | ||||
| 
 | ||||
|         <MudGrid> | ||||
|             <MudItem xs="12"> | ||||
|                 <MudCheckBox Label="Also permanently delete the files" @bind-Value="DeleteFiles" LabelPosition="LabelPosition.End" /> | ||||
|                 <MudCheckBox Label="Also permanently delete the files" @bind-Value="DeleteFiles" LabelPlacement="Placement.End" /> | ||||
|             </MudItem> | ||||
|         </MudGrid> | ||||
|     </DialogContent> | ||||
| @@ -7,7 +7,7 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|     public partial class DeleteDialog | ||||
|     { | ||||
|         [CascadingParameter] | ||||
|         public MudDialogInstance MudDialog { get; set; } = default!; | ||||
|         private IMudDialogInstance MudDialog { get; set; } = default!; | ||||
| 
 | ||||
|         [Parameter] | ||||
|         public int Count { get; set; } | ||||
| @@ -6,7 +6,7 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|     public partial class ExceptionDialog | ||||
|     { | ||||
|         [CascadingParameter] | ||||
|         public MudDialogInstance MudDialog { get; set; } = default!; | ||||
|         private IMudDialogInstance MudDialog { get; set; } = default!; | ||||
| 
 | ||||
|         [Parameter] | ||||
|         public Exception? Exception { get; set; } | ||||
| @@ -11,7 +11,7 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|         private static readonly IReadOnlyList<PropertyInfo> _properties = typeof(T).GetProperties(BindingFlags.Instance | BindingFlags.Public); | ||||
| 
 | ||||
|         [CascadingParameter] | ||||
|         public MudDialogInstance MudDialog { get; set; } = default!; | ||||
|         private IMudDialogInstance MudDialog { get; set; } = default!; | ||||
| 
 | ||||
|         protected IReadOnlyList<PropertyInfo> Columns => _properties; | ||||
| 
 | ||||
| @@ -14,7 +14,7 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|         protected IDialogService DialogService { get; set; } = default!; | ||||
| 
 | ||||
|         [CascadingParameter] | ||||
|         public MudDialogInstance MudDialog { get; set; } = default!; | ||||
|         private IMudDialogInstance MudDialog { get; set; } = default!; | ||||
| 
 | ||||
|         [Parameter] | ||||
|         public IEnumerable<string> Hashes { get; set; } = []; | ||||
| @@ -14,7 +14,7 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|         protected IDialogService DialogService { get; set; } = default!; | ||||
| 
 | ||||
|         [CascadingParameter] | ||||
|         public MudDialogInstance MudDialog { get; set; } = default!; | ||||
|         private IMudDialogInstance MudDialog { get; set; } = default!; | ||||
| 
 | ||||
|         [Parameter] | ||||
|         public IEnumerable<string> Hashes { get; set; } = []; | ||||
| @@ -7,7 +7,7 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|     public partial class MultipleFieldDialog | ||||
|     { | ||||
|         [CascadingParameter] | ||||
|         public MudDialogInstance MudDialog { get; set; } = default!; | ||||
|         private IMudDialogInstance MudDialog { get; set; } = default!; | ||||
| 
 | ||||
|         [Parameter] | ||||
|         public string Label { get; set; } = default!; | ||||
| @@ -8,7 +8,7 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|     public partial class NumericFieldDialog<T> where T : struct, INumber<T> | ||||
|     { | ||||
|         [CascadingParameter] | ||||
|         public MudDialogInstance MudDialog { get; set; } = default!; | ||||
|         private IMudDialogInstance MudDialog { get; set; } = default!; | ||||
| 
 | ||||
|         [Parameter] | ||||
|         public string? Label { get; set; } | ||||
| @@ -6,7 +6,6 @@ using Lantean.QBTMud.Services; | ||||
| using Microsoft.AspNetCore.Components; | ||||
| using MudBlazor; | ||||
| using System.Collections.ObjectModel; | ||||
| using static MudBlazor.Colors; | ||||
| 
 | ||||
| namespace Lantean.QBTMud.Components.Dialogs | ||||
| { | ||||
| @@ -25,13 +24,13 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|         protected IApiClient ApiClient { get; set; } = default!; | ||||
| 
 | ||||
|         [Inject] | ||||
|         protected IDataManager DataManager { get; set; } = default!; | ||||
|         protected ITorrentDataManager DataManager { get; set; } = default!; | ||||
| 
 | ||||
|         [Inject] | ||||
|         protected ILocalStorageService LocalStorage { get; set; } = default!; | ||||
| 
 | ||||
|         [CascadingParameter] | ||||
|         public MudDialogInstance MudDialog { get; set; } = default!; | ||||
|         private IMudDialogInstance MudDialog { get; set; } = default!; | ||||
| 
 | ||||
|         [Parameter] | ||||
|         public string? Hash { get; set; } | ||||
| @@ -427,7 +426,6 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|             { | ||||
|                 await LocalStorage.RemoveItemAsync(_preferencesStorageKey); | ||||
|             } | ||||
|              | ||||
|         } | ||||
| 
 | ||||
|         protected override async Task OnInitializedAsync() | ||||
| @@ -496,7 +494,7 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|             { | ||||
|                 var oldPath = renamedFile.Path + renamedFile.OriginalName; | ||||
|                 var newPath = renamedFile.Path + renamedFile.NewName; | ||||
|                  | ||||
| 
 | ||||
|                 await ApiClient.RenameFolder(Hash, oldPath, newPath); | ||||
|             } | ||||
| 
 | ||||
| @@ -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" /> | ||||
|                     </MudItem> | ||||
|                     <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="@("always")">Always</MudSelectItem> | ||||
|                             <MudSelectItem Value="@("never")">Never</MudSelectItem> | ||||
| @@ -103,4 +103,4 @@ | ||||
|         <MudButton OnClick="Cancel">Close</MudButton> | ||||
|         <MudButton Color="Color.Primary" OnClick="Submit">Save</MudButton> | ||||
|     </DialogActions> | ||||
| </MudDialog> | ||||
| </MudDialog> | ||||
| @@ -10,7 +10,7 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|         private readonly List<string> _unsavedRuleNames = []; | ||||
| 
 | ||||
|         [CascadingParameter] | ||||
|         public MudDialogInstance MudDialog { get; set; } = default!; | ||||
|         private IMudDialogInstance MudDialog { get; set; } = default!; | ||||
| 
 | ||||
|         [Inject] | ||||
|         protected IDialogService DialogService { get; set; } = default!; | ||||
| @@ -114,11 +114,11 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|             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) | ||||
|             { | ||||
|                 case "default": | ||||
| @@ -273,15 +273,15 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|             switch (SelectedRule.TorrentParams.Stopped) | ||||
|             { | ||||
|                 case null: | ||||
|                     AddPaused = "default"; | ||||
|                     AddStopped = "default"; | ||||
|                     break; | ||||
| 
 | ||||
|                 case true: | ||||
|                     AddPaused = "always"; | ||||
|                     AddStopped = "always"; | ||||
|                     break; | ||||
| 
 | ||||
|                 case false: | ||||
|                     AddPaused = "never"; | ||||
|                     AddStopped = "never"; | ||||
|                     break; | ||||
|             } | ||||
| 
 | ||||
							
								
								
									
										126
									
								
								src/Lantean.QBTMud/Components/Dialogs/SearchPluginsDialog.razor
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										126
									
								
								src/Lantean.QBTMud/Components/Dialogs/SearchPluginsDialog.razor
									
									
									
									
									
										Normal 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> | ||||
| @@ -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; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,4 +1,5 @@ | ||||
| @inherits SubmittableDialog | ||||
| @inherits SubmittableDialog | ||||
| @using Lantean.QBitTorrentClient.Models | ||||
| 
 | ||||
| <MudDialog> | ||||
|     <DialogContent> | ||||
| @@ -34,10 +35,19 @@ | ||||
|             <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" /> | ||||
|             </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> | ||||
|     </DialogContent> | ||||
|     <DialogActions> | ||||
|         <MudButton OnClick="Cancel">Cancel</MudButton> | ||||
|         <MudButton Color="Color.Primary" OnClick="Submit">Save</MudButton> | ||||
|     </DialogActions> | ||||
| </MudDialog> | ||||
| </MudDialog> | ||||
| @@ -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 Microsoft.AspNetCore.Components; | ||||
| using MudBlazor; | ||||
| @@ -8,7 +11,7 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|     public partial class ShareRatioDialog | ||||
|     { | ||||
|         [CascadingParameter] | ||||
|         public MudDialogInstance MudDialog { get; set; } = default!; | ||||
|         private IMudDialogInstance MudDialog { get; set; } = default!; | ||||
| 
 | ||||
|         [Parameter] | ||||
|         public string? Label { get; set; } | ||||
| @@ -16,6 +19,9 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|         [Parameter] | ||||
|         public ShareRatioMax? Value { get; set; } | ||||
| 
 | ||||
|         [Parameter] | ||||
|         public ShareRatioMax? CurrentValue { get; set; } | ||||
| 
 | ||||
|         [Parameter] | ||||
|         public bool Disabled { get; set; } | ||||
| 
 | ||||
| @@ -33,6 +39,8 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
| 
 | ||||
|         protected int InactiveMinutes { get; set; } | ||||
| 
 | ||||
|         protected ShareLimitAction SelectedShareLimitAction { get; set; } = ShareLimitAction.Default; | ||||
| 
 | ||||
|         protected bool CustomEnabled => ShareRatioType == 0; | ||||
| 
 | ||||
|         protected void RatioEnabledChanged(bool value) | ||||
| @@ -65,40 +73,75 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|             InactiveMinutes = value; | ||||
|         } | ||||
| 
 | ||||
|         protected void ShareLimitActionChanged(ShareLimitAction value) | ||||
|         { | ||||
|             SelectedShareLimitAction = value; | ||||
|         } | ||||
| 
 | ||||
|         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; | ||||
|                 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; | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             ShareRatioType = 0; | ||||
| 
 | ||||
|             if (baseline.RatioLimit >= 0) | ||||
|             { | ||||
|                 RatioEnabled = true; | ||||
|                 Ratio = baseline.RatioLimit; | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 ShareRatioType = 0; | ||||
|                 if (Value.RatioLimit >= 0) | ||||
|                 { | ||||
|                     RatioEnabled = true; | ||||
|                     Ratio = Value.RatioLimit; | ||||
|                 } | ||||
|                 if (Value.SeedingTimeLimit >= 0) | ||||
|                 { | ||||
|                     TotalMinutesEnabled = true; | ||||
|                     TotalMinutes = (int)Value.SeedingTimeLimit; | ||||
|                 } | ||||
|                 if (Value.InactiveSeedingTimeLimit >= 0) | ||||
|                 { | ||||
|                     InactiveMinutesEnabled = true; | ||||
|                     InactiveMinutes = (int)Value.InactiveSeedingTimeLimit; | ||||
|                 } | ||||
|                 Ratio = 0; | ||||
|             } | ||||
| 
 | ||||
|             if (baseline.SeedingTimeLimit >= 0) | ||||
|             { | ||||
|                 TotalMinutesEnabled = true; | ||||
|                 TotalMinutes = (int)baseline.SeedingTimeLimit; | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 TotalMinutes = 0; | ||||
|             } | ||||
| 
 | ||||
|             if (baseline.InactiveSeedingTimeLimit >= 0) | ||||
|             { | ||||
|                 InactiveMinutesEnabled = true; | ||||
|                 InactiveMinutes = (int)baseline.InactiveSeedingTimeLimit; | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 InactiveMinutes = 0; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         protected void ShareRatioTypeChanged(int value) | ||||
|         { | ||||
|             ShareRatioType = value; | ||||
|             if (!CustomEnabled) | ||||
|             { | ||||
|                 RatioEnabled = false; | ||||
|                 TotalMinutesEnabled = false; | ||||
|                 InactiveMinutesEnabled = false; | ||||
|                 SelectedShareLimitAction = ShareLimitAction.Default; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         protected void Cancel() | ||||
| @@ -112,16 +155,19 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|             if (ShareRatioType == Limits.GlobalLimit) | ||||
|             { | ||||
|                 result.RatioLimit = result.SeedingTimeLimit = result.InactiveSeedingTimeLimit = Limits.GlobalLimit; | ||||
|                 result.ShareLimitAction = ShareLimitAction.Default; | ||||
|             } | ||||
|             else if (ShareRatioType == Limits.NoLimit) | ||||
|             { | ||||
|                 result.RatioLimit = result.SeedingTimeLimit = result.InactiveSeedingTimeLimit = Limits.NoLimit; | ||||
|                 result.ShareLimitAction = ShareLimitAction.Default; | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 result.RatioLimit = RatioEnabled ? Ratio : Limits.NoLimit; | ||||
|                 result.SeedingTimeLimit = TotalMinutesEnabled ? TotalMinutes : Limits.NoLimit; | ||||
|                 result.InactiveSeedingTimeLimit = InactiveMinutesEnabled ? InactiveMinutes : Limits.NoLimit; | ||||
|                 result.ShareLimitAction = SelectedShareLimitAction; | ||||
|             } | ||||
|             MudDialog.Close(DialogResult.Ok(result)); | ||||
|         } | ||||
| @@ -133,4 +179,4 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|             return Task.CompletedTask; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
| @@ -8,7 +8,7 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|     public partial class SliderFieldDialog<T> where T : struct, INumber<T> | ||||
|     { | ||||
|         [CascadingParameter] | ||||
|         public MudDialogInstance MudDialog { get; set; } = default!; | ||||
|         private IMudDialogInstance MudDialog { get; set; } = default!; | ||||
| 
 | ||||
|         [Parameter] | ||||
|         public string? Label { get; set; } | ||||
| @@ -7,7 +7,7 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|     public partial class StringFieldDialog | ||||
|     { | ||||
|         [CascadingParameter] | ||||
|         public MudDialogInstance MudDialog { get; set; } = default!; | ||||
|         private IMudDialogInstance MudDialog { get; set; } = default!; | ||||
| 
 | ||||
|         [Parameter] | ||||
|         public string? Label { get; set; } | ||||
| @@ -7,7 +7,7 @@ namespace Lantean.QBTMud.Components.Dialogs | ||||
|     public partial class SubMenuDialog | ||||
|     { | ||||
|         [CascadingParameter] | ||||
|         public MudDialogInstance MudDialog { get; set; } = default!; | ||||
|         private IMudDialogInstance MudDialog { get; set; } = default!; | ||||
| 
 | ||||
|         [Parameter] | ||||
|         public UIAction? ParentAction { get; set; } | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user