mirror of
				https://github.com/lantean-code/qbtmud.git
				synced 2025-11-03 21:43:19 +00:00 
			
		
		
		
	Compare commits
	
		
			118 Commits
		
	
	
		
			v0.1.0
			...
			feature/v5
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					44fab60189 | ||
| 
						 | 
					3fd9f67866 | ||
| 
						 | 
					43d0a8e449 | ||
| 
						 | 
					05d5d8429e | ||
| 
						 | 
					7bc21cd5c9 | ||
| 
						 | 
					932a590a57 | ||
| 
						 | 
					5fae053b6e | ||
| 
						 | 
					4ee3f8417d | ||
| 
						 | 
					22e168926d | ||
| 
						 | 
					f78ea3d3d4 | ||
| 
						 | 
					f65183ac6a | ||
| 
						 | 
					711a800f53 | ||
| 
						 | 
					c8d9485847 | ||
| 
						 | 
					5e96cb0e05 | ||
| 
						 | 
					ccccf537c9 | ||
| 
						 | 
					c87441a633 | ||
| 
						 | 
					8eacd1ff78 | ||
| 
						 | 
					08ed3e6a4c | ||
| 
						 | 
					670cb3cc7d | ||
| 
						 | 
					59fb1ee8e7 | ||
| 
						 | 
					d482d58d3e | ||
| 
						 | 
					233946d8f5 | ||
| 
						 | 
					c268d11ba4 | ||
| 
						 | 
					4bf5eaca0e | ||
| 
						 | 
					22271ec308 | ||
| 
						 | 
					a0ece0c0bb | ||
| 
						 | 
					3eebdecffb | ||
| 
						 | 
					24a048eb06 | ||
| 
						 | 
					062da137a3 | ||
| 
						 | 
					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 | 
@@ -1,4 +1,11 @@
 | 
			
		||||
[*.cs]
 | 
			
		||||
root = true
 | 
			
		||||
 | 
			
		||||
[*]
 | 
			
		||||
charset = utf-8
 | 
			
		||||
end_of_line = crlf
 | 
			
		||||
insert_final_newline = true
 | 
			
		||||
 | 
			
		||||
[*.cs]
 | 
			
		||||
 | 
			
		||||
# IDE0290: Use primary constructor
 | 
			
		||||
csharp_style_prefer_primary_constructors = false
 | 
			
		||||
@@ -77,3 +84,4 @@ dotnet_style_operator_placement_when_wrapping = beginning_of_line
 | 
			
		||||
tab_width = 4
 | 
			
		||||
indent_size = 4
 | 
			
		||||
end_of_line = crlf
 | 
			
		||||
charset = utf-8
 | 
			
		||||
							
								
								
									
										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,33 @@
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
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
 | 
			
		||||
		AGENTS.md = AGENTS.md
 | 
			
		||||
		readme.md = readme.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}"
 | 
			
		||||
	ProjectSection(SolutionItems) = preProject
 | 
			
		||||
		test\AGENTS.md = test\AGENTS.md
 | 
			
		||||
	EndProjectSection
 | 
			
		||||
EndProject
 | 
			
		||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Source", "Source", "{2499085D-5140-4FBD-98AE-B281312FA6D6}"
 | 
			
		||||
	ProjectSection(SolutionItems) = preProject
 | 
			
		||||
		src\AGENTS.md = src\AGENTS.md
 | 
			
		||||
	EndProjectSection
 | 
			
		||||
EndProject
 | 
			
		||||
Global
 | 
			
		||||
	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 | 
			
		||||
		Debug|Any CPU = Debug|Any CPU
 | 
			
		||||
@@ -33,10 +46,20 @@ 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}
 | 
			
		||||
		{F0DDAB07-0D6C-40C7-AFE6-08FF2C4CC7E7} = {2499085D-5140-4FBD-98AE-B281312FA6D6}
 | 
			
		||||
		{83BC76CC-D51B-42AF-A6EE-FA400C300098} = {2499085D-5140-4FBD-98AE-B281312FA6D6}
 | 
			
		||||
		{796E865C-7AA6-4BD9-B12F-394801199A75} = {344EAF42-5D2B-4F56-8B28-1F3158A37E0A}
 | 
			
		||||
	EndGlobalSection
 | 
			
		||||
	GlobalSection(ExtensibilityGlobals) = postSolution
 | 
			
		||||
		SolutionGuid = {82E46DB7-956A-4971-BB18-1F20650EC1A4}
 | 
			
		||||
	EndGlobalSection
 | 
			
		||||
 
 | 
			
		||||
@@ -1,28 +0,0 @@
 | 
			
		||||
@if (IsMenu)
 | 
			
		||||
{
 | 
			
		||||
    @foreach (var action in Actions)
 | 
			
		||||
    {
 | 
			
		||||
        if (action.SeparatorBefore)
 | 
			
		||||
        {
 | 
			
		||||
            <MudDivider />
 | 
			
		||||
        }
 | 
			
		||||
        <MudMenuItem Icon="@action.Icon" IconColor="@action.Color" Href="@action.Href">@action.Text</MudMenuItem>
 | 
			
		||||
    }
 | 
			
		||||
    <MudMenuItem Icon="@Icons.Material.Filled.Undo" OnClick="ResetWebUI">Reset Web UI</MudMenuItem>
 | 
			
		||||
    <MudDivider />
 | 
			
		||||
    <MudMenuItem Icon="@Icons.Material.Filled.Logout" OnClick="Logout">Logout</MudMenuItem>
 | 
			
		||||
    <MudMenuItem Icon="@Icons.Material.Filled.ExitToApp" OnClick="Exit">Exit qBittorrent</MudMenuItem>
 | 
			
		||||
}
 | 
			
		||||
else
 | 
			
		||||
{
 | 
			
		||||
    <MudNavLink Icon="@Icons.Material.Outlined.Navigation" OnClick="NavigateBack">Torrents</MudNavLink>
 | 
			
		||||
    <MudDivider />
 | 
			
		||||
    @foreach (var action in Actions)
 | 
			
		||||
    {
 | 
			
		||||
        if (action.SeparatorBefore)
 | 
			
		||||
        {
 | 
			
		||||
            <MudDivider />
 | 
			
		||||
        }
 | 
			
		||||
        <MudNavLink Icon="@action.Icon" IconColor="@action.Color" Href="@action.Href">@action.Text</MudNavLink>
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,94 +0,0 @@
 | 
			
		||||
using Lantean.QBitTorrentClient;
 | 
			
		||||
using Lantean.QBitTorrentClient.Models;
 | 
			
		||||
using Lantean.QBTMud.Helpers;
 | 
			
		||||
using Lantean.QBTMud.Models;
 | 
			
		||||
using Microsoft.AspNetCore.Components;
 | 
			
		||||
using MudBlazor;
 | 
			
		||||
 | 
			
		||||
namespace Lantean.QBTMud.Components
 | 
			
		||||
{
 | 
			
		||||
    public partial class ApplicationActions
 | 
			
		||||
    {
 | 
			
		||||
        private List<UIAction>? _actions;
 | 
			
		||||
 | 
			
		||||
        [Inject]
 | 
			
		||||
        protected NavigationManager NavigationManager { get; set; } = default!;
 | 
			
		||||
 | 
			
		||||
        [Inject]
 | 
			
		||||
        protected IDialogService DialogService { get; set; } = default!;
 | 
			
		||||
 | 
			
		||||
        [Inject]
 | 
			
		||||
        protected IApiClient ApiClient { get; set; } = default!;
 | 
			
		||||
 | 
			
		||||
        [Parameter]
 | 
			
		||||
        public bool IsMenu { get; set; }
 | 
			
		||||
 | 
			
		||||
        [Parameter]
 | 
			
		||||
        [EditorRequired]
 | 
			
		||||
        public Preferences? Preferences { get; set; }
 | 
			
		||||
 | 
			
		||||
        protected IEnumerable<UIAction> Actions => GetActions();
 | 
			
		||||
 | 
			
		||||
        private IEnumerable<UIAction> GetActions()
 | 
			
		||||
        {
 | 
			
		||||
            if (_actions is not null)
 | 
			
		||||
            {
 | 
			
		||||
                foreach (var action in _actions)
 | 
			
		||||
                {
 | 
			
		||||
                    if (action.Name != "rss" || Preferences is not null && Preferences.RssProcessingEnabled)
 | 
			
		||||
                    {
 | 
			
		||||
                        yield return action;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected override void OnInitialized()
 | 
			
		||||
        {
 | 
			
		||||
            _actions =
 | 
			
		||||
            [
 | 
			
		||||
                new("statistics", "Statistics", Icons.Material.Filled.PieChart, Color.Default, "/statistics"),
 | 
			
		||||
                new("search", "Search", Icons.Material.Filled.Search, Color.Default, "/search"),
 | 
			
		||||
                new("rss", "RSS", Icons.Material.Filled.RssFeed, Color.Default, "/rss"),
 | 
			
		||||
                new("log", "Execution Log", Icons.Material.Filled.List, Color.Default, "/log"),
 | 
			
		||||
                new("blocks", "Blocked IPs", Icons.Material.Filled.DisabledByDefault, Color.Default, "/blocks"),
 | 
			
		||||
                new("tags", "Tag Management", Icons.Material.Filled.Label, Color.Default, "/tags", separatorBefore: true),
 | 
			
		||||
                new("categories", "Category Management", Icons.Material.Filled.List, Color.Default, "/categories"),
 | 
			
		||||
                new("settings", "Settings", Icons.Material.Filled.Settings, Color.Default, "/settings", separatorBefore: true),
 | 
			
		||||
                new("about", "About", Icons.Material.Filled.Info, Color.Default, "/about"),
 | 
			
		||||
            ];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected void NavigateBack()
 | 
			
		||||
        {
 | 
			
		||||
            NavigationManager.NavigateTo("/");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected async Task ResetWebUI()
 | 
			
		||||
        {
 | 
			
		||||
            var preferences = new UpdatePreferences
 | 
			
		||||
            {
 | 
			
		||||
                AlternativeWebuiEnabled = false,
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            await ApiClient.SetApplicationPreferences(preferences);
 | 
			
		||||
 | 
			
		||||
            NavigationManager.NavigateTo("/", true);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected async Task Logout()
 | 
			
		||||
        {
 | 
			
		||||
            await DialogService.ShowConfirmDialog("Logout?", "Are you sure you want to logout?", async () =>
 | 
			
		||||
            {
 | 
			
		||||
                await ApiClient.Logout();
 | 
			
		||||
 | 
			
		||||
                NavigationManager.NavigateTo("/", true);
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected async Task Exit()
 | 
			
		||||
        {
 | 
			
		||||
            await DialogService.ShowConfirmDialog("Quit?", "Are you sure you want to exit qBittorrent?", ApiClient.Shutdown);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -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 +0,0 @@
 | 
			
		||||
<div id="progress"></div>
 | 
			
		||||
@@ -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,11 +0,0 @@
 | 
			
		||||
<div class="@Classname">
 | 
			
		||||
    <div @onclick="EventUtil.AsNonRenderingEventHandler<MouseEventArgs>(OnClickHandler)" class="@LinkClassname" @onlongpress="OnLongPressInternal" @oncontextmenu="OnContextMenuInternal" @oncontextmenu:preventDefault>
 | 
			
		||||
        @if (!string.IsNullOrEmpty(Icon))
 | 
			
		||||
        {
 | 
			
		||||
            <MudIcon Icon="@Icon" Color="@IconColor" Class="@IconClassname" />
 | 
			
		||||
        }
 | 
			
		||||
        <div class="mud-nav-link-text">
 | 
			
		||||
            @ChildContent
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -1,3 +0,0 @@
 | 
			
		||||
<MudField Variant="Variant.Outlined" InnerPadding="false" Label="@Label" HelperText="@HelperText" Disabled="Disabled">
 | 
			
		||||
    <TickSwitch T="bool" Value="@Value" ValueChanged="ValueChangedCallback" Class="pt-1 pb-1" Disabled="Disabled" Validation="Validation" />
 | 
			
		||||
</MudField>
 | 
			
		||||
@@ -1 +0,0 @@
 | 
			
		||||
@ChildContent
 | 
			
		||||
@@ -1,6 +0,0 @@
 | 
			
		||||
<DynamicTable T="Lantean.QBitTorrentClient.Models.WebSeed"
 | 
			
		||||
              ColumnDefinitions="Columns"
 | 
			
		||||
              Items="WebSeeds"
 | 
			
		||||
              MultiSelection="false"
 | 
			
		||||
              SelectOnRowClick="false"
 | 
			
		||||
              Class="details-list" />
 | 
			
		||||
@@ -1,438 +0,0 @@
 | 
			
		||||
using Lantean.QBitTorrentClient;
 | 
			
		||||
using Lantean.QBTMud.Components.Dialogs;
 | 
			
		||||
using Lantean.QBTMud.Filter;
 | 
			
		||||
using Lantean.QBTMud.Models;
 | 
			
		||||
using MudBlazor;
 | 
			
		||||
 | 
			
		||||
namespace Lantean.QBTMud.Helpers
 | 
			
		||||
{
 | 
			
		||||
    public static class DialogHelper
 | 
			
		||||
    {
 | 
			
		||||
        public const long _maxFileSize = 4194304;
 | 
			
		||||
        public static readonly DialogOptions ConfirmDialogOptions = new() { BackgroundClass = "background-blur", MaxWidth = MaxWidth.Small, FullWidth = true };
 | 
			
		||||
        public static readonly DialogOptions FormDialogOptions = new() { CloseButton = true, MaxWidth = MaxWidth.Medium, BackgroundClass = "background-blur", FullWidth = true };
 | 
			
		||||
 | 
			
		||||
        public static readonly DialogOptions FullScreenDialogOptions = new() { CloseButton = true, MaxWidth = MaxWidth.ExtraExtraLarge, BackgroundClass = "background-blur", FullWidth = true };
 | 
			
		||||
        public static readonly DialogOptions NonBlurConfirmDialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
 | 
			
		||||
        public static readonly DialogOptions NonBlurFormDialogOptions = new() { CloseButton = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
 | 
			
		||||
 | 
			
		||||
        public static async Task<string?> InvokeAddCategoryDialog(this IDialogService dialogService, IApiClient apiClient)
 | 
			
		||||
        {
 | 
			
		||||
            var reference = await dialogService.ShowAsync<CategoryPropertiesDialog>("Add Category", NonBlurFormDialogOptions);
 | 
			
		||||
            var dialogResult = await reference.Result;
 | 
			
		||||
            if (dialogResult is null || dialogResult.Canceled || dialogResult.Data is null)
 | 
			
		||||
            {
 | 
			
		||||
                return null;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var category = (Category)dialogResult.Data;
 | 
			
		||||
 | 
			
		||||
            await apiClient.AddCategory(category.Name, category.SavePath);
 | 
			
		||||
 | 
			
		||||
            return category.Name;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public static async Task InvokeAddTorrentFileDialog(this IDialogService dialogService, IApiClient apiClient)
 | 
			
		||||
        {
 | 
			
		||||
            var result = await dialogService.ShowAsync<AddTorrentFileDialog>("Upload local torrent", FormDialogOptions);
 | 
			
		||||
            var dialogResult = await result.Result;
 | 
			
		||||
            if (dialogResult is null || dialogResult.Canceled || dialogResult.Data is null)
 | 
			
		||||
            {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var options = (AddTorrentFileOptions)dialogResult.Data;
 | 
			
		||||
 | 
			
		||||
            var streams = new List<Stream>();
 | 
			
		||||
 | 
			
		||||
            var files = new Dictionary<string, Stream>();
 | 
			
		||||
            foreach (var file in options.Files)
 | 
			
		||||
            {
 | 
			
		||||
                var stream = file.OpenReadStream(_maxFileSize);
 | 
			
		||||
                streams.Add(stream);
 | 
			
		||||
                files.Add(file.Name, stream);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var addTorrentParams = CreateAddTorrentParams(options);
 | 
			
		||||
            addTorrentParams.Torrents = files;
 | 
			
		||||
 | 
			
		||||
            await apiClient.AddTorrent(addTorrentParams);
 | 
			
		||||
 | 
			
		||||
            foreach (var stream in streams)
 | 
			
		||||
            {
 | 
			
		||||
                await stream.DisposeAsync();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        private static QBitTorrentClient.Models.AddTorrentParams CreateAddTorrentParams(TorrentOptions options)
 | 
			
		||||
        {
 | 
			
		||||
            var addTorrentParams = new QBitTorrentClient.Models.AddTorrentParams();
 | 
			
		||||
            addTorrentParams.AddToTopOfQueue = options.AddToTopOfQueue;
 | 
			
		||||
            addTorrentParams.AutoTorrentManagement = options.TorrentManagementMode;
 | 
			
		||||
            addTorrentParams.Category = options.Category;
 | 
			
		||||
            if (!string.IsNullOrEmpty(options.ContentLayout))
 | 
			
		||||
            {
 | 
			
		||||
                addTorrentParams.ContentLayout = Enum.Parse<QBitTorrentClient.Models.TorrentContentLayout>(options.ContentLayout);
 | 
			
		||||
            }
 | 
			
		||||
            if (string.IsNullOrEmpty(options.Cookie))
 | 
			
		||||
            {
 | 
			
		||||
                addTorrentParams.Cookie = options.Cookie;
 | 
			
		||||
            }
 | 
			
		||||
            addTorrentParams.DownloadLimit = options.DownloadLimit;
 | 
			
		||||
            addTorrentParams.DownloadPath = options.DownloadPath;
 | 
			
		||||
            addTorrentParams.FirstLastPiecePriority = options.DownloadFirstAndLastPiecesFirst;
 | 
			
		||||
            addTorrentParams.InactiveSeedingTimeLimit = options.InactiveSeedingTimeLimit;
 | 
			
		||||
            addTorrentParams.Paused = !options.StartTorrent;
 | 
			
		||||
            addTorrentParams.RatioLimit = options.RatioLimit;
 | 
			
		||||
            addTorrentParams.RenameTorrent = options.RenameTorrent;
 | 
			
		||||
            addTorrentParams.SavePath = options.SavePath;
 | 
			
		||||
            addTorrentParams.SeedingTimeLimit = options.SeedingTimeLimit;
 | 
			
		||||
            addTorrentParams.SequentialDownload = options.DownloadInSequentialOrder;
 | 
			
		||||
            if (!string.IsNullOrEmpty(options.ShareLimitAction))
 | 
			
		||||
            {
 | 
			
		||||
                addTorrentParams.ShareLimitAction = Enum.Parse<QBitTorrentClient.Models.ShareLimitAction>(options.ShareLimitAction);
 | 
			
		||||
            }
 | 
			
		||||
            addTorrentParams.SkipChecking = options.SkipHashCheck;
 | 
			
		||||
            if (!string.IsNullOrEmpty(options.StopCondition))
 | 
			
		||||
            {
 | 
			
		||||
                addTorrentParams.StopCondition = Enum.Parse<QBitTorrentClient.Models.StopCondition>(options.StopCondition);
 | 
			
		||||
            }
 | 
			
		||||
            addTorrentParams.Stopped = !options.StartTorrent;
 | 
			
		||||
            addTorrentParams.Tags = options.Tags;
 | 
			
		||||
            addTorrentParams.UploadLimit = options.UploadLimit;
 | 
			
		||||
            addTorrentParams.UseDownloadPath = options.UseDownloadPath;
 | 
			
		||||
            return addTorrentParams;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public static async Task InvokeAddTorrentLinkDialog(this IDialogService dialogService, IApiClient apiClient, string? url = null)
 | 
			
		||||
        {
 | 
			
		||||
            var parameters = new DialogParameters
 | 
			
		||||
            {
 | 
			
		||||
                { nameof(AddTorrentLinkDialog.Url), url }
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            var result = await dialogService.ShowAsync<AddTorrentLinkDialog>("Download from URLs", parameters, FormDialogOptions);
 | 
			
		||||
            var dialogResult = await result.Result;
 | 
			
		||||
            if (dialogResult is null || dialogResult.Canceled || dialogResult.Data is null)
 | 
			
		||||
            {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var options = (AddTorrentLinkOptions)dialogResult.Data;
 | 
			
		||||
 | 
			
		||||
            var addTorrentParams = CreateAddTorrentParams(options);
 | 
			
		||||
            addTorrentParams.Urls = options.Urls;
 | 
			
		||||
 | 
			
		||||
            await apiClient.AddTorrent(addTorrentParams);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public static async Task<bool> InvokeDeleteTorrentDialog(this IDialogService dialogService, IApiClient apiClient, params string[] hashes)
 | 
			
		||||
        {
 | 
			
		||||
            if (hashes.Length == 0)
 | 
			
		||||
            {
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var parameters = new DialogParameters
 | 
			
		||||
            {
 | 
			
		||||
                { nameof(DeleteDialog.Count), hashes.Length }
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            var reference = await dialogService.ShowAsync<DeleteDialog>($"Remove torrent{(hashes.Length == 1 ? "" : "s")}?", parameters, ConfirmDialogOptions);
 | 
			
		||||
            var dialogResult = await reference.Result;
 | 
			
		||||
            if (dialogResult is null || dialogResult.Canceled || dialogResult.Data is null)
 | 
			
		||||
            {
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            await apiClient.DeleteTorrents(hashes, (bool)dialogResult.Data);
 | 
			
		||||
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public static async Task InvokeDownloadRateDialog(this IDialogService dialogService, IApiClient apiClient, long rate, IEnumerable<string> hashes)
 | 
			
		||||
        {
 | 
			
		||||
            Func<long, string> valueDisplayFunc = v => v == Limits.NoLimit ? "∞" : v.ToString();
 | 
			
		||||
            Func<string, long> valueGetFunc = v => v == "∞" ? Limits.NoLimit : long.Parse(v);
 | 
			
		||||
 | 
			
		||||
            var parameters = new DialogParameters
 | 
			
		||||
            {
 | 
			
		||||
                { nameof(SliderFieldDialog<long>.Min), -1L },
 | 
			
		||||
                { nameof(SliderFieldDialog<long>.Max), 1000L },
 | 
			
		||||
                { nameof(SliderFieldDialog<long>.Value), rate / 1024 },
 | 
			
		||||
                { nameof(SliderFieldDialog<long>.ValueDisplayFunc), valueDisplayFunc },
 | 
			
		||||
                { nameof(SliderFieldDialog<long>.ValueGetFunc), valueGetFunc },
 | 
			
		||||
                { nameof(SliderFieldDialog<long>.Label), "Download rate limit" },
 | 
			
		||||
                { nameof(SliderFieldDialog<long>.Adornment), Adornment.End },
 | 
			
		||||
                { nameof(SliderFieldDialog<long>.AdornmentText), "KiB/s" },
 | 
			
		||||
            };
 | 
			
		||||
            var result = await dialogService.ShowAsync<SliderFieldDialog<long>>("Download Rate", parameters, FormDialogOptions);
 | 
			
		||||
 | 
			
		||||
            var dialogResult = await result.Result;
 | 
			
		||||
            if (dialogResult is null || dialogResult.Canceled || dialogResult.Data is null)
 | 
			
		||||
            {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
            var kibs = (long)dialogResult.Data;
 | 
			
		||||
            await apiClient.SetTorrentDownloadLimit(kibs * 1024, null, hashes.ToArray());
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public static async Task<string?> InvokeEditCategoryDialog(this IDialogService dialogService, IApiClient apiClient, string categoryName)
 | 
			
		||||
        {
 | 
			
		||||
            var category = (await apiClient.GetAllCategories()).FirstOrDefault(c => c.Key == categoryName).Value;
 | 
			
		||||
            var parameters = new DialogParameters
 | 
			
		||||
            {
 | 
			
		||||
                { nameof(CategoryPropertiesDialog.Category), category?.Name },
 | 
			
		||||
                { nameof(CategoryPropertiesDialog.SavePath), category?.SavePath },
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            var reference = await dialogService.ShowAsync<CategoryPropertiesDialog>("Edit Category", parameters, NonBlurFormDialogOptions);
 | 
			
		||||
            var dialogResult = await reference.Result;
 | 
			
		||||
            if (dialogResult is null || dialogResult.Canceled || dialogResult.Data is null)
 | 
			
		||||
            {
 | 
			
		||||
                return null;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var updatedCategory = (Category)dialogResult.Data;
 | 
			
		||||
 | 
			
		||||
            await apiClient.EditCategory(updatedCategory.Name, updatedCategory.SavePath);
 | 
			
		||||
 | 
			
		||||
            return updatedCategory.Name;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public static async Task InvokeRenameFilesDialog(this IDialogService dialogService, string hash)
 | 
			
		||||
        {
 | 
			
		||||
            var parameters = new DialogParameters
 | 
			
		||||
            {
 | 
			
		||||
                { nameof(RenameFilesDialog.Hash), hash }
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            await dialogService.ShowAsync<RenameFilesDialog>("Rename Files", parameters, FullScreenDialogOptions);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public static async Task InvokeRssRulesDialog(this IDialogService dialogService)
 | 
			
		||||
        {
 | 
			
		||||
            await dialogService.ShowAsync<RssRulesDialog>("Edit Rss Auto Downloading Rules", FullScreenDialogOptions);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public static async Task InvokeShareRatioDialog(this IDialogService dialogService, IApiClient apiClient, IEnumerable<Torrent> torrents)
 | 
			
		||||
        {
 | 
			
		||||
            var torrentShareRatios = torrents.Select(t => new ShareRatioMax
 | 
			
		||||
            {
 | 
			
		||||
                InactiveSeedingTimeLimit = t.InactiveSeedingTimeLimit,
 | 
			
		||||
                MaxInactiveSeedingTime = t.InactiveSeedingTimeLimit,
 | 
			
		||||
                MaxRatio = t.MaxRatio,
 | 
			
		||||
                MaxSeedingTime = t.MaxSeedingTime,
 | 
			
		||||
                RatioLimit = t.RatioLimit,
 | 
			
		||||
                SeedingTimeLimit = t.SeedingTimeLimit,
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            var torrentsHaveSameShareRatio = torrentShareRatios.Distinct().Count() == 1;
 | 
			
		||||
 | 
			
		||||
            var parameters = new DialogParameters
 | 
			
		||||
            {
 | 
			
		||||
                { nameof(ShareRatioDialog.Value), torrentsHaveSameShareRatio ? torrentShareRatios.FirstOrDefault() : null },
 | 
			
		||||
            };
 | 
			
		||||
            var result = await dialogService.ShowAsync<ShareRatioDialog>("Share ratio", parameters, FormDialogOptions);
 | 
			
		||||
 | 
			
		||||
            var dialogResult = await result.Result;
 | 
			
		||||
            if (dialogResult is null || dialogResult.Canceled || dialogResult.Data is null)
 | 
			
		||||
            {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var shareRatio = (ShareRatio)dialogResult.Data;
 | 
			
		||||
 | 
			
		||||
            await apiClient.SetTorrentShareLimit(shareRatio.RatioLimit, shareRatio.SeedingTimeLimit, shareRatio.InactiveSeedingTimeLimit, null, torrents.Select(t => t.Hash).ToArray());
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public static async Task InvokeStringFieldDialog(this IDialogService dialogService, string title, string label, string? value, Func<string, Task> onSuccess)
 | 
			
		||||
        {
 | 
			
		||||
            var result = await dialogService.ShowStringFieldDialog(title, label, value);
 | 
			
		||||
 | 
			
		||||
            if (result is not null)
 | 
			
		||||
            {
 | 
			
		||||
                await onSuccess(result);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public static async Task InvokeUploadRateDialog(this IDialogService dialogService, IApiClient apiClient, long rate, IEnumerable<string> hashes)
 | 
			
		||||
        {
 | 
			
		||||
            Func<long, string> valueDisplayFunc = v => v == Limits.NoLimit ? "∞" : v.ToString();
 | 
			
		||||
            Func<string, long> valueGetFunc = v => v == "∞" ? Limits.NoLimit : long.Parse(v);
 | 
			
		||||
 | 
			
		||||
            var parameters = new DialogParameters
 | 
			
		||||
            {
 | 
			
		||||
                { nameof(SliderFieldDialog<long>.Min), -1L },
 | 
			
		||||
                { nameof(SliderFieldDialog<long>.Max), 1000L },
 | 
			
		||||
                { nameof(SliderFieldDialog<long>.Value), rate / 1024 },
 | 
			
		||||
                { nameof(SliderFieldDialog<long>.ValueDisplayFunc), valueDisplayFunc },
 | 
			
		||||
                { nameof(SliderFieldDialog<long>.ValueGetFunc), valueGetFunc },
 | 
			
		||||
                { nameof(SliderFieldDialog<long>.Label), "Upload rate limit" },
 | 
			
		||||
                { nameof(SliderFieldDialog<long>.Adornment), Adornment.End },
 | 
			
		||||
                { nameof(SliderFieldDialog<long>.AdornmentText), "KiB/s" },
 | 
			
		||||
            };
 | 
			
		||||
            var result = await dialogService.ShowAsync<SliderFieldDialog<long>>("Upload Rate", parameters, FormDialogOptions);
 | 
			
		||||
 | 
			
		||||
            var dialogResult = await result.Result;
 | 
			
		||||
            if (dialogResult is null || dialogResult.Canceled || dialogResult.Data is null)
 | 
			
		||||
            {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
            var kibs = (long)dialogResult.Data;
 | 
			
		||||
            await apiClient.SetTorrentUploadLimit(kibs * 1024, null, hashes.ToArray());
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public static async Task<HashSet<QBitTorrentClient.Models.PeerId>?> ShowAddPeersDialog(this IDialogService dialogService)
 | 
			
		||||
        {
 | 
			
		||||
            var reference = await dialogService.ShowAsync<AddPeerDialog>("Add Peer", NonBlurFormDialogOptions);
 | 
			
		||||
            var dialogResult = await reference.Result;
 | 
			
		||||
 | 
			
		||||
            if (dialogResult is null || dialogResult.Canceled || dialogResult.Data is null)
 | 
			
		||||
            {
 | 
			
		||||
                return null;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var peers = (HashSet<QBitTorrentClient.Models.PeerId>)dialogResult.Data;
 | 
			
		||||
 | 
			
		||||
            return peers;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public static async Task<HashSet<string>?> ShowAddTagsDialog(this IDialogService dialogService)
 | 
			
		||||
        {
 | 
			
		||||
            var reference = await dialogService.ShowAsync<AddTagDialog>("Add Tags", NonBlurFormDialogOptions);
 | 
			
		||||
            var dialogResult = await reference.Result;
 | 
			
		||||
 | 
			
		||||
            if (dialogResult is null || dialogResult.Canceled || dialogResult.Data is null)
 | 
			
		||||
            {
 | 
			
		||||
                return null;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var tags = (HashSet<string>)dialogResult.Data;
 | 
			
		||||
 | 
			
		||||
            return tags;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public static async Task<HashSet<string>?> ShowAddTrackersDialog(this IDialogService dialogService)
 | 
			
		||||
        {
 | 
			
		||||
            var reference = await dialogService.ShowAsync<AddTrackerDialog>("Add Tracker", NonBlurFormDialogOptions);
 | 
			
		||||
            var dialogResult = await reference.Result;
 | 
			
		||||
 | 
			
		||||
            if (dialogResult is null || dialogResult.Canceled || dialogResult.Data is null)
 | 
			
		||||
            {
 | 
			
		||||
                return null;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var tags = (HashSet<string>)dialogResult.Data;
 | 
			
		||||
 | 
			
		||||
            return tags;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public static async Task<(HashSet<string> SelectedColumns, Dictionary<string, int?> ColumnWidths)> ShowColumnsOptionsDialog<T>(this IDialogService dialogService, List<ColumnDefinition<T>> columnDefinitions, HashSet<string> selectedColumns, Dictionary<string, int?> widths)
 | 
			
		||||
        {
 | 
			
		||||
            var parameters = new DialogParameters
 | 
			
		||||
            {
 | 
			
		||||
                { nameof(ColumnOptionsDialog<T>.Columns), columnDefinitions },
 | 
			
		||||
                { nameof(ColumnOptionsDialog<T>.SelectedColumns), selectedColumns },
 | 
			
		||||
                { nameof(ColumnOptionsDialog<T>.Widths), widths },
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            var reference = await dialogService.ShowAsync<ColumnOptionsDialog<T>>("Column Options", parameters, FormDialogOptions);
 | 
			
		||||
            var dialogResult = await reference.Result;
 | 
			
		||||
            if (dialogResult is null || dialogResult.Canceled || dialogResult.Data is null)
 | 
			
		||||
            {
 | 
			
		||||
                return default;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return ((HashSet<string>, Dictionary<string, int?>))dialogResult.Data;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public static async Task<bool> ShowConfirmDialog(this IDialogService dialogService, string title, string content)
 | 
			
		||||
        {
 | 
			
		||||
            var parameters = new DialogParameters
 | 
			
		||||
            {
 | 
			
		||||
                { nameof(ConfirmDialog.Content), content }
 | 
			
		||||
            };
 | 
			
		||||
            var result = await dialogService.ShowAsync<ConfirmDialog>(title, parameters, ConfirmDialogOptions);
 | 
			
		||||
 | 
			
		||||
            var dialogResult = await result.Result;
 | 
			
		||||
            return dialogResult is not null && !dialogResult.Canceled;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public static async Task ShowConfirmDialog(this IDialogService dialogService, string title, string content, Func<Task> onSuccess)
 | 
			
		||||
        {
 | 
			
		||||
            var parameters = new DialogParameters
 | 
			
		||||
            {
 | 
			
		||||
                { nameof(ConfirmDialog.Content), content }
 | 
			
		||||
            };
 | 
			
		||||
            var result = await dialogService.ShowAsync<ConfirmDialog>(title, parameters, ConfirmDialogOptions);
 | 
			
		||||
 | 
			
		||||
            var dialogResult = await result.Result;
 | 
			
		||||
            if (dialogResult is null || dialogResult.Canceled || dialogResult.Data is null)
 | 
			
		||||
            {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            await onSuccess();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public static async Task ShowConfirmDialog(this IDialogService dialogService, string title, string content, Action onSuccess)
 | 
			
		||||
        {
 | 
			
		||||
            await dialogService.ShowConfirmDialog(title, content, () =>
 | 
			
		||||
            {
 | 
			
		||||
                onSuccess();
 | 
			
		||||
 | 
			
		||||
                return Task.CompletedTask;
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public static async Task<List<PropertyFilterDefinition<T>>?> ShowFilterOptionsDialog<T>(this IDialogService dialogService, List<PropertyFilterDefinition<T>>? propertyFilterDefinitions)
 | 
			
		||||
        {
 | 
			
		||||
            var parameters = new DialogParameters
 | 
			
		||||
            {
 | 
			
		||||
                { nameof(FilterOptionsDialog<T>.FilterDefinitions), propertyFilterDefinitions },
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            var result = await dialogService.ShowAsync<FilterOptionsDialog<T>>("Filters", parameters, FormDialogOptions);
 | 
			
		||||
 | 
			
		||||
            var dialogResult = await result.Result;
 | 
			
		||||
            if (dialogResult is null || dialogResult.Canceled || dialogResult.Data is null)
 | 
			
		||||
            {
 | 
			
		||||
                return null;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return (List<PropertyFilterDefinition<T>>?)dialogResult.Data;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public static async Task<string?> ShowStringFieldDialog(this IDialogService dialogService, string title, string label, string? value)
 | 
			
		||||
        {
 | 
			
		||||
            var parameters = new DialogParameters
 | 
			
		||||
            {
 | 
			
		||||
                { nameof(StringFieldDialog.Label), label },
 | 
			
		||||
                { nameof(StringFieldDialog.Value), value }
 | 
			
		||||
            };
 | 
			
		||||
            var result = await dialogService.ShowAsync<StringFieldDialog>(title, parameters, FormDialogOptions);
 | 
			
		||||
 | 
			
		||||
            var dialogResult = await result.Result;
 | 
			
		||||
            if (dialogResult is null || dialogResult.Canceled || dialogResult.Data is null)
 | 
			
		||||
            {
 | 
			
		||||
                return null;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return (string)dialogResult.Data;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public static async Task ShowSubMenu(this IDialogService dialogService, IEnumerable<string> hashes, UIAction parent, Dictionary<string, Torrent> torrents, QBitTorrentClient.Models.Preferences? preferences)
 | 
			
		||||
        {
 | 
			
		||||
            var parameters = new DialogParameters
 | 
			
		||||
            {
 | 
			
		||||
                { nameof(SubMenuDialog.ParentAction), parent },
 | 
			
		||||
                { nameof(SubMenuDialog.Hashes), hashes },
 | 
			
		||||
                { nameof(SubMenuDialog.Torrents), torrents },
 | 
			
		||||
                { nameof(SubMenuDialog.Preferences), preferences },
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            await dialogService.ShowAsync<SubMenuDialog>(parent.Text, parameters, FormDialogOptions);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -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,143 +0,0 @@
 | 
			
		||||
using Blazored.LocalStorage;
 | 
			
		||||
using Lantean.QBTMud.Components;
 | 
			
		||||
using Microsoft.AspNetCore.Components;
 | 
			
		||||
using MudBlazor;
 | 
			
		||||
using MudBlazor.Services;
 | 
			
		||||
 | 
			
		||||
namespace Lantean.QBTMud.Layout
 | 
			
		||||
{
 | 
			
		||||
    public partial class MainLayout : IBrowserViewportObserver, IAsyncDisposable
 | 
			
		||||
    {
 | 
			
		||||
        private const string _isDarkModeStorageKey = "MainLayout.IsDarkMode";
 | 
			
		||||
        private const string _drawerOpenStorageKey = "MainLayout.DrawerOpen";
 | 
			
		||||
 | 
			
		||||
        private bool _disposedValue;
 | 
			
		||||
 | 
			
		||||
        [Inject]
 | 
			
		||||
        protected NavigationManager NavigationManager { get; set; } = default!;
 | 
			
		||||
 | 
			
		||||
        [Inject]
 | 
			
		||||
        private IBrowserViewportService BrowserViewportService { get; set; } = default!;
 | 
			
		||||
 | 
			
		||||
        [Inject]
 | 
			
		||||
        protected ILocalStorageService LocalStorage { get; set; } = default!;
 | 
			
		||||
 | 
			
		||||
        protected bool DrawerOpen { get; set; } = true;
 | 
			
		||||
 | 
			
		||||
        protected bool ErrorDrawerOpen { get; set; } = false;
 | 
			
		||||
 | 
			
		||||
        public Guid Id => Guid.NewGuid();
 | 
			
		||||
 | 
			
		||||
        protected EnhancedErrorBoundary? ErrorBoundary { get; set; }
 | 
			
		||||
 | 
			
		||||
        protected bool IsDarkMode { get; set; }
 | 
			
		||||
 | 
			
		||||
        protected MudThemeProvider MudThemeProvider { get; set; } = default!;
 | 
			
		||||
 | 
			
		||||
        private Menu Menu { get; set; } = default!;
 | 
			
		||||
 | 
			
		||||
        ResizeOptions IBrowserViewportObserver.ResizeOptions { get; } = new()
 | 
			
		||||
        {
 | 
			
		||||
            ReportRate = 50,
 | 
			
		||||
            NotifyOnBreakpointOnly = true
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        protected MudTheme Theme { get; set; }
 | 
			
		||||
 | 
			
		||||
        public MainLayout()
 | 
			
		||||
        {
 | 
			
		||||
            Theme = new MudTheme();
 | 
			
		||||
            Theme.Typography.Default.FontFamily = ["Nunito Sans"];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected async Task ToggleDrawer()
 | 
			
		||||
        {
 | 
			
		||||
            DrawerOpen = !DrawerOpen;
 | 
			
		||||
            await LocalStorage.SetItemAsync(_drawerOpenStorageKey, DrawerOpen);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected override async Task OnParametersSetAsync()
 | 
			
		||||
        {
 | 
			
		||||
            var drawerOpen = await LocalStorage.GetItemAsync<bool?>(_drawerOpenStorageKey);
 | 
			
		||||
            if (drawerOpen is not null)
 | 
			
		||||
            {
 | 
			
		||||
                DrawerOpen = drawerOpen.Value;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected override async Task OnAfterRenderAsync(bool firstRender)
 | 
			
		||||
        {
 | 
			
		||||
            if (firstRender)
 | 
			
		||||
            {
 | 
			
		||||
                var isDarkMode = await LocalStorage.GetItemAsync<bool?>(_isDarkModeStorageKey);
 | 
			
		||||
                if (isDarkMode is null)
 | 
			
		||||
                {
 | 
			
		||||
                    IsDarkMode = true;
 | 
			
		||||
                }
 | 
			
		||||
                else
 | 
			
		||||
                {
 | 
			
		||||
                    IsDarkMode = isDarkMode.Value;
 | 
			
		||||
                }
 | 
			
		||||
                await MudThemeProvider.WatchSystemPreference(OnSystemPreferenceChanged);
 | 
			
		||||
                await BrowserViewportService.SubscribeAsync(this, fireImmediately: true);
 | 
			
		||||
                await InvokeAsync(StateHasChanged);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected Task OnSystemPreferenceChanged(bool value)
 | 
			
		||||
        {
 | 
			
		||||
            IsDarkMode = value;
 | 
			
		||||
            return Task.CompletedTask;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public Task NotifyBrowserViewportChangeAsync(BrowserViewportEventArgs browserViewportEventArgs)
 | 
			
		||||
        {
 | 
			
		||||
            if (browserViewportEventArgs.Breakpoint == Breakpoint.Sm && DrawerOpen)
 | 
			
		||||
            {
 | 
			
		||||
                DrawerOpen = false;
 | 
			
		||||
            }
 | 
			
		||||
            else if (browserViewportEventArgs.Breakpoint > Breakpoint.Sm && !DrawerOpen)
 | 
			
		||||
            {
 | 
			
		||||
                DrawerOpen = true;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return Task.CompletedTask;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected void ToggleErrorDrawer()
 | 
			
		||||
        {
 | 
			
		||||
            ErrorDrawerOpen = !ErrorDrawerOpen;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected void Cleared()
 | 
			
		||||
        {
 | 
			
		||||
            ErrorDrawerOpen = false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected virtual async Task DisposeAsync(bool disposing)
 | 
			
		||||
        {
 | 
			
		||||
            if (!_disposedValue)
 | 
			
		||||
            {
 | 
			
		||||
                if (disposing)
 | 
			
		||||
                {
 | 
			
		||||
                    await BrowserViewportService.UnsubscribeAsync(this);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                _disposedValue = true;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected async Task DarkModeChanged(bool value)
 | 
			
		||||
        {
 | 
			
		||||
            IsDarkMode = value;
 | 
			
		||||
            await LocalStorage.SetItemAsync(_isDarkModeStorageKey, value);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public async ValueTask DisposeAsync()
 | 
			
		||||
        {
 | 
			
		||||
            // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
 | 
			
		||||
            await DisposeAsync(disposing: true);
 | 
			
		||||
            GC.SuppressFinalize(this);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -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,41 +0,0 @@
 | 
			
		||||
namespace Lantean.QBTMud.Models
 | 
			
		||||
{
 | 
			
		||||
    public class RssList
 | 
			
		||||
    {
 | 
			
		||||
        public RssList(Dictionary<string, RssFeed> feeds, List<RssArticle> articles)
 | 
			
		||||
        {
 | 
			
		||||
            Feeds = feeds;
 | 
			
		||||
 | 
			
		||||
            foreach (var article in articles)
 | 
			
		||||
            {
 | 
			
		||||
                var feed = Feeds[article.Feed];
 | 
			
		||||
                feed.ArticleCount++;
 | 
			
		||||
                if (!article.IsRead)
 | 
			
		||||
                {
 | 
			
		||||
                    feed.UnreadCount++;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                Articles.Add(article);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public Dictionary<string, RssFeed> Feeds { get; }
 | 
			
		||||
 | 
			
		||||
        public List<RssArticle> Articles { get; } = [];
 | 
			
		||||
 | 
			
		||||
        public int UnreadCount => Feeds.Values.Sum(f => f.UnreadCount);
 | 
			
		||||
 | 
			
		||||
        internal void MarkAllUnreadAsRead()
 | 
			
		||||
        {
 | 
			
		||||
            foreach (var feed in Feeds)
 | 
			
		||||
            {
 | 
			
		||||
                feed.Value.UnreadCount = 0;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        internal void MarkAsUnread(string selectedFeed)
 | 
			
		||||
        {
 | 
			
		||||
            Feeds[selectedFeed].UnreadCount = 0;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -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,239 +0,0 @@
 | 
			
		||||
using Lantean.QBitTorrentClient;
 | 
			
		||||
using Lantean.QBTMud.Helpers;
 | 
			
		||||
using Lantean.QBTMud.Models;
 | 
			
		||||
using Lantean.QBTMud.Services;
 | 
			
		||||
using Microsoft.AspNetCore.Components;
 | 
			
		||||
using MudBlazor;
 | 
			
		||||
 | 
			
		||||
namespace Lantean.QBTMud.Pages
 | 
			
		||||
{
 | 
			
		||||
    public partial class Rss : IAsyncDisposable
 | 
			
		||||
    {
 | 
			
		||||
        private readonly bool _refreshEnabled = true;
 | 
			
		||||
 | 
			
		||||
        private readonly CancellationTokenSource _timerCancellationToken = new();
 | 
			
		||||
        private bool _disposedValue;
 | 
			
		||||
 | 
			
		||||
        [Inject]
 | 
			
		||||
        protected IApiClient ApiClient { get; set; } = default!;
 | 
			
		||||
 | 
			
		||||
        [Inject]
 | 
			
		||||
        protected IDialogService DialogService { get; set; } = default!;
 | 
			
		||||
 | 
			
		||||
        [Inject]
 | 
			
		||||
        protected NavigationManager NavigationManager { get; set; } = default!;
 | 
			
		||||
 | 
			
		||||
        [Inject]
 | 
			
		||||
        protected IDataManager DataManager { get; set; } = default!;
 | 
			
		||||
 | 
			
		||||
        [CascadingParameter]
 | 
			
		||||
        public MainData? MainData { get; set; }
 | 
			
		||||
 | 
			
		||||
        [CascadingParameter(Name = "DrawerOpen")]
 | 
			
		||||
        public bool DrawerOpen { get; set; }
 | 
			
		||||
 | 
			
		||||
        [Parameter]
 | 
			
		||||
        public string? Hash { get; set; }
 | 
			
		||||
 | 
			
		||||
        protected int ActiveTab { get; set; } = 0;
 | 
			
		||||
 | 
			
		||||
        protected int RefreshInterval => MainData?.ServerState.RefreshInterval ?? 1500;
 | 
			
		||||
 | 
			
		||||
        protected ServerState? ServerState => MainData?.ServerState;
 | 
			
		||||
 | 
			
		||||
        protected string? SelectedFeed { get; set; }
 | 
			
		||||
 | 
			
		||||
        protected string? SelectedArticle { get; set; }
 | 
			
		||||
 | 
			
		||||
        protected RssList? RssList { get; set; }
 | 
			
		||||
 | 
			
		||||
        protected Dictionary<string, RssFeed> Feeds => RssList?.Feeds ?? [];
 | 
			
		||||
 | 
			
		||||
        protected List<RssArticle> Articles { get; } = [];
 | 
			
		||||
 | 
			
		||||
        protected int UnreadCount => RssList?.UnreadCount ?? 0;
 | 
			
		||||
 | 
			
		||||
        protected RssArticle? Article { get; set; }
 | 
			
		||||
 | 
			
		||||
        protected void SelectedFeedChanged(string value)
 | 
			
		||||
        {
 | 
			
		||||
            SelectedFeed = value;
 | 
			
		||||
            SelectedArticle = null;
 | 
			
		||||
 | 
			
		||||
            Articles.Clear();
 | 
			
		||||
 | 
			
		||||
            if (RssList is null)
 | 
			
		||||
            {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            IEnumerable<RssArticle> articles;
 | 
			
		||||
 | 
			
		||||
            if (value == "unread")
 | 
			
		||||
            {
 | 
			
		||||
                articles = RssList.Articles.Where(a => !a.IsRead);
 | 
			
		||||
            }
 | 
			
		||||
            else
 | 
			
		||||
            {
 | 
			
		||||
                articles = RssList.Articles.Where(a => a.Feed == value);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            foreach (var article in articles)
 | 
			
		||||
            {
 | 
			
		||||
                Articles.Add(article);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected async Task SelectedArticleChanged(string value)
 | 
			
		||||
        {
 | 
			
		||||
            Article = null;
 | 
			
		||||
            SelectedArticle = value;
 | 
			
		||||
 | 
			
		||||
            if (RssList is null)
 | 
			
		||||
            {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var article = RssList.Articles.Find(a => a.Id == value);
 | 
			
		||||
            if (article is null)
 | 
			
		||||
            {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            article.IsRead = true;
 | 
			
		||||
            Articles.First(a => a.Id == value).IsRead = true;
 | 
			
		||||
            Article = article;
 | 
			
		||||
 | 
			
		||||
            await ApiClient.MarkRssItemAsRead(article.Feed, article.Id);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected override async Task OnInitializedAsync()
 | 
			
		||||
        {
 | 
			
		||||
            await GetRssList();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        private async Task GetRssList()
 | 
			
		||||
        {
 | 
			
		||||
            var items = await ApiClient.GetAllRssItems(true);
 | 
			
		||||
            RssList = DataManager.CreateRssList(items);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected async Task DownloadItem(string? url)
 | 
			
		||||
        {
 | 
			
		||||
            await DialogService.InvokeAddTorrentLinkDialog(ApiClient, url);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected void NavigateBack()
 | 
			
		||||
        {
 | 
			
		||||
            NavigationManager.NavigateTo("/");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected async Task NewSubscription()
 | 
			
		||||
        {
 | 
			
		||||
            var url = await DialogService.ShowStringFieldDialog("RSS Feed URL", "Feed URL", null);
 | 
			
		||||
            if (url is not null)
 | 
			
		||||
            {
 | 
			
		||||
                await ApiClient.AddRssFeed(url);
 | 
			
		||||
 | 
			
		||||
                await GetRssList();
 | 
			
		||||
 | 
			
		||||
                await InvokeAsync(StateHasChanged);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected async Task MarkAsRead()
 | 
			
		||||
        {
 | 
			
		||||
            if (SelectedFeed is null)
 | 
			
		||||
            {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (SelectedFeed == "unread")
 | 
			
		||||
            {
 | 
			
		||||
                if (RssList is null)
 | 
			
		||||
                {
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                var articles = RssList.Articles.Where(a => !a.IsRead);
 | 
			
		||||
                foreach (var article in articles)
 | 
			
		||||
                {
 | 
			
		||||
                    await ApiClient.MarkRssItemAsRead(article.Feed, article.Id);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                RssList.MarkAllUnreadAsRead();
 | 
			
		||||
            }
 | 
			
		||||
            else
 | 
			
		||||
            {
 | 
			
		||||
                await ApiClient.MarkRssItemAsRead(SelectedFeed);
 | 
			
		||||
 | 
			
		||||
                RssList?.MarkAsUnread(SelectedFeed);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            foreach (var article in Articles)
 | 
			
		||||
            {
 | 
			
		||||
                article.IsRead = true;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected async Task UpdateAll()
 | 
			
		||||
        {
 | 
			
		||||
            foreach (var (path, feed) in Feeds)
 | 
			
		||||
            {
 | 
			
		||||
                feed.IsLoading = true;
 | 
			
		||||
                await ApiClient.RefreshRssItem(path);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected async Task EditDownloadRules()
 | 
			
		||||
        {
 | 
			
		||||
            await DialogService.InvokeRssRulesDialog();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected override async Task OnAfterRenderAsync(bool firstRender)
 | 
			
		||||
        {
 | 
			
		||||
            if (!_refreshEnabled)
 | 
			
		||||
            {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (!firstRender)
 | 
			
		||||
            {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            using (var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(RefreshInterval)))
 | 
			
		||||
            {
 | 
			
		||||
                while (!_timerCancellationToken.IsCancellationRequested && await timer.WaitForNextTickAsync())
 | 
			
		||||
                {
 | 
			
		||||
                    await GetRssList();
 | 
			
		||||
 | 
			
		||||
                    await InvokeAsync(StateHasChanged);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected virtual Task DisposeAsync(bool disposing)
 | 
			
		||||
        {
 | 
			
		||||
            if (!_disposedValue)
 | 
			
		||||
            {
 | 
			
		||||
                if (disposing)
 | 
			
		||||
                {
 | 
			
		||||
                    _timerCancellationToken.Cancel();
 | 
			
		||||
                    _timerCancellationToken.Dispose();
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                _disposedValue = true;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return Task.CompletedTask;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public async ValueTask DisposeAsync()
 | 
			
		||||
        {
 | 
			
		||||
            // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
 | 
			
		||||
            await DisposeAsync(disposing: true);
 | 
			
		||||
            GC.SuppressFinalize(this);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -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.
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
<Router AppAssembly="@typeof(App).Assembly">
 | 
			
		||||
<Router AppAssembly="@typeof(App).Assembly">
 | 
			
		||||
    <Found Context="routeData">
 | 
			
		||||
        <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
 | 
			
		||||
        <FocusOnNavigate RouteData="@routeData" Selector="h1" />
 | 
			
		||||
							
								
								
									
										55
									
								
								src/Lantean.QBTMud/Components/ApplicationActions.razor
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								src/Lantean.QBTMud/Components/ApplicationActions.razor
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,55 @@
 | 
			
		||||
@if (IsMenu)
 | 
			
		||||
{
 | 
			
		||||
    @foreach (var action in Actions)
 | 
			
		||||
    {
 | 
			
		||||
        if (action.SeparatorBefore)
 | 
			
		||||
        {
 | 
			
		||||
            <MudDivider />
 | 
			
		||||
        }
 | 
			
		||||
        @if (!string.IsNullOrEmpty(action.Href))
 | 
			
		||||
        {
 | 
			
		||||
            <MudMenuItem Icon="@action.Icon" IconColor="@action.Color" Href="@action.Href">@action.Text</MudMenuItem>
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            <MudMenuItem Icon="@action.Icon" IconColor="@action.Color" OnClick="action.Callback">@action.Text</MudMenuItem>
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    <MudDivider />
 | 
			
		||||
    <MudMenuItem Icon="@Icons.Material.Filled.PlayArrow"
 | 
			
		||||
                 IconColor="Color.Success"
 | 
			
		||||
                 Disabled="@(_startAllInProgress || MainData?.LostConnection == true)"
 | 
			
		||||
                 OnClick="StartAllTorrents">
 | 
			
		||||
        Start all torrents
 | 
			
		||||
    </MudMenuItem>
 | 
			
		||||
    <MudMenuItem Icon="@Icons.Material.Filled.Stop"
 | 
			
		||||
                 IconColor="Color.Warning"
 | 
			
		||||
                 Disabled="@(_stopAllInProgress || MainData?.LostConnection == true)"
 | 
			
		||||
                 OnClick="StopAllTorrents">
 | 
			
		||||
        Stop all torrents
 | 
			
		||||
    </MudMenuItem>
 | 
			
		||||
    <MudMenuItem Icon="@Icons.Material.Filled.Undo" OnClick="ResetWebUI">Reset Web UI</MudMenuItem>
 | 
			
		||||
    <MudDivider />
 | 
			
		||||
    <MudMenuItem Icon="@Icons.Material.Filled.Logout" OnClick="Logout">Logout</MudMenuItem>
 | 
			
		||||
    <MudMenuItem Icon="@Icons.Material.Filled.ExitToApp" OnClick="Exit">Exit qBittorrent</MudMenuItem>
 | 
			
		||||
}
 | 
			
		||||
else
 | 
			
		||||
{
 | 
			
		||||
    <MudNavLink Icon="@Icons.Material.Outlined.Navigation" OnClick="NavigateBack">Torrents</MudNavLink>
 | 
			
		||||
    <MudDivider />
 | 
			
		||||
    @foreach (var action in Actions)
 | 
			
		||||
    {
 | 
			
		||||
        if (action.SeparatorBefore)
 | 
			
		||||
        {
 | 
			
		||||
            <MudDivider />
 | 
			
		||||
        }
 | 
			
		||||
        @if (!string.IsNullOrEmpty(action.Href))
 | 
			
		||||
        {
 | 
			
		||||
            <MudNavLink Icon="@action.Icon" IconColor="@action.Color" Href="@action.Href">@action.Text</MudNavLink>
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            <MudNavLink Icon="@action.Icon" IconColor="@action.Color" OnClick="action.Callback">@action.Text</MudNavLink>
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										230
									
								
								src/Lantean.QBTMud/Components/ApplicationActions.razor.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										230
									
								
								src/Lantean.QBTMud/Components/ApplicationActions.razor.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,230 @@
 | 
			
		||||
using Lantean.QBitTorrentClient;
 | 
			
		||||
using Lantean.QBitTorrentClient.Models;
 | 
			
		||||
using Lantean.QBTMud.Helpers;
 | 
			
		||||
using Lantean.QBTMud.Interop;
 | 
			
		||||
using Lantean.QBTMud.Models;
 | 
			
		||||
using Microsoft.AspNetCore.Components;
 | 
			
		||||
using Microsoft.JSInterop;
 | 
			
		||||
using MudBlazor;
 | 
			
		||||
 | 
			
		||||
namespace Lantean.QBTMud.Components
 | 
			
		||||
{
 | 
			
		||||
    public partial class ApplicationActions
 | 
			
		||||
    {
 | 
			
		||||
        private List<UIAction>? _actions;
 | 
			
		||||
        private bool _startAllInProgress;
 | 
			
		||||
        private bool _stopAllInProgress;
 | 
			
		||||
        private bool _registerMagnetHandlerInProgress;
 | 
			
		||||
 | 
			
		||||
        [Inject]
 | 
			
		||||
        protected NavigationManager NavigationManager { get; set; } = default!;
 | 
			
		||||
 | 
			
		||||
        [Inject]
 | 
			
		||||
        protected IDialogWorkflow DialogWorkflow { get; set; } = default!;
 | 
			
		||||
 | 
			
		||||
        [Inject]
 | 
			
		||||
        protected IApiClient ApiClient { get; set; } = default!;
 | 
			
		||||
 | 
			
		||||
        [Inject]
 | 
			
		||||
        protected ISnackbar Snackbar { get; set; } = default!;
 | 
			
		||||
 | 
			
		||||
        [Inject]
 | 
			
		||||
        protected IJSRuntime JSRuntime { get; set; } = default!;
 | 
			
		||||
 | 
			
		||||
        [Parameter]
 | 
			
		||||
        public bool IsMenu { get; set; }
 | 
			
		||||
 | 
			
		||||
        [Parameter]
 | 
			
		||||
        [EditorRequired]
 | 
			
		||||
        public Preferences? Preferences { get; set; }
 | 
			
		||||
 | 
			
		||||
        [CascadingParameter]
 | 
			
		||||
        public Lantean.QBTMud.Models.MainData? MainData { get; set; }
 | 
			
		||||
 | 
			
		||||
        protected IEnumerable<UIAction> Actions => GetActions();
 | 
			
		||||
 | 
			
		||||
        private IEnumerable<UIAction> GetActions()
 | 
			
		||||
        {
 | 
			
		||||
            if (_actions is not null)
 | 
			
		||||
            {
 | 
			
		||||
                foreach (var action in _actions)
 | 
			
		||||
                {
 | 
			
		||||
                    if (action.Name != "rss" || Preferences is not null && Preferences.RssProcessingEnabled)
 | 
			
		||||
                    {
 | 
			
		||||
                        yield return action;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected override void OnInitialized()
 | 
			
		||||
        {
 | 
			
		||||
            _actions =
 | 
			
		||||
            [
 | 
			
		||||
                new("statistics", "Statistics", Icons.Material.Filled.PieChart, Color.Default, "/statistics"),
 | 
			
		||||
                new("search", "Search", Icons.Material.Filled.Search, Color.Default, "/search"),
 | 
			
		||||
                new("rss", "RSS", Icons.Material.Filled.RssFeed, Color.Default, "/rss"),
 | 
			
		||||
                new("log", "Execution Log", Icons.Material.Filled.List, Color.Default, "/log"),
 | 
			
		||||
                new("blocks", "Blocked IPs", Icons.Material.Filled.DisabledByDefault, Color.Default, "/blocks"),
 | 
			
		||||
                new("cookies", "Cookie Manager", Icons.Material.Filled.Cookie, Color.Default, "/cookies"),
 | 
			
		||||
                new("registerMagnetHandler", "Register magnet handler", CustomIcons.Magnet, Color.Default, EventCallback.Factory.Create(this, RegisterMagnetHandler)),
 | 
			
		||||
                new("tags", "Tag Management", Icons.Material.Filled.Label, Color.Default, "/tags", separatorBefore: true),
 | 
			
		||||
                new("categories", "Category Management", Icons.Material.Filled.List, Color.Default, "/categories"),
 | 
			
		||||
                new("settings", "Settings", Icons.Material.Filled.Settings, Color.Default, "/settings", separatorBefore: true),
 | 
			
		||||
                new("about", "About", Icons.Material.Filled.Info, Color.Default, "/about"),
 | 
			
		||||
            ];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected void NavigateBack()
 | 
			
		||||
        {
 | 
			
		||||
            NavigationManager.NavigateTo("/");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected async Task ResetWebUI()
 | 
			
		||||
        {
 | 
			
		||||
            var preferences = new UpdatePreferences
 | 
			
		||||
            {
 | 
			
		||||
                AlternativeWebuiEnabled = false,
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            await ApiClient.SetApplicationPreferences(preferences);
 | 
			
		||||
 | 
			
		||||
            NavigationManager.NavigateTo("/", true);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected async Task Logout()
 | 
			
		||||
        {
 | 
			
		||||
            await DialogWorkflow.ShowConfirmDialog("Logout?", "Are you sure you want to logout?", async () =>
 | 
			
		||||
            {
 | 
			
		||||
                await ApiClient.Logout();
 | 
			
		||||
 | 
			
		||||
                NavigationManager.NavigateTo("/", true);
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected async Task Exit()
 | 
			
		||||
        {
 | 
			
		||||
            await DialogWorkflow.ShowConfirmDialog("Quit?", "Are you sure you want to exit qBittorrent?", ApiClient.Shutdown);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        private async Task RegisterMagnetHandler()
 | 
			
		||||
        {
 | 
			
		||||
            if (_registerMagnetHandlerInProgress)
 | 
			
		||||
            {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            _registerMagnetHandlerInProgress = true;
 | 
			
		||||
 | 
			
		||||
            try
 | 
			
		||||
            {
 | 
			
		||||
                var templateUrl = BuildMagnetHandlerTemplateUrl();
 | 
			
		||||
                var result = await JSRuntime.RegisterMagnetHandler(templateUrl);
 | 
			
		||||
 | 
			
		||||
                var status = (result.Status ?? string.Empty).ToLowerInvariant();
 | 
			
		||||
                switch (status)
 | 
			
		||||
                {
 | 
			
		||||
                    case "success":
 | 
			
		||||
                        Snackbar?.Add("Magnet handler registered. Magnet links will now open in qBittorrent WebUI.", Severity.Success);
 | 
			
		||||
                        break;
 | 
			
		||||
 | 
			
		||||
                    case "insecure":
 | 
			
		||||
                        Snackbar?.Add("Access this WebUI over HTTPS to register the magnet handler.", Severity.Warning);
 | 
			
		||||
                        break;
 | 
			
		||||
 | 
			
		||||
                    case "unsupported":
 | 
			
		||||
                        Snackbar?.Add("This browser does not support registering magnet handlers.", Severity.Warning);
 | 
			
		||||
                        break;
 | 
			
		||||
 | 
			
		||||
                    default:
 | 
			
		||||
                        var message = string.IsNullOrWhiteSpace(result.Message)
 | 
			
		||||
                            ? "Unable to register the magnet handler."
 | 
			
		||||
                            : $"Unable to register the magnet handler: {result.Message}";
 | 
			
		||||
                        Snackbar?.Add(message, Severity.Error);
 | 
			
		||||
                        break;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            catch (JSException exception)
 | 
			
		||||
            {
 | 
			
		||||
                Snackbar?.Add($"Unable to register the magnet handler: {exception.Message}", Severity.Error);
 | 
			
		||||
            }
 | 
			
		||||
            finally
 | 
			
		||||
            {
 | 
			
		||||
                _registerMagnetHandlerInProgress = false;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected async Task StartAllTorrents()
 | 
			
		||||
        {
 | 
			
		||||
            if (_startAllInProgress)
 | 
			
		||||
            {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (MainData?.LostConnection == true)
 | 
			
		||||
            {
 | 
			
		||||
                Snackbar?.Add("qBittorrent client is not reachable.", Severity.Warning);
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            _startAllInProgress = true;
 | 
			
		||||
            try
 | 
			
		||||
            {
 | 
			
		||||
                await ApiClient.StartAllTorrents();
 | 
			
		||||
                Snackbar?.Add("All torrents started.", Severity.Success);
 | 
			
		||||
            }
 | 
			
		||||
            catch (HttpRequestException exception)
 | 
			
		||||
            {
 | 
			
		||||
                Snackbar?.Add($"Unable to start torrents: {exception.Message}", Severity.Error);
 | 
			
		||||
            }
 | 
			
		||||
            finally
 | 
			
		||||
            {
 | 
			
		||||
                _startAllInProgress = false;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        protected async Task StopAllTorrents()
 | 
			
		||||
        {
 | 
			
		||||
            if (_stopAllInProgress)
 | 
			
		||||
            {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (MainData?.LostConnection == true)
 | 
			
		||||
            {
 | 
			
		||||
                Snackbar?.Add("qBittorrent client is not reachable.", Severity.Warning);
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            _stopAllInProgress = true;
 | 
			
		||||
            try
 | 
			
		||||
            {
 | 
			
		||||
                await ApiClient.StopAllTorrents();
 | 
			
		||||
                Snackbar?.Add("All torrents stopped.", Severity.Info);
 | 
			
		||||
            }
 | 
			
		||||
            catch (HttpRequestException exception)
 | 
			
		||||
            {
 | 
			
		||||
                Snackbar?.Add($"Unable to stop torrents: {exception.Message}", Severity.Error);
 | 
			
		||||
            }
 | 
			
		||||
            finally
 | 
			
		||||
            {
 | 
			
		||||
                _stopAllInProgress = false;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        private string BuildMagnetHandlerTemplateUrl()
 | 
			
		||||
        {
 | 
			
		||||
            var baseUri = NavigationManager.BaseUri;
 | 
			
		||||
            if (string.IsNullOrEmpty(baseUri))
 | 
			
		||||
            {
 | 
			
		||||
                return "#download=%s";
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var trimmedBase = baseUri.EndsWith('/')
 | 
			
		||||
                ? baseUri[..^1]
 | 
			
		||||
                : baseUri;
 | 
			
		||||
 | 
			
		||||
            return $"{trimmedBase}/#download=%s";
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
@inherits SubmittableDialog
 | 
			
		||||
@inherits SubmittableDialog
 | 
			
		||||
 | 
			
		||||
<MudDialog>
 | 
			
		||||
    <DialogContent>
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
using Lantean.QBitTorrentClient.Models;
 | 
			
		||||
using Lantean.QBitTorrentClient.Models;
 | 
			
		||||
using Lantean.QBTMud.Models;
 | 
			
		||||
using Microsoft.AspNetCore.Components;
 | 
			
		||||
using MudBlazor;
 | 
			
		||||
@@ -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; } = [];
 | 
			
		||||
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
@inherits SubmittableDialog
 | 
			
		||||
@inherits SubmittableDialog
 | 
			
		||||
 | 
			
		||||
<MudDialog>
 | 
			
		||||
    <DialogContent>
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
using Lantean.QBitTorrentClient;
 | 
			
		||||
using Lantean.QBitTorrentClient;
 | 
			
		||||
using Lantean.QBTMud.Models;
 | 
			
		||||
using Microsoft.AspNetCore.Components;
 | 
			
		||||
using MudBlazor;
 | 
			
		||||
@@ -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>
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
using Lantean.QBTMud.Models;
 | 
			
		||||
using Lantean.QBTMud.Models;
 | 
			
		||||
using Microsoft.AspNetCore.Components;
 | 
			
		||||
using Microsoft.AspNetCore.Components.Forms;
 | 
			
		||||
using MudBlazor;
 | 
			
		||||
@@ -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();
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
<MudDialog>
 | 
			
		||||
<MudDialog>
 | 
			
		||||
    <DialogContent>
 | 
			
		||||
        <MudGrid>
 | 
			
		||||
            <MudItem xs="12">
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
using Lantean.QBTMud.Models;
 | 
			
		||||
using Lantean.QBTMud.Models;
 | 
			
		||||
using Lantean.QBTMud.Services;
 | 
			
		||||
using Microsoft.AspNetCore.Components;
 | 
			
		||||
using MudBlazor;
 | 
			
		||||
@@ -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);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
@inherits SubmittableDialog
 | 
			
		||||
@inherits SubmittableDialog
 | 
			
		||||
 | 
			
		||||
<MudDialog>
 | 
			
		||||
    <DialogContent>
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
using Lantean.QBTMud.Models;
 | 
			
		||||
using Lantean.QBTMud.Models;
 | 
			
		||||
using Microsoft.AspNetCore.Components;
 | 
			
		||||
using MudBlazor;
 | 
			
		||||
 | 
			
		||||
@@ -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; } = [];
 | 
			
		||||
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
@inherits SubmittableDialog
 | 
			
		||||
@inherits SubmittableDialog
 | 
			
		||||
 | 
			
		||||
<MudDialog>
 | 
			
		||||
    <DialogContent>
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
using Lantean.QBitTorrentClient;
 | 
			
		||||
using Lantean.QBitTorrentClient;
 | 
			
		||||
using Lantean.QBTMud.Models;
 | 
			
		||||
using Microsoft.AspNetCore.Components;
 | 
			
		||||
using MudBlazor;
 | 
			
		||||
@@ -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!;
 | 
			
		||||
@@ -1,16 +1,17 @@
 | 
			
		||||
@typeparam T
 | 
			
		||||
@typeparam T
 | 
			
		||||
@inherits SubmittableDialog
 | 
			
		||||
 | 
			
		||||
<MudDialog>
 | 
			
		||||
    <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))" />
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
using Lantean.QBTMud.Models;
 | 
			
		||||
using Lantean.QBTMud.Models;
 | 
			
		||||
using Microsoft.AspNetCore.Components;
 | 
			
		||||
using MudBlazor;
 | 
			
		||||
 | 
			
		||||
@@ -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)
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
@inherits SubmittableDialog
 | 
			
		||||
@inherits SubmittableDialog
 | 
			
		||||
 | 
			
		||||
<MudDialog>
 | 
			
		||||
    <DialogContent>
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
using Lantean.QBTMud.Models;
 | 
			
		||||
using Lantean.QBTMud.Models;
 | 
			
		||||
using Microsoft.AspNetCore.Components;
 | 
			
		||||
using MudBlazor;
 | 
			
		||||
 | 
			
		||||
@@ -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!;
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
@inherits SubmittableDialog
 | 
			
		||||
@inherits SubmittableDialog
 | 
			
		||||
 | 
			
		||||
<MudDialog>
 | 
			
		||||
    <DialogContent>
 | 
			
		||||
@@ -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>
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
using Lantean.QBTMud.Models;
 | 
			
		||||
using Lantean.QBTMud.Models;
 | 
			
		||||
using Microsoft.AspNetCore.Components;
 | 
			
		||||
using MudBlazor;
 | 
			
		||||
 | 
			
		||||
@@ -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; }
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
<MudDialog>
 | 
			
		||||
<MudDialog>
 | 
			
		||||
    <DialogContent>
 | 
			
		||||
        <MudGrid>
 | 
			
		||||
            @if (Exception is null)
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
using Microsoft.AspNetCore.Components;
 | 
			
		||||
using Microsoft.AspNetCore.Components;
 | 
			
		||||
using MudBlazor;
 | 
			
		||||
 | 
			
		||||
namespace Lantean.QBTMud.Components.Dialogs
 | 
			
		||||
@@ -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; }
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
@typeparam T
 | 
			
		||||
@typeparam T
 | 
			
		||||
@inherits SubmittableDialog
 | 
			
		||||
 | 
			
		||||
<MudDialog ContentStyle="mix-width: 400px">
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
using Lantean.QBTMud.Filter;
 | 
			
		||||
using Lantean.QBTMud.Filter;
 | 
			
		||||
using Lantean.QBTMud.Models;
 | 
			
		||||
using Microsoft.AspNetCore.Components;
 | 
			
		||||
using MudBlazor;
 | 
			
		||||
@@ -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;
 | 
			
		||||
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
<MudDialog>
 | 
			
		||||
<MudDialog>
 | 
			
		||||
    <DialogContent>
 | 
			
		||||
        <MudGrid>
 | 
			
		||||
            <MudItem xs="12">
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
using Lantean.QBitTorrentClient;
 | 
			
		||||
using Lantean.QBitTorrentClient;
 | 
			
		||||
using Lantean.QBTMud.Helpers;
 | 
			
		||||
using Microsoft.AspNetCore.Components;
 | 
			
		||||
using MudBlazor;
 | 
			
		||||
@@ -11,10 +11,10 @@ namespace Lantean.QBTMud.Components.Dialogs
 | 
			
		||||
        protected IApiClient ApiClient { get; set; } = default!;
 | 
			
		||||
 | 
			
		||||
        [Inject]
 | 
			
		||||
        protected IDialogService DialogService { get; set; } = default!;
 | 
			
		||||
        protected IDialogWorkflow DialogWorkflow { get; set; } = default!;
 | 
			
		||||
 | 
			
		||||
        [CascadingParameter]
 | 
			
		||||
        public MudDialogInstance MudDialog { get; set; } = default!;
 | 
			
		||||
        private IMudDialogInstance MudDialog { get; set; } = default!;
 | 
			
		||||
 | 
			
		||||
        [Parameter]
 | 
			
		||||
        public IEnumerable<string> Hashes { get; set; } = [];
 | 
			
		||||
@@ -106,7 +106,7 @@ namespace Lantean.QBTMud.Components.Dialogs
 | 
			
		||||
 | 
			
		||||
        protected async Task AddCategory()
 | 
			
		||||
        {
 | 
			
		||||
            var addedCategoy = await DialogService.InvokeAddCategoryDialog(ApiClient);
 | 
			
		||||
            var addedCategoy = await DialogWorkflow.InvokeAddCategoryDialog();
 | 
			
		||||
            if (addedCategoy is null)
 | 
			
		||||
            {
 | 
			
		||||
                return;
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
<MudDialog>
 | 
			
		||||
<MudDialog>
 | 
			
		||||
    <DialogContent>
 | 
			
		||||
        <MudGrid>
 | 
			
		||||
            <MudItem xs="12">
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
using Lantean.QBitTorrentClient;
 | 
			
		||||
using Lantean.QBitTorrentClient;
 | 
			
		||||
using Lantean.QBTMud.Helpers;
 | 
			
		||||
using Microsoft.AspNetCore.Components;
 | 
			
		||||
using MudBlazor;
 | 
			
		||||
@@ -11,10 +11,10 @@ namespace Lantean.QBTMud.Components.Dialogs
 | 
			
		||||
        protected IApiClient ApiClient { get; set; } = default!;
 | 
			
		||||
 | 
			
		||||
        [Inject]
 | 
			
		||||
        protected IDialogService DialogService { get; set; } = default!;
 | 
			
		||||
        protected IDialogWorkflow DialogWorkflow { get; set; } = default!;
 | 
			
		||||
 | 
			
		||||
        [CascadingParameter]
 | 
			
		||||
        public MudDialogInstance MudDialog { get; set; } = default!;
 | 
			
		||||
        private IMudDialogInstance MudDialog { get; set; } = default!;
 | 
			
		||||
 | 
			
		||||
        [Parameter]
 | 
			
		||||
        public IEnumerable<string> Hashes { get; set; } = [];
 | 
			
		||||
@@ -102,7 +102,7 @@ namespace Lantean.QBTMud.Components.Dialogs
 | 
			
		||||
 | 
			
		||||
        protected async Task AddTag()
 | 
			
		||||
        {
 | 
			
		||||
            var addedTags = await DialogService.ShowAddTagsDialog();
 | 
			
		||||
            var addedTags = await DialogWorkflow.ShowAddTagsDialog();
 | 
			
		||||
 | 
			
		||||
            if (addedTags is null || addedTags.Count == 0)
 | 
			
		||||
            {
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
@inherits SubmittableDialog
 | 
			
		||||
@inherits SubmittableDialog
 | 
			
		||||
 | 
			
		||||
<MudDialog>
 | 
			
		||||
    <DialogContent>
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
using Lantean.QBTMud.Models;
 | 
			
		||||
using Lantean.QBTMud.Models;
 | 
			
		||||
using Microsoft.AspNetCore.Components;
 | 
			
		||||
using MudBlazor;
 | 
			
		||||
 | 
			
		||||
@@ -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!;
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
@typeparam T
 | 
			
		||||
@typeparam T
 | 
			
		||||
@inherits SubmittableDialog
 | 
			
		||||
 | 
			
		||||
<MudDialog>
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
using Lantean.QBTMud.Models;
 | 
			
		||||
using Lantean.QBTMud.Models;
 | 
			
		||||
using Microsoft.AspNetCore.Components;
 | 
			
		||||
using MudBlazor;
 | 
			
		||||
using System.Numerics;
 | 
			
		||||
@@ -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; }
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
<MudDialog>
 | 
			
		||||
<MudDialog>
 | 
			
		||||
    <DialogContent>
 | 
			
		||||
        <MudGrid>
 | 
			
		||||
            <MudItem xs="4">
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
using Blazored.LocalStorage;
 | 
			
		||||
using Blazored.LocalStorage;
 | 
			
		||||
using Lantean.QBitTorrentClient;
 | 
			
		||||
using Lantean.QBTMud.Helpers;
 | 
			
		||||
using Lantean.QBTMud.Models;
 | 
			
		||||
@@ -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);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
<MudDialog>
 | 
			
		||||
<MudDialog>
 | 
			
		||||
    <DialogContent>
 | 
			
		||||
        <MudGrid>
 | 
			
		||||
            <MudItem xs="3">
 | 
			
		||||
@@ -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>
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
using Lantean.QBitTorrentClient;
 | 
			
		||||
using Lantean.QBitTorrentClient;
 | 
			
		||||
using Lantean.QBTMud.Helpers;
 | 
			
		||||
using Microsoft.AspNetCore.Components;
 | 
			
		||||
using MudBlazor;
 | 
			
		||||
@@ -10,11 +10,14 @@ 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!;
 | 
			
		||||
 | 
			
		||||
        [Inject]
 | 
			
		||||
        protected IDialogWorkflow DialogWorkflow { get; set; } = default!;
 | 
			
		||||
 | 
			
		||||
        [Inject]
 | 
			
		||||
        protected IApiClient ApiClient { get; set; } = default!;
 | 
			
		||||
 | 
			
		||||
@@ -114,11 +117,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":
 | 
			
		||||
@@ -194,7 +197,7 @@ namespace Lantean.QBTMud.Components.Dialogs
 | 
			
		||||
 | 
			
		||||
        protected async Task AddRule()
 | 
			
		||||
        {
 | 
			
		||||
            var ruleName = await DialogService.ShowStringFieldDialog("Add Rule", "Name", null);
 | 
			
		||||
            var ruleName = await DialogWorkflow.ShowStringFieldDialog("Add Rule", "Name", null);
 | 
			
		||||
            if (ruleName is null)
 | 
			
		||||
            {
 | 
			
		||||
                return;
 | 
			
		||||
@@ -273,15 +276,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;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user