Full Code of Maciejonos/qbitwebui for AI

master 1d1e3469ce89 cached
183 files
1.2 MB
346.3k tokens
786 symbols
1 requests
Download .txt
Showing preview only (1,296K chars total). Download the full file or copy to clipboard to get everything.
Repository: Maciejonos/qbitwebui
Branch: master
Commit: 1d1e3469ce89
Files: 183
Total size: 1.2 MB

Directory structure:
gitextract_1rmw6cng/

├── .dockerignore
├── .github/
│   ├── FUNDING.yml
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug_report.md
│   │   └── feature_request.md
│   └── workflows/
│       ├── docker.yml
│       ├── docs.yml
│       └── tests.yml
├── .gitignore
├── .npmrc
├── .prettierrc
├── Dockerfile
├── LICENSE
├── README.md
├── __tests__/
│   ├── __mocks__/
│   │   └── bun-sqlite.ts
│   ├── api/
│   │   ├── auth.test.ts
│   │   ├── crossSeed.test.ts
│   │   ├── files.test.ts
│   │   ├── instances.test.ts
│   │   ├── integrations.test.ts
│   │   └── qbittorrent.test.ts
│   ├── hooks/
│   │   ├── useInstance.test.tsx
│   │   └── usePagination.test.tsx
│   ├── reporter.ts
│   ├── server/
│   │   ├── crossSeedCache.test.ts
│   │   ├── crossSeedMatcher.test.ts
│   │   ├── crossSeedScheduler.test.ts
│   │   ├── crossSeedWorker.test.ts
│   │   ├── fetch.test.ts
│   │   ├── logger.test.ts
│   │   ├── rateLimit.test.ts
│   │   └── url.test.ts
│   ├── themes/
│   │   └── themes.test.ts
│   └── utils/
│       ├── fileTree.test.ts
│       ├── format.test.ts
│       ├── pagination.test.ts
│       ├── ratioThresholds.test.ts
│       └── search.test.ts
├── docs/
│   ├── .vitepress/
│   │   ├── config.ts
│   │   └── theme/
│   │       ├── custom.css
│   │       └── index.ts
│   ├── guide/
│   │   ├── configuration.md
│   │   ├── docker.md
│   │   ├── features.md
│   │   ├── getting-started.md
│   │   └── network-agent/
│   │       └── index.md
│   └── index.md
├── eslint.config.js
├── index.html
├── net-agent/
│   ├── Dockerfile
│   ├── README.md
│   ├── go.mod
│   └── main.go
├── package.json
├── src/
│   ├── App.tsx
│   ├── api/
│   │   ├── auth.ts
│   │   ├── crossSeed.ts
│   │   ├── files.ts
│   │   ├── instances.ts
│   │   ├── integrations.ts
│   │   ├── netAgent.ts
│   │   ├── qbittorrent.ts
│   │   └── stats.ts
│   ├── components/
│   │   ├── AddTorrentModal.tsx
│   │   ├── AuthForm.tsx
│   │   ├── CategoryTagManager.tsx
│   │   ├── ContextMenu.tsx
│   │   ├── CrossSeedManager.tsx
│   │   ├── DateSettingsPopup.tsx
│   │   ├── FileBrowser.tsx
│   │   ├── FilterBar.tsx
│   │   ├── Header.tsx
│   │   ├── InstanceManager.tsx
│   │   ├── Layout.tsx
│   │   ├── LogViewer.tsx
│   │   ├── NetworkTools.tsx
│   │   ├── OrphanManager.tsx
│   │   ├── RSSManager.tsx
│   │   ├── RatioThresholdPopup.tsx
│   │   ├── SearchPanel.tsx
│   │   ├── SettingsPanel.tsx
│   │   ├── Statistics.tsx
│   │   ├── StatusBar.tsx
│   │   ├── ThemeManager.tsx
│   │   ├── ThemeSwitcher.tsx
│   │   ├── TorrentDetailsPanel.tsx
│   │   ├── TorrentList.tsx
│   │   ├── TorrentRow.tsx
│   │   ├── ViewSelector.tsx
│   │   ├── columns.ts
│   │   ├── settings/
│   │   │   ├── AdvancedTab.tsx
│   │   │   ├── BehaviorTab.tsx
│   │   │   ├── BitTorrentTab.tsx
│   │   │   ├── ConnectionTab.tsx
│   │   │   ├── DownloadsTab.tsx
│   │   │   ├── RSSTab.tsx
│   │   │   ├── SpeedTab.tsx
│   │   │   ├── WebUITab.tsx
│   │   │   └── index.ts
│   │   └── ui/
│   │       ├── Checkbox.tsx
│   │       ├── MultiSelect.tsx
│   │       ├── Select.tsx
│   │       ├── Toggle.tsx
│   │       └── index.ts
│   ├── contexts/
│   │   ├── InstanceProvider.tsx
│   │   ├── PaginationProvider.tsx
│   │   ├── ThemeContext.ts
│   │   ├── ThemeProvider.tsx
│   │   ├── instanceContext.ts
│   │   └── paginationContext.ts
│   ├── hooks/
│   │   ├── useClickOutside.ts
│   │   ├── useCrossSeed.ts
│   │   ├── useInstance.ts
│   │   ├── usePagination.ts
│   │   ├── useRSSManager.ts
│   │   ├── useStats.ts
│   │   ├── useSyncMaindata.ts
│   │   ├── useTheme.ts
│   │   ├── useTorrentDetails.ts
│   │   ├── useTorrents.ts
│   │   ├── useTransferInfo.ts
│   │   └── useUpdateCheck.ts
│   ├── index.css
│   ├── main.tsx
│   ├── mobile/
│   │   ├── MobileApp.tsx
│   │   ├── MobileCrossSeedManager.tsx
│   │   ├── MobileFileBrowser.tsx
│   │   ├── MobileInstancePicker.tsx
│   │   ├── MobileLogViewer.tsx
│   │   ├── MobileNetworkTools.tsx
│   │   ├── MobileOrphanManager.tsx
│   │   ├── MobileRSSManager.tsx
│   │   ├── MobileSearchPanel.tsx
│   │   ├── MobileStatistics.tsx
│   │   ├── MobileStats.tsx
│   │   ├── MobileThemeManager.tsx
│   │   ├── MobileThemeSwitcher.tsx
│   │   ├── MobileTools.tsx
│   │   ├── MobileTorrentDetail.tsx
│   │   └── MobileTorrentList.tsx
│   ├── server/
│   │   ├── db/
│   │   │   └── index.ts
│   │   ├── index.ts
│   │   ├── middleware/
│   │   │   └── auth.ts
│   │   ├── routes/
│   │   │   ├── auth.ts
│   │   │   ├── crossSeed.ts
│   │   │   ├── files.ts
│   │   │   ├── instances.ts
│   │   │   ├── integrations.ts
│   │   │   ├── proxy.ts
│   │   │   ├── stats.ts
│   │   │   └── tools.ts
│   │   └── utils/
│   │       ├── crossSeedCache.ts
│   │       ├── crossSeedMatcher.ts
│   │       ├── crossSeedScheduler.ts
│   │       ├── crossSeedWorker.ts
│   │       ├── crypto.ts
│   │       ├── fetch.ts
│   │       ├── logger.ts
│   │       ├── qbt.ts
│   │       ├── rateLimit.ts
│   │       ├── statsRecorder.ts
│   │       ├── torznab.ts
│   │       └── url.ts
│   ├── themes/
│   │   └── index.ts
│   ├── types/
│   │   ├── preferences.ts
│   │   ├── qbittorrent.ts
│   │   ├── rss.ts
│   │   ├── torrentDetails.ts
│   │   └── views.ts
│   └── utils/
│       ├── colorUtils.ts
│       ├── customViews.ts
│       ├── dateSettings.ts
│       ├── fileTree.ts
│       ├── format.ts
│       ├── markdown.tsx
│       ├── pagination.ts
│       ├── ratioThresholds.ts
│       └── search.ts
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
├── tsconfig.server.json
├── vite.config.ts
└── vitest.config.ts

================================================
FILE CONTENTS
================================================

================================================
FILE: .dockerignore
================================================
node_modules
dist
.git
*.md
docs


================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms

github: [Maciejonos]
buy_me_a_coffee: maciejonos


================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''

---

**Describe the bug**
A clear and concise description of what the bug is.

**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error

**Expected behavior**
A clear and concise description of what you expected to happen.

**Screenshots**
If applicable, add screenshots to help explain your problem.

**Desktop (please complete the following information):**
 - OS: [e.g. iOS]
 - Browser [e.g. chrome, safari]
 - Version [e.g. 22]

**Smartphone (please complete the following information):**
 - Device: [e.g. iPhone6]
 - OS: [e.g. iOS8.1]
 - Browser [e.g. stock browser, safari]
 - Version [e.g. 22]

**Additional context**
Add any other context about the problem here.


================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''

---

**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]

**Describe the solution you'd like**
A clear and concise description of what you want to happen.

**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.

**Additional context**
Add any other context or screenshots about the feature request here.


================================================
FILE: .github/workflows/docker.yml
================================================
name: Docker

on:
  push:
    tags: ['v*']
  workflow_dispatch:

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/metadata-action@v5
        id: meta
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=raw,value=latest

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          platforms: linux/amd64,linux/arm64
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

  agent:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/metadata-action@v5
        id: meta
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-agent
          tags: |
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=raw,value=latest

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - uses: docker/build-push-action@v6
        with:
          context: ./net-agent
          push: true
          platforms: linux/amd64,linux/arm64
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}


================================================
FILE: .github/workflows/docs.yml
================================================
name: Docs

on:
  push:
    branches: [master]
    paths:
      - 'docs/**'
      - '.github/workflows/docs.yml'
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: pages
  cancel-in-progress: false

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: oven-sh/setup-bun@v2

      - run: bun install --frozen-lockfile

      - run: bun run docs:build

      - uses: actions/configure-pages@v4

      - uses: actions/upload-pages-artifact@v3
        with:
          path: docs/.vitepress/dist

  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/deploy-pages@v4
        id: deployment


================================================
FILE: .github/workflows/tests.yml
================================================
name: CI

on:
  push:
    branches: [master]
  pull_request:
    branches: [master]

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - run: bun install --frozen-lockfile
      - run: bun run lint
      - run: bun run build
      - run: bun run test
        env:
          CI: true

  agent:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      - run: go build -o /dev/null .
        working-directory: net-agent


================================================
FILE: .gitignore
================================================
# local data
node_modules
dist
*.local
*.log
CLAUDE.md
docker-compose.yml
data/
*.env

#enforce package manager and runtime
package-lock.json
yarn.lock
pnpm-lock.yaml

# dbs
*.db
*.db-wal
*.db-shm

# tests
coverage/

# docs
docs/.vitepress/dist
docs/.vitepress/cache


================================================
FILE: .npmrc
================================================
engine-strict=true


================================================
FILE: .prettierrc
================================================
{
	"useTabs": true,
	"tabWidth": 2,
	"semi": false,
	"singleQuote": true,
	"trailingComma": "es5",
	"printWidth": 120
}


================================================
FILE: Dockerfile
================================================
FROM oven/bun:alpine AS builder
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build

FROM oven/bun:alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/src/server ./src/server
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./

ENV NODE_ENV=production
ENV PORT=3000
ENV DATABASE_PATH=/data/qbitwebui.db
ENV SALT_PATH=/data/.salt

EXPOSE 3000
VOLUME /data

CMD ["bun", "run", "src/server/index.ts"]


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2026 Maciej

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
<div align="center">
 <img width="200" height="200" alt="logo" src="https://github.com/user-attachments/assets/431cf92d-d8e6-4be7-a5b6-642ed6ab9898" />

### A modern web interface for managing multiple qBittorrent instances

Built with [React](https://react.dev/), [Hono](https://hono.dev/), and [Bun](https://bun.sh/)

[![GitHub stars](https://img.shields.io/github/stars/Maciejonos/qbitwebui?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://github.com/Maciejonos/qbitwebui/stargazers)
[![GitHub License](https://img.shields.io/github/license/Maciejonos/qbitwebui?style=for-the-badge&labelColor=101418&color=abedd5)](https://github.com/Maciejonos/qbitwebui/blob/master/LICENSE)
[![GitHub release](https://img.shields.io/github/v/release/Maciejonos/qbitwebui?style=for-the-badge&labelColor=101418&color=b9c8da)](https://github.com/Maciejonos/qbitwebui/releases)
[![Docker Build](https://img.shields.io/github/actions/workflow/status/Maciejonos/qbitwebui/docker.yml?style=for-the-badge&labelColor=101418&color=4EB329&label=build)](https://github.com/Maciejonos/qbitwebui/actions)

**[Documentation](https://maciejonos.github.io/qbitwebui/)** · **[Docker Examples](https://maciejonos.github.io/qbitwebui/guide/docker)** · **[All Features](https://maciejonos.github.io/qbitwebui/guide/features)**

</div>

<div align="center">
<img width="800" alt="main" src="https://github.com/user-attachments/assets/64ae19ea-9029-442c-97dd-958af04e21d1" />
</div>

<details>
<summary><h3>Mobile UI</h3></summary>
<div align="center">
<table>
  <tr>
   <td> <img width="295" alt="mobile" src="https://github.com/user-attachments/assets/ea14587c-1b12-46c7-afdc-def83b5e3e7c" /></td>
   <td> <img width="295" alt="mobile-detailed" src="https://github.com/user-attachments/assets/97c1ddf1-8df0-4acd-a6a1-5690badd7aa7" /></td>
  </tr>
</table>
</div>
</details>

## Features

See [features section](https://maciejonos.github.io/qbitwebui/guide/features) for more details.

- **Multi-instance** - Manage multiple qBittorrent instances from one dashboard
- **Cross seed** - Automatic cross seed directly in qbitwebui. (experimental)
- **Instance statistics** - Overview of all instances with status, speeds, torrent counts
- **Prowlarr integration** - Search indexers and send torrents directly to qBittorrent
- **Real-time monitoring** - Auto-refresh torrent status, speeds, progress
- **Customizable columns** - Show/hide columns, drag and drop reorder
- **Torrent management** - Add via magnet/file, set priorities, manage trackers/peers
- **Organization** - Filter by status, category, tag, or tracker, custom views
- **Bulk actions** - Multi-select with context menu, keyboard navigation
- **Themes** - Multiple color themes included
- **File browser** - Browse and download files from your downloads directory
- **RSS management** - Define rules, add RSS feeds, manage folders
- **Network agent** - Speedtest, IP check, DNS diagnostics - [setup instructions](https://maciejonos.github.io/qbitwebui/guide/network-agent)

## Docker

See [Docker section](https://maciejonos.github.io/qbitwebui/guide/docker) for all setup options.

```yaml
services:
  qbitwebui:
    image: ghcr.io/maciejonos/qbitwebui:latest
    ports:
      - "3000:3000"
    environment:
      # Generate your own: openssl rand -hex 32
      - ENCRYPTION_KEY=your-secret-key-here
      # Uncomment to disable login (single-user mode)
      # - DISABLE_AUTH=true
      # Uncomment to disable registration (creates default admin account)
      # - DISABLE_REGISTRATION=true
      # Uncomment to allow HTTPS with self-signed certificates
      # - ALLOW_SELF_SIGNED_CERTS=true
      # Uncomment to enable file browser
      # - DOWNLOADS_PATH=/downloads
    volumes:
      - ./data:/data
      # Uncomment to enable file browser (read-only: browse & download only)
      # - /path/to/your/downloads:/downloads:ro
      # Or mount read-write to enable delete/move/copy/rename
      # - /path/to/your/downloads:/downloads
    restart: unless-stopped
```

## Development

```bash
export ENCRYPTION_KEY=$(openssl rand -hex 32)

bun install
bun run dev
```

## Tech Stack

React 19, TypeScript, Tailwind CSS v4, Vite, TanStack Query, Hono, SQLite, Bun

## Star History

[![Star History Chart](https://api.star-history.com/svg?repos=Maciejonos/qbitwebui&type=date&legend=top-left)](https://www.star-history.com/#Maciejonos/qbitwebui&type=date&legend=top-left)
## Credits

Big thanks to [cross-seed](https://github.com/cross-seed/cross-seed). A huge chunk of Qbitwebui cross seed implementation is basically taken from cross-seed directly, or ported and slightly adjusted. Qbitwebui is of course in no way associated or endorsed by cross-seed.

I highly recommend to check cross-seed out, if you want something very reliable. 

## License

MIT


================================================
FILE: __tests__/__mocks__/bun-sqlite.ts
================================================
import { vi } from 'vitest'

export class Database {
	exec = vi.fn()
	run = vi.fn(() => ({ changes: 0, lastInsertRowid: 0 }))
	query = vi.fn(() => ({
		get: vi.fn(),
		all: vi.fn(() => []),
	}))
	close = vi.fn()
}


================================================
FILE: __tests__/api/auth.test.ts
================================================
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { register, login, logout, getMe, changePassword } from '../../src/api/auth'

// Mock fetch globally
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)

describe('auth API', () => {
    beforeEach(() => {
        mockFetch.mockReset()
    })

    afterEach(() => {
        vi.clearAllMocks()
    })

    describe('register', () => {
        it('sends correct request and returns user on success', async () => {
            const mockUser = { id: 1, username: 'testuser' }
            mockFetch.mockResolvedValueOnce({
                ok: true,
                json: () => Promise.resolve(mockUser),
            })

            const result = await register('testuser', 'password123')

            expect(mockFetch).toHaveBeenCalledWith('/api/auth/register', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                credentials: 'include',
                body: JSON.stringify({ username: 'testuser', password: 'password123' }),
            })
            expect(result).toEqual(mockUser)
        })

        it('throws error with message from response on failure', async () => {
            mockFetch.mockResolvedValueOnce({
                ok: false,
                json: () => Promise.resolve({ error: 'Username already exists' }),
            })

            await expect(register('testuser', 'password'))
                .rejects.toThrow('Username already exists')
        })

        it('throws default error when no error message in response', async () => {
            mockFetch.mockResolvedValueOnce({
                ok: false,
                json: () => Promise.resolve({}),
            })

            await expect(register('testuser', 'password'))
                .rejects.toThrow('Registration failed')
        })
    })

    describe('login', () => {
        it('sends correct request and returns user on success', async () => {
            const mockUser = { id: 1, username: 'testuser' }
            mockFetch.mockResolvedValueOnce({
                ok: true,
                json: () => Promise.resolve(mockUser),
            })

            const result = await login('testuser', 'password123')

            expect(mockFetch).toHaveBeenCalledWith('/api/auth/login', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                credentials: 'include',
                body: JSON.stringify({ username: 'testuser', password: 'password123' }),
            })
            expect(result).toEqual(mockUser)
        })

        it('throws error with message on invalid credentials', async () => {
            mockFetch.mockResolvedValueOnce({
                ok: false,
                json: () => Promise.resolve({ error: 'Invalid credentials' }),
            })

            await expect(login('testuser', 'wrongpassword'))
                .rejects.toThrow('Invalid credentials')
        })
    })

    describe('logout', () => {
        it('sends POST request to logout endpoint', async () => {
            mockFetch.mockResolvedValueOnce({ ok: true })

            await logout()

            expect(mockFetch).toHaveBeenCalledWith('/api/auth/logout', {
                method: 'POST',
                credentials: 'include',
            })
        })
    })

    describe('getMe', () => {
        it('returns user when authenticated', async () => {
            const mockUser = { id: 1, username: 'testuser' }
            mockFetch.mockResolvedValueOnce({
                ok: true,
                json: () => Promise.resolve(mockUser),
            })

            const result = await getMe()

            expect(mockFetch).toHaveBeenCalledWith('/api/auth/me', {
                credentials: 'include',
            })
            expect(result).toEqual(mockUser)
        })

        it('returns null when not authenticated', async () => {
            mockFetch.mockResolvedValueOnce({ ok: false })

            const result = await getMe()

            expect(result).toBeNull()
        })
    })

    describe('changePassword', () => {
        it('sends correct request on success', async () => {
            mockFetch.mockResolvedValueOnce({ ok: true })

            await changePassword('oldpass', 'newpass')

            expect(mockFetch).toHaveBeenCalledWith('/api/auth/password', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                credentials: 'include',
                body: JSON.stringify({ currentPassword: 'oldpass', newPassword: 'newpass' }),
            })
        })

        it('throws error when current password is wrong', async () => {
            mockFetch.mockResolvedValueOnce({
                ok: false,
                json: () => Promise.resolve({ error: 'Current password is incorrect' }),
            })

            await expect(changePassword('wrongpass', 'newpass'))
                .rejects.toThrow('Current password is incorrect')
        })
    })
})


================================================
FILE: __tests__/api/crossSeed.test.ts
================================================
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
	getCrossSeedConfig,
	updateCrossSeedConfig,
	triggerScan,
	getSchedulerStatus,
	getInstanceStatus,
	clearCache,
	getCacheStats,
	getSearchHistory,
	getDecisions,
	getIndexers,
	stopScan,
	getLogs,
} from '../../src/api/crossSeed'

describe('crossSeed API', () => {
	const mockFetch = vi.fn()
	const originalFetch = global.fetch

	beforeEach(() => {
		global.fetch = mockFetch
		mockFetch.mockReset()
	})

	afterEach(() => {
		global.fetch = originalFetch
	})

	describe('getCrossSeedConfig', () => {
		it('fetches config for instance', async () => {
			const mockConfig = {
				instance_id: 1,
				enabled: true,
				interval_hours: 24,
				dry_run: false,
				category_suffix: '_cross-seed',
				tag: 'cross-seed',
				skip_recheck: false,
				integration_id: 1,
				indexer_ids: [1, 2],
				last_run: null,
				next_run: null,
			}

			mockFetch.mockResolvedValueOnce({
				ok: true,
				json: () => Promise.resolve(mockConfig),
			})

			const result = await getCrossSeedConfig(1)

			expect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/config/1', { credentials: 'include' })
			expect(result).toEqual(mockConfig)
		})

		it('throws on error response', async () => {
			mockFetch.mockResolvedValueOnce({
				ok: false,
			})

			await expect(getCrossSeedConfig(1)).rejects.toThrow('Failed to fetch cross-seed config')
		})
	})

	describe('updateCrossSeedConfig', () => {
		it('updates config successfully', async () => {
			mockFetch.mockResolvedValueOnce({
				ok: true,
				json: () => Promise.resolve({ success: true }),
			})

			await updateCrossSeedConfig(1, { enabled: true, interval_hours: 12, indexer_ids: [1, 2] })

			expect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/config/1', {
				method: 'PUT',
				headers: { 'Content-Type': 'application/json' },
				credentials: 'include',
				body: JSON.stringify({ enabled: true, interval_hours: 12, indexer_ids: [1, 2] }),
			})
		})

		it('throws on error with message', async () => {
			mockFetch.mockResolvedValueOnce({
				ok: false,
				json: () => Promise.resolve({ error: 'Invalid config' }),
			})

			await expect(updateCrossSeedConfig(1, {})).rejects.toThrow('Invalid config')
		})
	})

	describe('triggerScan', () => {
		it('triggers scan without force', async () => {
			const mockResult = {
				instanceId: 1,
				torrentsTotal: 150,
				torrentsScanned: 100,
				torrentsSkipped: 50,
				matchesFound: 5,
				torrentsAdded: 3,
				errors: [],
				dryRun: false,
				startedAt: 1704067200000,
				completedAt: 1704067260000,
			}

			mockFetch.mockResolvedValueOnce({
				ok: true,
				json: () => Promise.resolve(mockResult),
			})

			const result = await triggerScan(1)

			expect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/scan/1', {
				method: 'POST',
				headers: { 'Content-Type': 'application/json' },
				credentials: 'include',
				body: JSON.stringify({ force: false }),
			})
			expect(result).toEqual(mockResult)
		})

		it('triggers force scan', async () => {
			mockFetch.mockResolvedValueOnce({
				ok: true,
				json: () => Promise.resolve({}),
			})

			await triggerScan(1, true)

			expect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/scan/1', {
				method: 'POST',
				headers: { 'Content-Type': 'application/json' },
				credentials: 'include',
				body: JSON.stringify({ force: true }),
			})
		})
	})

	describe('getSchedulerStatus', () => {
		it('fetches all scheduler statuses', async () => {
			const mockStatuses = [
				{ instanceId: 1, enabled: true, running: false },
				{ instanceId: 2, enabled: false, running: false },
			]

			mockFetch.mockResolvedValueOnce({
				ok: true,
				json: () => Promise.resolve(mockStatuses),
			})

			const result = await getSchedulerStatus()

			expect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/status', { credentials: 'include' })
			expect(result).toEqual(mockStatuses)
		})
	})

	describe('getIndexers', () => {
		it('fetches indexers for instance', async () => {
			const mockIndexers = [{ id: 1, name: 'Indexer A', protocol: 'torrent', supportsSearch: true, categories: [2000] }]

			mockFetch.mockResolvedValueOnce({
				ok: true,
				json: () => Promise.resolve(mockIndexers),
			})

			const result = await getIndexers(1)

			expect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/indexers/1', { credentials: 'include' })
			expect(result).toEqual(mockIndexers)
		})

		it('throws on error response', async () => {
			mockFetch.mockResolvedValueOnce({
				ok: false,
				json: () => Promise.resolve({ error: 'No Prowlarr integration configured' }),
			})

			await expect(getIndexers(1)).rejects.toThrow('No Prowlarr integration configured')
		})
	})

	describe('getInstanceStatus', () => {
		it('fetches status for specific instance', async () => {
			const mockStatus = {
				instanceId: 1,
				enabled: true,
				running: true,
				lastRun: 1704067200,
				nextRun: 1704153600,
			}

			mockFetch.mockResolvedValueOnce({
				ok: true,
				json: () => Promise.resolve(mockStatus),
			})

			const result = await getInstanceStatus(1)

			expect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/status/1', { credentials: 'include' })
			expect(result).toEqual(mockStatus)
		})
	})

	describe('clearCache', () => {
		it('clears cache for instance', async () => {
			mockFetch.mockResolvedValueOnce({
				ok: true,
				json: () => Promise.resolve({ cacheCleared: 10, outputCleared: 5 }),
			})

			const result = await clearCache(1)

			expect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/cache/1/clear', {
				method: 'POST',
				credentials: 'include',
			})
			expect(result.cacheCleared).toBe(10)
			expect(result.outputCleared).toBe(5)
		})
	})

	describe('getCacheStats', () => {
		it('fetches cache statistics', async () => {
			const mockStats = {
				cache: { count: 50, totalSize: 1024000 },
				output: { count: 10, files: ['file1.torrent', 'file2.torrent'] },
			}

			mockFetch.mockResolvedValueOnce({
				ok: true,
				json: () => Promise.resolve(mockStats),
			})

			const result = await getCacheStats(1)

			expect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/cache/1/stats', { credentials: 'include' })
			expect(result).toEqual(mockStats)
		})
	})

	describe('getSearchHistory', () => {
		it('fetches search history with pagination', async () => {
			const mockHistory = {
				searchees: [
					{ id: 1, torrent_name: 'Movie 1', decision_count: 5 },
					{ id: 2, torrent_name: 'Movie 2', decision_count: 3 },
				],
				total: 100,
			}

			mockFetch.mockResolvedValueOnce({
				ok: true,
				json: () => Promise.resolve(mockHistory),
			})

			const result = await getSearchHistory(1, 50, 10)

			expect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/history/1?limit=50&offset=10', {
				credentials: 'include',
			})
			expect(result.total).toBe(100)
		})

		it('uses default pagination', async () => {
			mockFetch.mockResolvedValueOnce({
				ok: true,
				json: () => Promise.resolve({ searchees: [], total: 0 }),
			})

			await getSearchHistory(1)

			expect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/history/1?limit=100&offset=0', {
				credentials: 'include',
			})
		})
	})

	describe('getDecisions', () => {
		it('fetches decisions for searchee', async () => {
			const mockDecisions = [
				{ id: 1, decision: 'MATCH', candidate_name: 'Match 1' },
				{ id: 2, decision: 'SIZE_MISMATCH', candidate_name: 'No Match' },
			]

			mockFetch.mockResolvedValueOnce({
				ok: true,
				json: () => Promise.resolve(mockDecisions),
			})

			const result = await getDecisions(1, 5)

			expect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/history/1/5/decisions', {
				credentials: 'include',
			})
			expect(result).toEqual(mockDecisions)
		})
	})

	describe('stopScan', () => {
		it('stops scan for instance', async () => {
			mockFetch.mockResolvedValueOnce({
				ok: true,
				json: () => Promise.resolve({ stopped: true }),
			})

			const result = await stopScan(1)

			expect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/stop/1', {
				method: 'POST',
				credentials: 'include',
			})
			expect(result.stopped).toBe(true)
		})

		it('throws on error response', async () => {
			mockFetch.mockResolvedValueOnce({
				ok: false,
				json: () => Promise.resolve({ error: 'No scan running' }),
			})

			await expect(stopScan(1)).rejects.toThrow('No scan running')
		})
	})

	describe('getLogs', () => {
		it('fetches logs with limit', async () => {
			const mockLogs = [{ timestamp: '2024-01-01T00:00:00.000Z', level: 'INFO', message: 'Test' }]

			mockFetch.mockResolvedValueOnce({
				ok: true,
				json: () => Promise.resolve(mockLogs),
			})

			const result = await getLogs(200)

			expect(mockFetch).toHaveBeenCalledWith('/api/cross-seed/logs?limit=200', { credentials: 'include' })
			expect(result).toEqual(mockLogs)
		})
	})
})


================================================
FILE: __tests__/api/files.test.ts
================================================
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
    listFiles,
    getDownloadUrl,
    checkWritable,
    deleteFiles,
    moveFiles,
    copyFiles,
    renameFile,
} from '../../src/api/files'

const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)

describe('files API', () => {
    beforeEach(() => {
        mockFetch.mockReset()
    })

    describe('listFiles', () => {
        it('fetches files with encoded path', async () => {
            const mockFiles = [
                { name: 'file1.txt', size: 1024, isDirectory: false, modified: 1234567890 },
                { name: 'folder', size: 0, isDirectory: true, modified: 1234567900 },
            ]
            mockFetch.mockResolvedValueOnce({
                ok: true,
                json: () => Promise.resolve(mockFiles),
            })

            const result = await listFiles('/downloads/movies')

            expect(mockFetch).toHaveBeenCalledWith(
                '/api/files?path=%2Fdownloads%2Fmovies',
                { credentials: 'include' }
            )
            expect(result).toEqual(mockFiles)
        })

        it('handles special characters in path', async () => {
            mockFetch.mockResolvedValueOnce({
                ok: true,
                json: () => Promise.resolve([]),
            })

            await listFiles('/downloads/My Movies & Shows')

            expect(mockFetch).toHaveBeenCalledWith(
                expect.stringContaining('My%20Movies%20%26%20Shows'),
                expect.anything()
            )
        })

        it('throws error with message from server', async () => {
            mockFetch.mockResolvedValueOnce({
                ok: false,
                json: () => Promise.resolve({ error: 'Path not found' }),
            })

            await expect(listFiles('/nonexistent')).rejects.toThrow('Path not found')
        })
    })

    describe('getDownloadUrl', () => {
        it('returns encoded download URL', () => {
            const url = getDownloadUrl('/downloads/movie.mkv')
            expect(url).toBe('/api/files/download?path=%2Fdownloads%2Fmovie.mkv')
        })

        it('handles special characters', () => {
            const url = getDownloadUrl('/downloads/Movie (2024) [1080p].mkv')
            expect(url).toContain('Movie%20')
            expect(url).toContain('%5B1080p%5D')
        })
    })

    describe('checkWritable', () => {
        it('returns true when writable', async () => {
            mockFetch.mockResolvedValueOnce({
                ok: true,
                json: () => Promise.resolve({ writable: true }),
            })

            const result = await checkWritable()

            expect(result).toBe(true)
        })

        it('returns false when not writable', async () => {
            mockFetch.mockResolvedValueOnce({
                ok: true,
                json: () => Promise.resolve({ writable: false }),
            })

            const result = await checkWritable()

            expect(result).toBe(false)
        })

        it('returns false on request failure', async () => {
            mockFetch.mockResolvedValueOnce({ ok: false })

            const result = await checkWritable()

            expect(result).toBe(false)
        })
    })

    describe('deleteFiles', () => {
        it('sends delete request with paths', async () => {
            mockFetch.mockResolvedValueOnce({ ok: true })

            await deleteFiles(['/downloads/file1.txt', '/downloads/file2.txt'])

            expect(mockFetch).toHaveBeenCalledWith('/api/files/delete', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                credentials: 'include',
                body: JSON.stringify({ paths: ['/downloads/file1.txt', '/downloads/file2.txt'] }),
            })
        })

        it('throws error on failure', async () => {
            mockFetch.mockResolvedValueOnce({
                ok: false,
                json: () => Promise.resolve({ error: 'Permission denied' }),
            })

            await expect(deleteFiles(['/protected/file'])).rejects.toThrow('Permission denied')
        })
    })

    describe('moveFiles', () => {
        it('sends move request with paths and destination', async () => {
            mockFetch.mockResolvedValueOnce({ ok: true })

            await moveFiles(['/downloads/file.txt'], '/archive')

            expect(mockFetch).toHaveBeenCalledWith('/api/files/move', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                credentials: 'include',
                body: JSON.stringify({ paths: ['/downloads/file.txt'], destination: '/archive' }),
            })
        })

        it('throws error on move failure', async () => {
            mockFetch.mockResolvedValueOnce({
                ok: false,
                json: () => Promise.resolve({ error: 'Destination not found' }),
            })

            await expect(moveFiles(['/file'], '/nonexistent')).rejects.toThrow('Destination not found')
        })
    })

    describe('copyFiles', () => {
        it('sends copy request with paths and destination', async () => {
            mockFetch.mockResolvedValueOnce({ ok: true })

            await copyFiles(['/downloads/file.txt'], '/backup')

            expect(mockFetch).toHaveBeenCalledWith('/api/files/copy', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                credentials: 'include',
                body: JSON.stringify({ paths: ['/downloads/file.txt'], destination: '/backup' }),
            })
        })
    })

    describe('renameFile', () => {
        it('sends rename request', async () => {
            mockFetch.mockResolvedValueOnce({ ok: true })

            await renameFile('/downloads/old.txt', 'new.txt')

            expect(mockFetch).toHaveBeenCalledWith('/api/files/rename', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                credentials: 'include',
                body: JSON.stringify({ path: '/downloads/old.txt', newName: 'new.txt' }),
            })
        })

        it('throws error on rename failure', async () => {
            mockFetch.mockResolvedValueOnce({
                ok: false,
                json: () => Promise.resolve({ error: 'File already exists' }),
            })

            await expect(renameFile('/file', 'existing')).rejects.toThrow('File already exists')
        })
    })
})


================================================
FILE: __tests__/api/instances.test.ts
================================================
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
    getInstances,
    createInstance,
    updateInstance,
    deleteInstance,
    type CreateInstanceData,
} from '../../src/api/instances'

const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)

describe('instances API', () => {
    beforeEach(() => {
        mockFetch.mockReset()
    })

    describe('getInstances', () => {
        it('fetches and returns instance list', async () => {
            const mockInstances = [
                { id: 1, label: 'Home', url: 'http://localhost:8080', qbt_username: 'admin', skip_auth: false, created_at: 1234567890 },
                { id: 2, label: 'Server', url: 'http://192.168.1.100:8080', qbt_username: null, skip_auth: true, created_at: 1234567900 },
            ]
            mockFetch.mockResolvedValueOnce({
                ok: true,
                json: () => Promise.resolve(mockInstances),
            })

            const result = await getInstances()

            expect(mockFetch).toHaveBeenCalledWith('/api/instances', {
                credentials: 'include',
            })
            expect(result).toEqual(mockInstances)
            expect(result).toHaveLength(2)
        })

        it('throws error on failure', async () => {
            mockFetch.mockResolvedValueOnce({ ok: false })

            await expect(getInstances()).rejects.toThrow('Failed to fetch instances')
        })
    })

    describe('createInstance', () => {
        it('creates instance with all fields', async () => {
            const createData: CreateInstanceData = {
                label: 'New Instance',
                url: 'http://localhost:9090',
                qbt_username: 'admin',
                qbt_password: 'secret',
                skip_auth: false,
            }
            const mockInstance = {
                id: 3,
                ...createData,
                qbt_password: undefined,
                created_at: Date.now(),
            }
            mockFetch.mockResolvedValueOnce({
                ok: true,
                json: () => Promise.resolve(mockInstance),
            })

            const result = await createInstance(createData)

            expect(mockFetch).toHaveBeenCalledWith('/api/instances', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                credentials: 'include',
                body: JSON.stringify(createData),
            })
            expect(result.id).toBe(3)
            expect(result.label).toBe('New Instance')
        })

        it('creates instance with minimal fields', async () => {
            const createData: CreateInstanceData = {
                label: 'Minimal',
                url: 'http://localhost:8080',
            }
            mockFetch.mockResolvedValueOnce({
                ok: true,
                json: () => Promise.resolve({ id: 1, ...createData, skip_auth: false, created_at: 0 }),
            })

            await createInstance(createData)

            expect(mockFetch).toHaveBeenCalled()
        })

        it('throws error with message from server', async () => {
            mockFetch.mockResolvedValueOnce({
                ok: false,
                json: () => Promise.resolve({ error: 'Instance with this label already exists' }),
            })

            await expect(createInstance({ label: 'Dup', url: 'http://test' }))
                .rejects.toThrow('Instance with this label already exists')
        })
    })

    describe('updateInstance', () => {
        it('updates instance with partial data', async () => {
            const updateData = { label: 'Updated Label' }
            const mockUpdated = {
                id: 1,
                label: 'Updated Label',
                url: 'http://localhost:8080',
                qbt_username: 'admin',
                skip_auth: false,
                created_at: 1234567890,
            }
            mockFetch.mockResolvedValueOnce({
                ok: true,
                json: () => Promise.resolve(mockUpdated),
            })

            const result = await updateInstance(1, updateData)

            expect(mockFetch).toHaveBeenCalledWith('/api/instances/1', {
                method: 'PUT',
                headers: { 'Content-Type': 'application/json' },
                credentials: 'include',
                body: JSON.stringify(updateData),
            })
            expect(result.label).toBe('Updated Label')
        })

        it('throws error on update failure', async () => {
            mockFetch.mockResolvedValueOnce({
                ok: false,
                json: () => Promise.resolve({ error: 'Instance not found' }),
            })

            await expect(updateInstance(999, { label: 'Test' }))
                .rejects.toThrow('Instance not found')
        })
    })

    describe('deleteInstance', () => {
        it('deletes instance by id', async () => {
            mockFetch.mockResolvedValueOnce({ ok: true })

            await deleteInstance(5)

            expect(mockFetch).toHaveBeenCalledWith('/api/instances/5', {
                method: 'DELETE',
                credentials: 'include',
            })
        })

        it('throws error on deletion failure', async () => {
            mockFetch.mockResolvedValueOnce({ ok: false })

            await expect(deleteInstance(999)).rejects.toThrow('Failed to delete instance')
        })
    })
})


================================================
FILE: __tests__/api/integrations.test.ts
================================================
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
    getIntegrations,
    createIntegration,
    deleteIntegration,
    testIntegrationConnection,
    getIndexers,
    search,
    grabRelease,
} from '../../src/api/integrations'

const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)

describe('integrations API', () => {
    beforeEach(() => {
        mockFetch.mockReset()
    })

    describe('getIntegrations', () => {
        it('fetches integrations list', async () => {
            const mockIntegrations = [
                { id: 1, type: 'prowlarr', label: 'My Prowlarr', url: 'http://localhost:9696', created_at: 123456 },
            ]
            mockFetch.mockResolvedValueOnce({
                ok: true,
                json: () => Promise.resolve(mockIntegrations),
            })

            const result = await getIntegrations()

            expect(mockFetch).toHaveBeenCalledWith('/api/integrations', { credentials: 'include' })
            expect(result).toEqual(mockIntegrations)
        })

        it('throws on failure', async () => {
            mockFetch.mockResolvedValueOnce({ ok: false })

            await expect(getIntegrations()).rejects.toThrow('Failed to fetch integrations')
        })
    })

    describe('createIntegration', () => {
        it('creates integration with data', async () => {
            const createData = {
                type: 'prowlarr',
                label: 'My Prowlarr',
                url: 'http://localhost:9696',
                api_key: 'secret123',
            }
            const mockResult = { id: 1, ...createData, created_at: 123456 }
            mockFetch.mockResolvedValueOnce({
                ok: true,
                json: () => Promise.resolve(mockResult),
            })

            const result = await createIntegration(createData)

            expect(mockFetch).toHaveBeenCalledWith('/api/integrations', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                credentials: 'include',
                body: JSON.stringify(createData),
            })
            expect(result.id).toBe(1)
        })

        it('throws error with message from server', async () => {
            mockFetch.mockResolvedValueOnce({
                ok: false,
                json: () => Promise.resolve({ error: 'Invalid API key' }),
            })

            await expect(createIntegration({
                type: 'prowlarr',
                label: 'Test',
                url: 'http://test',
                api_key: 'invalid',
            })).rejects.toThrow('Invalid API key')
        })
    })

    describe('deleteIntegration', () => {
        it('deletes integration by id', async () => {
            mockFetch.mockResolvedValueOnce({ ok: true })

            await deleteIntegration(5)

            expect(mockFetch).toHaveBeenCalledWith('/api/integrations/5', {
                method: 'DELETE',
                credentials: 'include',
            })
        })

        it('throws on failure', async () => {
            mockFetch.mockResolvedValueOnce({ ok: false })

            await expect(deleteIntegration(999)).rejects.toThrow('Failed to delete integration')
        })
    })

    describe('testIntegrationConnection', () => {
        it('returns success with version', async () => {
            mockFetch.mockResolvedValueOnce({
                ok: true,
                json: () => Promise.resolve({ success: true, version: '1.0.0' }),
            })

            const result = await testIntegrationConnection('http://localhost:9696', 'apikey123')

            expect(mockFetch).toHaveBeenCalledWith('/api/integrations/test', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                credentials: 'include',
                body: JSON.stringify({ url: 'http://localhost:9696', api_key: 'apikey123' }),
            })
            expect(result.success).toBe(true)
            expect(result.version).toBe('1.0.0')
        })

        it('returns failure with error', async () => {
            mockFetch.mockResolvedValueOnce({
                ok: true,
                json: () => Promise.resolve({ success: false, error: 'Connection refused' }),
            })

            const result = await testIntegrationConnection('http://invalid', 'apikey')

            expect(result.success).toBe(false)
            expect(result.error).toBe('Connection refused')
        })
    })

    describe('getIndexers', () => {
        it('fetches indexers for integration', async () => {
            const mockIndexers = [
                { id: 1, name: 'Indexer 1', enable: true, protocol: 'torrent' },
                { id: 2, name: 'Indexer 2', enable: false, protocol: 'usenet' },
            ]
            mockFetch.mockResolvedValueOnce({
                ok: true,
                json: () => Promise.resolve(mockIndexers),
            })

            const result = await getIndexers(1)

            expect(mockFetch).toHaveBeenCalledWith('/api/integrations/1/indexers', { credentials: 'include' })
            expect(result).toHaveLength(2)
        })

        it('throws on failure', async () => {
            mockFetch.mockResolvedValueOnce({ ok: false })

            await expect(getIndexers(1)).rejects.toThrow('Failed to fetch indexers')
        })
    })

    describe('search', () => {
        it('searches with query only', async () => {
            const mockResults = [
                { guid: '123', indexerId: 1, indexer: 'Test', title: 'Result', size: 1000, publishDate: '2024-01-01', categories: [] },
            ]
            mockFetch.mockResolvedValueOnce({
                ok: true,
                json: () => Promise.resolve(mockResults),
            })

            const result = await search(1, 'test query')

            expect(mockFetch).toHaveBeenCalledWith(
                expect.stringContaining('/api/integrations/1/search?query=test+query'),
                expect.anything()
            )
            expect(result).toEqual(mockResults)
        })

        it('searches with options', async () => {
            mockFetch.mockResolvedValueOnce({
                ok: true,
                json: () => Promise.resolve([]),
            })

            await search(1, 'test', { indexerIds: '1,2', categories: '5000', type: 'movie' })

            expect(mockFetch).toHaveBeenCalledWith(
                expect.stringContaining('indexerIds=1%2C2'),
                expect.anything()
            )
        })

        it('throws error with message from server', async () => {
            mockFetch.mockResolvedValueOnce({
                ok: false,
                json: () => Promise.resolve({ error: 'Rate limited' }),
            })

            await expect(search(1, 'test')).rejects.toThrow('Rate limited')
        })
    })

    describe('grabRelease', () => {
        it('grabs release with download URL', async () => {
            mockFetch.mockResolvedValueOnce({ ok: true })

            await grabRelease(1, {
                guid: 'abc123',
                indexerId: 1,
                downloadUrl: 'http://example.com/download',
            }, 5, { savepath: '/downloads/complete', downloadPath: '/downloads/incomplete' })

            expect(mockFetch).toHaveBeenCalledWith('/api/integrations/1/grab', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                credentials: 'include',
                body: expect.stringMatching(/"instanceId":5.*"savepath":"\/downloads\/complete".*"downloadPath":"\/downloads\/incomplete"|"instanceId":5.*"downloadPath":"\/downloads\/incomplete".*"savepath":"\/downloads\/complete"/),
            })
        })

        it('grabs release with magnet URL', async () => {
            mockFetch.mockResolvedValueOnce({ ok: true })

            await grabRelease(1, {
                guid: 'abc123',
                indexerId: 1,
                magnetUrl: 'magnet:?xt=urn:btih:abc123',
            }, 5)

            expect(mockFetch).toHaveBeenCalled()
        })

        it('throws error on failure', async () => {
            mockFetch.mockResolvedValueOnce({
                ok: false,
                json: () => Promise.resolve({ error: 'Indexer offline' }),
            })

            await expect(grabRelease(1, { guid: '123', indexerId: 1 }, 5))
                .rejects.toThrow('Indexer offline')
        })
    })
})


================================================
FILE: __tests__/api/qbittorrent.test.ts
================================================
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
    getTorrents,
    getTransferInfo,
    getSyncMaindata,
    stopTorrents,
    startTorrents,
    deleteTorrents,
    getCategories,
    getTags,
    createTags,
    deleteTags,
    setCategory,
    addTags,
    removeTags,
    getTorrentProperties,
    getTorrentTrackers,
    getTorrentFiles,
    renameTorrent,
    setTorrentLocation,
    setTorrentDownloadPath,
    addTrackers,
    removeTrackers,
    getPreferences,
    getLog,
    getPeerLog,
    getSpeedLimitsMode,
    toggleSpeedLimitsMode,
    createCategory,
    editCategory,
    removeCategories,
    setFilePriority,
    getRSSItems,
    getRSSRules,
} from '../../src/api/qbittorrent'

const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)

describe('qBittorrent API', () => {
    const instanceId = 1

    beforeEach(() => {
        mockFetch.mockReset()
    })

    // Helper to create successful JSON response
    const jsonResponse = (data: unknown) => ({
        ok: true,
        text: () => Promise.resolve(JSON.stringify(data)),
    })

    describe('getTorrents', () => {
        it('fetches torrents without filters', async () => {
            const mockTorrents = [{ hash: 'abc123', name: 'Test Torrent' }]
            mockFetch.mockResolvedValueOnce(jsonResponse(mockTorrents))

            const result = await getTorrents(instanceId)

            expect(mockFetch).toHaveBeenCalledWith(
                '/api/instances/1/qbt/v2/torrents/info',
                expect.objectContaining({ credentials: 'include' })
            )
            expect(result).toEqual(mockTorrents)
        })

        it('fetches torrents with filter options', async () => {
            mockFetch.mockResolvedValueOnce(jsonResponse([]))

            await getTorrents(instanceId, { filter: 'downloading', category: 'movies', tag: 'hd' })

            expect(mockFetch).toHaveBeenCalledWith(
                expect.stringContaining('filter=downloading'),
                expect.anything()
            )
            expect(mockFetch).toHaveBeenCalledWith(
                expect.stringContaining('category=movies'),
                expect.anything()
            )
            expect(mockFetch).toHaveBeenCalledWith(
                expect.stringContaining('tag=hd'),
                expect.anything()
            )
        })

        it('skips filter=all in request', async () => {
            mockFetch.mockResolvedValueOnce(jsonResponse([]))

            await getTorrents(instanceId, { filter: 'all' })

            expect(mockFetch).toHaveBeenCalledWith(
                '/api/instances/1/qbt/v2/torrents/info',
                expect.anything()
            )
        })
    })

    describe('getTransferInfo', () => {
        it('fetches transfer info', async () => {
            const mockInfo = { dl_info_speed: 1024, up_info_speed: 512 }
            mockFetch.mockResolvedValueOnce(jsonResponse(mockInfo))

            const result = await getTransferInfo(instanceId)

            expect(mockFetch).toHaveBeenCalledWith(
                '/api/instances/1/qbt/v2/transfer/info',
                expect.anything()
            )
            expect(result).toEqual(mockInfo)
        })
    })

    describe('getSyncMaindata', () => {
        it('fetches sync maindata', async () => {
            const mockData = { rid: 1, torrents: {} }
            mockFetch.mockResolvedValueOnce(jsonResponse(mockData))

            const result = await getSyncMaindata(instanceId)

            expect(mockFetch).toHaveBeenCalledWith(
                '/api/instances/1/qbt/v2/sync/maindata?rid=0',
                expect.anything()
            )
            expect(result).toEqual(mockData)
        })
    })

    describe('torrent actions', () => {
        it('stops torrents', async () => {
            mockFetch.mockResolvedValueOnce(jsonResponse({}))

            await stopTorrents(instanceId, ['hash1', 'hash2'])

            expect(mockFetch).toHaveBeenCalledWith(
                '/api/instances/1/qbt/v2/torrents/stop',
                expect.objectContaining({
                    method: 'POST',
                    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                })
            )
        })

        it('starts torrents', async () => {
            mockFetch.mockResolvedValueOnce(jsonResponse({}))

            await startTorrents(instanceId, ['hash1'])

            expect(mockFetch).toHaveBeenCalledWith(
                '/api/instances/1/qbt/v2/torrents/start',
                expect.anything()
            )
        })

        it('deletes torrents without files', async () => {
            mockFetch.mockResolvedValueOnce(jsonResponse({}))

            await deleteTorrents(instanceId, ['hash1'], false)

            const call = mockFetch.mock.calls[0]
            expect(call[0]).toBe('/api/instances/1/qbt/v2/torrents/delete')
            expect(call[1].body.get('deleteFiles')).toBe('false')
        })

        it('deletes torrents with files', async () => {
            mockFetch.mockResolvedValueOnce(jsonResponse({}))

            await deleteTorrents(instanceId, ['hash1'], true)

            const call = mockFetch.mock.calls[0]
            expect(call[1].body.get('deleteFiles')).toBe('true')
        })

	it('changes torrent save path', async () => {
            mockFetch.mockResolvedValueOnce(jsonResponse({}))

            await setTorrentLocation(instanceId, ['hash1', 'hash2'], '/downloads/new-path')

            const call = mockFetch.mock.calls[0]
            expect(call[0]).toBe('/api/instances/1/qbt/v2/torrents/setLocation')
            expect(call[1].body.get('hashes')).toBe('hash1|hash2')
            expect(call[1].body.get('location')).toBe('/downloads/new-path')
        })

        it('changes torrent download path', async () => {
            mockFetch.mockResolvedValueOnce(jsonResponse({}))

            await setTorrentDownloadPath(instanceId, ['hash1'], '/downloads/incomplete')

            const call = mockFetch.mock.calls[0]
            expect(call[0]).toBe('/api/instances/1/qbt/v2/torrents/setDownloadPath')
            expect(call[1].body.get('hashes')).toBe('hash1')
            expect(call[1].body.get('downloadPath')).toBe('/downloads/incomplete')
        })
    })

    describe('categories', () => {
        it('gets categories', async () => {
            const mockCategories = { movies: { name: 'movies', savePath: '/downloads/movies' } }
            mockFetch.mockResolvedValueOnce(jsonResponse(mockCategories))

            const result = await getCategories(instanceId)

            expect(result).toEqual(mockCategories)
        })

        it('creates category with save path', async () => {
            mockFetch.mockResolvedValueOnce(jsonResponse({}))

            await createCategory(instanceId, 'movies', '/downloads/movies')

            expect(mockFetch).toHaveBeenCalledWith(
                '/api/instances/1/qbt/v2/torrents/createCategory',
                expect.anything()
            )
        })

        it('edits category', async () => {
            mockFetch.mockResolvedValueOnce(jsonResponse({}))

            await editCategory(instanceId, 'movies', '/new/path')

            expect(mockFetch).toHaveBeenCalledWith(
                '/api/instances/1/qbt/v2/torrents/editCategory',
                expect.anything()
            )
        })

        it('removes categories', async () => {
            mockFetch.mockResolvedValueOnce(jsonResponse({}))

            await removeCategories(instanceId, ['movies', 'tv'])

            expect(mockFetch).toHaveBeenCalledWith(
                '/api/instances/1/qbt/v2/torrents/removeCategories',
                expect.anything()
            )
        })

        it('sets category on torrents', async () => {
            mockFetch.mockResolvedValueOnce(jsonResponse({}))

            await setCategory(instanceId, ['hash1', 'hash2'], 'movies')

            expect(mockFetch).toHaveBeenCalledWith(
                '/api/instances/1/qbt/v2/torrents/setCategory',
                expect.anything()
            )
        })
    })

    describe('tags', () => {
        it('gets tags', async () => {
            mockFetch.mockResolvedValueOnce(jsonResponse(['tag1', 'tag2']))

            const result = await getTags(instanceId)

            expect(result).toEqual(['tag1', 'tag2'])
        })

        it('creates tags', async () => {
            mockFetch.mockResolvedValueOnce(jsonResponse({}))

            await createTags(instanceId, 'newtag')

            expect(mockFetch).toHaveBeenCalledWith(
                '/api/instances/1/qbt/v2/torrents/createTags',
                expect.anything()
            )
        })

        it('deletes tags', async () => {
            mockFetch.mockResolvedValueOnce(jsonResponse({}))

            await deleteTags(instanceId, 'oldtag')

            expect(mockFetch).toHaveBeenCalledWith(
                '/api/instances/1/qbt/v2/torrents/deleteTags',
                expect.anything()
            )
        })

        it('adds tags to torrents', async () => {
            mockFetch.mockResolvedValueOnce(jsonResponse({}))

            await addTags(instanceId, ['hash1'], 'tag1,tag2')

            expect(mockFetch).toHaveBeenCalledWith(
                '/api/instances/1/qbt/v2/torrents/addTags',
                expect.anything()
            )
        })

        it('removes tags from torrents', async () => {
            mockFetch.mockResolvedValueOnce(jsonResponse({}))

            await removeTags(instanceId, ['hash1'], 'tag1')

            expect(mockFetch).toHaveBeenCalledWith(
                '/api/instances/1/qbt/v2/torrents/removeTags',
                expect.anything()
            )
        })
    })

    describe('torrent details', () => {
        it('gets torrent properties', async () => {
            const mockProps = { save_path: '/downloads', total_size: 1000000 }
            mockFetch.mockResolvedValueOnce(jsonResponse(mockProps))

            const result = await getTorrentProperties(instanceId, 'abc123')

            expect(mockFetch).toHaveBeenCalledWith(
                '/api/instances/1/qbt/v2/torrents/properties?hash=abc123',
                expect.anything()
            )
            expect(result).toEqual(mockProps)
        })

        it('gets torrent trackers', async () => {
            const mockTrackers = [{ url: 'http://tracker.example.com', status: 2 }]
            mockFetch.mockResolvedValueOnce(jsonResponse(mockTrackers))

            const result = await getTorrentTrackers(instanceId, 'abc123')

            expect(result).toEqual(mockTrackers)
        })

        it('gets torrent files', async () => {
            const mockFiles = [{ name: 'file.mkv', size: 1000000 }]
            mockFetch.mockResolvedValueOnce(jsonResponse(mockFiles))

            const result = await getTorrentFiles(instanceId, 'abc123')

            expect(result).toEqual(mockFiles)
        })
    })

    describe('torrent management', () => {
        it('renames torrent', async () => {
            mockFetch.mockResolvedValueOnce(jsonResponse({}))

            await renameTorrent(instanceId, 'abc123', 'New Name')

            expect(mockFetch).toHaveBeenCalledWith(
                '/api/instances/1/qbt/v2/torrents/rename',
                expect.anything()
            )
        })

        it('adds trackers', async () => {
            mockFetch.mockResolvedValueOnce(jsonResponse({}))

            await addTrackers(instanceId, 'abc123', ['http://tracker1.com', 'http://tracker2.com'])

            expect(mockFetch).toHaveBeenCalledWith(
                '/api/instances/1/qbt/v2/torrents/addTrackers',
                expect.anything()
            )
        })

        it('removes trackers', async () => {
            mockFetch.mockResolvedValueOnce(jsonResponse({}))

            await removeTrackers(instanceId, 'abc123', ['http://tracker1.com'])

            expect(mockFetch).toHaveBeenCalledWith(
                '/api/instances/1/qbt/v2/torrents/removeTrackers',
                expect.anything()
            )
        })

        it('sets file priority', async () => {
            mockFetch.mockResolvedValueOnce(jsonResponse({}))

            await setFilePriority(instanceId, 'abc123', [0, 1, 2], 7)

            expect(mockFetch).toHaveBeenCalledWith(
                '/api/instances/1/qbt/v2/torrents/filePrio',
                expect.anything()
            )
        })
    })

    describe('preferences', () => {
        it('gets preferences', async () => {
            const mockPrefs = { save_path: '/downloads', listen_port: 6881 }
            mockFetch.mockResolvedValueOnce(jsonResponse(mockPrefs))

            const result = await getPreferences(instanceId)

            expect(result).toEqual(mockPrefs)
        })
    })

    describe('speed limits', () => {
        it('gets speed limits mode', async () => {
            mockFetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve('1') })

            const result = await getSpeedLimitsMode(instanceId)

            expect(result).toBe(1)
        })

        it('toggles speed limits mode', async () => {
            mockFetch.mockResolvedValueOnce({ ok: true })

            await toggleSpeedLimitsMode(instanceId)

            expect(mockFetch).toHaveBeenCalledWith(
                '/api/instances/1/qbt/v2/transfer/toggleSpeedLimitsMode',
                expect.objectContaining({ method: 'POST' })
            )
        })
    })

    describe('logs', () => {
        it('gets log entries', async () => {
            const mockLogs = [{ id: 1, message: 'Test log', timestamp: 123456, type: 1 }]
            mockFetch.mockResolvedValueOnce(jsonResponse(mockLogs))

            const result = await getLog(instanceId)

            expect(result).toEqual(mockLogs)
        })

        it('gets log with filter options', async () => {
            mockFetch.mockResolvedValueOnce(jsonResponse([]))

            await getLog(instanceId, { normal: true, warning: true, critical: true, lastKnownId: 10 })

            expect(mockFetch).toHaveBeenCalledWith(
                expect.stringContaining('normal=true'),
                expect.anything()
            )
            expect(mockFetch).toHaveBeenCalledWith(
                expect.stringContaining('last_known_id=10'),
                expect.anything()
            )
        })

        it('gets peer log', async () => {
            const mockPeerLogs = [{ id: 1, ip: '192.168.1.1', timestamp: 123456, blocked: false, reason: '' }]
            mockFetch.mockResolvedValueOnce(jsonResponse(mockPeerLogs))

            const result = await getPeerLog(instanceId)

            expect(result).toEqual(mockPeerLogs)
        })
    })

    describe('RSS', () => {
        it('gets RSS items', async () => {
            const mockItems = { 'Feed 1': { url: 'http://feed.com/rss' } }
            mockFetch.mockResolvedValueOnce(jsonResponse(mockItems))

            const result = await getRSSItems(instanceId)

            expect(result).toEqual(mockItems)
        })

        it('gets RSS rules', async () => {
            const mockRules = { 'Rule 1': { enabled: true, mustContain: 'test' } }
            mockFetch.mockResolvedValueOnce(jsonResponse(mockRules))

            const result = await getRSSRules(instanceId)

            expect(result).toEqual(mockRules)
        })
    })

    describe('error handling', () => {
        it('throws on non-ok response', async () => {
            mockFetch.mockResolvedValueOnce({ ok: false, status: 500, text: () => Promise.resolve('Error') })

            await expect(getTorrents(instanceId)).rejects.toThrow('API error: 500')
        })

        it('throws on empty response', async () => {
            mockFetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve('') })

            await expect(getTorrents(instanceId)).rejects.toThrow('Empty response from API')
        })

        it('throws on invalid JSON', async () => {
            mockFetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve('not json') })

            await expect(getTorrents(instanceId)).rejects.toThrow('Invalid JSON response')
        })
    })
})


================================================
FILE: __tests__/hooks/useInstance.test.tsx
================================================
import { describe, it, expect, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import React from 'react'
import { useInstance } from '../../src/hooks/useInstance'
import { InstanceContext } from '../../src/contexts/instanceContext'
import type { Instance } from '../../src/api/instances'

describe('useInstance', () => {
    const mockInstance: Instance = {
        id: 1,
        label: 'Test Instance',
        url: 'http://localhost:8080',
        qbt_username: 'admin',
        skip_auth: false,
        created_at: 1234567890,
    }

    it('returns instance from context', () => {
        const wrapper = ({ children }: { children: React.ReactNode }) =>
            React.createElement(
                InstanceContext.Provider,
                { value: { instance: mockInstance, setInstance: vi.fn() } },
                children
            )

        const { result } = renderHook(() => useInstance(), { wrapper })

        expect(result.current).toEqual(mockInstance)
        expect(result.current.id).toBe(1)
        expect(result.current.label).toBe('Test Instance')
    })

    it('throws error when used outside provider', () => {
        // Suppress console.error for this test
        const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { })

        expect(() => {
            renderHook(() => useInstance())
        }).toThrow('useInstance must be used within InstanceProvider')

        consoleSpy.mockRestore()
    })
})


================================================
FILE: __tests__/hooks/usePagination.test.tsx
================================================
import { describe, it, expect, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import React from 'react'
import { usePagination } from '../../src/hooks/usePagination'
import { PaginationContext } from '../../src/contexts/paginationContext'

describe('usePagination', () => {
    const mockPaginationContext = {
        page: 1,
        perPage: 50,
        setPage: vi.fn(),
        setPerPage: vi.fn(),
    }

    it('returns pagination context values', () => {
        const wrapper = ({ children }: { children: React.ReactNode }) =>
            React.createElement(
                PaginationContext.Provider,
                { value: mockPaginationContext },
                children
            )

        const { result } = renderHook(() => usePagination(), { wrapper })

        expect(result.current.page).toBe(1)
        expect(result.current.perPage).toBe(50)
        expect(typeof result.current.setPage).toBe('function')
        expect(typeof result.current.setPerPage).toBe('function')
    })

    it('throws error when used outside provider', () => {
        const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { })

        expect(() => {
            renderHook(() => usePagination())
        }).toThrow('usePagination must be used within PaginationProvider')

        consoleSpy.mockRestore()
    })
})


================================================
FILE: __tests__/reporter.ts
================================================
import type { Reporter, Vitest } from "vitest/node";
import pc from "picocolors";
import path from "node:path";

type TaskState = "pass" | "fail" | "skip" | "todo" | "pending" | "unknown";

type AnyTask = {
    id?: string;
    type?: "suite" | "test";
    name?: string;
    mode?: "run" | "skip" | "only" | "todo";
    tasks?: AnyTask[];
    result?: {
        state?: unknown;
        duration?: number;
        errors?: unknown[];
    };
};

type AnyFile = {
    filepath?: string;
    name?: string;
    file?: string;
    tasks?: AnyTask[];
};

type TaskResultPack = {
    id: string;
    result?: {
        state?: unknown;
        duration?: number;
        errors?: unknown[];
    };
};

function normalisePath(p: string) {
    return p.replaceAll("\\", "/");
}

function toRelative(p: string) {
    try {
        return path.relative(process.cwd(), p);
    } catch {
        return p;
    }
}

function safeBasename(p: string) {
    try {
        return path.basename(p);
    } catch {
        return p;
    }
}

function safeDirname(p: string) {
    try {
        return path.dirname(p);
    } catch {
        return "";
    }
}

function normaliseState(rawState: unknown, mode: unknown): TaskState {
    if (mode === "skip") return "skip";
    if (mode === "todo") return "todo";

    const s = String(rawState ?? "").toLowerCase();

    if (s === "pass" || s === "passed" || s === "success") return "pass";
    if (s === "fail" || s === "failed") return "fail";
    if (s === "skip" || s === "skipped") return "skip";
    if (s === "todo") return "todo";

    if (!s) return "pending";
    return "unknown";
}

function iconFor(state: TaskState) {
    switch (state) {
        case "pass":
            return pc.green("✔");
        case "fail":
            return pc.red("✖");
        case "skip":
            return pc.yellow("↷");
        case "todo":
            return pc.yellow("…");
        case "pending":
            return pc.gray("·");
        default:
            return pc.gray("?");
    }
}

function colourName(state: TaskState, text: string) {
    switch (state) {
        case "pass":
            return pc.white(text);
        case "fail":
            return pc.red(text);
        case "skip":
        case "todo":
            return pc.yellow(text);
        default:
            return pc.gray(text);
    }
}

function formatDuration(ms?: number) {
    if (!ms || ms <= 0) return "";
    if (ms < 1000) return pc.dim(` ${Math.round(ms)}ms`);
    return pc.dim(` ${(ms / 1000).toFixed(2)}s`);
}

export default class PrettyReporter implements Reporter {
    private ctx: Vitest | undefined;

    private startMs = 0;

    private indexed = false;
    private totalTests = 0;

    private pass = 0;
    private fail = 0;
    private skip = 0;
    private todo = 0;

    private completed = new Set<string>();
    private lastProgressRender = 0;

    onInit(ctx: Vitest) {
        try {
            this.ctx = ctx;
            this.startMs = Date.now();

            process.stdout.write(pc.cyan(pc.bold("\n QBITWEBUI TEST SUITE \n")));
            process.stdout.write(pc.gray(" Running tests…\n\n"));
        } catch (e) {
            console.error("Reporter init error:", e);
        }
    }

    onTaskUpdate(packs: TaskResultPack[]) {
        try {
            this.ensureIndexedFromState();

            for (const pack of packs ?? []) {
                if (!pack?.id) continue;
                if (!pack.result) continue;

                const state = normaliseState(pack.result.state, undefined);

                const terminal =
                    state === "pass" || state === "fail" || state === "skip" || state === "todo";

                if (!terminal) continue;
                if (this.completed.has(pack.id)) continue;

                this.completed.add(pack.id);

                if (state === "pass") this.pass += 1;
                if (state === "fail") this.fail += 1;
                if (state === "skip") this.skip += 1;
                if (state === "todo") this.todo += 1;
            }

            // Progress bar (throttled)
            const now = Date.now();
            if (now - this.lastProgressRender < 1) return;
            this.lastProgressRender = now;

            this.renderProgressLine();
        } catch (e) {
            console.error("Reporter update error:", e);
        }
    }

    onTestRunEnd() {
        try {
            // Clear the progress line
            process.stdout.write("\r\x1b[2K\n");
            this.printReportFromState();
        } catch (e) {
            console.error("Reporter error:", e);
        }
    }

    private getStateFiles(): AnyFile[] {
        const ctx = this.ctx as { state?: { getFiles?: () => unknown; files?: unknown } } | undefined;
        const state = ctx?.state;

        const filesFromGetter = state?.getFiles?.();
        if (Array.isArray(filesFromGetter)) return filesFromGetter;

        const filesFromProp = state?.files;
        if (Array.isArray(filesFromProp)) return filesFromProp;

        return [];
    }

    private ensureIndexedFromState() {
        if (this.indexed) return;

        const files = this.getStateFiles();
        if (!files.length) return;

        let total = 0;

        const walk = (t: AnyTask) => {
            if (!t) return;
            if (t.type === "test") total += 1;
            if (Array.isArray(t.tasks)) t.tasks.forEach(walk);
        };

        files.forEach((f) => {
            if (Array.isArray(f.tasks)) f.tasks.forEach(walk);
        });

        this.totalTests = total;
        this.indexed = true;
    }

    private renderProgressLine() {
        const total = Math.max(this.totalTests, 1);
        const done = Math.min(this.completed.size, total);
        const pct = this.totalTests ? Math.round((done / total) * 100) : 0;

        const width = 28;
        const filled = Math.round((pct / 100) * width);
        const bar =
            pc.green("█".repeat(filled)) + pc.gray("░".repeat(width - filled));

        const elapsed = (Date.now() - this.startMs) / 1000;

        const line = [
            pc.dim(" Progress "),
            "[",
            bar,
            "] ",
            pc.white(`${pct}%`),
            pc.dim(`  (${done}/${this.totalTests})`),
            pc.dim("  | "),
            pc.green(`✔ ${this.pass}`),
            pc.dim(" "),
            pc.red(`✖ ${this.fail}`),
            pc.dim(" "),
            pc.yellow(`↷ ${this.skip}`),
            this.todo ? pc.dim(" ") : "",
            this.todo ? pc.yellow(`… ${this.todo}`) : "",
            pc.dim(`  | ${elapsed.toFixed(1)}s`),
        ].join("");

        process.stdout.write("\r\x1b[2K" + line);
    }

    private printReportFromState() {
        const files = this.getStateFiles();

        const endMs = Date.now();
        const duration = ((endMs - this.startMs) / 1000).toFixed(2);

        // Recompute final totals from the actual state (authoritative)
        const totals = this.computeTotals(files);
        this.pass = totals.pass;
        this.fail = totals.fail;
        this.skip = totals.skip;
        this.todo = totals.todo;
        this.totalTests = totals.total;

        process.stdout.write(pc.cyan(pc.bold("\n RESULTS \n")));

        // Group by directory
        const grouped = new Map<string, AnyFile[]>();
        for (const file of files) {
            const raw = file.filepath ?? file.file ?? file.name ?? "";
            const rel = normalisePath(toRelative(raw));
            const dir = normalisePath(safeDirname(rel)) || ".";
            const arr = grouped.get(dir) ?? [];
            arr.push(file);
            grouped.set(dir, arr);
        }

        const dirs = [...grouped.keys()].sort((a, b) => a.localeCompare(b));

        for (const dir of dirs) {
            const niceDir = dir === "." ? "__tests__" : dir;
            process.stdout.write(pc.magenta(pc.bold(`\n 📁 ${niceDir}\n`)));

            const dirFiles = grouped.get(dir) ?? [];
            dirFiles.sort((a, b) => {
                const ap = a.filepath ?? a.file ?? a.name ?? "";
                const bp = b.filepath ?? b.file ?? b.name ?? "";
                return ap.localeCompare(bp);
            });

            for (const file of dirFiles) {
                const raw = file.filepath ?? file.file ?? file.name ?? "";
                const rel = normalisePath(toRelative(raw));
                const fname = safeBasename(rel);

                const stats = this.computeFileTotals(file);

                const badge =
                    stats.fail > 0
                        ? pc.red(` ${stats.fail} failed`)
                        : pc.green(` ${stats.pass} passed`);

                const extrasParts: string[] = [];
                if (stats.skip > 0) extrasParts.push(pc.yellow(`${stats.skip} skipped`));
                if (stats.todo > 0) extrasParts.push(pc.yellow(`${stats.todo} todo`));

                const extras = extrasParts.length
                    ? pc.dim(` (${extrasParts.join(", ")})`)
                    : "";

                process.stdout.write(
                    `  ${pc.dim(fname)}${pc.dim("  ")}${badge}${extras}${formatDuration(
                        stats.durationMs,
                    )}\n`,
                );

                if (Array.isArray(file.tasks) && file.tasks.length) {
                    for (const t of file.tasks) this.printTaskTree(t, 4);
                } else {
                    process.stdout.write(pc.dim("    (no tasks collected)\n"));
                }
            }
        }

        const done = this.pass + this.fail + this.skip + this.todo;
        const pct = this.totalTests ? Math.round((done / this.totalTests) * 100) : 0;

        process.stdout.write(pc.gray("\n ─────────────────────────────────────────────\n"));
        process.stdout.write(
            ` Summary: ${pc.green(`✔ ${this.pass}`)}  ${pc.red(
                `✖ ${this.fail}`,
            )}  ${pc.yellow(`↷ ${this.skip}`)}${this.todo ? `  ${pc.yellow(`… ${this.todo}`)}` : ""
            }\n`,
        );
        process.stdout.write(
            ` Progress: ${pct}% (${done}/${this.totalTests})\n`,
        );
        process.stdout.write(` Time: ${duration}s\n`);

        if (this.fail > 0) {
            process.stdout.write(
                pc.red(
                    `\n ${this.fail} failing test(s). Check your code.\n\n`,
                ),
            );
            process.exitCode = 1;
        } else {
            process.stdout.write(
                pc.green(`\n All tests passed. \n\n`),
            );
            process.exitCode = 0;
        }
    }

    private computeTotals(files: AnyFile[]) {
        let pass = 0;
        let fail = 0;
        let skip = 0;
        let todo = 0;
        let total = 0;

        const walk = (t: AnyTask) => {
            if (!t) return;

            if (t.type === "test") {
                total += 1;
                const st = normaliseState(t.result?.state, t.mode);
                if (st === "pass") pass += 1;
                else if (st === "fail") fail += 1;
                else if (st === "skip") skip += 1;
                else if (st === "todo") todo += 1;
            }

            if (Array.isArray(t.tasks)) t.tasks.forEach(walk);
        };

        files.forEach((f) => {
            if (Array.isArray(f.tasks)) f.tasks.forEach(walk);
        });

        return { pass, fail, skip, todo, total };
    }

    private computeFileTotals(file: AnyFile) {
        let pass = 0;
        let fail = 0;
        let skip = 0;
        let todo = 0;
        let durationMs = 0;

        const walk = (t: AnyTask) => {
            if (!t) return;

            if (t.type === "test") {
                const st = normaliseState(t.result?.state, t.mode);
                if (st === "pass") pass += 1;
                else if (st === "fail") fail += 1;
                else if (st === "skip") skip += 1;
                else if (st === "todo") todo += 1;

                if (typeof t.result?.duration === "number") {
                    durationMs += t.result.duration;
                }
            }

            if (Array.isArray(t.tasks)) t.tasks.forEach(walk);
        };

        if (Array.isArray(file.tasks)) file.tasks.forEach(walk);

        return { pass, fail, skip, todo, durationMs };
    }

    private printTaskTree(task: AnyTask, indent: number) {
        const pad = " ".repeat(indent);

        if (task.type === "suite") {
            if (task.name && task.name.trim()) {
                process.stdout.write(`${pad}${pc.blue(pc.bold(task.name))}\n`);
            }
            if (Array.isArray(task.tasks)) {
                for (const child of task.tasks) this.printTaskTree(child, indent + 2);
            }
            return;
        }

        if (task.type === "test") {
            const state = normaliseState(task.result?.state, task.mode);
            const icon = iconFor(state);
            const name = colourName(state, task.name ?? "(unnamed test)");
            const time = formatDuration(task.result?.duration);

            process.stdout.write(`${pad}${icon} ${name}${time}\n`);

            if (state === "fail" && Array.isArray(task.result?.errors)) {
                for (const err of task.result.errors) {
                    let msg: string;
                    if (err instanceof Error) {
                        msg = err.message.split("\n")[0];
                    } else if (typeof err === "string") {
                        msg = err.split("\n")[0];
                    } else if (err && typeof err === "object") {
                        try {
                            msg = JSON.stringify(err).slice(0, 100);
                        } catch {
                            msg = "[object]";
                        }
                    } else {
                        msg = String(err ?? "Unknown error").split("\n")[0];
                    }

                    process.stdout.write(`${pad}  ${pc.red("└─ ")}${pc.dim(msg)}\n`);
                }
            }
        }
    }
}


================================================
FILE: __tests__/server/crossSeedCache.test.ts
================================================
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'
import { existsSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'

const TEST_DATA_PATH = join(tmpdir(), `crossseed-test-${process.pid}`)
process.env.DATA_PATH = TEST_DATA_PATH

import {
	cacheTorrent,
	getCachedTorrent,
	hasCachedTorrent,
	clearCacheForInstance,
	clearOutputForInstance,
	saveTorrentToOutput,
	getCacheStats,
	getOutputStats,
	_resetCachePaths,
} from '../../src/server/utils/crossSeedCache'

describe('crossSeedCache', () => {
	const TEST_INSTANCE = 99999
	const HASH_1 = 'abc123def456abc123def456abc123def456abc1'
	const HASH_2 = 'def456abc123def456abc123def456abc123def4'

	beforeAll(() => {
		_resetCachePaths()
	})

	afterAll(() => {
		rmSync(TEST_DATA_PATH, { recursive: true, force: true })
	})

	beforeEach(() => {
		clearCacheForInstance(TEST_INSTANCE)
		clearOutputForInstance(TEST_INSTANCE)
	})

	afterEach(() => {
		clearCacheForInstance(TEST_INSTANCE)
		clearOutputForInstance(TEST_INSTANCE)
	})

	describe('cacheTorrent and getCachedTorrent', () => {
		it('caches and retrieves torrent data', () => {
			const torrentData = Buffer.from('test torrent data')

			cacheTorrent(TEST_INSTANCE, HASH_1, torrentData)

			const cached = getCachedTorrent(TEST_INSTANCE, HASH_1)
			expect(cached).not.toBeNull()
			expect(cached?.toString()).toBe('test torrent data')
		})

		it('overwrites existing cache', () => {
			const data1 = Buffer.from('first')
			const data2 = Buffer.from('second')

			cacheTorrent(TEST_INSTANCE, HASH_1, data1)
			cacheTorrent(TEST_INSTANCE, HASH_1, data2)

			const cached = getCachedTorrent(TEST_INSTANCE, HASH_1)
			expect(cached?.toString()).toBe('second')
		})

		it('returns null for non-existent cache', () => {
			expect(getCachedTorrent(TEST_INSTANCE, HASH_2)).toBeNull()
		})
	})

	describe('hasCachedTorrent', () => {
		it('returns true when torrent is cached', () => {
			cacheTorrent(TEST_INSTANCE, HASH_1, Buffer.from('data'))
			expect(hasCachedTorrent(TEST_INSTANCE, HASH_1)).toBe(true)
		})

		it('returns false when torrent is not cached', () => {
			expect(hasCachedTorrent(TEST_INSTANCE, HASH_2)).toBe(false)
		})
	})

	describe('clearCacheForInstance', () => {
		it('clears all cache for instance', () => {
			cacheTorrent(TEST_INSTANCE, HASH_1, Buffer.from('data1'))
			cacheTorrent(TEST_INSTANCE, HASH_2, Buffer.from('data2'))

			const cleared = clearCacheForInstance(TEST_INSTANCE)

			expect(cleared).toBe(2)
			expect(hasCachedTorrent(TEST_INSTANCE, HASH_1)).toBe(false)
			expect(hasCachedTorrent(TEST_INSTANCE, HASH_2)).toBe(false)
		})

		it('returns 0 when no cache exists', () => {
			expect(clearCacheForInstance(88888)).toBe(0)
		})
	})

	describe('saveTorrentToOutput', () => {
		it('saves torrent to output directory', () => {
			const name = 'test-torrent'
			const data = Buffer.from('torrent data')

			const path = saveTorrentToOutput(TEST_INSTANCE, name, HASH_1, data)

			expect(path).toContain('test-torrent')
			expect(path).toContain('.torrent')
			expect(existsSync(path)).toBe(true)
		})

		it('sanitizes filename', () => {
			const name = 'test/torrent:with*bad?chars'
			const data = Buffer.from('data')

			const path = saveTorrentToOutput(TEST_INSTANCE, name, HASH_2, data)
			const filename = path.split('/').pop()!

			expect(filename).not.toContain(':')
			expect(filename).not.toContain('*')
			expect(filename).not.toContain('?')
			expect(filename).toContain('test_torrent_with_bad_chars')
		})
	})

	describe('clearOutputForInstance', () => {
		it('clears output directory for instance', () => {
			saveTorrentToOutput(TEST_INSTANCE, 'torrent1', HASH_1, Buffer.from('data1'))
			saveTorrentToOutput(TEST_INSTANCE, 'torrent2', HASH_2, Buffer.from('data2'))

			const cleared = clearOutputForInstance(TEST_INSTANCE)

			expect(cleared).toBe(2)
		})
	})

	describe('getCacheStats', () => {
		it('returns correct stats for cached torrents', () => {
			cacheTorrent(TEST_INSTANCE, HASH_1, Buffer.from('12345'))
			cacheTorrent(TEST_INSTANCE, HASH_2, Buffer.from('1234567890'))

			const stats = getCacheStats(TEST_INSTANCE)

			expect(stats.count).toBe(2)
			expect(stats.totalSize).toBe(15)
		})

		it('returns zero stats for empty cache', () => {
			const stats = getCacheStats(77777)

			expect(stats.count).toBe(0)
			expect(stats.totalSize).toBe(0)
		})
	})

	describe('getOutputStats', () => {
		it('returns correct stats for output files', () => {
			saveTorrentToOutput(TEST_INSTANCE, 'torrent1', HASH_1, Buffer.from('data'))
			saveTorrentToOutput(TEST_INSTANCE, 'torrent2', HASH_2, Buffer.from('moredata'))

			const stats = getOutputStats(TEST_INSTANCE)

			expect(stats.count).toBe(2)
			expect(stats.files.length).toBe(2)
		})

		it('returns empty stats for no output', () => {
			const stats = getOutputStats(66666)

			expect(stats.count).toBe(0)
			expect(stats.files).toEqual([])
		})
	})

})


================================================
FILE: __tests__/server/crossSeedMatcher.test.ts
================================================
import { describe, it, expect, vi } from 'vitest'

vi.mock('../../src/server/db', () => ({
	db: {
		exec: vi.fn(),
		run: vi.fn(),
		query: vi.fn(() => ({ get: vi.fn(), all: vi.fn(() => []) })),
	},
	CrossSeedDecisionType: {
		MATCH: 'MATCH',
		MATCH_SIZE_ONLY: 'MATCH_SIZE_ONLY',
		SIZE_MISMATCH: 'SIZE_MISMATCH',
		FILE_TREE_MISMATCH: 'FILE_TREE_MISMATCH',
		ALREADY_EXISTS: 'ALREADY_EXISTS',
		DOWNLOAD_FAILED: 'DOWNLOAD_FAILED',
		NO_DOWNLOAD_LINK: 'NO_DOWNLOAD_LINK',
		BLOCKED_RELEASE: 'BLOCKED_RELEASE',
	},
	BlocklistType: {
		NAME: 'name',
		NAME_REGEX: 'nameRegex',
		FOLDER: 'folder',
		FOLDER_REGEX: 'folderRegex',
		CATEGORY: 'category',
		TAG: 'tag',
		TRACKER: 'tracker',
		INFOHASH: 'infoHash',
		SIZE_BELOW: 'sizeBelow',
		SIZE_ABOVE: 'sizeAbove',
		LEGACY: 'legacy',
	},
}))

import {
	matchTorrentsBySizes,
	preFilterCandidate,
	type FileInfo,
} from '../../src/server/utils/crossSeedMatcher'
import { CrossSeedDecisionType } from '../../src/server/db'

describe('crossSeedMatcher', () => {
	describe('matchTorrentsBySizes', () => {
		describe('exact matches', () => {
			it('matches identical single file torrents', () => {
				const source: FileInfo[] = [{ name: 'movie.mkv', size: 1000000 }]
				const candidate: FileInfo[] = [{ name: 'movie.mkv', size: 1000000 }]

				const result = matchTorrentsBySizes(source, candidate)
				expect(result.matched).toBe(true)
				expect(result.decision).toBe(CrossSeedDecisionType.MATCH)
			})

			it('matches multi-file torrents with same files', () => {
				const source: FileInfo[] = [
					{ name: 'video.mkv', size: 5000000 },
					{ name: 'subs.srt', size: 50000 },
					{ name: 'info.nfo', size: 1000 },
				]
				const candidate: FileInfo[] = [
					{ name: 'video.mkv', size: 5000000 },
					{ name: 'subs.srt', size: 50000 },
					{ name: 'info.nfo', size: 1000 },
				]

				const result = matchTorrentsBySizes(source, candidate)
				expect(result.matched).toBe(true)
				expect(result.decision).toBe(CrossSeedDecisionType.MATCH)
			})
		})

		describe('flexible matching - different names, same sizes', () => {
			it('matches when file names differ but sizes match', () => {
				const source: FileInfo[] = [{ name: 'Movie.2024.1080p.mkv', size: 5000000 }]
				const candidate: FileInfo[] = [{ name: 'different-name.mkv', size: 5000000 }]

				const result = matchTorrentsBySizes(source, candidate)
				expect(result.matched).toBe(true)
				expect(result.decision).toBe(CrossSeedDecisionType.MATCH_SIZE_ONLY)
			})

			it('matches multi-file with different names but same sizes', () => {
				const source: FileInfo[] = [
					{ name: 'ep01.mkv', size: 500000 },
					{ name: 'ep02.mkv', size: 500000 },
					{ name: 'ep03.mkv', size: 500000 },
				]
				const candidate: FileInfo[] = [
					{ name: 's01e01.mkv', size: 500000 },
					{ name: 's01e02.mkv', size: 500000 },
					{ name: 's01e03.mkv', size: 500000 },
				]

				const result = matchTorrentsBySizes(source, candidate)
				expect(result.matched).toBe(true)
			})
		})

		describe('mismatches', () => {
			it('rejects when candidate file size not found in searchee', () => {
				const source: FileInfo[] = [
					{ name: 'file1.mkv', size: 1000 },
					{ name: 'file2.mkv', size: 1000 },
				]
				const candidate: FileInfo[] = [{ name: 'file1.mkv', size: 2000 }]

				const result = matchTorrentsBySizes(source, candidate)
				expect(result.matched).toBe(false)
				expect(result.decision).toBe(CrossSeedDecisionType.SIZE_MISMATCH)
			})

			it('allows searchee to have extra files (candidate subset of searchee)', () => {
				const source: FileInfo[] = [
					{ name: 'file1.mkv', size: 1000 },
					{ name: 'file2.mkv', size: 2000 },
					{ name: 'extra.nfo', size: 500 },
				]
				const candidate: FileInfo[] = [
					{ name: 'file1.mkv', size: 1000 },
					{ name: 'file2.mkv', size: 2000 },
				]

				const result = matchTorrentsBySizes(source, candidate)
				expect(result.matched).toBe(true)
				expect(result.decision).toBe(CrossSeedDecisionType.MATCH)
			})

			it('rejects when sizes do not match', () => {
				const source: FileInfo[] = [{ name: 'movie.mkv', size: 1000000 }]
				const candidate: FileInfo[] = [{ name: 'movie.mkv', size: 999999 }]

				const result = matchTorrentsBySizes(source, candidate)
				expect(result.matched).toBe(false)
				expect(result.decision).toBe(CrossSeedDecisionType.SIZE_MISMATCH)
			})

			it('rejects when multi-file sizes partially match', () => {
				const source: FileInfo[] = [
					{ name: 'ep01.mkv', size: 500000 },
					{ name: 'ep02.mkv', size: 500000 },
				]
				const candidate: FileInfo[] = [
					{ name: 'ep01.mkv', size: 500000 },
					{ name: 'ep02.mkv', size: 499999 },
				]

				const result = matchTorrentsBySizes(source, candidate)
				expect(result.matched).toBe(false)
				expect(result.decision).toBe(CrossSeedDecisionType.SIZE_MISMATCH)
			})
		})

		describe('edge cases', () => {
			it('rejects empty file arrays', () => {
				const source: FileInfo[] = []
				const candidate: FileInfo[] = []

				const result = matchTorrentsBySizes(source, candidate)
				expect(result.matched).toBe(false)
			})

			it('handles files with duplicate sizes correctly', () => {
				const source: FileInfo[] = [
					{ name: 'ep01.mkv', size: 500000 },
					{ name: 'ep02.mkv', size: 500000 },
					{ name: 'ep03.mkv', size: 500000 },
				]
				const candidate: FileInfo[] = [
					{ name: 'different1.mkv', size: 500000 },
					{ name: 'different2.mkv', size: 500000 },
					{ name: 'different3.mkv', size: 500000 },
				]

				const result = matchTorrentsBySizes(source, candidate)
				expect(result.matched).toBe(true)
			})

			it('handles very large file sizes', () => {
				const largeSize = 50 * 1024 * 1024 * 1024
				const source: FileInfo[] = [{ name: 'large.mkv', size: largeSize }]
				const candidate: FileInfo[] = [{ name: 'large.mkv', size: largeSize }]

				const result = matchTorrentsBySizes(source, candidate)
				expect(result.matched).toBe(true)
			})

			it('handles zero-size files', () => {
				const source: FileInfo[] = [
					{ name: 'empty.txt', size: 0 },
					{ name: 'video.mkv', size: 1000000 },
				]
				const candidate: FileInfo[] = [
					{ name: 'empty.txt', size: 0 },
					{ name: 'video.mkv', size: 1000000 },
				]

				const result = matchTorrentsBySizes(source, candidate)
				expect(result.matched).toBe(true)
			})
		})
	})

	describe('preFilterCandidate', () => {
		describe('passing filters', () => {
			it('passes when sizes are within threshold', () => {
				const result = preFilterCandidate('Movie 2024', 1000000, 'Movie 2024', 1000000)
				expect(result.pass).toBe(true)
			})

			it('passes when sizes are close (within 2%)', () => {
				const result = preFilterCandidate('Movie', 1000000, 'Movie', 1019000)
				expect(result.pass).toBe(true)
			})

			it('passes with different name formatting', () => {
				const result = preFilterCandidate('Movie.2024.1080p', 1000000, 'Movie 2024 1080p', 1000000)
				expect(result.pass).toBe(true)
			})

			it('passes when release group is missing on one side', () => {
				const result = preFilterCandidate('Movie.2024.1080p', 1000000, 'Movie.2024.1080p-GROUP', 1000000)
				expect(result.pass).toBe(true)
			})

			it('passes when source tag is missing on one side', () => {
				const result = preFilterCandidate('Movie.2024.1080p', 1000000, 'Movie.2024.1080p.WEB-DL.NF', 1000000)
				expect(result.pass).toBe(true)
			})
		})

		describe('failing filters', () => {
			it('fails when resolution differs', () => {
				const result = preFilterCandidate('Movie.2024.1080p', 1000000, 'Movie.2024.720p', 1000000)
				expect(result.pass).toBe(false)
				expect(result.reason?.toLowerCase()).toContain('resolution')
			})

			it('fails when release group differs', () => {
				const result = preFilterCandidate('Movie.2024.1080p-GROUPA', 1000000, 'Movie.2024.1080p-GROUPB', 1000000)
				expect(result.pass).toBe(false)
				expect(result.reason?.toLowerCase()).toContain('group')
			})

			it('fails when source tag differs', () => {
				const result = preFilterCandidate(
					'Movie.2024.1080p.AMZN.WEB-DL.x264-GROUP',
					1000000,
					'Movie.2024.1080p.NF.WEB-DL.x264-GROUP',
					1000000
				)
				expect(result.pass).toBe(false)
				expect(result.reason?.toLowerCase()).toContain('source')
			})

			it('fails when proper/repack mismatch', () => {
				const result = preFilterCandidate('Movie.2024.1080p.PROPER-GROUP', 1000000, 'Movie.2024.1080p-GROUP', 1000000)
				expect(result.pass).toBe(false)
				expect(result.reason?.toLowerCase()).toContain('proper')
			})

			it('fails when sizes differ too much', () => {
				const result = preFilterCandidate('Movie', 1000000, 'Movie', 2000000)
				expect(result.pass).toBe(false)
				expect(result.reason?.toLowerCase()).toContain('size')
			})

			it('fails when candidate is much smaller', () => {
				const result = preFilterCandidate('Movie', 1000000, 'Movie', 100000)
				expect(result.pass).toBe(false)
			})
		})

		describe('edge cases', () => {
			it('handles zero source size with zero candidate', () => {
				const result = preFilterCandidate('Movie', 0, 'Movie', 0)
				expect(result.pass).toBe(true)
			})

			it('handles zero source size with non-zero candidate without dividing by zero', () => {
				const result = preFilterCandidate('Movie', 0, 'Movie', 1000)
				expect(result.pass).toBe(false)
				expect(result.reason).toBeDefined()
				expect(result.reason).not.toContain('Infinity')
				expect(result.reason).toContain('100.0%')
			})

			it('handles missing candidate size', () => {
				const result = preFilterCandidate('Movie', 1000000, 'Movie', undefined as unknown as number)
				expect(result.pass).toBe(true)
			})
		})
	})
})


================================================
FILE: __tests__/server/crossSeedScheduler.test.ts
================================================
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'

vi.mock('../../src/server/db', () => ({
	db: {
		query: vi.fn(() => ({
			get: vi.fn(),
			all: vi.fn(() => []),
		})),
		run: vi.fn(),
	},
}))

vi.mock('../../src/server/utils/crossSeedWorker', () => ({
	runCrossSeedScan: vi.fn(),
}))

vi.mock('../../src/server/utils/logger', () => ({
	log: {
		info: vi.fn(),
		error: vi.fn(),
		warn: vi.fn(),
	},
}))

import {
	isInstanceRunning,
	triggerManualScan,
	stopScheduler,
} from '../../src/server/utils/crossSeedScheduler'
import { runCrossSeedScan } from '../../src/server/utils/crossSeedWorker'

const mockRunCrossSeedScan = runCrossSeedScan as Mock

describe('crossSeedScheduler', () => {
	beforeEach(() => {
		vi.clearAllMocks()
		stopScheduler()
	})

	afterEach(() => {
		stopScheduler()
	})

	describe('isInstanceRunning', () => {
		it('returns false when no scan is running', () => {
			expect(isInstanceRunning(1)).toBe(false)
		})

		it('returns true when a scan is in progress', async () => {
			let resolvePromise: () => void
			const scanPromise = new Promise<void>((resolve) => {
				resolvePromise = resolve
			})

			mockRunCrossSeedScan.mockImplementation(() => scanPromise)

			const triggerPromise = triggerManualScan(1, 1, false).catch(() => {})

			await new Promise((r) => setTimeout(r, 10))
			expect(isInstanceRunning(1)).toBe(true)

			resolvePromise!()
			await triggerPromise
			expect(isInstanceRunning(1)).toBe(false)
		})
	})

	describe('triggerManualScan', () => {
		it('prevents concurrent scans on the same instance', async () => {
			let resolveFirst: (value: unknown) => void
			const firstScanPromise = new Promise((resolve) => {
				resolveFirst = resolve
			})

			mockRunCrossSeedScan.mockImplementationOnce(() => firstScanPromise)

			const firstTrigger = triggerManualScan(1, 1, false)

			await new Promise((r) => setTimeout(r, 10))

			await expect(triggerManualScan(1, 1, false)).rejects.toThrow('Scan already in progress')

			resolveFirst!({ instanceId: 1 })
			await firstTrigger
		})

		it('allows concurrent scans on different instances', async () => {
			let resolveFirst: (value: unknown) => void
			let resolveSecond: (value: unknown) => void

			mockRunCrossSeedScan
				.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve }))
				.mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve }))

			const firstTrigger = triggerManualScan(1, 1, false)
			await new Promise((r) => setTimeout(r, 10))

			const secondTrigger = triggerManualScan(2, 1, false)
			await new Promise((r) => setTimeout(r, 10))

			expect(isInstanceRunning(1)).toBe(true)
			expect(isInstanceRunning(2)).toBe(true)

			resolveFirst!({ instanceId: 1 })
			resolveSecond!({ instanceId: 2 })

			await firstTrigger
			await secondTrigger
		})

		it('clears running state on error', async () => {
			mockRunCrossSeedScan.mockRejectedValueOnce(new Error('Scan failed'))

			await expect(triggerManualScan(1, 1, false)).rejects.toThrow('Scan failed')
			expect(isInstanceRunning(1)).toBe(false)
		})

		it('clears running state on success', async () => {
			mockRunCrossSeedScan.mockResolvedValueOnce({
				instanceId: 1,
				torrentsTotal: 10,
				torrentsScanned: 5,
				torrentsSkipped: 5,
				matchesFound: 1,
				torrentsAdded: 1,
				errors: [],
				dryRun: false,
				startedAt: Date.now(),
				completedAt: Date.now(),
			})

			await triggerManualScan(1, 1, false)
			expect(isInstanceRunning(1)).toBe(false)
		})
	})
})


================================================
FILE: __tests__/server/crossSeedWorker.test.ts
================================================
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'

const { state, db, fsMocks } = vi.hoisted(() => {
	const state = {
		config: null as null | {
			instance_id: number
			enabled: number
			interval_hours: number
			delay_seconds: number
			dry_run: number
			category_suffix: string
			tag: string
			skip_recheck: number
			integration_id: number | null
			indexer_ids: string | null
			match_mode: 'strict' | 'flexible'
			link_dir: string | null
			blocklist: string | null
			include_single_episodes: number
			last_run: number | null
			next_run: number | null
			updated_at: number
		},
		integration: null as null | {
			id: number
			user_id: number
			type: string
			label: string
			url: string
			api_key_encrypted: string
			created_at: number
		},
		instance: null as null | {
			id: number
			user_id: number
			label: string
			url: string
			qbt_username: string | null
			qbt_password_encrypted: string | null
			skip_auth: number
			created_at: number
		},
		searchees: new Map<string, {
			id: number
			instance_id: number
			torrent_hash: string
			torrent_name: string
			total_size: number
			file_count: number
			file_sizes: string
			first_searched: number
			last_searched: number
		}>(),
		decisions: new Map<string, {
			searchee_id?: number
			guid?: string
			info_hash: string | null
			candidate_name?: string
			candidate_size?: number
			decision: string
			first_seen?: number
			last_seen: number
		}>(),
		nextSearcheeId: 1,
	}

	const db = {
		query: vi.fn((sql: string) => ({
			get: (...params: unknown[]) => {
				if (sql.includes('FROM cross_seed_config')) {
					return state.config && state.config.instance_id === params[0] ? state.config : undefined
				}
				if (sql.includes('FROM integrations')) {
					return state.integration && state.integration.id === params[0] && state.integration.user_id === params[1]
						? state.integration
						: undefined
				}
				if (sql.includes('FROM instances')) {
					return state.instance && state.instance.id === params[0] && state.instance.user_id === params[1]
						? state.instance
						: undefined
				}
				if (sql.includes('FROM cross_seed_searchee') && sql.includes('torrent_hash = ?')) {
					const key = `${params[0]}:${params[1]}`
					const row = state.searchees.get(key)
					return row ? { id: row.id } : undefined
				}
				if (sql.includes('FROM cross_seed_decision') && sql.includes('guid = ?')) {
					return state.decisions.get(`${params[0]}:${params[1]}`)
				}
				return undefined
			},
			all: (...params: unknown[]) => {
				if (sql.includes('FROM cross_seed_searchee')) {
					return Array.from(state.searchees.values()).filter((row) => row.instance_id === params[0])
				}
				return []
			},
		})),
		run: vi.fn((sql: string, params: unknown[]) => {
			if (sql.startsWith('INSERT INTO cross_seed_searchee')) {
				const [instanceId, hash, name, size, fileCount, fileSizesJson] = params
				const key = `${instanceId}:${hash}`
				let row = state.searchees.get(key)
				const now = Math.floor(Date.now() / 1000)
				if (!row) {
					row = {
						id: state.nextSearcheeId++,
						instance_id: instanceId as number,
						torrent_hash: hash as string,
						torrent_name: name as string,
						total_size: size as number,
						file_count: fileCount as number,
						file_sizes: fileSizesJson as string,
						first_searched: now,
						last_searched: now,
					}
				} else {
					row.last_searched = now
				}
				state.searchees.set(key, row)
				return { changes: 1, lastInsertRowid: row.id }
			}
			if (sql.startsWith('INSERT INTO cross_seed_decision')) {
				const [searcheeId, guid, info_hash, candidate_name, candidate_size, decision] = params
				const key = `${searcheeId}:${guid}`
				const now = Math.floor(Date.now() / 1000)
				const existing = state.decisions.get(key)
				if (existing) {
					existing.info_hash = info_hash as string | null
					existing.decision = decision as string
					existing.last_seen = now
				} else {
					state.decisions.set(key, {
						searchee_id: searcheeId as number,
						guid: guid as string,
						info_hash: info_hash as string | null,
						candidate_name: candidate_name as string,
						candidate_size: candidate_size as number,
						decision: decision as string,
						first_seen: now,
						last_seen: now,
					})
				}
				return { changes: 1 }
			}
			if (sql.startsWith('UPDATE cross_seed_decision SET last_seen')) {
				const [lastSeen, searcheeId, guid] = params
				const key = `${searcheeId}:${guid}`
				const entry = state.decisions.get(key)
				if (entry) entry.last_seen = lastSeen as number
				return { changes: entry ? 1 : 0 }
			}
			if (sql.startsWith('UPDATE cross_seed_config SET last_run')) {
				const [lastRun, instanceId] = params
				if (state.config && state.config.instance_id === instanceId) {
					state.config.last_run = lastRun as number
				}
				return { changes: 1 }
			}
			return { changes: 0 }
		}),
	}

	const fsMocks = {
		link: vi.fn().mockResolvedValue(undefined),
		mkdir: vi.fn().mockResolvedValue(undefined),
		stat: vi.fn().mockResolvedValue({ dev: 1 }),
		access: vi.fn().mockResolvedValue(undefined),
	}

	return { state, db, fsMocks }
})

vi.mock('../../src/server/db', () => ({
	db,
	CrossSeedDecisionType: {
		MATCH: 'MATCH',
		MATCH_SIZE_ONLY: 'MATCH_SIZE_ONLY',
		SIZE_MISMATCH: 'SIZE_MISMATCH',
		FILE_COUNT_MISMATCH: 'FILE_COUNT_MISMATCH',
		ALREADY_EXISTS: 'ALREADY_EXISTS',
		DOWNLOAD_FAILED: 'DOWNLOAD_FAILED',
		NO_DOWNLOAD_LINK: 'NO_DOWNLOAD_LINK',
	},
	MatchMode: {
		STRICT: 'strict',
		FLEXIBLE: 'flexible',
	},
}))

vi.mock('../../src/server/utils/qbt', () => ({
	loginToQbt: vi.fn(),
}))

vi.mock('../../src/server/utils/crypto', () => ({
	decrypt: vi.fn(() => 'apikey'),
}))

vi.mock('../../src/server/utils/torznab', () => ({
	searchAllIndexers: vi.fn(),
	downloadTorrentDirect: vi.fn(),
}))

vi.mock('../../src/server/utils/crossSeedCache', () => ({
	cacheTorrent: vi.fn(),
	saveTorrentToOutput: vi.fn(() => '/tmp/output.torrent'),
}))

vi.mock('fs/promises', () => ({
	...fsMocks,
	default: fsMocks,
}))

vi.mock('../../src/server/utils/fetch', () => ({
	fetchWithTls: vi.fn(),
}))

vi.mock('../../src/server/utils/logger', () => ({
	log: {
		info: vi.fn(),
		warn: vi.fn(),
		error: vi.fn(),
	},
}))

import { runCrossSeedScan } from '../../src/server/utils/crossSeedWorker'
import { loginToQbt } from '../../src/server/utils/qbt'
import { searchAllIndexers, downloadTorrentDirect } from '../../src/server/utils/torznab'
import { cacheTorrent, saveTorrentToOutput } from '../../src/server/utils/crossSeedCache'
import { fetchWithTls } from '../../src/server/utils/fetch'

const mockLoginToQbt = loginToQbt as Mock
const mockSearchAllIndexers = searchAllIndexers as Mock
const mockDownloadTorrentDirect = downloadTorrentDirect as Mock
const mockCacheTorrent = cacheTorrent as Mock
const mockSaveTorrentToOutput = saveTorrentToOutput as Mock
const mockFetchWithTls = fetchWithTls as Mock

function makeTorrentData(name: string, length: number): Buffer {
	return Buffer.from(`d4:infod4:name${name.length}:${name}6:lengthi${length}eee`)
}

type BencodeValue = number | string | Buffer | BencodeValue[] | { [key: string]: BencodeValue }

function encodeBencode(data: BencodeValue): Buffer {
	if (typeof data === 'number') {
		return Buffer.from(`i${data}e`)
	}
	if (Buffer.isBuffer(data)) {
		return Buffer.concat([Buffer.from(`${data.length}:`), data])
	}
	if (typeof data === 'string') {
		const buf = Buffer.from(data)
		return Buffer.concat([Buffer.from(`${buf.length}:`), buf])
	}
	if (Array.isArray(data)) {
		const parts: Buffer[] = [Buffer.from('l')]
		for (const item of data) {
			parts.push(encodeBencode(item))
		}
		parts.push(Buffer.from('e'))
		return Buffer.concat(parts)
	}
	const parts: Buffer[] = [Buffer.from('d')]
	const keys = Object.keys(data).sort()
	for (const key of keys) {
		parts.push(encodeBencode(key))
		parts.push(encodeBencode(data[key]))
	}
	parts.push(Buffer.from('e'))
	return Buffer.concat(parts)
}

function makeMultiFileTorrentData(
	name: string,
	files: Array<{ path: string[]; length: number }>
): Buffer {
	return encodeBencode({
		info: {
			name,
			files: files.map((file) => ({ length: file.length, path: file.path })),
		},
	})
}

function resetState() {
	state.config = {
		instance_id: 1,
		enabled: 1,
		interval_hours: 24,
		delay_seconds: 0,
		dry_run: 0,
		category_suffix: '_cross-seed',
		tag: 'cross-seed',
		skip_recheck: 0,
		integration_id: 10,
		indexer_ids: null,
		match_mode: 'strict',
		link_dir: null,
		blocklist: null,
		include_single_episodes: 0,
		last_run: null,
		next_run: null,
		updated_at: Math.floor(Date.now() / 1000),
	}
	state.integration = {
		id: 10,
		user_id: 1,
		type: 'prowlarr',
		label: 'Prowlarr',
		url: 'http://prowlarr',
		api_key_encrypted: 'encrypted',
		created_at: Math.floor(Date.now() / 1000),
	}
	state.instance = {
		id: 1,
		user_id: 1,
		label: 'QBT',
		url: 'http://qbt',
		qbt_username: 'user',
		qbt_password_encrypted: 'pass',
		skip_auth: 0,
		created_at: Math.floor(Date.now() / 1000),
	}
	state.searchees.clear()
	state.decisions.clear()
	state.nextSearcheeId = 1
}

function mockQbtResponses(torrents: unknown[], files: unknown[]) {
	let addedTorrent = false
	mockFetchWithTls.mockImplementation((url: string) => {
		if (url.endsWith('/api/v2/app/version')) {
			return Promise.resolve(new Response('v5.0.0', { status: 200 }))
		}
		if (url.includes('/api/v2/torrents/info')) {
			const hashMatch = url.match(/hashes=([a-fA-F0-9]+)/)
			if (hashMatch) {
				const queriedHash = hashMatch[1].toUpperCase()
				const found = (torrents as { hash: string }[]).find((t) => t.hash.toUpperCase() === queriedHash)
				if (found) {
					return Promise.resolve(new Response(JSON.stringify([found]), { status: 200 }))
				}
				if (addedTorrent) {
					return Promise.resolve(
						new Response(
							JSON.stringify([
								{
									hash: queriedHash,
									name: 'Added',
									state: 'pausedUP',
									amount_left: 0,
								},
							]),
							{ status: 200 }
						)
					)
				}
				return Promise.resolve(new Response('[]', { status: 200 }))
			}
			return Promise.resolve(new Response(JSON.stringify(torrents), { status: 200 }))
		}
		if (url.includes('/api/v2/torrents/files')) {
			return Promise.resolve(new Response(JSON.stringify(files), { status: 200 }))
		}
		if (url.endsWith('/api/v2/torrents/add')) {
			addedTorrent = true
			return Promise.resolve(new Response('Ok.', { status: 200 }))
		}
		if (url.includes('/api/v2/torrents/stop') || url.includes('/api/v2/torrents/start') || url.includes('/api/v2/torrents/recheck')) {
			return Promise.resolve(new Response('', { status: 200 }))
		}
		return Promise.resolve(new Response('Not Found', { status: 404 }))
	})
}

describe('crossSeedWorker', () => {
	beforeEach(() => {
		vi.clearAllMocks()
		resetState()
		mockLoginToQbt.mockResolvedValue({ success: true, cookie: 'SID=abc' })
	})

	it('adds a matched torrent when not dry-run', async () => {
		const torrents = [
			{
				hash: 'HASH1',
				name: 'Movie.2024.1080p.mkv',
				size: 1000,
				state: 'uploading',
				category: 'movies',
				tags: '',
				save_path: '/downloads',
				content_path: '/downloads/Movie.2024.1080p.mkv',
				progress: 1,
			},
		]
		const files = [{ name: 'Movie.2024.1080p.mkv', size: 1000 }]
		mockQbtResponses(torrents, files)

		mockSearchAllIndexers.mockResolvedValue([
			{
				guid: 'guid-1',
				title: 'Movie 2024 1080p',
				link: 'http://indexer/download/1',
				size: 1000,
				pubDate: '',
				indexer: 'Test',
				indexerId: 1,
			},
		])

		mockDownloadTorrentDirect.mockResolvedValue(makeTorrentData('Movie.2024.1080p.mkv', 1000))

		const result = await runCrossSeedScan({ instanceId: 1, userId: 1, force: false })

		expect(result.matchesFound).toBe(1)
		expect(result.torrentsAdded).toBe(1)
		expect(result.torrentsScanned).toBe(1)
		expect(result.torrentsSkipped).toBe(0)
		expect(mockCacheTorrent).toHaveBeenCalledTimes(1)
		expect(mockSaveTorrentToOutput).not.toHaveBeenCalled()
		expect(mockFetchWithTls.mock.calls.some((call) => String(call[0]).endsWith('/api/v2/torrents/add'))).toBe(true)
	})

	it('matches multi-file torrents in strict mode using basenames', async () => {
		state.config!.match_mode = 'strict'

		const torrents = [
			{
				hash: 'HASH2',
				name: 'Show.S01',
				size: 3000,
				state: 'uploading',
				category: 'shows',
				tags: '',
				save_path: '/downloads',
				content_path: '/downloads/Show.S01',
				progress: 1,
			},
		]
		const files = [
			{ name: 'Show.S01/E01.mkv', size: 1000 },
			{ name: 'Show.S01/E02.mkv', size: 2000 },
		]
		mockQbtResponses(torrents, files)

		mockSearchAllIndexers.mockResolvedValue([
			{
				guid: 'guid-2',
				title: 'Show S01',
				link: 'http://indexer/download/2',
				size: 3000,
				pubDate: '',
				indexer: 'Test',
				indexerId: 1,
			},
		])

		mockDownloadTorrentDirect.mockResolvedValue(
			makeMultiFileTorrentData('Show.S01', [
				{ path: ['E01.mkv'], length: 1000 },
				{ path: ['E02.mkv'], length: 2000 },
			])
		)

		const result = await runCrossSeedScan({ instanceId: 1, userId: 1, force: false })

		expect(result.matchesFound).toBe(1)
		expect(result.torrentsAdded).toBe(1)
		expect(mockFetchWithTls.mock.calls.some((call) => String(call[0]).endsWith('/api/v2/torrents/add'))).toBe(true)
	})

	it('adds size-only matches in flexible mode using hardlinks', async () => {
		state.config!.match_mode = 'flexible'
		state.config!.link_dir = '/links'

		const torrents = [
			{
				hash: 'HASH3',
				name: 'Movie.2024.1080p.mkv',
				size: 1000,
				state: 'uploading',
				category: 'movies',
				tags: '',
				save_path: '/downloads',
				content_path: '/downloads/Movie.2024.1080p.mkv',
				progress: 1,
			},
		]
		const files = [{ name: 'Movie.2024.1080p.mkv', size: 1000 }]
		mockQbtResponses(torrents, files)

		mockSearchAllIndexers.mockResolvedValue([
			{
				guid: 'guid-3',
				title: 'Movie 2024 1080p',
				link: 'http://indexer/download/3',
				size: 1000,
				pubDate: '',
				indexer: 'Test',
				indexerId: 1,
			},
		])

		mockDownloadTorrentDirect.mockResolvedValue(makeTorrentData('Movie.2024.1080p.REPACK.mkv', 1000))

		const result = await runCrossSeedScan({ instanceId: 1, userId: 1, force: false })

		expect(result.matchesFound).toBe(1)
		expect(result.torrentsAdded).toBe(1)
		expect(fsMocks.link.mock.calls.length).toBeGreaterThan(0)
		expect(fsMocks.link.mock.calls[0][0]).toBe('/downloads/Movie.2024.1080p.mkv')
		expect(fsMocks.link.mock.calls[0][1]).toBe('/links/Movie.2024.1080p.REPACK.mkv')
	})

	it('detects structure mismatch when single-file source matches multi-file candidate with folder', async () => {
		state.config!.match_mode = 'flexible'
		state.config!.link_dir = '/links'

		const torrents = [
			{
				hash: 'HASH4',
				name: 'Movie.2024.1080p.mkv',
				size: 1000,
				state: 'uploading',
				category: 'movies',
				tags: '',
				save_path: '/downloads',
				content_path: '/downloads/Movie.2024.1080p.mkv',
				progress: 1,
			},
		]
		const files = [{ name: 'Movie.2024.1080p.mkv', size: 1000 }]
		mockQbtResponses(torrents, files)

		mockSearchAllIndexers.mockResolvedValue([
			{
				guid: 'guid-4',
				title: 'Movie (2024)',
				link: 'http://indexer/download/4',
				size: 1000,
				pubDate: '',
				indexer: 'Test',
				indexerId: 1,
			},
		])

		mockDownloadTorrentDirect.mockResolvedValue(
			makeMultiFileTorrentData('Movie (2024)', [{ path: ['Movie.2024.1080p.mkv'], length: 1000 }])
		)

		const result = await runCrossSeedScan({ instanceId: 1, userId: 1, force: false })

		expect(result.matchesFound).toBe(1)
		expect(result.torrentsAdded).toBe(1)
		expect(fsMocks.link.mock.calls.length).toBeGreaterThan(0)
		expect(fsMocks.link.mock.calls[0][1]).toBe('/links/Movie (2024)/Movie.2024.1080p.mkv')
	})

	it('constructs correct source paths for multi-file hardlinks', async () => {
		state.config!.match_mode = 'flexible'
		state.config!.link_dir = '/links'

		const torrents = [
			{
				hash: 'HASH5',
				name: 'Show.S01',
				size: 3000,
				state: 'uploading',
				category: 'shows',
				tags: '',
				save_path: '/downloads',
				content_path: '/downloads/Show.S01',
				progress: 1,
			},
		]
		const files = [
			{ name: 'Show.S01/E01.mkv', size: 1000 },
			{ name: 'Show.S01/E02.mkv', size: 2000 },
		]
		mockQbtResponses(torrents, files)

		mockSearchAllIndexers.mockResolvedValue([
			{
				guid: 'guid-5',
				title: 'Show Season 1',
				link: 'http://indexer/download/5',
				size: 3000,
				pubDate: '',
				indexer: 'Test',
				indexerId: 1,
			},
		])

		mockDownloadTorrentDirect.mockResolvedValue(
			makeMultiFileTorrentData('Show Season 1', [
				{ path: ['Episode01.mkv'], length: 1000 },
				{ path: ['Episode02.mkv'], length: 2000 },
			])
		)

		const result = await runCrossSeedScan({ instanceId: 1, userId: 1, force: false })

		expect(result.matchesFound).toBe(1)
		expect(result.torrentsAdded).toBe(1)
		expect(fsMocks.link.mock.calls[0][0]).toBe('/downloads/Show.S01/E01.mkv')
		expect(fsMocks.link.mock.calls[0][1]).toBe('/links/Show Season 1/Episode01.mkv')
		expect(fsMocks.link.mock.calls[1][0]).toBe('/downloads/Show.S01/E02.mkv')
		expect(fsMocks.link.mock.calls[1][1]).toBe('/links/Show Season 1/Episode02.mkv')
	})

	it('saves to output in dry-run mode', async () => {
		state.config!.dry_run = 1

		const torrents = [
			{
				hash: 'HASH1',
				name: 'Movie.2024.1080p.mkv',
				size: 1000,
				state: 'uploading',
				category: 'movies',
				tags: '',
				save_path: '/downloads',
				content_path: '/downloads/Movie.2024.1080p.mkv',
				progress: 1,
			},
		]
		const files = [{ name: 'Movie.2024.1080p.mkv', size: 1000 }]
		mockQbtResponses(torrents, files)

		mockSearchAllIndexers.mockResolvedValue([
			{
				guid: 'guid-1',
				title: 'Movie 2024 1080p',
				link: 'http://indexer/download/1',
				size: 1000,
				pubDate: '',
				indexer: 'Test',
				indexerId: 1,
			},
		])
		mockDownloadTorrentDirect.mockResolvedValue(makeTorrentData('Movie.2024.1080p.mkv', 1000))

		const result = await runCrossSeedScan({ instanceId: 1, userId: 1, force: false })

		expect(result.matchesFound).toBe(1)
		expect(result.torrentsAdded).toBe(0)
		expect(mockSaveTorrentToOutput).toHaveBeenCalledTimes(1)
		expect(mockFetchWithTls.mock.calls.some((call) => String(call[0]).endsWith('/api/v2/torrents/add'))).toBe(false)
	})

	it('skips torrents that were already searched when not forced', async () => {
		state.searchees.set('1:HASH1', {
			id: 1,
			instance_id: 1,
			torrent_hash: 'HASH1',
			torrent_name: 'Movie',
			total_size: 1000,
			file_count: 1,
			file_sizes: '[1000]',
			first_searched: 0,
			last_searched: 0,
		})

		const torrents = [
			{
				hash: 'HASH1',
				name: 'Movie.2024.1080p.mkv',
				size: 1000,
				state: 'uploading',
				category: 'movies',
				tags: '',
				save_path: '/downloads',
				content_path: '/downloads/Movie.2024.1080p.mkv',
				progress: 1,
			},
		]
		mockQbtResponses(torrents, [])

		const result = await runCrossSeedScan({ instanceId: 1, userId: 1, force: false })

		expect(result.torrentsSkipped).toBe(1)
		expect(result.torrentsScanned).toBe(0)
		expect(mockSearchAllIndexers).not.toHaveBeenCalled()
		expect(mockDownloadTorrentDirect).not.toHaveBeenCalled()
	})

	it('skips candidates already in the client by infohash', async () => {
		const torrents = [
			{
				hash: 'HASH1',
				name: 'Movie.2024.1080p.mkv',
				size: 1000,
				state: 'uploading',
				category: 'movies',
				tags: '',
				save_path: '/downloads',
				content_path: '/downloads/Movie.2024.1080p.mkv',
				progress: 1,
			},
			{
				hash: 'EXISTING',
				name: 'Already added',
				size: 1000,
				state: 'uploading',
				category: 'movies',
				tags: '',
				save_path: '/downloads',
				content_path: '/downloads/Already added',
				progress: 0.5,
			},
		]
		mockQbtResponses(torrents, [{ name: 'Movie.2024.1080p.mkv', size: 1000 }])

		mockSearchAllIndexers.mockResolvedValue([
			{
				guid: 'guid-1',
				title: 'Movie 2024 1080p',
				link: 'http://indexer/download/1',
				size: 1000,
				pubDate: '',
				indexer: 'Test',
				indexerId: 1,
				infoHash: 'EXISTING',
			},
		])

		const result = await runCrossSeedScan({ instanceId: 1, userId: 1, force: true })

		expect(result.matchesFound).toBe(0)
		expect(mockDownloadTorrentDirect).not.toHaveBeenCalled()
	})

	it('passes configured indexer ids to search', async () => {
		state.config!.indexer_ids = JSON.stringify([5, 9])

		const torrents = [
			{
				hash: 'HASH1',
				name: 'Movie.2024.1080p.mkv',
				size: 1000,
				state: 'uploading',
				category: 'movies',
				tags: '',
				save_path: '/downloads',
				content_path: '/downloads/Movie.2024.1080p.mkv',
				progress: 1,
			},
		]
		const files = [{ name: 'Movie.2024.1080p.mkv', size: 1000 }]
		mockQbtResponses(torrents, files)

		mockSearchAllIndexers.mockResolvedValue([])

		await runCrossSeedScan({ instanceId: 1, userId: 1, force: false })

		expect(mockSearchAllIndexers).toHaveBeenCalledWith('http://prowlarr', 'apikey', 'Movie 2024 1080p', 10, [5, 9])
	})

	it('returns error when qBittorrent login fails', async () => {
		mockLoginToQbt.mockResolvedValueOnce({ success: false, error: 'bad credentials' })

		const result = await runCrossSeedScan({ instanceId: 1, userId: 1, force: false })

		expect(result.errors[0]).toContain('qBittorrent login failed')
		expect(mockFetchWithTls).not.toHaveBeenCalled()
	})

	it('returns error when qBittorrent torrent list fetch fails', async () => {
		mockFetchWithTls.mockImplementation((url: string) => {
			if (url.endsWith('/api/v2/app/version')) {
				return Promise.resolve(new Response('v5.0.0', { status: 200 }))
			}
			if (url.includes('/api/v2/torrents/info')) {
				return Promise.resolve(new Response('fail', { status: 500 }))
			}
			return Promise.resolve(new Response('Not Found', { status: 404 }))
		})

		const result = await runCrossSeedScan({ instanceId: 1, userId: 1, force: false })

		expect(result.errors[0]).toContain('Failed to fetch torrents from qBittorrent')
		expect(mockSearchAllIndexers).not.toHaveBeenCalled()
	})

	it('records search errors when torznab search throws', async () => {
		const torrents = [
			{
				hash: 'HASH1',
				name: 'Movie.2024.1080p.mkv',
				size: 1000,
				state: 'uploading',
				category: 'movies',
				tags: '',
				save_path: '/downloads',
				content_path: '/downloads/Movie.2024.1080p.mkv',
				progress: 1,
			},
		]
		const files = [{ name: 'Movie.2024.1080p.mkv', size: 1000 }]
		mockQbtResponses(torrents, files)

		mockSearchAllIndexers.mockRejectedValueOnce(new Error('torznab down'))

		const result = await runCrossSeedScan({ instanceId: 1, userId: 1, force: false })

		expect(result.errors[0]).toContain('Search failed for Movie.2024.1080p.mkv')
		expect(result.matchesFound).toBe(0)
	})

	it('records add failure when qBittorrent rejects the torrent', async () => {
		const torrents = [
			{
				hash: 'HASH1',
				name: 'Movie.2024.1080p.mkv',
				size: 1000,
				state: 'uploading',
				category: 'movies',
				tags: '',
				save_path: '/downloads',
				content_path: '/downloads/Movie.2024.1080p.mkv',
				progress: 1,
			},
		]
		const files = [{ name: 'Movie.2024.1080p.mkv', size: 1000 }]

		mockFetchWithTls.mockImplementation((url: string) => {
			if (url.endsWith('/api/v2/torrents/info')) {
				return Promise.resolve(new Response(JSON.stringify(torrents), { status: 200 }))
			}
			if (url.includes('/api/v2/torrents/files')) {
				return Promise.resolve(new Response(JSON.stringify(files), { status: 200 }))
			}
			if (url.endsWith('/api/v2/torrents/add')) {
				return Promise.resolve(new Response('Nope', { status: 200 }))
			}
			return Promise.resolve(new Response('Not Found', { status: 404 }))
		})

		mockSearchAllIndexers.mockResolvedValue([
			{
				guid: 'guid-1',
				title: 'Movie 2024 1080p',
				link: 'http://indexer/download/1',
				size: 1000,
				pubDate: '',
				indexer: 'Test',
				indexerId: 1,
			},
		])
		mockDownloadTorrentDirect.mockResolvedValue(makeTorrentData('Movie.2024.1080p.mkv', 1000))

		const result = await runCrossSeedScan({ instanceId: 1, userId: 1, force: false })

		expect(result.matchesFound).toBe(1)
		expect(result.torrentsAdded).toBe(0)
		expect(result.errors[0]).toContain('Failed to add torrent: Movie 2024 1080p')
	})

	it('updates last_seen for existing decisions', async () => {
		state.searchees.set('1:HASH1', {
			id: 1,
			instance_id: 1,
			torrent_hash: 'HASH1',
			torrent_name: 'Movie',
			total_size: 1000,
			file_count: 1,
			file_sizes: '[1000]',
			first_searched: 0,
			last_searched: 0,
		})
		state.decisions.set('1:guid-1', {
			decision: 'SIZE_MISMATCH',
			info_hash: null,
			last_seen: 100,
		})

		const torrents = [
			{
				hash: 'HASH1',
				name: 'Movie.2024.1080p.mkv',
				size: 1000,
				state: 'uploading',
				category: 'movies',
				tags: '',
				save_path: '/downloads',
				content_path: '/downloads/Movie.2024.1080p.mkv',
				progress: 1,
			},
		]
		const files = [{ name: 'Movie.2024.1080p.mkv', size: 1000 }]
		mockQbtResponses(torrents, files)

		mockSearchAllIndexers.mockResolvedValue([
			{
				guid: 'guid-1',
				title: 'Movie 2024 1080p',
				link: 'http://indexer/download/1',
				size: 1000,
				pubDate: '',
				indexer: 'Test',
				indexerId: 1,
			},
		])
		mockDownloadTorrentDirect.mockResolvedValue(makeTorrentData('Movie.2024.1080p.mkv', 2000))

		await runCrossSeedScan({ instanceId: 1, userId: 1, force: true })

		const updated = state.decisions.get('1:guid-1')
		expect(updated?.last_seen).toBeGreaterThan(100)
	})
})


================================================
FILE: __tests__/server/fetch.test.ts
================================================
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fetchWithTls } from '../../src/server/utils/fetch'

// Mock global fetch
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)

describe('fetchWithTls', () => {
    beforeEach(() => {
        mockFetch.mockReset()
    })

    describe('successful requests', () => {
        it('makes basic fetch request', async () => {
            const mockResponse = new Response('OK', { status: 200 })
            mockFetch.mockResolvedValueOnce(mockResponse)

            const result = await fetchWithTls('http://localhost:8080/api')

            expect(mockFetch).toHaveBeenCalledWith(
                'http://localhost:8080/api',
                expect.anything()
            )
            expect(result).toBe(mockResponse)
        })

        it('passes through request options', async () => {
            mockFetch.mockResolvedValueOnce(new Response('OK'))

            await fetchWithTls('http://localhost:8080/api', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ test: true }),
            })

            expect(mockFetch).toHaveBeenCalledWith(
                'http://localhost:8080/api',
                expect.objectContaining({
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                })
            )
        })
    })

    describe('error handling', () => {
        it('rethrows non-certificate errors', async () => {
            mockFetch.mockRejectedValueOnce(new Error('Network error'))

            await expect(fetchWithTls('http://localhost:8080'))
                .rejects.toThrow('Network error')
        })

        it('provides helpful message for self-signed cert errors', async () => {
            mockFetch.mockRejectedValueOnce(new Error('self-signed certificate'))

            await expect(fetchWithTls('http://localhost:8080'))
                .rejects.toThrow('TLS certificate validation failed')
        })

        it('handles SELF_SIGNED_CERT_IN_CHAIN error', async () => {
            mockFetch.mockRejectedValueOnce(new Error('SELF_SIGNED_CERT_IN_CHAIN'))

            await expect(fetchWithTls('http://localhost:8080'))
                .rejects.toThrow('TLS certificate validation failed')
        })

        it('handles certificate expired errors', async () => {
            mockFetch.mockRejectedValueOnce(new Error('certificate has expired'))

            await expect(fetchWithTls('http://localhost:8080'))
                .rejects.toThrow('TLS certificate validation failed')
        })

        it('handles CERT_HAS_EXPIRED error', async () => {
            mockFetch.mockRejectedValueOnce(new Error('CERT_HAS_EXPIRED'))

            await expect(fetchWithTls('http://localhost:8080'))
                .rejects.toThrow('TLS certificate validation failed')
        })
    })

    describe('request types', () => {
        it('handles GET requests', async () => {
            mockFetch.mockResolvedValueOnce(new Response('{}'))

            await fetchWithTls('http://localhost/api', { method: 'GET' })

            expect(mockFetch).toHaveBeenCalledWith(
                expect.any(String),
                expect.objectContaining({ method: 'GET' })
            )
        })

        it('handles POST requests with body', async () => {
            mockFetch.mockResolvedValueOnce(new Response('{}'))

            await fetchWithTls('http://localhost/api', {
                method: 'POST',
                body: 'test=value',
            })

            expect(mockFetch).toHaveBeenCalledWith(
                expect.any(String),
                expect.objectContaining({ method: 'POST', body: 'test=value' })
            )
        })

        it('handles DELETE requests', async () => {
            mockFetch.mockResolvedValueOnce(new Response(''))

            await fetchWithTls('http://localhost/api/1', { method: 'DELETE' })

            expect(mockFetch).toHaveBeenCalledWith(
                expect.any(String),
                expect.objectContaining({ method: 'DELETE' })
            )
        })
    })
})


================================================
FILE: __tests__/server/logger.test.ts
================================================
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { log } from '../../src/server/utils/logger'

describe('logger utilities', () => {
    let consoleSpy: { log: ReturnType<typeof vi.spyOn>; warn: ReturnType<typeof vi.spyOn>; error: ReturnType<typeof vi.spyOn> }

    beforeEach(() => {
        consoleSpy = {
            log: vi.spyOn(console, 'log').mockImplementation(() => { }),
            warn: vi.spyOn(console, 'warn').mockImplementation(() => { }),
            error: vi.spyOn(console, 'error').mockImplementation(() => { }),
        }
    })

    afterEach(() => {
        vi.restoreAllMocks()
    })

    describe('log.info', () => {
        it('logs message with INFO level', () => {
            log.info('Test message')
            expect(consoleSpy.log).toHaveBeenCalledOnce()
            expect(consoleSpy.log.mock.calls[0][0]).toContain('[INFO]')
            expect(consoleSpy.log.mock.calls[0][0]).toContain('Test message')
        })

        it('includes timestamp', () => {
            log.info('Test')
            const call = consoleSpy.log.mock.calls[0][0]
            // Timestamp format: [2024-01-18T12:00:00.000Z]
            expect(call).toMatch(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
        })
    })

    describe('log.warn', () => {
        it('logs message with WARN level', () => {
            log.warn('Warning message')
            expect(consoleSpy.warn).toHaveBeenCalledOnce()
            expect(consoleSpy.warn.mock.calls[0][0]).toContain('[WARN]')
            expect(consoleSpy.warn.mock.calls[0][0]).toContain('Warning message')
        })
    })

    describe('log.error', () => {
        it('logs message with ERROR level', () => {
            log.error('Error message')
            expect(consoleSpy.error).toHaveBeenCalledOnce()
            expect(consoleSpy.error.mock.calls[0][0]).toContain('[ERROR]')
            expect(consoleSpy.error.mock.calls[0][0]).toContain('Error message')
        })
    })

    describe('log formatting', () => {
        it('handles empty messages', () => {
            log.info('')
            expect(consoleSpy.log).toHaveBeenCalledOnce()
        })

        it('handles special characters', () => {
            log.info('Message with "quotes" and <brackets>')
            expect(consoleSpy.log.mock.calls[0][0]).toContain('Message with "quotes" and <brackets>')
        })

        it('handles unicode characters', () => {
            log.info('🚀 Unicode message ✅')
            expect(consoleSpy.log.mock.calls[0][0]).toContain('🚀 Unicode message ✅')
        })
    })
})


================================================
FILE: __tests__/server/rateLimit.test.ts
================================================
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { checkRateLimit, resetRateLimit } from '../../src/server/utils/rateLimit'

describe('rateLimit utilities', () => {
    beforeEach(() => {
        // Reset all rate limits before each test
        resetRateLimit('test-key')
        resetRateLimit('another-key')
    })

    describe('checkRateLimit', () => {
        it('allows first request', () => {
            const result = checkRateLimit('test-key')
            expect(result.allowed).toBe(true)
            expect(result.retryAfter).toBeUndefined()
        })

        it('allows requests up to the limit', () => {
            for (let i = 0; i < 5; i++) {
                const result = checkRateLimit('test-key')
                expect(result.allowed).toBe(true)
            }
        })

        it('blocks requests after limit is exceeded', () => {
            // Use up all attempts
            for (let i = 0; i < 5; i++) {
                checkRateLimit('test-key')
            }

            // This should be blocked
            const result = checkRateLimit('test-key')
            expect(result.allowed).toBe(false)
            expect(result.retryAfter).toBeDefined()
            expect(result.retryAfter).toBeGreaterThan(0)
        })

        it('tracks limits per key independently', () => {
            // Use up key1 limit
            for (let i = 0; i < 5; i++) {
                checkRateLimit('key1')
            }

            // key2 should still be allowed
            const result = checkRateLimit('key2')
            expect(result.allowed).toBe(true)
        })

        it('returns retryAfter in seconds', () => {
            // Use up all attempts
            for (let i = 0; i < 5; i++) {
                checkRateLimit('test-key')
            }

            const result = checkRateLimit('test-key')
            expect(result.allowed).toBe(false)
            // retryAfter should be in seconds (less than 60 since window is 60s)
            expect(result.retryAfter).toBeLessThanOrEqual(60)
            expect(result.retryAfter).toBeGreaterThan(0)
        })
    })

    describe('resetRateLimit', () => {
        it('resets rate limit counter', () => {
            // Use up all attempts
            for (let i = 0; i < 5; i++) {
                checkRateLimit('test-key')
            }

            // Should be blocked
            expect(checkRateLimit('test-key').allowed).toBe(false)

            // Reset
            resetRateLimit('test-key')

            // Should be allowed again
            expect(checkRateLimit('test-key').allowed).toBe(true)
        })

        it('does not affect other keys', () => {
            // Use up both keys
            for (let i = 0; i < 5; i++) {
                checkRateLimit('key1')
                checkRateLimit('key2')
            }

            // Reset only key1
            resetRateLimit('key1')

            // key1 should be allowed, key2 should be blocked
            expect(checkRateLimit('key1').allowed).toBe(true)
            expect(checkRateLimit('key2').allowed).toBe(false)
        })

        it('handles resetting non-existent key', () => {
            // This should not throw
            expect(() => resetRateLimit('nonexistent')).not.toThrow()
        })
    })

    describe('rate limit timing', () => {
        it('resets after window expires', async () => {
            vi.useFakeTimers()

            // Use up all attempts
            for (let i = 0; i < 5; i++) {
                checkRateLimit('test-key')
            }
            expect(checkRateLimit('test-key').allowed).toBe(false)

            // Advance time past the window (60 seconds)
            vi.advanceTimersByTime(61 * 1000)

            // Should be allowed again
            expect(checkRateLimit('test-key').allowed).toBe(true)

            vi.useRealTimers()
        })

        it('does not reset before window expires', async () => {
            vi.useFakeTimers()

            // Use up all attempts
            for (let i = 0; i < 5; i++) {
                checkRateLimit('test-key')
            }

            // Advance time but not past the window
            vi.advanceTimersByTime(30 * 1000)

            // Should still be blocked
            expect(checkRateLimit('test-key').allowed).toBe(false)

            vi.useRealTimers()
        })
    })

    describe('concurrent usage patterns', () => {
        it('handles rapid sequential requests', () => {
            let blockedCount = 0
            for (let i = 0; i < 10; i++) {
                const result = checkRateLimit('rapid-test')
                if (!result.allowed) blockedCount++
            }
            // 5 allowed, 5 blocked
            expect(blockedCount).toBe(5)
        })
    })
})


================================================
FILE: __tests__/server/url.test.ts
================================================
import { describe, it, expect } from 'vitest'
import { isUrlAllowed, validateUrl } from '../../src/server/utils/url'

describe('url utilities', () => {
    describe('isUrlAllowed', () => {
        describe('valid URLs', () => {
            it('allows http URLs', () => {
                const result = isUrlAllowed('http://localhost:8080')
                expect(result.allowed).toBe(true)
            })

            it('allows https URLs', () => {
                const result = isUrlAllowed('https://example.com')
                expect(result.allowed).toBe(true)
            })

            it('allows IP addresses', () => {
                const result = isUrlAllowed('http://192.168.1.100:8080')
                expect(result.allowed).toBe(true)
            })

            it('allows URLs with paths', () => {
                const result = isUrlAllowed('http://localhost:8080/api/v1')
                expect(result.allowed).toBe(true)
            })

            it('allows URLs with query strings', () => {
                const result = isUrlAllowed('https://example.com/search?q=test')
                expect(result.allowed).toBe(true)
            })
        })

        describe('invalid URLs', () => {
            it('rejects invalid URL format', () => {
                const result = isUrlAllowed('not-a-url')
                expect(result.allowed).toBe(false)
                expect(result.reason).toBe('Invalid URL format')
            })

            it('rejects empty string', () => {
                const result = isUrlAllowed('')
                expect(result.allowed).toBe(false)
                expect(result.reason).toBe('Invalid URL format')
            })

            it('rejects ftp protocol', () => {
                const result = isUrlAllowed('ftp://example.com')
                expect(result.allowed).toBe(false)
                expect(result.reason).toBe('Only HTTP/HTTPS protocols allowed')
            })

            it('rejects file protocol', () => {
                const result = isUrlAllowed('file:///etc/passwd')
                expect(result.allowed).toBe(false)
                expect(result.reason).toBe('Only HTTP/HTTPS protocols allowed')
            })

            it('rejects javascript protocol', () => {
                const result = isUrlAllowed('javascript:alert(1)')
                expect(result.allowed).toBe(false)
                expect(result.reason).toBe('Only HTTP/HTTPS protocols allowed')
            })
        })

        describe('cloud metadata protection', () => {
            it('blocks AWS metadata endpoint', () => {
                const result = isUrlAllowed('http://169.254.169.254/latest/meta-data/')
                expect(result.allowed).toBe(false)
                expect(result.reason).toBe('Cloud metadata endpoints not allowed')
            })

            it('blocks Google Cloud metadata endpoint', () => {
                const result = isUrlAllowed('http://metadata.google.internal/computeMetadata/')
                expect(result.allowed).toBe(false)
                expect(result.reason).toBe('Cloud metadata endpoints not allowed')
            })

            it('blocks AWS metadata internal', () => {
                const result = isUrlAllowed('http://metadata.aws.internal/')
                expect(result.allowed).toBe(false)
                expect(result.reason).toBe('Cloud metadata endpoints not allowed')
            })

            it('blocks ECS metadata endpoint', () => {
                const result = isUrlAllowed('http://169.254.170.2/v2/credentials')
                expect(result.allowed).toBe(false)
                expect(result.reason).toBe('Cloud metadata endpoints not allowed')
            })

            it('blocks any 169.254.x.x link-local address', () => {
                const result = isUrlAllowed('http://169.254.1.1/')
                expect(result.allowed).toBe(false)
                expect(result.reason).toBe('Cloud metadata endpoints not allowed')
            })

            it('handles case-insensitive hostnames', () => {
                const result = isUrlAllowed('http://METADATA.GOOGLE.INTERNAL/')
                expect(result.allowed).toBe(false)
            })
        })
    })

    describe('validateUrl', () => {
        it('does not throw for valid URLs', () => {
            expect(() => validateUrl('http://localhost:8080')).not.toThrow()
            expect(() => validateUrl('https://example.com')).not.toThrow()
        })

        it('throws for invalid URL format', () => {
            expect(() => validateUrl('not-a-url')).toThrow('Invalid URL format')
        })

        it('throws for invalid protocol', () => {
            expect(() => validateUrl('ftp://example.com')).toThrow('Only HTTP/HTTPS protocols allowed')
        })

        it('throws for cloud metadata endpoints', () => {
            expect(() => validateUrl('http://169.254.169.254/')).toThrow('Cloud metadata endpoints not allowed')
        })
    })
})


================================================
FILE: __tests__/themes/themes.test.ts
================================================
import { describe, it, expect } from 'vitest'
import { themes, getThemeById } from '../../src/themes/index'

describe('themes', () => {
    describe('themes array', () => {
        it('contains expected theme count', () => {
            expect(themes.length).toBeGreaterThanOrEqual(5)
        })

        it('has default theme first', () => {
            expect(themes[0].id).toBe('default')
        })

        it('all themes have required properties', () => {
            for (const theme of themes) {
                expect(theme.id).toBeTruthy()
                expect(theme.name).toBeTruthy()
                expect(theme.colors).toBeDefined()
                expect(theme.colors.bgPrimary).toBeTruthy()
                expect(theme.colors.bgSecondary).toBeTruthy()
                expect(theme.colors.textPrimary).toBeTruthy()
                expect(theme.colors.accent).toBeTruthy()
                expect(theme.colors.error).toBeTruthy()
                expect(theme.colors.warning).toBeTruthy()
            }
        })

        it('all colors are valid hex codes', () => {
            const hexPattern = /^#[0-9A-Fa-f]{6}$/

            for (const theme of themes) {
                for (const [key, value] of Object.entries(theme.colors)) {
                    expect(value, `${theme.id}.colors.${key}`).toMatch(hexPattern)
                }
            }
        })

        it('has unique theme IDs', () => {
            const ids = themes.map(t => t.id)
            const uniqueIds = new Set(ids)
            expect(uniqueIds.size).toBe(ids.length)
        })

        it('has unique theme names', () => {
            const names = themes.map(t => t.name)
            const uniqueNames = new Set(names)
            expect(uniqueNames.size).toBe(names.length)
        })
    })

    describe('individual themes', () => {
        it('default (Midnight) theme has correct structure', () => {
            const midnight = themes.find(t => t.id === 'default')
            expect(midnight).toBeDefined()
            expect(midnight?.name).toBe('Midnight')
            expect(midnight?.colors.accent).toBe('#00d4aa')
        })

        it('catppuccin theme exists', () => {
            const catppuccin = themes.find(t => t.id === 'catppuccin')
            expect(catppuccin).toBeDefined()
            expect(catppuccin?.name).toBe('Catppuccin')
        })

        it('dracula theme exists', () => {
            const dracula = themes.find(t => t.id === 'dracula')
            expect(dracula).toBeDefined()
            expect(dracula?.colors.accent).toBe('#bd93f9')
        })

        it('nord theme exists', () => {
            const nord = themes.find(t => t.id === 'nord')
            expect(nord).toBeDefined()
        })

        it('gruvbox theme exists', () => {
            const gruvbox = themes.find(t => t.id === 'gruvbox')
            expect(gruvbox).toBeDefined()
        })

        it('everforest theme exists', () => {
            const everforest = themes.find(t => t.id === 'everforest')
            expect(everforest).toBeDefined()
        })
    })

    describe('getThemeById', () => {
        it('returns correct theme for valid ID', () => {
            const theme = getThemeById('catppuccin')
            expect(theme.id).toBe('catppuccin')
            expect(theme.name).toBe('Catppuccin')
        })

        it('returns default theme for unknown ID', () => {
            const theme = getThemeById('nonexistent')
            expect(theme).toEqual(themes[0])
            expect(theme.id).toBe('default')
        })

        it('returns default theme for empty string', () => {
            const theme = getThemeById('')
            expect(theme.id).toBe('default')
        })

        it('returns theme with all color properties', () => {
            const theme = getThemeById('nord')
            expect(theme.colors.bgPrimary).toBeDefined()
            expect(theme.colors.bgSecondary).toBeDefined()
            expect(theme.colors.bgTertiary).toBeDefined()
            expect(theme.colors.textPrimary).toBeDefined()
            expect(theme.colors.textSecondary).toBeDefined()
            expect(theme.colors.textMuted).toBeDefined()
            expect(theme.colors.accent).toBeDefined()
            expect(theme.colors.accentContrast).toBeDefined()
            expect(theme.colors.warning).toBeDefined()
            expect(theme.colors.error).toBeDefined()
            expect(theme.colors.border).toBeDefined()
            expect(theme.colors.progress).toBeDefined()
        })
    })

    describe('theme color accessibility', () => {
        it('text colors are light on dark backgrounds', () => {
            for (const theme of themes) {
                // Primary text should be light (high value)
                const textPrimary = parseInt(theme.colors.textPrimary.slice(1, 3), 16)
                expect(textPrimary, `${theme.id} textPrimary should be light`).toBeGreaterThan(150)

                // Primary background should be dark (low value)
                const bgPrimary = parseInt(theme.colors.bgPrimary.slice(1, 3), 16)
                expect(bgPrimary, `${theme.id} bgPrimary should be dark`).toBeLessThan(80)
            }
        })
    })
})


================================================
FILE: __tests__/utils/fileTree.test.ts
================================================
import { describe, it, expect } from 'vitest'
import { buildFileTree, flattenVisibleNodes, getInitialExpanded, type FileTreeNode } from '../../src/utils/fileTree'
import type { TorrentFile } from '../../src/types/torrentDetails'

// Helper to create mock TorrentFile
function createFile(name: string, size = 1000, priority = 1, progress = 0, availability = 1): TorrentFile {
    return { name, size, priority, progress, availability, index: 0, piece_range: [0, 0], is_seed: false }
}

describe('buildFileTree', () => {
    it('creates flat file list for single-level files', () => {
        const files: TorrentFile[] = [
            createFile('file1.txt'),
            createFile('file2.txt'),
        ]
        const tree = buildFileTree(files)
        expect(tree).toHaveLength(2)
        expect(tree[0].name).toBe('file1.txt')
        expect(tree[0].isFolder).toBe(false)
        expect(tree[1].name).toBe('file2.txt')
    })

    it('creates folder structure from paths', () => {
        const files: TorrentFile[] = [
            createFile('folder/file1.txt'),
            createFile('folder/file2.txt'),
        ]
        const tree = buildFileTree(files)
        expect(tree).toHaveLength(1)
        expect(tree[0].name).toBe('folder')
        expect(tree[0].isFolder).toBe(true)
        expect(tree[0].children).toHaveLength(2)
    })

    it('creates nested folder structure', () => {
        const files: TorrentFile[] = [
            createFile('a/b/c/file.txt'),
        ]
        const tree = buildFileTree(files)
        expect(tree[0].name).toBe('a')
        expect(tree[0].children[0].name).toBe('b')
        expect(tree[0].children[0].children[0].name).toBe('c')
        expect(tree[0].children[0].children[0].children[0].name).toBe('file.txt')
    })

    it('calculates folder sizes correctly', () => {
        const files: TorrentFile[] = [
            createFile('folder/file1.txt', 1000),
            createFile('folder/file2.txt', 2000),
        ]
        const tree = buildFileTree(files)
        expect(tree[0].size).toBe(3000)
    })

    it('sorts folders before files', () => {
        const files: TorrentFile[] = [
            createFile('zfile.txt'),
            createFile('afolder/file.txt'),
        ]
        const tree = buildFileTree(files)
        expect(tree[0].name).toBe('afolder')
        expect(tree[0].isFolder).toBe(true)
        expect(tree[1].name).toBe('zfile.txt')
        expect(tree[1].isFolder).toBe(false)
    })

    it('sorts nodes alphabetically within their type', () => {
        const files: TorrentFile[] = [
            createFile('zfile.txt'),
            createFile('afile.txt'),
            createFile('mfile.txt'),
        ]
        const tree = buildFileTree(files)
        expect(tree[0].name).toBe('afile.txt')
        expect(tree[1].name).toBe('mfile.txt')
        expect(tree[2].name).toBe('zfile.txt')
    })

    it('maps priority values correctly', () => {
        const files: TorrentFile[] = [
            createFile('skip.txt', 100, 0),
            createFile('normal.txt', 100, 1),
            createFile('high.txt', 100, 6),
            createFile('max.txt', 100, 7),
        ]
        const tree = buildFileTree(files)
        expect(tree.find(n => n.name === 'skip.txt')?.priority).toBe('skip')
        expect(tree.find(n => n.name === 'normal.txt')?.priority).toBe('normal')
        expect(tree.find(n => n.name === 'high.txt')?.priority).toBe('high')
        expect(tree.find(n => n.name === 'max.txt')?.priority).toBe('max')
    })

    it('sets mixed priority for folders with different file priorities', () => {
        const files: TorrentFile[] = [
            createFile('folder/file1.txt', 100, 1),
            createFile('folder/file2.txt', 100, 6),
        ]
        const tree = buildFileTree(files)
        expect(tree[0].priority).toBe('mixed')
    })

    it('calculates folder progress correctly', () => {
        const files: TorrentFile[] = [
            createFile('folder/file1.txt', 1000, 1, 0.5),
            createFile('folder/file2.txt', 1000, 1, 1.0),
        ]
        const tree = buildFileTree(files)
        expect(tree[0].progress).toBeCloseTo(0.75)
    })
})

describe('flattenVisibleNodes', () => {
    function createTestTree(): FileTreeNode[] {
        return [
            {
                name: 'folder1',
                path: 'folder1',
                isFolder: true,
                size: 1000,
                progress: 0,
                priority: 'normal',
                availability: 1,
                fileIndices: [0, 1],
                children: [
                    {
                        name: 'file1.txt',
                        path: 'folder1/file1.txt',
                        isFolder: false,
                        size: 500,
                        progress: 0,
                        priority: 'normal',
                        availability: 1,
                        fileIndices: [0],
                        children: [],
                    },
                    {
                        name: 'file2.txt',
                        path: 'folder1/file2.txt',
                        isFolder: false,
                        size: 500,
                        progress: 0,
                        priority: 'normal',
                        availability: 1,
                        fileIndices: [1],
                        children: [],
                    },
                ],
            },
            {
                name: 'file3.txt',
                path: 'file3.txt',
                isFolder: false,
                size: 1000,
                progress: 0,
                priority: 'normal',
                availability: 1,
                fileIndices: [2],
                children: [],
            },
        ]
    }

    it('returns only top-level nodes when nothing is expanded', () => {
        const tree = createTestTree()
        const expanded = new Set<string>()
        const flattened = flattenVisibleNodes(tree, expanded)
        expect(flattened).toHaveLength(2)
        expect(flattened[0].node.name).toBe('folder1')
        expect(flattened[0].depth).toBe(0)
        expect(flattened[1].node.name).toBe('file3.txt')
    })

    it('includes children when folder is expanded', () => {
        const tree = createTestTree()
        const expanded = new Set(['folder1'])
        const flattened = flattenVisibleNodes(tree, expanded)
        expect(flattened).toHaveLength(4)
        expect(flattened[0].node.name).toBe('folder1')
        expect(flattened[0].depth).toBe(0)
        expect(flattened[1].node.name).toBe('file1.txt')
        expect(flattened[1].depth).toBe(1)
        expect(flattened[2].node.name).toBe('file2.txt')
        expect(flattened[2].depth).toBe(1)
    })

    it('correctly tracks depth for nested expansions', () => {
        const tree: FileTreeNode[] = [{
            name: 'a',
            path: 'a',
            isFolder: true,
            size: 0,
            progress: 0,
            priority: 'normal',
            availability: 0,
            fileIndices: [],
            children: [{
                name: 'b',
                path: 'a/b',
                isFolder: true,
                size: 0,
                progress: 0,
                priority: 'normal',
                availability: 0,
                fileIndices: [],
                children: [{
                    name: 'file.txt',
                    path: 'a/b/file.txt',
                    isFolder: false,
                    size: 100,
                    progress: 0,
                    priority: 'normal',
                    availability: 0,
                    fileIndices: [0],
                    children: [],
                }],
            }],
        }]

        const expanded = new Set(['a', 'a/b'])
        const flattened = flattenVisibleNodes(tree, expanded)
        expect(flattened).toHaveLength(3)
        expect(flattened[0].depth).toBe(0)
        expect(flattened[1].depth).toBe(1)
        expect(flattened[2].depth).toBe(2)
    })
})

describe('getInitialExpanded', () => {
    it('returns empty set for files only', () => {
        const tree: FileTreeNode[] = [
            {
                name: 'file.txt',
                path: 'file.txt',
                isFolder: false,
                size: 100,
                progress: 0,
                priority: 'normal',
                availability: 0,
                fileIndices: [0],
                children: [],
            },
        ]
        const expanded = getInitialExpanded(tree)
        expect(expanded.size).toBe(0)
    })

    it('expands single-child folder paths', () => {
        const tree: FileTreeNode[] = [{
            name: 'a',
            path: 'a',
            isFolder: true,
            size: 100,
            progress: 0,
            priority: 'normal',
            availability: 0,
            fileIndices: [],
            children: [{
                name: 'b',
                path: 'a/b',
                isFolder: true,
                size: 100,
                progress: 0,
                priority: 'normal',
                availability: 0,
                fileIndices: [],
                children: [{
                    name: 'file.txt',
                    path: 'a/b/file.txt',
                    isFolder: false,
                    size: 100,
                    progress: 0,
                    priority: 'normal',
                    availability: 0,
                    fileIndices: [0],
                    children: [],
                }],
            }],
        }]

        const expanded = getInitialExpanded(tree)
        expect(expanded.has('a')).toBe(true)
        expect(expanded.has('a/b')).toBe(true)
    })

    it('stops expanding when there are multiple folders', () => {
        const tree: FileTreeNode[] = [{
            name: 'a',
            path: 'a',
            isFolder: true,
            size: 0,
            progress: 0,
            priority: 'normal',
            availability: 0,
            fileIndices: [],
            children: [
                {
                    name: 'b',
                    path: 'a/b',
                    isFolder: true,
                    size: 0,
                    progress: 0,
                    priority: 'normal',
                    availability: 0,
                    fileIndices: [],
                    children: [],
                },
                {
                    name: 'c',
                    path: 'a/c',
                    isFolder: true,
                    size: 0,
                    progress: 0,
                    priority: 'normal',
                    availability: 0,
                    fileIndices: [],
                    children: [],
                },
            ],
        }]

        const expanded = getInitialExpanded(tree)
        expect(expanded.has('a')).toBe(true)
        expect(expanded.has('a/b')).toBe(false)
        expect(expanded.has('a/c')).toBe(false)
    })
})


================================================
FILE: __tests__/utils/format.test.ts
================================================
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
    formatSpeed,
    formatSize,
    formatCompactSpeed,
    formatCompactSize,
    formatEta,
    formatDate,
    formatDuration,
    formatRelativeTime,
    formatRelativeDate,
    normalizeSearch,
} from '../../src/utils/format'

describe('formatSpeed', () => {
    it('formats bytes per second', () => {
        expect(formatSpeed(0)).toBe('0 B/s')
        expect(formatSpeed(512)).toBe('512 B/s')
        expect(formatSpeed(1023)).toBe('1023 B/s')
    })

    it('formats kibibytes per second', () => {
        expect(formatSpeed(1024)).toBe('1.0 KiB/s')
        expect(formatSpeed(1536)).toBe('1.5 KiB/s')
        expect(formatSpeed(1024 * 1024 - 1)).toBe('1024.0 KiB/s')
    })

    it('formats mebibytes per second', () => {
        expect(formatSpeed(1024 * 1024)).toBe('1.00 MiB/s')
        expect(formatSpeed(1.5 * 1024 * 1024)).toBe('1.50 MiB/s')
        expect(formatSpeed(100 * 1024 * 1024)).toBe('100.00 MiB/s')
    })

    it('returns dash when showZero is false and value is 0', () => {
        expect(formatSpeed(0, false)).toBe('—')
    })
})

describe('formatSize', () => {
    it('formats bytes', () => {
        expect(formatSize(0)).toBe('0 B')
        expect(formatSize(1)).toBe('1 B')
        expect(formatSize(1023)).toBe('1023 B')
    })

    it('formats kibibytes', () => {
        expect(formatSize(1024)).toBe('1.0 KiB')
        expect(formatSize(1536)).toBe('1.5 KiB')
    })

    it('formats mebibytes', () => {
        expect(formatSize(1024 * 1024)).toBe('1.0 MiB')
        expect(formatSize(500 * 1024 * 1024)).toBe('500.0 MiB')
    })

    it('formats gibibytes', () => {
        expect(formatSize(1024 * 1024 * 1024)).toBe('1.00 GiB')
        expect(formatSize(4.7 * 1024 * 1024 * 1024)).toBe('4.70 GiB')
    })

    it('formats tebibytes', () => {
        expect(formatSize(1024 * 1024 * 1024 * 1024)).toBe('1.00 TiB')
        expect(formatSize(2.5 * 1024 * 1024 * 1024 * 1024)).toBe('2.50 TiB')
    })
})

describe('formatCompactSpeed', () => {
    it('returns dash for zero', () => {
        expect(formatCompactSpeed(0)).toBe('-')
    })

    it('formats compact bytes', () => {
        expect(formatCompactSpeed(512)).toBe('512B')
    })

    it('formats compact kibibytes', () => {
        expect(formatCompactSpeed(1024)).toBe('1Ki')
        expect(formatCompactSpeed(2048)).toBe('2Ki')
    })

    it('formats compact mebibytes', () => {
        expect(formatCompactSpeed(1024 * 1024)).toBe('1.0Mi')
        expect(formatCompactSpeed(10.5 * 1024 * 1024)).toBe('10.5Mi')
    })
})

describe('formatCompactSize', () => {
    it('formats compact bytes', () => {
        expect(formatCompactSize(512)).toBe('512B')
    })

    it('formats compact kibibytes', () => {
        expect(formatCompactSize(1024)).toBe('1Ki')
    })

    it('formats compact mebibytes', () => {
        expect(formatCompactSize(1024 * 1024)).toBe('1Mi')
    })

    it('formats compact gibibytes', () => {
        expect(formatCompactSize(1024 * 1024 * 1024)).toBe('1.0Gi')
    })
})

describe('formatEta', () => {
    it('returns infinity for negative values', () => {
        expect(formatEta(-1)).toBe('∞')
        expect(formatEta(-1000)).toBe('∞')
    })

    it('returns infinity for qBittorrent unknown value', () => {
        expect(formatEta(8640000)).toBe('∞')
    })

    it('formats seconds', () => {
        expect(formatEta(0)).toBe('0s')
        expect(formatEta(30)).toBe('30s')
        expect(formatEta(59)).toBe('59s')
    })

    it('formats minutes', () => {
        expect(formatEta(60)).toBe('1m')
        expect(formatEta(120)).toBe('2m')
        expect(formatEta(3599)).toBe('59m')
    })

    it('formats hours and minutes', () => {
        expect(formatEta(3600)).toBe('1h 0m')
        expect(formatEta(3661)).toBe('1h 1m')
        expect(formatEta(7200)).toBe('2h 0m')
    })

    it('formats days', () => {
        expect(formatEta(86400)).toBe('1d')
        expect(formatEta(172800)).toBe('2d')
    })
})

describe('formatDate', () => {
    it('returns dash for zero or negative timestamp', () => {
        expect(formatDate(0)).toBe('—')
        expect(formatDate(-1)).toBe('—')
    })

    it('formats valid timestamps', () => {
        // Just verify it returns a non-empty string for valid timestamps
        const result = formatDate(1704067200) // Jan 1, 2024 00:00:00 UTC
        expect(result).toBeTruthy()
        expect(result).not.toBe('—')
    })
})

describe('formatDuration', () => {
    it('returns dash for negative values', () => {
        expect(formatDuration(-1)).toBe('—')
    })

    it('formats seconds only', () => {
        expect(formatDuration(0)).toBe('0s')
        expect(formatDuration(30)).toBe('30s')
        expect(formatDuration(59)).toBe('59s')
    })

    it('formats minutes and seconds', () => {
        expect(formatDuration(60)).toBe('1m 0s')
        expect(formatDuration(125)).toBe('2m 5s')
    })

    it('formats hours, minutes, and seconds', () => {
        expect(formatDuration(3600)).toBe('1h 0m 0s')
        expect(formatDuration(3665)).toBe('1h 1m 5s')
    })

    it('formats days, hours, and minutes', () => {
        expect(formatDuration(86400)).toBe('1d 0h 0m')
        expect(formatDuration(90061)).toBe('1d 1h 1m')
    })
})

describe('formatRelativeTime', () => {
    beforeEach(() => {
        vi.useFakeTimers()
        vi.setSystemTime(new Date('2024-01-15T12:00:00Z'))
    })

    afterEach(() => {
        vi.useRealTimers()
    })

    it('returns Never for zero or negative timestamp', () => {
        expect(formatRelativeTime(0)).toBe('Never')
        expect(formatRelativeTime(-1)).toBe('Never')
    })

    it('returns Just now for recent timestamps', () => {
        const now = Math.floor(Date.now() / 1000)
        expect(formatRelativeTime(now)).toBe('Just now')
        expect(formatRelativeTime(now - 30)).toBe('Just now')
    })

    it('formats minutes ago', () => {
        const now = Math.floor(Date.now() / 1000)
        expect(formatRelativeTime(now - 60)).toBe('1m ago')
        expect(formatRelativeTime(now - 300)).toBe('5m ago')
    })

    it('formats hours ago', () => {
        const now = Math.floor(Date.now() / 1000)
        expect(formatRelativeTime(now - 3600)).toBe('1h ago')
        expect(formatRelativeTime(now - 7200)).toBe('2h ago')
    })

    it('formats days ago', () => {
        const now = Math.floor(Date.now() / 1000)
        expect(formatRelativeTime(now - 86400)).toBe('1d ago')
        expect(formatRelativeTime(now - 259200)).toBe('3d ago')
    })

    it('formats weeks ago', () => {
        const now = Math.floor(Date.now() / 1000)
        expect(formatRelativeTime(now - 604800)).toBe('1w ago')
        expect(formatRelativeTime(now - 1209600)).toBe('2w ago')
    })
})

describe('formatRelativeDate', () => {
    beforeEach(() => {
        vi.useFakeTimers()
        vi.setSystemTime(new Date('2024-01-15T12:00:00Z'))
    })

    afterEach(() => {
        vi.useRealTimers()
    })

    it('returns dash for zero or negative timestamp', () => {
        expect(formatRelativeDate(0)).toBe('-')
        expect(formatRelativeDate(-1)).toBe('-')
    })

    it('returns Today for same day', () => {
        const todayTimestamp = Math.floor(Date.now() / 1000)
        expect(formatRelativeDate(todayTimestamp)).toBe('Today')
    })

    it('returns Yesterday for previous day', () => {
        const yesterdayTimestamp = Math.floor(Date.now() / 1000) - 86400
        expect(formatRelativeDate(yesterdayTimestamp)).toBe('Yesterday')
    })

    it('formats days ago within a week', () => {
        const threeDaysAgo = Math.floor(Date.now() / 1000) - 86400 * 3
        expect(formatRelativeDate(threeDaysAgo)).toBe('3d ago')
    })
})

describe('normalizeSearch', () => {
    it('converts to lowercase', () => {
        expect(normalizeSearch('HELLO')).toBe('hello')
        expect(normalizeSearch('Hello World')).toBe('hello world')
    })

    it('replaces dots, underscores, and hyphens with spaces', () => {
        expect(normalizeSearch('hello.world')).toBe('hello world')
        expect(normalizeSearch('hello_world')).toBe('hello world')
        expect(normalizeSearch('hello-world')).toBe('hello world')
    })

    it('normalizes multiple separators', () => {
        expect(normalizeSearch('hello...world')).toBe('hello world')
        expect(normalizeSearch('hello___world')).toBe('hello world')
        expect(normalizeSearch('hello---world')).toBe('hello world')
        expect(normalizeSearch('hello._-world')).toBe('hello world')
    })

    it('handles torrent-style names', () => {
        expect(normalizeSearch('Movie.Name.2024.1080p.BluRay')).toBe('movie name 2024 1080p bluray')
    })

    it('handles empty string', () => {
        expect(normalizeSearch('')).toBe('')
    })

    it('handles strings with only separators', () => {
        expect(normalizeSearch('...')).toBe(' ')
        expect(normalizeSearch('___')).toBe(' ')
    })

    it('preserves numbers', () => {
        expect(normalizeSearch('file123.txt')).toBe('file123 txt')
    })

    it('handles mixed separators at start and end', () => {
        expect(normalizeSearch('.hello.')).toBe(' hello ')
        expect(normalizeSearch('-test-')).toBe(' test ')
    })
})

// Additional edge case tests
describe('format edge cases', () => {
    describe('formatSpeed edge cases', () => {
        it('handles very large values', () => {
            expect(formatSpeed(1024 * 1024 * 1024)).toBe('1024.00 MiB/s')
        })

        it('handles floating point precision', () => {
            expect(formatSpeed(1536)).toBe('1.5 KiB/s')
        })
    })

    describe('formatSize edge cases', () => {
        it('handles exact boundary values', () => {
            expect(formatSize(1024)).toBe('1.0 KiB')
            expect(formatSize(1024 * 1024)).toBe('1.0 MiB')
            expect(formatSize(1024 * 1024 * 1024)).toBe('1.00 GiB')
            expect(formatSize(1024 * 1024 * 1024 * 1024)).toBe('1.00 TiB')
        })

        it('handles values just below boundaries', () => {
            expect(formatSize(1023)).toBe('1023 B')
            expect(formatSize(1024 * 1024 - 1)).toBe('1024.0 KiB')
        })
    })

    describe('formatEta edge cases', () => {
        it('handles exact boundary transitions', () => {
            expect(formatEta(59)).toBe('59s')
            expect(formatEta(60)).toBe('1m')
            expect(formatEta(3599)).toBe('59m')
            expect(formatEta(3600)).toBe('1h 0m')
            expect(formatEta(86399)).toBe('23h 59m')
            expect(formatEta(86400)).toBe('1d')
        })
    })

    describe('formatDuration edge cases', () => {
        it('handles exact day boundary', () => {
            expect(formatDuration(86400)).toBe('1d 0h 0m')
        })

        it('handles complex durations', () => {
            expect(formatDuration(90061)).toBe('1d 1h 1m')
        })
    })
})


================================================
FILE: __tests__/utils/pagination.test.ts
================================================
import { describe, it, expect } from 'vitest'
import { PER_PAGE_OPTIONS } from '../../src/utils/pagination'

describe('pagination', () => {
    describe('PER_PAGE_OPTIONS', () => {
        it('contains expected values', () => {
            expect(PER_PAGE_OPTIONS).toEqual([25, 50, 100, 200])
        })

        it('is readonly', () => {
            // TypeScript should prevent mutation, but verify the values
            expect(PER_PAGE_OPTIONS[0]).toBe(25)
            expect(PER_PAGE_OPTIONS.length).toBe(4)
        })

        it('values are in ascending order', () => {
            for (let i = 1; i < PER_PAGE_OPTIONS.length; i++) {
                expect(PER_PAGE_OPTIONS[i]).toBeGreaterThan(PER_PAGE_OPTIONS[i - 1])
            }
        })

        it('starts with a reasonable minimum', () => {
            expect(PER_PAGE_OPTIONS[0]).toBeGreaterThanOrEqual(10)
        })

        it('has a reasonable maximum', () => {
            expect(PER_PAGE_OPTIONS[PER_PAGE_OPTIONS.length - 1]).toBeLessThanOrEqual(500)
        })
    })
})


================================================
FILE: __tests__/utils/ratioThresholds.test.ts
================================================
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { loadRatioThreshold, saveRatioThreshold } from '../../src/utils/ratioThresholds'

describe('ratioThresholds', () => {
    // Mock localStorage
    const localStorageMock = (() => {
        let store: Record<string, string> = {}
        return {
            getItem: vi.fn((key: string) => store[key] ?? null),
            setItem: vi.fn((key: string, value: string) => {
                store[key] = value
            }),
            clear: () => {
                store = {}
            },
        }
    })()

    beforeEach(() => {
        localStorageMock.clear()
        vi.stubGlobal('localStorage', localStorageMock)
    })

    describe('loadRatioThreshold', () => {
        it('returns default threshold (1.0) when no value stored', () => {
            expect(loadRatioThreshold()).toBe(1.0)
        })

        it('returns stored threshold when valid', () => {
            localStorageMock.setItem('ratioThreshold', '2.5')
            expect(loadRatioThreshold()).toBe(2.5)
        })

        it('returns default for invalid stored value', () => {
            localStorageMock.setItem('ratioThreshold', 'not-a-number')
            expect(loadRatioThreshold()).toBe(1.0)
        })

        it('returns default for negative stored value', () => {
            localStorageMock.setItem('ratioThreshold', '-1')
            expect(loadRatioThreshold()).toBe(1.0)
        })

        it('accepts zero as valid threshold', () => {
            localStorageMock.setItem('ratioThreshold', '0')
            expect(loadRatioThreshold()).toBe(0)
        })

        it('handles decimal values correctly', () => {
            localStorageMock.setItem('ratioThreshold', '0.5')
            expect(loadRatioThreshold()).toBe(0.5)
        })

        it('handles large values', () => {
            localStorageMock.setItem('ratioThreshold', '100')
            expect(loadRatioThreshold()).toBe(100)
        })
    })

    describe('saveRatioThreshold', () => {
        it('saves threshold to localStorage', () => {
            saveRatioThreshold(2.0)
            expect(localStorageMock.setItem).toHaveBeenCalledWith('ratioThreshold', '2')
        })

        it('saves decimal values', () => {
            saveRatioThreshold(1.5)
            expect(localStorageMock.setItem).toHaveBeenCalledWith('ratioThreshold', '1.5')
        })

        it('saves zero', () => {
            saveRatioThreshold(0)
            expect(localStorageMock.setItem).toHaveBeenCalledWith('ratioThreshold', '0')
        })
    })
})


================================================
FILE: __tests__/utils/search.test.ts
================================================
import { describe, it, expect } from 'vitest'
import { extractTags, sortResults, filterResults } from '../../src/utils/search'

describe('extractTags', () => {
    it('extracts resolution tags', () => {
        const titles = [
            'Movie.2024.1080p.BluRay',
            'Show.S01E01.720p.WEB-DL',
            'Film.2024.2160p.UHD',
        ]
        const tags = extractTags(titles)
        expect(tags.find(t => t.tag === '1080P')).toBeTruthy()
        expect(tags.find(t => t.tag === '720P')).toBeTruthy()
        expect(tags.find(t => t.tag === '2160P')).toBeTruthy()
    })

    it('extracts codec tags', () => {
        const titles = ['Movie.x264.mkv', 'Film.x265.mp4', 'Show.HEVC.avi']
        const tags = extractTags(titles)
        expect(tags.find(t => t.tag === 'X264')).toBeTruthy()
        expect(tags.find(t => t.tag === 'X265')).toBeTruthy()
        expect(tags.find(t => t.tag === 'HEVC')).toBeTruthy()
    })

    it('extracts source tags', () => {
        const titles = [
            'Movie.BluRay.x264',
            'Show.WEB-DL.1080p',
            'Film.HDRip.720p',
        ]
        const tags = extractTags(titles)
        expect(tags.find(t => t.tag === 'BLURAY')).toBeTruthy()
        expect(tags.find(t => t.tag === 'WEB-DL')).toBeTruthy()
        expect(tags.find(t => t.tag === 'HDRIP')).toBeTruthy()
    })

    it('counts tag occurrences', () => {
        const titles = [
            'Movie1.1080p.x264',
            'Movie2.1080p.x265',
            'Movie3.1080p.HEVC',
        ]
        const tags = extractTags(titles)
        const tag1080p = tags.find(t => t.tag === '1080P')
        expect(tag1080p?.count).toBe(3)
    })

    it('sorts by count descending', () => {
        const titles = [
            'Movie1.1080p.x264',
            'Movie2.1080p.x264',
            'Movie3.720p.x264',
        ]
        const tags = extractTags(titles)
        // x264 appears 3 times, 1080p appears 2 times
        expect(tags[0].tag).toBe('X264')
        expect(tags[0].count).toBe(3)
    })

    it('returns empty array for titles with no tags', () => {
        const titles = ['just a regular title', 'another title here']
        const tags = extractTags(titles)
        expect(tags).toHaveLength(0)
    })
})

describe('sortResults', () => {
    const mockResults = [
        { title: 'Movie A', seeders: 100, size: 1000, publishDate: '2024-01-15' },
        { title: 'Movie B', seeders: 50, size: 2000, publishDate: '2024-01-10' },
        { title: 'Movie C', seeders: 200, size: 500, publishDate: '2024-01-20' },
    ]

    describe('sorting by seeders', () => {
        it('sorts by seeders descending (default)', () => {
            const sorted = sortResults(mockResults, 'seeders', false)
            expect(sorted[0].title).toBe('Movie C')
            expect(sorted[1].title).toBe('Movie A')
            expect(sorted[2].title).toBe('Movie B')
        })

        it('sorts by seeders ascending', () => {
            const sorted = sortResults(mockResults, 'seeders', true)
            expect(sorted[0].title).toBe('Movie B')
            expect(sorted[1].title).toBe('Movie A')
            expect(sorted[2].title).toBe('Movie C')
        })
    })

    describe('sorting by size', () => {
        it('sorts by size descending (default)', () => {
            const sorted = sortResults(mockResults, 'size', false)
            expect(sorted[0].title).toBe('Movie B')
            expect(sorted[1].title).toBe('Movie A')
            expect(sorted[2].title).toBe('Movie C')
        })

        it('sorts by size ascending', () => {
            const sorted = sortResults(mockResults, 'size', true)
            expect(sorted[0].title).toBe('Movie C')
            expect(sorted[1].title).toBe('Movie A')
            expect(sorted[2].title).toBe('Movie B')
        })
    })

    describe('sorting by age', () => {
        it('sorts by age descending (newest first)', () => {
            const sorted = sortResults(mockResults, 'age', false)
            expect(sorted[0].title).toBe('Movie C')
            expect(sorted[1].title).toBe('Movie A')
            expect(sorted[2].title).toBe('Movie B')
        })

        it('sorts by age ascending (oldest first)', () => {
            const sorted = sortResults(mockResults, 'age', true)
            expect(sorted[0].title).toBe('Movie B')
            expect(sorted[1].title).toBe('Movie A')
            expect(sorted[2].title).toBe('Movie C')
        })
    })

    it('handles missing seeders', () => {
        const results = [
            { title: 'A', size: 100, publishDate: '2024-01-01' },
            { title: 'B', seeders: 10, size: 100, publishDate: '2024-01-01' },
        ]
        const sorted = sortResults(results, 'seeders', false)
        expect(sorted[0].title).toBe('B')
        expect(sorted[1].title).toBe('A')
    })

    it('does not mutate original array', () => {
        const original = [...mockResults]
        sortResults(mockResults, 'seeders', false)
        expect(mockResults).toEqual(original)
    })
})

describe('filterResults', () => {
    const mockResults = [
        { title: 'The Matrix 1999', extra: 'data' },
        { title: 'Matrix Reloaded 2003', extra: 'info' },
        { title: 'Inception 2010', extra: 'value' },
    ]

    it('returns all results when filter is empty', () => {
        expect(filterResults(mockResults, '')).toHaveLength(3)
    })

    it('filters by title case-insensitively', () => {
        const filtered = filterResults(mockResults, 'matrix')
        expect(filtered).toHaveLength(2)
        expect(filtered[0].title).toBe('The Matrix 1999')
        expect(filtered[1].title).toBe('Matrix Reloaded 2003')
    })

    it('handles uppercase filter', () => {
        const filtered = filterResults(mockResults, 'INCEPTION')
        expect(filtered).toHaveLength(1)
        expect(filtered[0].title).toBe('Inception 2010')
    })

    it('returns empty array when no matches', () => {
        const filtered = filterResults(mockResults, 'nonexistent')
        expect(filtered).toHaveLength(0)
    })

    it('matches partial strings', () => {
        const filtered = filterResults(mockResults, 'rix')
        expect(filtered).toHaveLength(2)
    })
})


================================================
FILE: docs/.vitepress/config.ts
================================================
import { defineConfig } from 'vitepress'

export default defineConfig({
	title: 'qbitwebui',
	description: 'Modern web interface for qBittorrent',
	base: '/qbitwebui/',
	head: [
		['link', { rel: 'icon', href: '/qbitwebui/logo.svg' }]
	],
	themeConfig: {
		logo: '/logo.svg',
		nav: [
			{ text: 'Guide', link: '/guide/getting-started' },
			{ text: 'GitHub', link: 'https://github.com/Maciejonos/qbitwebui' },
		],
		sidebar: [
			{
				text: 'Guide',
				items: [
					{ text: 'Getting Started', link: '/guide/getting-started' },
					{ text: 'Configuration', link: '/guide/configuration' },
					{ text: 'Features', link: '/guide/features' },
					{ text: 'Docker', link: '/guide/docker' },
				],
			},
			{
				text: 'Add-ons',
				items: [{ text: 'Network Agent', link: '/guide/network-agent/' }],
			},
		],
		socialLinks: [{ icon: 'github', link: 'https://github.com/Maciejonos/qbitwebui' }],
		search: { provider: 'local' },
		footer: { message: 'Released under the MIT License.' },
	},
})


================================================
FILE: docs/.vitepress/theme/custom.css
================================================
:root {
    --vp-c-brand-1: #0d7a6e;
    --vp-c-brand-2: #0f665c;
    --vp-c-brand-3: #15803d;
    --vp-c-brand-soft: rgba(13, 122, 110, 0.1);
}

.dark {
    --vp-c-bg: #07070a;
    --vp-c-bg-alt: #0a0a0f;
    --vp-sidebar-bg-color: #0a0a0f;
    --vp-c-bg-soft: #0e0e14;
    --vp-code-block-bg: #0e0e14;
    --vp-c-border: #32323e;
    --vp-c-divider: #32323e;
    --vp-c-gutter: #32323e;
    --vp-c-text-1: #e8e8ed;
    --vp-c-text-2: #b8b8c8;
    --vp-c-text-3: #8a8a9e;
    --vp-c-brand-1: #00d4aa;
    --vp-c-brand-2: #33eec9;
    --vp-c-brand-3: #00b38f;
    --vp-c-brand-soft: rgba(0, 212, 170, 0.15);
    --vp-c-warning-1: #f7b731;
    --vp-c-warning-2: #e0a01f;
    --vp-c-danger-1: #f43f5e;
    --vp-c-danger-2: #e11d48;
}

.VPHero .name {
    -webkit-background-clip: text;
    background-clip: text;
    -webkit-text-fill-color: transparent;
}

:root .VPHero .name {
    background-image: linear-gradient(135deg, #16a34a 0%, #0d9488 100%);
}

.dark .VPHero .name {
    background-image: linear-gradient(135deg, #9bda65 0%, #33c9a9 50%, #1ec6b7 100%);
}

.VPButton.brand {
    border-color: transparent;
    transition: all 0.2s ease;
}

:root .VPButton.brand {
    color: white !important;
    background-image: linear-gradient(135deg, #0d9488 0%, #0d7a6e 100%);
}

.dark .VPButton.brand {
    background-color: #00d4aa;
    background-image: none;
    color: #070a09 !important;
    font-weight: 600;
}

.dark .VPButton.brand:hover {
    background-color: #33eec9;
    color: #070a09 !important;
}

::selection {
    background: rgba(0, 212, 170, 0.3);
    color: inherit;
}

================================================
FILE: docs/.vitepress/theme/index.ts
================================================
import DefaultTheme from 'vitepress/theme'
import './custom.css'

export default DefaultTheme

================================================
FILE: docs/guide/configuration.md
================================================
# Configuration

All configuration is done through environment variables.

## Required

| Variable | Description |
|----------|-------------|
| `ENCRYPTION_KEY` | AES-256 key for encrypting stored credentials. Minimum 32 characters. |

Generate a key:
```bash
openssl rand -hex 32
```

## Server

| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `3000` | HTTP server port |
| `DATABASE_PATH` | `./data/qbitwebui.db` | SQLite database location |
| `SALT_PATH` | `./data/.salt` | Encryption salt file location |

## Authentication

| Variable | Default | Description |
|----------|---------|-------------|
| `DISABLE_AUTH` | `false` | Skip authentication entirely |
| `DISABLE_REGISTRATION` | `false` | Prevent new user signups |

### Disable Auth

Use when running behind an authenticating reverse proxy (Authelia, Authentik, etc.):

```yaml
environment:
  - DISABLE_AUTH=true
```

::: danger
Only use behind a properly secured reverse proxy. Anyone who can reach qbitwebui will have full access.
:::

### Disable Registration

Lock down to existing users only. On first start with no users, generates a random admin password printed to logs:

```yaml
environment:
  - DISABLE_REGISTRATION=true
```

## Features

| Variable | Default | Description |
|----------|---------|-------------|
| `DOWNLOADS_PATH` | - | Enable file browser at this path |
| `ALLOW_SELF_SIGNED_CERTS` | `false` | Accept self-signed TLS certificates |

### File Browser

Mount your downloads directory and set the path:

```yaml
environment:
  - DOWNLOADS_PATH=/downloads
volumes:
  - /path/to/downloads:/downloads:ro
```

The `:ro` makes it read-only. Remove for write access (delete, move, rename).

### Self-Signed Certificates

If your qBittorrent uses HTTPS with a self-signed certificate:

```yaml
environment:
  - ALLOW_SELF_SIGNED_CERTS=true
```

## Database

SQLite database stores:

| Data | Security |
|------|----------|
| Users | Passwords hashed with bcrypt (cost 12) |
| Sessions | Random tokens, 7-day expiry |
| Instances | Credentials encrypted with AES-256-GCM |
| Integrations | API keys encrypted with AES-256-GCM |

### Backup

```bash
cp ./data/qbitwebui.db ./backup/
```

### Restore

```bash
cp ./backup/qbitwebui.db ./data/
```

Use the same `ENCRYPTION_KEY` after restore.


================================================
FILE: docs/guide/docker.md
================================================
# Docker Deployment

## Images

| Image | Description |
|-------|-------------|
| `ghcr.io/maciejonos/qbitwebui` | Main application |
| `ghcr.io/maciejonos/qbitwebui-agent` | Network diagnostics agent |

Both support `linux/amd64` and `linux/arm64`.

## Quick Start

```bash
docker run -d \
  --name qbitwebui \
  -p 3000:3000 \
  -v ./data:/data \
  -e ENCRYPTION_KEY=$(openssl rand -hex 32) \
  ghcr.io/maciejonos/qbitwebui:latest
```

## Docker Compose Examples

### Basic

```yaml
services:
  qbitwebui:
    image: ghcr.io/maciejonos/qbitwebui:latest
    container_name: qbitwebui
    ports:
      - "3000:3000"
    volumes:
      - ./qbitwebui-data:/data
    environment:
      - ENCRYPTION_KEY=your-32-character-minimum-key-here
    restart: unless-stopped
```

### With File Browser

```yaml
services:
  qbitwebui:
    image: ghcr.io/maciejonos/qbitwebui:latest
    container_name: qbitwebui
    ports:
      - "3000:3000"
    volumes:
      - ./qbitwebui-data:/data
      - /path/to/your/downloads:/downloads:ro
    environment:
      - ENCRYPTION_KEY=your-32-character-minimum-key-here
      - DOWNLOADS_PATH=/downloads
    restart: unless-stopped
```

### Full Stack (qBittorrent + Agent + qbitwebui)

Complete setup with all components:

```yaml
services:
  qbittorrent:
    image: linuxserver/qbittorrent:latest
    container_name: qbittorrent
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Europe/London
      - WEBUI_PORT=8080
    volumes:
      - ./qbittorrent-config:/config
      - ./downloads:/downloads
    ports:
      - "8080:8080"      # qBittorrent WebUI
      - "6881:6881"      # BitTorrent TCP
      - "6881:6881/udp"  # BitTorrent UDP
      - "9876:9876"      # Network Agent
    restart: unless-stopped

  net-agent:
    image: ghcr.io/maciejonos/qbitwebui-agent:latest
    container_name: net-agent
    network_mode: "service:qbittorrent"
    environment:
      - QBT_URL=http://localhost:8080
    depends_on:
      - qbittorrent
    restart: unless-stopped

  qbitwebui:
    image: ghcr.io/maciejonos/qbitwebui:latest
    container_name: qbitwebui
    ports:
      - "3000:3000"
    volumes:
      - ./qbitwebui-data:/data
      - ./downloads:/downloads:ro
    environment:
      - ENCRYPTION_KEY=your-32-character-minimum-key-here
      - DOWNLOADS_PATH=/downloads
    depends_on:
      - qbittorrent
    restart: unless-stopped
```

### With VPN (Gluetun)

Route qBittorrent through a VPN:

```yaml
services:
  gluetun:
    image: qmcgaw/gluetun:latest
    container_name: gluetun
    cap_add:
      - NET_ADMIN
    devices:
      - /dev/net/tun:/dev/net/tun
    environment:
      - VPN_SERVICE_PROVIDER=mullvad  # or your provider
      - VPN_TYPE=wireguard
      - WIREGUARD_PRIVATE_KEY=your-private-key
      - WIREGUARD_ADDRESSES=10.x.x.x/32
      - SERVER_COUNTRIES=Sweden
    ports:
      - "8080:8080"      # qBittorrent WebUI
      - "6881:6881"      # BitTorrent
      - "6881:6881/udp"
      - "9876:9876"      # Network Agent
    restart: unless-stopped

  qbittorrent:
    image: linuxserver/qbittorrent:latest
    container_name: qbittorrent
    network_mode: "service:gluetun"
    environment:
      - PUID=1000
      - PGID=1000
      - WEBUI_PORT=8080
    volumes:
      - ./qbittorrent-config:/config
      - ./downloads:/downloads
    depends_on:
      - gluetun
    restart: unless-stopped

  net-agent:
    image: ghcr.io/maciejonos/qbitwebui-agent:latest
    container_name: net-agent
    network_mode: "service:gluetun"
    environment:
      - QBT_URL=http://localhost:8080
    depends_on:
      - qbittorrent
    restart: unless-stopped

  qbitwebui:
    image: ghcr.io/maciejonos/qbitwebui:latest
    container_name: qbitwebui
    ports:
      - "3000:3000"
    volumes:
      - ./qbitwebui-data:/data
      - ./downloads:/downloads:ro
    environment:
      - ENCRYPTION_KEY=your-32-character-minimum-key-here
      - DOWNLOADS_PATH=/downloads
    depends_on:
      - qbittorrent
    restart: unless-stopped
```

::: tip
With VPN setup, use the Network Agent to verify your VPN is working correctly by checking the external IP.
:::

### Multiple Instances

Manage multiple qBittorrent instances:

```yaml
services:
  qbittorrent-1:
    image: linuxserver/qbittorrent:latest
    container_name: qbittorrent-1
    environment:
      - PUID=1000
      - PGID=1000
      - WEBUI_PORT=8080
    volumes:
      - ./qbt1-config:/config
      - ./downloads-1:/downloads
    ports:
      - "8080:8080"
      - "6881:6881"
      - "6881:6881/udp"
    restart: unless-stopped

  qbittorrent-2:
    image: linuxserver/qbittorrent:latest
    container_name: qbittorrent-2
    environment:
      - PUID=1000
      - PGID=1000
      - WEBUI_PORT=8080
    volumes:
      - ./qbt2-config:/config
      - ./downloads-2:/downloads
    ports:
      - "8081:8080"
      - "6882:6881"
      - "6882:6881/udp"
    restart: unless-stopped

  qbitwebui:
    image: ghcr.io/maciejonos/qbitwebui:latest
    container_name: qbitwebui
    ports:
      - "3000:3000"
    volumes:
      - ./qbitwebui-data:/data
    environment:
      - ENCRYPTION_KEY=your-32-character-minimum-key-here
    restart: unless-stopped
```

Add both instances in qbitwebui with their respective URLs (`http://host:8080` and `http://host:8081`).

## Reverse Proxy

### Nginx

```nginx
server {
    listen 443 ssl http2;
    server_name qbit.example.com;

    ssl_certificate /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;

    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
```

### Caddy

```
qbit.example.com {
    reverse_proxy localhost:3000
}
```

### Traefik (Labels)

```yaml
services:
  qbitwebui:
    image: ghcr.io/maciejonos/qbitwebui:latest
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.qbitwebui.rule=Host(`qbit.example.com`)"
      - "traefik.http.routers.qbitwebui.entrypoints=websecure"
      - "traefik.http.routers.qbitwebui.tls=true"
      - "traefik.http.routers.qbitwebui.tls.certresolver=letsencrypt"
      - "traefik.http.services.qbitwebui.loadbalancer.server.port=3000"
    # ... rest of config
```

### With External Authentication

Using Authelia, Authentik, or similar:

```yaml
services:
  qbitwebui:
    image: ghcr.io/maciejonos/qbitwebui:latest
    environment:
      - ENCRYPTION_KEY=your-key
      - DISABLE_AUTH=true  # Let reverse proxy handle auth
    # ... rest of config
```

## Updating

### Manual

```bash
docker compose pull
docker compose up -d
```

### Watchtower (Automatic)

```yaml
services:
  watchtower:
    image: containrrr/watchtower:latest
    container_name: watchtower
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      - WATCHTOWER_CLEANUP=true
      - WATCHTOWER_SCHEDULE=0 0 4 * * *  # 4 AM daily
    restart: unless-stopped
```

## Volumes

| Path | Description |
|------|-------------|
| `/data` | Database and encryption salt (required) |
| `/downloads` | Downloads directory for file browser (optional) |

## Ports

| Port | Service |
|------|---------|
| `3000` | qbitwebui web interface |
| `9876` | Network agent (exposed through qBittorrent container) |

## Health Check

qbitwebui exposes `/api/config` for health checks:

```yaml
healthcheck:
  test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/config"]
  interval: 30s
  timeout: 10s
  retries: 3
```


================================================
FILE: docs/guide/features.md
================================================
# Features

## Multi-Instance Dashboard

Manage multiple qBittorrent instances from one interface:

- Overview cards showing status, speeds, and torrent counts
- Aggregate statistics across all instances
- Quick switching between instances
- Connection testing and version display

### Instance Options

| Option | Description |
|--------|-------------|
| Skip Authentication | Use when qBittorrent has IP bypass enabled |
| Enable Network Agent | Connect to net-agent for diagnostics |

## Torrent Management

### List View

- Sortable columns (name, size, progress, speed, ratio, etc.)
- Filter by status: All, Downloading, Seeding, Completed, Paused, Active, Inactive, Stalled, Checking, Error
- Filter by category, tag, or tracker
- Search by torrent name
- Customizable columns with drag-to-reorder
- Resizable column widths (persisted)

### Actions

| Action | Description |
|--------|-------------|
| Start/Stop | Resume or pause torrents |
| Recheck | Verify torrent data integrity |
| Reannounce | Force tracker announce |
| Delete | Remove torrent, optionally with files |
| Rename | Change torrent name |
| Export | Download .torrent file |
| Set Category | Assign to a category |
| Add/Remove Tags | Manage torrent tags |

### Details Panel

Expandable panel showing:

- **General**: Size, progress, ratio, ETA, speeds, seeds/peers, dates, save path
- **Trackers**: List with status, add/remove trackers
- **Peers**: Connected peers with client, flags, progress, speeds
- **Files**: File tree with individual progress and priority control
- **HTTP Sources**: Web seed URLs

### Keyboard Shortcuts

| Key | Action |
|-----|--------|
| `↑` `↓` | Navigate between torrents |
| `Ctrl+A` | Select all torrents |
| `Escape` | Clear selection |

### Context Menu

Right-click any torrent for quick actions including category/tag submenus.

## Custom Views

Save your current filter and column setup:

1. Configure filters, columns, and sort order
2. Click the view selector → Save View
3. Name your view
4. Switch between saved views instantly

## Categories & Tags

### Categories

- Create categories with optional custom save paths
- Edit save paths for existing categories
- Delete categories
- Assign via context menu or details panel

### Tags

- Create and delete tags
- Add/remove tags from torrents
- Filter torrents by tag

## Prowlarr Integration

Search across all your indexers without leaving qbitwebui.

### Setup

1. Go to any instance → Settings icon → Integrations tab
2. Click **Add Prowlarr**
3. Enter Prowlarr URL and API key
4. Test connection and save

### Searching

1. Click the search icon in the header
2. Enter search query
3. Filter by indexer or category
4. View results with seeders, size, age, freeleech status
5. Click grab → select instance → optionally set category/path → confirm

## RSS Manager

Manage RSS feeds and auto-download rules.

### Feeds

- Add feeds by URL
- Organize in folders
- Refresh feeds manually
- View articles with grab option

### Auto-Download Rules

- Create rules with name patterns (regex supported)
- Filter by category, episode, season
- Set target category and save path
- Preview matching articles

## File Browser

Browse and manage downloaded files (requires `DOWNLOADS_PATH`).

### Operations

| Operation | Description |
|-----------|-------------|
| Browse | Navigate directories |
| Download | Download files or folders (as tar) |
| Delete | Remove files/directories |
| Move | Move to another location |
| Copy | Copy to another location |
| Rename | Rename file or directory |

## Cross-Seed (Experimental)

Find cross-seeding opportunities using Prowlarr indexers.

### How It Works

1. Configure Prowlarr integration
2. Select which indexers to search
3. Run scan on your torrents
4. Review matches (size, name similarity)
5. Add matches to start cross-seeding

### Options

- Match mode: Strict or Flexible
- Dry run: Preview without adding
- Category suffix for cross-seeded torrents
- Blocklist for excluding certain releases

## Orphan Manager

Detect and clean up problematic torrents.

### Detects

- Torrents with missing files on disk
- Torrents with unregistered tracker status

### Actions

- Scan all instances at once
- Bulk select orphans
- Delete with or without files

## Statistics

View transfer history with multiple time periods:

- 15 minutes, 30 minutes, 1 hour
- 4 hours, 12 hours, 24 hours
- 7 days, 30 days, all-time

Toggle between per-instance and aggregate views.

## Log Viewer

View qBittorrent logs in real-time.

### Application Logs

Filter by level:
- Normal
- Info
- Warning
- Critical

### Peer Logs

Connection events with IP, client, and direction.

Auto-refresh available for both.

## Settings Panel

Edit qBittorre
Download .txt
gitextract_1rmw6cng/

├── .dockerignore
├── .github/
│   ├── FUNDING.yml
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug_report.md
│   │   └── feature_request.md
│   └── workflows/
│       ├── docker.yml
│       ├── docs.yml
│       └── tests.yml
├── .gitignore
├── .npmrc
├── .prettierrc
├── Dockerfile
├── LICENSE
├── README.md
├── __tests__/
│   ├── __mocks__/
│   │   └── bun-sqlite.ts
│   ├── api/
│   │   ├── auth.test.ts
│   │   ├── crossSeed.test.ts
│   │   ├── files.test.ts
│   │   ├── instances.test.ts
│   │   ├── integrations.test.ts
│   │   └── qbittorrent.test.ts
│   ├── hooks/
│   │   ├── useInstance.test.tsx
│   │   └── usePagination.test.tsx
│   ├── reporter.ts
│   ├── server/
│   │   ├── crossSeedCache.test.ts
│   │   ├── crossSeedMatcher.test.ts
│   │   ├── crossSeedScheduler.test.ts
│   │   ├── crossSeedWorker.test.ts
│   │   ├── fetch.test.ts
│   │   ├── logger.test.ts
│   │   ├── rateLimit.test.ts
│   │   └── url.test.ts
│   ├── themes/
│   │   └── themes.test.ts
│   └── utils/
│       ├── fileTree.test.ts
│       ├── format.test.ts
│       ├── pagination.test.ts
│       ├── ratioThresholds.test.ts
│       └── search.test.ts
├── docs/
│   ├── .vitepress/
│   │   ├── config.ts
│   │   └── theme/
│   │       ├── custom.css
│   │       └── index.ts
│   ├── guide/
│   │   ├── configuration.md
│   │   ├── docker.md
│   │   ├── features.md
│   │   ├── getting-started.md
│   │   └── network-agent/
│   │       └── index.md
│   └── index.md
├── eslint.config.js
├── index.html
├── net-agent/
│   ├── Dockerfile
│   ├── README.md
│   ├── go.mod
│   └── main.go
├── package.json
├── src/
│   ├── App.tsx
│   ├── api/
│   │   ├── auth.ts
│   │   ├── crossSeed.ts
│   │   ├── files.ts
│   │   ├── instances.ts
│   │   ├── integrations.ts
│   │   ├── netAgent.ts
│   │   ├── qbittorrent.ts
│   │   └── stats.ts
│   ├── components/
│   │   ├── AddTorrentModal.tsx
│   │   ├── AuthForm.tsx
│   │   ├── CategoryTagManager.tsx
│   │   ├── ContextMenu.tsx
│   │   ├── CrossSeedManager.tsx
│   │   ├── DateSettingsPopup.tsx
│   │   ├── FileBrowser.tsx
│   │   ├── FilterBar.tsx
│   │   ├── Header.tsx
│   │   ├── InstanceManager.tsx
│   │   ├── Layout.tsx
│   │   ├── LogViewer.tsx
│   │   ├── NetworkTools.tsx
│   │   ├── OrphanManager.tsx
│   │   ├── RSSManager.tsx
│   │   ├── RatioThresholdPopup.tsx
│   │   ├── SearchPanel.tsx
│   │   ├── SettingsPanel.tsx
│   │   ├── Statistics.tsx
│   │   ├── StatusBar.tsx
│   │   ├── ThemeManager.tsx
│   │   ├── ThemeSwitcher.tsx
│   │   ├── TorrentDetailsPanel.tsx
│   │   ├── TorrentList.tsx
│   │   ├── TorrentRow.tsx
│   │   ├── ViewSelector.tsx
│   │   ├── columns.ts
│   │   ├── settings/
│   │   │   ├── AdvancedTab.tsx
│   │   │   ├── BehaviorTab.tsx
│   │   │   ├── BitTorrentTab.tsx
│   │   │   ├── ConnectionTab.tsx
│   │   │   ├── DownloadsTab.tsx
│   │   │   ├── RSSTab.tsx
│   │   │   ├── SpeedTab.tsx
│   │   │   ├── WebUITab.tsx
│   │   │   └── index.ts
│   │   └── ui/
│   │       ├── Checkbox.tsx
│   │       ├── MultiSelect.tsx
│   │       ├── Select.tsx
│   │       ├── Toggle.tsx
│   │       └── index.ts
│   ├── contexts/
│   │   ├── InstanceProvider.tsx
│   │   ├── PaginationProvider.tsx
│   │   ├── ThemeContext.ts
│   │   ├── ThemeProvider.tsx
│   │   ├── instanceContext.ts
│   │   └── paginationContext.ts
│   ├── hooks/
│   │   ├── useClickOutside.ts
│   │   ├── useCrossSeed.ts
│   │   ├── useInstance.ts
│   │   ├── usePagination.ts
│   │   ├── useRSSManager.ts
│   │   ├── useStats.ts
│   │   ├── useSyncMaindata.ts
│   │   ├── useTheme.ts
│   │   ├── useTorrentDetails.ts
│   │   ├── useTorrents.ts
│   │   ├── useTransferInfo.ts
│   │   └── useUpdateCheck.ts
│   ├── index.css
│   ├── main.tsx
│   ├── mobile/
│   │   ├── MobileApp.tsx
│   │   ├── MobileCrossSeedManager.tsx
│   │   ├── MobileFileBrowser.tsx
│   │   ├── MobileInstancePicker.tsx
│   │   ├── MobileLogViewer.tsx
│   │   ├── MobileNetworkTools.tsx
│   │   ├── MobileOrphanManager.tsx
│   │   ├── MobileRSSManager.tsx
│   │   ├── MobileSearchPanel.tsx
│   │   ├── MobileStatistics.tsx
│   │   ├── MobileStats.tsx
│   │   ├── MobileThemeManager.tsx
│   │   ├── MobileThemeSwitcher.tsx
│   │   ├── MobileTools.tsx
│   │   ├── MobileTorrentDetail.tsx
│   │   └── MobileTorrentList.tsx
│   ├── server/
│   │   ├── db/
│   │   │   └── index.ts
│   │   ├── index.ts
│   │   ├── middleware/
│   │   │   └── auth.ts
│   │   ├── routes/
│   │   │   ├── auth.ts
│   │   │   ├── crossSeed.ts
│   │   │   ├── files.ts
│   │   │   ├── instances.ts
│   │   │   ├── integrations.ts
│   │   │   ├── proxy.ts
│   │   │   ├── stats.ts
│   │   │   └── tools.ts
│   │   └── utils/
│   │       ├── crossSeedCache.ts
│   │       ├── crossSeedMatcher.ts
│   │       ├── crossSeedScheduler.ts
│   │       ├── crossSeedWorker.ts
│   │       ├── crypto.ts
│   │       ├── fetch.ts
│   │       ├── logger.ts
│   │       ├── qbt.ts
│   │       ├── rateLimit.ts
│   │       ├── statsRecorder.ts
│   │       ├── torznab.ts
│   │       └── url.ts
│   ├── themes/
│   │   └── index.ts
│   ├── types/
│   │   ├── preferences.ts
│   │   ├── qbittorrent.ts
│   │   ├── rss.ts
│   │   ├── torrentDetails.ts
│   │   └── views.ts
│   └── utils/
│       ├── colorUtils.ts
│       ├── customViews.ts
│       ├── dateSettings.ts
│       ├── fileTree.ts
│       ├── format.ts
│       ├── markdown.tsx
│       ├── pagination.ts
│       ├── ratioThresholds.ts
│       └── search.ts
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
├── tsconfig.server.json
├── vite.config.ts
└── vitest.config.ts
Download .txt
SYMBOL INDEX (786 symbols across 126 files)

FILE: __tests__/__mocks__/bun-sqlite.ts
  class Database (line 3) | class Database {

FILE: __tests__/reporter.ts
  type TaskState (line 5) | type TaskState = "pass" | "fail" | "skip" | "todo" | "pending" | "unknown";
  type AnyTask (line 7) | type AnyTask = {
  type AnyFile (line 20) | type AnyFile = {
  type TaskResultPack (line 27) | type TaskResultPack = {
  function normalisePath (line 36) | function normalisePath(p: string) {
  function toRelative (line 40) | function toRelative(p: string) {
  function safeBasename (line 48) | function safeBasename(p: string) {
  function safeDirname (line 56) | function safeDirname(p: string) {
  function normaliseState (line 64) | function normaliseState(rawState: unknown, mode: unknown): TaskState {
  function iconFor (line 79) | function iconFor(state: TaskState) {
  function colourName (line 96) | function colourName(state: TaskState, text: string) {
  function formatDuration (line 110) | function formatDuration(ms?: number) {
  class PrettyReporter (line 116) | class PrettyReporter implements Reporter {
    method onInit (line 132) | onInit(ctx: Vitest) {
    method onTaskUpdate (line 144) | onTaskUpdate(packs: TaskResultPack[]) {
    method onTestRunEnd (line 179) | onTestRunEnd() {
    method getStateFiles (line 189) | private getStateFiles(): AnyFile[] {
    method ensureIndexedFromState (line 202) | private ensureIndexedFromState() {
    method renderProgressLine (line 224) | private renderProgressLine() {
    method printReportFromState (line 257) | private printReportFromState() {
    method computeTotals (line 361) | private computeTotals(files: AnyFile[]) {
    method computeFileTotals (line 390) | private computeFileTotals(file: AnyFile) {
    method printTaskTree (line 420) | private printTaskTree(task: AnyTask, indent: number) {

FILE: __tests__/server/crossSeedCache.test.ts
  constant TEST_DATA_PATH (line 6) | const TEST_DATA_PATH = join(tmpdir(), `crossseed-test-${process.pid}`)

FILE: __tests__/server/crossSeedWorker.test.ts
  function makeTorrentData (line 240) | function makeTorrentData(name: string, length: number): Buffer {
  type BencodeValue (line 244) | type BencodeValue = number | string | Buffer | BencodeValue[] | { [key: ...
  function encodeBencode (line 246) | function encodeBencode(data: BencodeValue): Buffer {
  function makeMultiFileTorrentData (line 275) | function makeMultiFileTorrentData(
  function resetState (line 287) | function resetState() {
  function mockQbtResponses (line 331) | function mockQbtResponses(torrents: unknown[], files: unknown[]) {

FILE: __tests__/utils/fileTree.test.ts
  function createFile (line 6) | function createFile(name: string, size = 1000, priority = 1, progress = ...
  function createTestTree (line 113) | function createTestTree(): FileTreeNode[] {

FILE: net-agent/main.go
  function main (line 22) | func main() {
  function detectSkipAuth (line 62) | func detectSkipAuth() bool {
  function withLogging (line 75) | func withLogging(next http.HandlerFunc) http.HandlerFunc {
  type statusWriter (line 84) | type statusWriter struct
    method WriteHeader (line 89) | func (w *statusWriter) WriteHeader(status int) {
  function withAuth (line 94) | func withAuth(next http.HandlerFunc) http.HandlerFunc {
  function validateSID (line 120) | func validateSID(sid string) bool {
  function handleHealth (line 134) | func handleHealth(w http.ResponseWriter, r *http.Request) {
  function handleIP (line 139) | func handleIP(w http.ResponseWriter, r *http.Request) {
  function handleSpeedtest (line 152) | func handleSpeedtest(w http.ResponseWriter, r *http.Request) {
  function handleSpeedtestServers (line 175) | func handleSpeedtestServers(w http.ResponseWriter, r *http.Request) {
  function handleDNS (line 190) | func handleDNS(w http.ResponseWriter, r *http.Request) {
  function handleInterfaces (line 211) | func handleInterfaces(w http.ResponseWriter, r *http.Request) {
  function handleExec (line 226) | func handleExec(w http.ResponseWriter, r *http.Request) {

FILE: src/App.tsx
  type View (line 26) | type View = 'loading' | 'auth' | 'instances' | 'torrents' | 'mobile'
  type Tab (line 27) | type Tab = 'dashboard' | 'tools'
  type Tool (line 28) | type Tool = 'indexers' | 'files' | 'orphans' | 'rss' | 'logs' | 'cross-s...
  function parseHash (line 30) | function parseHash(): { tab: Tab; instanceId: number | null; tool: Tool } {
  function setHash (line 48) | function setHash(tab: Tab, instanceId: number | null, tool?: Tool) {
  function App (line 58) | function App() {

FILE: src/api/auth.ts
  type User (line 1) | interface User {
  function register (line 6) | async function register(username: string, password: string): Promise<Use...
  function login (line 20) | async function login(username: string, password: string): Promise<User> {
  function logout (line 34) | async function logout(): Promise<void> {
  function getMe (line 41) | async function getMe(): Promise<User | null> {
  function changePassword (line 49) | async function changePassword(currentPassword: string, newPassword: stri...

FILE: src/api/crossSeed.ts
  type MatchMode (line 1) | type MatchMode = 'strict' | 'flexible'
  type CrossSeedConfig (line 3) | interface CrossSeedConfig {
  type TorznabIndexer (line 22) | interface TorznabIndexer {
  type ScanResult (line 30) | interface ScanResult {
  type SchedulerStatus (line 43) | interface SchedulerStatus {
  type CacheStats (line 55) | interface CacheStats {
  type Searchee (line 60) | interface Searchee {
  type Decision (line 73) | interface Decision {
  function getCrossSeedConfig (line 85) | async function getCrossSeedConfig(instanceId: number): Promise<CrossSeed...
  function updateCrossSeedConfig (line 91) | async function updateCrossSeedConfig(
  function getIndexers (line 108) | async function getIndexers(instanceId: number, integrationId?: number): ...
  function triggerScan (line 118) | async function triggerScan(instanceId: number, force = false): Promise<S...
  function getSchedulerStatus (line 132) | async function getSchedulerStatus(): Promise<SchedulerStatus[]> {
  function getInstanceStatus (line 138) | async function getInstanceStatus(instanceId: number): Promise<SchedulerS...
  function clearCache (line 144) | async function clearCache(instanceId: number): Promise<{ cacheCleared: n...
  function getCacheStats (line 153) | async function getCacheStats(instanceId: number): Promise<CacheStats> {
  function getSearchHistory (line 159) | async function getSearchHistory(
  function getDecisions (line 170) | async function getDecisions(instanceId: number, searcheeId: number): Pro...
  function stopScan (line 178) | async function stopScan(instanceId: number): Promise<{ stopped: boolean ...
  type LogEntry (line 190) | interface LogEntry {
  function getLogs (line 196) | async function getLogs(limit = 100): Promise<LogEntry[]> {

FILE: src/api/files.ts
  type FileEntry (line 1) | interface FileEntry {
  function listFiles (line 8) | async function listFiles(path: string): Promise<FileEntry[]> {
  function getDownloadUrl (line 19) | function getDownloadUrl(path: string): string {
  function checkWritable (line 23) | async function checkWritable(): Promise<boolean> {
  function deleteFiles (line 30) | async function deleteFiles(paths: string[]): Promise<void> {
  function moveFiles (line 43) | async function moveFiles(paths: string[], destination: string): Promise<...
  function copyFiles (line 56) | async function copyFiles(paths: string[], destination: string): Promise<...
  function renameFile (line 69) | async function renameFile(path: string, newName: string): Promise<void> {

FILE: src/api/instances.ts
  type Instance (line 1) | interface Instance {
  type CreateInstanceData (line 12) | interface CreateInstanceData {
  type UpdateInstanceData (line 22) | interface UpdateInstanceData {
  function getInstances (line 32) | async function getInstances(): Promise<Instance[]> {
  function createInstance (line 40) | async function createInstance(data: CreateInstanceData): Promise<Instanc...
  function updateInstance (line 54) | async function updateInstance(id: number, data: UpdateInstanceData): Pro...
  function deleteInstance (line 68) | async function deleteInstance(id: number): Promise<void> {

FILE: src/api/integrations.ts
  type Integration (line 1) | interface Integration {
  type CreateIntegrationData (line 9) | interface CreateIntegrationData {
  type Indexer (line 16) | interface Indexer {
  type ProwlarrCategory (line 23) | interface ProwlarrCategory {
  type SearchResult (line 29) | interface SearchResult {
  function getIntegrations (line 44) | async function getIntegrations(): Promise<Integration[]> {
  function createIntegration (line 50) | async function createIntegration(data: CreateIntegrationData): Promise<I...
  function deleteIntegration (line 64) | async function deleteIntegration(id: number): Promise<void> {
  function testIntegrationConnection (line 72) | async function testIntegrationConnection(
  function getIndexers (line 89) | async function getIndexers(integrationId: number): Promise<Indexer[]> {
  function getProwlarrCategories (line 95) | async function getProwlarrCategories(integrationId: number): Promise<Pro...
  function search (line 101) | async function search(
  function grabRelease (line 119) | async function grabRelease(

FILE: src/api/netAgent.ts
  type IpInfo (line 1) | interface IpInfo {
  type SpeedtestResult (line 12) | interface SpeedtestResult {
  type SpeedtestServer (line 24) | interface SpeedtestServer {
  type DnsInfo (line 33) | interface DnsInfo {
  type NetworkInterface (line 38) | interface NetworkInterface {
  function agentRequest (line 48) | async function agentRequest<T>(instanceId: number, endpoint: string): Pr...
  function getIpInfo (line 59) | async function getIpInfo(instanceId: number): Promise<IpInfo> {
  function runSpeedtest (line 63) | async function runSpeedtest(instanceId: number, serverId?: number): Prom...
  function getSpeedtestServers (line 68) | async function getSpeedtestServers(instanceId: number): Promise<{ server...
  function getDnsInfo (line 72) | async function getDnsInfo(instanceId: number): Promise<DnsInfo> {
  function getInterfaces (line 76) | async function getInterfaces(instanceId: number): Promise<NetworkInterfa...
  function execCommand (line 80) | async function execCommand(instanceId: number, cmd: string): Promise<{ o...
  function checkAgentHealth (line 84) | async function checkAgentHealth(instanceId: number): Promise<boolean> {

FILE: src/api/qbittorrent.ts
  function getBase (line 7) | function getBase(instanceId: number): string {
  function request (line 11) | async function request<T>(instanceId: number, endpoint: string, options?...
  function action (line 30) | async function action(instanceId: number, endpoint: string, options?: Re...
  type TorrentFilterOptions (line 40) | interface TorrentFilterOptions {
  function getTorrents (line 46) | async function getTorrents(instanceId: number, options: TorrentFilterOpt...
  function getTransferInfo (line 55) | async function getTransferInfo(instanceId: number): Promise<TransferInfo> {
  function getSyncMaindata (line 59) | async function getSyncMaindata(instanceId: number): Promise<SyncMaindata> {
  function stopTorrents (line 63) | async function stopTorrents(instanceId: number, hashes: string[]): Promi...
  function startTorrents (line 71) | async function startTorrents(instanceId: number, hashes: string[]): Prom...
  function recheckTorrents (line 79) | async function recheckTorrents(instanceId: number, hashes: string[]): Pr...
  function reannounceTorrents (line 87) | async function reannounceTorrents(instanceId: number, hashes: string[]):...
  function deleteTorrents (line 95) | async function deleteTorrents(instanceId: number, hashes: string[], dele...
  type AddTorrentOptions (line 106) | interface AddTorrentOptions {
  function addTorrent (line 117) | async function addTorrent(instanceId: number, options: AddTorrentOptions...
  type Category (line 156) | interface Category {
  function getCategories (line 161) | async function getCategories(instanceId: number): Promise<Record<string,...
  function getTorrentProperties (line 165) | async function getTorrentProperties(instanceId: number, hash: string): P...
  function getTorrentTrackers (line 169) | async function getTorrentTrackers(instanceId: number, hash: string): Pro...
  function getTorrentPeers (line 173) | async function getTorrentPeers(instanceId: number, hash: string): Promis...
  function getTorrentFiles (line 177) | async function getTorrentFiles(instanceId: number, hash: string): Promis...
  function getTorrentWebSeeds (line 181) | async function getTorrentWebSeeds(instanceId: number, hash: string): Pro...
  function setCategory (line 185) | async function setCategory(instanceId: number, hashes: string[], categor...
  function addTags (line 193) | async function addTags(instanceId: number, hashes: string[], tags: strin...
  function removeTags (line 201) | async function removeTags(instanceId: number, hashes: string[], tags: st...
  function getTags (line 209) | async function getTags(instanceId: number): Promise<string[]> {
  function createTags (line 213) | async function createTags(instanceId: number, tags: string): Promise<voi...
  function deleteTags (line 221) | async function deleteTags(instanceId: number, tags: string): Promise<voi...
  function createCategory (line 229) | async function createCategory(instanceId: number, category: string, save...
  function editCategory (line 239) | async function editCategory(instanceId: number, category: string, savePa...
  function removeCategories (line 247) | async function removeCategories(instanceId: number, categories: string[]...
  function setFilePriority (line 255) | async function setFilePriority(
  function renameTorrent (line 268) | async function renameTorrent(instanceId: number, hash: string, name: str...
  function setTorrentLocation (line 276) | async function setTorrentLocation(instanceId: number, hashes: string[], ...
  function setTorrentDownloadPath (line 284) | async function setTorrentDownloadPath(instanceId: number, hashes: string...
  function addTrackers (line 292) | async function addTrackers(instanceId: number, hash: string, urls: strin...
  function removeTrackers (line 300) | async function removeTrackers(instanceId: number, hash: string, urls: st...
  function fetchTorrentBlob (line 308) | async function fetchTorrentBlob(instanceId: number, hash: string): Promi...
  function downloadBlob (line 314) | function downloadBlob(blob: Blob, filename: string) {
  function exportTorrents (line 323) | async function exportTorrents(instanceId: number, torrents: { hash: stri...
  function getSpeedLimitsMode (line 338) | async function getSpeedLimitsMode(instanceId: number): Promise<number> {
  function toggleSpeedLimitsMode (line 343) | async function toggleSpeedLimitsMode(instanceId: number): Promise<void> {
  function getPreferences (line 350) | async function getPreferences(instanceId: number): Promise<QBittorrentPr...
  function setPreferences (line 354) | async function setPreferences(instanceId: number, prefs: Partial<QBittor...
  function getRSSItems (line 366) | async function getRSSItems(instanceId: number, withData = false): Promis...
  function postRSS (line 370) | async function postRSS(instanceId: number, endpoint: string, params: Rec...
  function addRSSFeed (line 383) | async function addRSSFeed(instanceId: number, url: string, path?: string...
  function addRSSFolder (line 389) | async function addRSSFolder(instanceId: number, path: string): Promise<v...
  function removeRSSItem (line 393) | async function removeRSSItem(instanceId: number, path: string): Promise<...
  function moveRSSItem (line 397) | async function moveRSSItem(instanceId: number, itemPath: string, destPat...
  function refreshRSSItem (line 401) | async function refreshRSSItem(instanceId: number, itemPath: string): Pro...
  function markRSSAsRead (line 405) | async function markRSSAsRead(instanceId: number, itemPath: string, artic...
  function getRSSRules (line 411) | async function getRSSRules(instanceId: number): Promise<RSSRules> {
  function setRSSRule (line 415) | async function setRSSRule(instanceId: number, ruleName: string, ruleDef:...
  function removeRSSRule (line 419) | async function removeRSSRule(instanceId: number, ruleName: string): Prom...
  function renameRSSRule (line 423) | async function renameRSSRule(instanceId: number, ruleName: string, newRu...
  function getMatchingArticles (line 427) | async function getMatchingArticles(instanceId: number, ruleName: string)...
  type LogEntry (line 431) | interface LogEntry {
  type PeerLogEntry (line 438) | interface PeerLogEntry {
  type LogOptions (line 446) | interface LogOptions {
  function getLog (line 454) | async function getLog(instanceId: number, options: LogOptions = {}): Pro...
  function getPeerLog (line 465) | async function getPeerLog(instanceId: number, lastKnownId?: number): Pro...

FILE: src/api/stats.ts
  type PeriodStats (line 1) | interface PeriodStats {
  function getStats (line 10) | async function getStats(period: string): Promise<PeriodStats[]> {
  function getPeriods (line 16) | async function getPeriods(): Promise<string[]> {

FILE: src/components/AddTorrentModal.tsx
  type Props (line 5) | interface Props {
  type Tab (line 10) | type Tab = 'link' | 'file'
  function AddTorrentModal (line 12) | function AddTorrentModal({ open, onClose }: Props) {

FILE: src/components/AuthForm.tsx
  type Props (line 5) | interface Props {
  function AuthForm (line 9) | function AuthForm({ onSuccess }: Props) {

FILE: src/components/CategoryTagManager.tsx
  type Props (line 13) | interface Props {
  type Tab (line 18) | type Tab = 'categories' | 'tags'
  function CategoryTagManager (line 20) | function CategoryTagManager({ open, onClose }: Props) {

FILE: src/components/ContextMenu.tsx
  type Props (line 21) | interface Props {
  type Submenu (line 28) | type Submenu = 'category' | 'addTag' | 'removeTag' | 'delete' | null
  type EditorMode (line 29) | type EditorMode = 'rename' | 'savePath' | 'downloadPath' | null
  function ContextMenu (line 31) | function ContextMenu({ x, y, torrents, onClose }: Props) {
  function MenuItem (line 318) | function MenuItem({

FILE: src/components/CrossSeedManager.tsx
  type Props (line 6) | interface Props {
  function CrossSeedManager (line 10) | function CrossSeedManager({ instances }: Props) {

FILE: src/components/DateSettingsPopup.tsx
  type Props (line 4) | interface Props {
  function DateSettingsPopup (line 11) | function DateSettingsPopup({ anchor, hideTime, onSave, onClose }: Props) {

FILE: src/components/FileBrowser.tsx
  function formatDate (line 15) | function formatDate(timestamp: number): string {
  type FolderPickerProps (line 25) | interface FolderPickerProps {
  function FolderPicker (line 31) | function FolderPicker({ title, onConfirm, onCancel }: FolderPickerProps) {
  type Props (line 141) | interface Props {
  function FileBrowser (line 145) | function FileBrowser({ enabled }: Props) {

FILE: src/components/FilterBar.tsx
  type Props (line 33) | interface Props {
  function FilterBar (line 38) | function FilterBar({ filter, onFilterChange }: Props) {
  function SearchInput (line 60) | function SearchInput({ value, onChange }: { value: string; onChange: (s:...
  type DropdownProps (line 80) | interface DropdownProps<T extends string> {
  function Dropdown (line 88) | function Dropdown<T extends string>({ value, onChange, options, placehol...
  type CategoryDropdownProps (line 156) | interface CategoryDropdownProps {
  function CategoryDropdown (line 162) | function CategoryDropdown({ value, onChange, categories }: CategoryDropd...
  type TagDropdownProps (line 225) | interface TagDropdownProps {
  function TagDropdown (line 231) | function TagDropdown({ value, onChange, tags }: TagDropdownProps) {
  function ManageButton (line 293) | function ManageButton({ onClick }: { onClick: () => void }) {
  type TrackerDropdownProps (line 307) | interface TrackerDropdownProps {
  function TrackerDropdown (line 313) | function TrackerDropdown({ value, onChange, trackers }: TrackerDropdownP...
  type ColumnSelectorProps (line 325) | interface ColumnSelectorProps {
  function ColumnSelector (line 334) | function ColumnSelector({ columns, visible, onChange, columnOrder, onReo...

FILE: src/components/Header.tsx
  type Tab (line 9) | type Tab = 'dashboard' | 'tools'
  type Props (line 11) | interface Props {
  function Header (line 20) | function Header({ activeTab, onTabChange, username, authDisabled, onLogo...

FILE: src/components/InstanceManager.tsx
  type Tab (line 42) | type Tab = 'dashboard' | 'tools'
  type Tool (line 43) | type Tool = 'indexers' | 'files' | 'orphans' | 'rss' | 'logs' | 'cross-s...
  type InstanceStats (line 45) | interface InstanceStats {
  function SpeedGraph (line 61) | function SpeedGraph({ history, color }: { history: number[]; color: stri...
  type Props (line 82) | interface Props {
  function InstanceManager (line 93) | function InstanceManager({

FILE: src/components/Layout.tsx
  type Tab (line 5) | type Tab = 'dashboard' | 'tools'
  type Props (line 7) | interface Props {
  function Layout (line 16) | function Layout({ children, onTabChange, username, authDisabled, onLogou...

FILE: src/components/LogViewer.tsx
  constant LOG_TYPES (line 7) | const LOG_TYPES = {
  type LogTab (line 14) | type LogTab = 'main' | 'peers'
  type SortOrder (line 15) | type SortOrder = 'newest' | 'oldest'
  type Props (line 17) | interface Props {
  function LogViewer (line 21) | function LogViewer({ instances }: Props) {

FILE: src/components/NetworkTools.tsx
  type Props (line 34) | interface Props {
  type CardStatus (line 38) | type CardStatus = 'idle' | 'loading' | 'success' | 'error'
  type CardState (line 40) | interface CardState<T> {
  function formatBandwidth (line 46) | function formatBandwidth(bps: number): string {
  function RunButton (line 51) | function RunButton({ onClick, disabled, loading }: { onClick: () => void...
  function NetworkTools (line 64) | function NetworkTools({ instances }: Props) {

FILE: src/components/OrphanManager.tsx
  type OrphanTorrent (line 7) | interface OrphanTorrent {
  type Props (line 17) | interface Props {
  function OrphanManager (line 21) | function OrphanManager({ instances }: Props) {

FILE: src/components/RSSManager.tsx
  type Tab (line 8) | type Tab = 'feeds' | 'rules'
  type ArticleDownloadProps (line 10) | interface ArticleDownloadProps {
  function ArticleDownload (line 17) | function ArticleDownload({ article, idx, instances, rss }: ArticleDownlo...
  type Props (line 88) | interface Props {
  function RSSManager (line 92) | function RSSManager({ instances }: Props) {

FILE: src/components/RatioThresholdPopup.tsx
  type Props (line 3) | interface Props {
  function RatioThresholdPopup (line 10) | function RatioThresholdPopup({ anchor, threshold, onSave, onClose }: Pro...

FILE: src/components/SearchPanel.tsx
  function formatAge (line 22) | function formatAge(dateStr: string): string {
  function SearchPanel (line 34) | function SearchPanel() {

FILE: src/components/SettingsPanel.tsx
  type SettingsTab (line 15) | type SettingsTab = 'behavior' | 'downloads' | 'connection' | 'speed' | '...
  constant TABS (line 17) | const TABS: { id: SettingsTab; label: string; icon: ReactNode }[] = [
  type Props (line 108) | interface Props {
  function SettingsPanel (line 113) | function SettingsPanel({ instance, onClose }: Props) {

FILE: src/components/Statistics.tsx
  function Statistics (line 6) | function Statistics() {

FILE: src/components/StatusBar.tsx
  function formatLimit (line 20) | function formatLimit(bytes: number): string {
  function PerPageDropdown (line 25) | function PerPageDropdown({ value, onChange }: { value: number; onChange:...
  function useAltSpeedMode (line 74) | function useAltSpeedMode(instanceId: number) {
  function StatusBar (line 105) | function StatusBar() {

FILE: src/components/ThemeManager.tsx
  type View (line 9) | type View = 'list' | 'editor'
  type ThemeManagerProps (line 11) | interface ThemeManagerProps {
  function ThemeManager (line 15) | function ThemeManager({ onClose }: ThemeManagerProps) {
  type ListViewProps (line 118) | interface ListViewProps {
  function ListView (line 128) | function ListView({ customThemes, onClose, onNew, onEdit, onDelete, onEx...
  type EditorViewProps (line 208) | interface EditorViewProps {
  function EditorView (line 215) | function EditorView({ initialTheme, existingNames, onSave, onBack }: Edi...
  function ColorInput (line 349) | function ColorInput({ label, value, onChange }: { label: string; value: ...

FILE: src/components/ThemeSwitcher.tsx
  function ThemeSwitcher (line 6) | function ThemeSwitcher() {
  function ThemeRow (line 103) | function ThemeRow({

FILE: src/components/TorrentDetailsPanel.tsx
  type Props (line 31) | interface Props {
  type Tab (line 42) | type Tab = 'general' | 'trackers' | 'peers' | 'http' | 'content'
  constant TABS (line 44) | const TABS: { id: Tab; label: string; Icon: React.ComponentType<{ classN...
  constant MIN_HEIGHT (line 52) | const MIN_HEIGHT = 120
  constant MAX_HEIGHT_PERCENT (line 53) | const MAX_HEIGHT_PERCENT = 0.55
  constant COLLAPSED_HEIGHT (line 54) | const COLLAPSED_HEIGHT = 36
  constant TRACKER_STATUSES (line 56) | const TRACKER_STATUSES: Record<number, { label: string; colorVar: string...
  function StatusBadge (line 64) | function StatusBadge({ status }: { status: number }) {
  function LoadingSkeleton (line 77) | function LoadingSkeleton() {
  function EmptyState (line 93) | function EmptyState({ message }: { message: string }) {
  function formatLimit (line 104) | function formatLimit(limit: number): string {
  function InfoCell (line 110) | function InfoCell({
  function GeneralTab (line 145) | function GeneralTab({ hash, category, tags }: { hash: string; category: ...
  function TrackersTab (line 344) | function TrackersTab({ hash }: { hash: string }) {
  function PeersTab (line 510) | function PeersTab({ hash }: { hash: string }) {
  function HttpSourcesTab (line 578) | function HttpSourcesTab({ hash }: { hash: string }) {
  constant PRIORITY_OPTIONS (line 600) | const PRIORITY_OPTIONS = [
  constant PRIORITY_TO_VALUE (line 607) | const PRIORITY_TO_VALUE: Record<string, number> = { skip: 0, normal: 1, ...
  constant PRIORITY_COLORS (line 609) | const PRIORITY_COLORS: Record<string, string> = {
  function ContentTabInner (line 617) | function ContentTabInner({ hash, files }: { hash: string; files: Torrent...
  function ContentTab (line 763) | function ContentTab({ hash }: { hash: string }) {
  function TorrentDetailsPanel (line 770) | function TorrentDetailsPanel({ hash, name, category, tags, expanded, onT...

FILE: src/components/TorrentList.tsx
  constant DEFAULT_PANEL_HEIGHT (line 51) | const DEFAULT_PANEL_HEIGHT = 220
  function SortIcon (line 53) | function SortIcon({ active, asc }: { active: boolean; asc: boolean }) {
  function ActionButton (line 64) | function ActionButton({
  function TorrentList (line 90) | function TorrentList() {

FILE: src/components/TorrentRow.tsx
  type StateType (line 6) | type StateType = 'accent' | 'warning' | 'muted' | 'info' | 'error'
  function getStateInfo (line 8) | function getStateInfo(state: TorrentState): { label: string; type: State...
  function getColor (line 35) | function getColor(type: StateType): string {
  constant TWO_PART_TLD_MARKERS (line 46) | const TWO_PART_TLD_MARKERS = new Set(['co', 'com', 'net', 'org', 'gov', ...
  function getTrackerName (line 48) | function getTrackerName(tracker: string): string {
  type CellContext (line 71) | interface CellContext {
  function renderCell (line 81) | function renderCell(columnId: string, torrent: Torrent, ctx: CellContext...
  type Props (line 273) | interface Props {
  function TorrentRow (line 286) | function TorrentRow({

FILE: src/components/ViewSelector.tsx
  type ViewSelectorProps (line 6) | interface ViewSelectorProps {
  function ViewSelector (line 16) | function ViewSelector({

FILE: src/components/columns.ts
  type SortKey (line 1) | type SortKey =
  type ColumnDef (line 23) | interface ColumnDef {
  constant COLUMNS (line 29) | const COLUMNS: ColumnDef[] = [
  constant DEFAULT_VISIBLE_COLUMNS (line 52) | const DEFAULT_VISIBLE_COLUMNS = new Set([
  constant DEFAULT_COLUMN_ORDER (line 64) | const DEFAULT_COLUMN_ORDER = COLUMNS.map((c) => c.id)

FILE: src/components/settings/AdvancedTab.tsx
  type Props (line 5) | interface Props {
  constant RESUME_DATA_STORAGE_TYPES (line 10) | const RESUME_DATA_STORAGE_TYPES = [
  constant TORRENT_CONTENT_REMOVE_OPTIONS (line 15) | const TORRENT_CONTENT_REMOVE_OPTIONS = [
  constant DISK_IO_TYPES (line 20) | const DISK_IO_TYPES = [
  constant DISK_IO_MODES (line 26) | const DISK_IO_MODES = [
  constant UTP_TCP_MIXED_MODES (line 31) | const UTP_TCP_MIXED_MODES = [
  constant UPLOAD_SLOTS_BEHAVIORS (line 36) | const UPLOAD_SLOTS_BEHAVIORS = [
  constant UPLOAD_CHOKING_ALGORITHMS (line 41) | const UPLOAD_CHOKING_ALGORITHMS = [
  function AdvancedTab (line 47) | function AdvancedTab({ preferences, onChange }: Props) {

FILE: src/components/settings/BehaviorTab.tsx
  type Props (line 5) | interface Props {
  constant LOCALES (line 10) | const LOCALES = [
  constant FILE_LOG_AGE_TYPES (line 31) | const FILE_LOG_AGE_TYPES = [
  function BehaviorTab (line 37) | function BehaviorTab({ preferences, onChange }: Props) {

FILE: src/components/settings/BitTorrentTab.tsx
  type Props (line 4) | interface Props {
  constant ENCRYPTION_OPTIONS (line 9) | const ENCRYPTION_OPTIONS = [
  constant RATIO_ACTION_OPTIONS (line 15) | const RATIO_ACTION_OPTIONS = [
  function BitTorrentTab (line 22) | function BitTorrentTab({ preferences, onChange }: Props) {

FILE: src/components/settings/ConnectionTab.tsx
  type Props (line 4) | interface Props {
  constant PROTOCOL_OPTIONS (line 9) | const PROTOCOL_OPTIONS = [
  constant PROXY_TYPES (line 15) | const PROXY_TYPES = [
  function ConnectionTab (line 24) | function ConnectionTab({ preferences, onChange }: Props) {

FILE: src/components/settings/DownloadsTab.tsx
  type Props (line 4) | interface Props {
  constant CONTENT_LAYOUT_OPTIONS (line 9) | const CONTENT_LAYOUT_OPTIONS = [
  constant STOP_CONDITION_OPTIONS (line 15) | const STOP_CONDITION_OPTIONS = [
  function DownloadsTab (line 21) | function DownloadsTab({ preferences, onChange }: Props) {

FILE: src/components/settings/RSSTab.tsx
  type Props (line 4) | interface Props {
  function RSSTab (line 9) | function RSSTab({ preferences, onChange }: Props) {

FILE: src/components/settings/SpeedTab.tsx
  constant SCHEDULER_DAYS (line 5) | const SCHEDULER_DAYS = [
  type Props (line 18) | interface Props {
  function bytesToKB (line 23) | function bytesToKB(bytes: number | undefined): string {
  function kbToBytes (line 28) | function kbToBytes(kb: string): number {
  function SpeedTab (line 33) | function SpeedTab({ preferences, onChange }: Props) {

FILE: src/components/settings/WebUITab.tsx
  type Props (line 5) | interface Props {
  constant DYNDNS_SERVICES (line 10) | const DYNDNS_SERVICES = [
  function WebUITab (line 15) | function WebUITab({ preferences, onChange }: Props) {

FILE: src/components/ui/Checkbox.tsx
  function Checkbox (line 4) | function Checkbox({

FILE: src/components/ui/MultiSelect.tsx
  type Option (line 5) | interface Option {
  type MultiSelectProps (line 10) | interface MultiSelectProps {
  function MultiSelect (line 17) | function MultiSelect({ options, selected, onChange, placeholder = 'Selec...

FILE: src/components/ui/Select.tsx
  type SelectProps (line 4) | interface SelectProps<T extends string | number> {
  function Select (line 12) | function Select<T extends string | number>({

FILE: src/components/ui/Toggle.tsx
  function Toggle (line 1) | function Toggle({ checked, onChange }: { checked: boolean; onChange: (v:...

FILE: src/contexts/InstanceProvider.tsx
  type Props (line 5) | interface Props {
  function InstanceProvider (line 10) | function InstanceProvider({ instance, children }: Props) {

FILE: src/contexts/PaginationProvider.tsx
  function PaginationProvider (line 4) | function PaginationProvider({ children }: { children: ReactNode }) {

FILE: src/contexts/ThemeContext.ts
  type ThemeContextValue (line 4) | interface ThemeContextValue {

FILE: src/contexts/ThemeProvider.tsx
  constant STORAGE_KEY (line 5) | const STORAGE_KEY = 'qbitwebui-theme'
  constant CUSTOM_THEMES_KEY (line 6) | const CUSTOM_THEMES_KEY = 'qbitwebui-custom-themes'
  function applyTheme (line 8) | function applyTheme(colors: (typeof themes)[0]['colors']) {
  function ThemeProvider (line 24) | function ThemeProvider({ children }: { children: ReactNode }) {

FILE: src/contexts/instanceContext.ts
  type InstanceContextValue (line 4) | interface InstanceContextValue {

FILE: src/contexts/paginationContext.ts
  type PaginationContextValue (line 3) | interface PaginationContextValue {

FILE: src/hooks/useClickOutside.ts
  function useClickOutside (line 3) | function useClickOutside(ref: RefObject<HTMLElement | null>, handler: ()...

FILE: src/hooks/useCrossSeed.ts
  function useCrossSeed (line 21) | function useCrossSeed(instances: Instance[]) {
  function formatTimestamp (line 229) | function formatTimestamp(ts: number | null): string {
  constant LOG_LEVEL_COLORS (line 239) | const LOG_LEVEL_COLORS: Record<string, string> = {

FILE: src/hooks/useInstance.ts
  function useInstance (line 5) | function useInstance(): Instance {

FILE: src/hooks/usePagination.ts
  function usePagination (line 4) | function usePagination() {

FILE: src/hooks/useRSSManager.ts
  constant FEED_REFRESH_DELAY (line 19) | const FEED_REFRESH_DELAY = 500
  type FlatFeed (line 21) | interface FlatFeed {
  function flattenFeeds (line 30) | function flattenFeeds(items: RSSItems, parentPath = '', depth = 0): Flat...
  type UseRSSManagerOptions (line 62) | interface UseRSSManagerOptions {
  function useRSSManager (line 67) | function useRSSManager({ instances, onViewChange }: UseRSSManagerOptions) {

FILE: src/hooks/useStats.ts
  constant PERIODS (line 4) | const PERIODS = [
  type PeriodData (line 18) | interface PeriodData {
  type InstanceOption (line 26) | interface InstanceOption {
  type UseStatsResult (line 31) | interface UseStatsResult {
  function useStats (line 40) | function useStats(): UseStatsResult {

FILE: src/hooks/useSyncMaindata.ts
  function useSyncMaindata (line 5) | function useSyncMaindata() {

FILE: src/hooks/useTheme.ts
  function useTheme (line 4) | function useTheme() {

FILE: src/hooks/useTorrentDetails.ts
  function useTorrentProperties (line 5) | function useTorrentProperties(hash: string | null) {
  function useTorrentTrackers (line 15) | function useTorrentTrackers(hash: string | null) {
  function useTorrentPeers (line 25) | function useTorrentPeers(hash: string | null) {
  function useTorrentFiles (line 35) | function useTorrentFiles(hash: string | null) {
  function useTorrentWebSeeds (line 44) | function useTorrentWebSeeds(hash: string | null) {
  function useSetFilePriority (line 53) | function useSetFilePriority() {
  function useAddTrackers (line 77) | function useAddTrackers() {
  function useRemoveTrackers (line 86) | function useRemoveTrackers() {

FILE: src/hooks/useTorrents.ts
  function useTorrents (line 6) | function useTorrents(options: TorrentFilterOptions = {}) {
  function useStopTorrents (line 15) | function useStopTorrents() {
  function useStartTorrents (line 24) | function useStartTorrents() {
  function useRecheckTorrents (line 33) | function useRecheckTorrents() {
  function useReannounceTorrents (line 42) | function useReannounceTorrents() {
  function useDeleteTorrents (line 49) | function useDeleteTorrents() {
  function useAddTorrent (line 59) | function useAddTorrent() {
  function useCategories (line 69) | function useCategories() {
  function useTags (line 77) | function useTags() {
  function useSetCategory (line 85) | function useSetCategory() {
  function useAddTags (line 95) | function useAddTags() {
  function useRemoveTags (line 107) | function useRemoveTags() {
  function useRenameTorrent (line 119) | function useRenameTorrent() {
  function useSetTorrentLocation (line 128) | function useSetTorrentLocation() {
  function useSetTorrentDownloadPath (line 141) | function useSetTorrentDownloadPath() {
  function useCreateTag (line 154) | function useCreateTag() {
  function useDeleteTag (line 163) | function useDeleteTag() {
  function useCreateCategory (line 175) | function useCreateCategory() {
  function useEditCategory (line 185) | function useEditCategory() {
  function useDeleteCategory (line 195) | function useDeleteCategory() {
  function useExportTorrents (line 207) | function useExportTorrents() {

FILE: src/hooks/useTransferInfo.ts
  function useTransferInfo (line 5) | function useTransferInfo() {

FILE: src/hooks/useUpdateCheck.ts
  type GitHubRelease (line 5) | interface GitHubRelease {
  function compareVersions (line 11) | function compareVersions(current: string, latest: string): number {
  function useUpdateCheck (line 21) | function useUpdateCheck() {

FILE: src/mobile/MobileApp.tsx
  type MainTab (line 21) | type MainTab = 'torrents' | 'tools'
  type Tool (line 22) | type Tool = 'search' | 'files' | 'orphans' | 'rss' | 'logs' | 'cross-see...
  function parseHash (line 47) | function parseHash(): { tab: MainTab; tool: Tool } {
  function setHash (line 58) | function setHash(tab: MainTab, tool: Tool) {
  function useAltSpeedMode (line 66) | function useAltSpeedMode(instanceId: number | null) {
  type Props (line 106) | interface Props {
  function MobileApp (line 112) | function MobileApp({ username, onLogout, authDisabled }: Props) {

FILE: src/mobile/MobileCrossSeedManager.tsx
  type Props (line 8) | interface Props {
  function MobileCrossSeedManager (line 13) | function MobileCrossSeedManager({ instances, onBack }: Props): ReactNode {

FILE: src/mobile/MobileFileBrowser.tsx
  function formatDate (line 27) | function formatDate(timestamp: number): string {
  type Props (line 31) | interface Props {
  function MobileFileBrowser (line 35) | function MobileFileBrowser({ onBack }: Props) {

FILE: src/mobile/MobileInstancePicker.tsx
  type Props (line 5) | interface Props {
  function MobileInstancePicker (line 11) | function MobileInstancePicker({ instances, current, onChange }: Props) {

FILE: src/mobile/MobileLogViewer.tsx
  constant LOG_TYPES (line 6) | const LOG_TYPES = {
  type LogTab (line 13) | type LogTab = 'main' | 'peers'
  type SortOrder (line 14) | type SortOrder = 'newest' | 'oldest'
  type Props (line 16) | interface Props {
  function MobileLogViewer (line 21) | function MobileLogViewer({ instances, onBack }: Props) {

FILE: src/mobile/MobileNetworkTools.tsx
  type Props (line 37) | interface Props {
  type CardStatus (line 42) | type CardStatus = 'idle' | 'loading' | 'success' | 'error'
  type CardState (line 44) | interface CardState<T> {
  function formatBandwidth (line 50) | function formatBandwidth(bps: number): string {
  function MobileNetworkTools (line 55) | function MobileNetworkTools({ instances, onBack }: Props) {

FILE: src/mobile/MobileOrphanManager.tsx
  type OrphanTorrent (line 7) | interface OrphanTorrent {
  type Props (line 17) | interface Props {
  function MobileOrphanManager (line 22) | function MobileOrphanManager({ instances, onBack }: Props) {

FILE: src/mobile/MobileRSSManager.tsx
  type Tab (line 8) | type Tab = 'feeds' | 'rules'
  type View (line 9) | type View = 'list' | 'articles' | 'editor'
  type MobileArticleDownloadProps (line 11) | interface MobileArticleDownloadProps {
  function MobileArticleDownload (line 18) | function MobileArticleDownload({ article, idx, instances, rss }: MobileA...
  type Props (line 89) | interface Props {
  function MobileRSSManager (line 94) | function MobileRSSManager({ instances, onBack }: Props) {

FILE: src/mobile/MobileSearchPanel.tsx
  function formatAge (line 22) | function formatAge(dateStr: string): string {
  type Props (line 34) | interface Props {
  function MobileSearchPanel (line 39) | function MobileSearchPanel({ instances, onBack }: Props) {

FILE: src/mobile/MobileStatistics.tsx
  type Props (line 6) | interface Props {
  function MobileStatistics (line 10) | function MobileStatistics({ onBack }: Props): ReactNode {

FILE: src/mobile/MobileStats.tsx
  type Props (line 7) | interface Props {
  function MobileStats (line 11) | function MobileStats({ instances }: Props) {

FILE: src/mobile/MobileThemeManager.tsx
  type View (line 9) | type View = 'list' | 'editor'
  type MobileThemeManagerProps (line 11) | interface MobileThemeManagerProps {
  function MobileThemeManager (line 15) | function MobileThemeManager({ onClose }: MobileThemeManagerProps) {
  type ListViewProps (line 130) | interface ListViewProps {
  function ListView (line 139) | function ListView({ customThemes, onNew, onEdit, onDelete, onExport, onI...
  type EditorViewProps (line 225) | interface EditorViewProps {
  function EditorView (line 232) | function EditorView({ initialTheme, existingNames, onSave, onBack }: Edi...
  function ColorInput (line 369) | function ColorInput({ label, value, onChange }: { label: string; value: ...

FILE: src/mobile/MobileThemeSwitcher.tsx
  function MobileThemeSwitcher (line 6) | function MobileThemeSwitcher() {
  function ThemeRow (line 97) | function ThemeRow({

FILE: src/mobile/MobileTools.tsx
  type Tool (line 28) | type Tool = 'search' | 'files' | 'orphans' | 'rss' | 'logs' | 'cross-see...
  function LazyTool (line 39) | function LazyTool({ children }: { children: ReactNode }): ReactNode {
  type Props (line 43) | interface Props {
  function MobileTools (line 49) | function MobileTools({ instances, activeTool, onToolChange }: Props): Re...

FILE: src/mobile/MobileTorrentDetail.tsx
  type Tab (line 10) | type Tab = 'general' | 'files' | 'trackers' | 'peers' | 'http'
  type PathEditorMode (line 11) | type PathEditorMode = 'savePath' | 'downloadPath' | null
  constant PAUSED_STATES (line 13) | const PAUSED_STATES: TorrentState[] = ['pausedDL', 'pausedUP', 'stoppedD...
  function getTrackerStatus (line 15) | function getTrackerStatus(status: number): string {
  function getPriorityLabel (line 28) | function getPriorityLabel(priority: number): string {
  type Props (line 39) | interface Props {
  function MobileTorrentDetail (line 45) | function MobileTorrentDetail({ torrentHash, instanceId, onClose }: Props) {
  function InfoRow (line 558) | function InfoRow({
  function PathRow (line 584) | function PathRow({

FILE: src/mobile/MobileTorrentList.tsx
  type TorrentWithInstance (line 29) | type TorrentWithInstance = Torrent & { instanceId: number; instanceLabel...
  type StatusFilter (line 31) | type StatusFilter = 'all' | 'downloading' | 'seeding' | 'paused'
  type SortField (line 32) | type SortField = 'dlspeed' | 'upspeed' | 'ratio' | 'seeding_time' | 'add...
  constant DOWNLOADING_STATES (line 34) | const DOWNLOADING_STATES: TorrentState[] = [
  constant SEEDING_STATES (line 43) | const SEEDING_STATES: TorrentState[] = ['uploading', 'forcedUP', 'stalle...
  constant PAUSED_STATES (line 44) | const PAUSED_STATES: TorrentState[] = ['pausedDL', 'pausedUP', 'stoppedD...
  type StateInfo (line 46) | type StateInfo = { color: string; label: string; icon: 'download' | 'upl...
  constant STATE_INFO (line 48) | const STATE_INFO: Partial<Record<TorrentState, StateInfo>> = {
  function getStateInfo (line 59) | function getStateInfo(state: TorrentState): StateInfo {
  function MobileSelect (line 67) | function MobileSelect<T extends string>({
  constant STATE_ICONS (line 134) | const STATE_ICONS = {
  function StateIcon (line 142) | function StateIcon({ type, color }: { type: keyof typeof STATE_ICONS; co...
  type Props (line 147) | interface Props {
  constant STATUS_OPTIONS (line 155) | const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [
  constant SORT_OPTIONS (line 162) | const SORT_OPTIONS: { value: SortField; label: string }[] = [
  function MobileTorrentList (line 171) | function MobileTorrentList({ instances, search, compact, onToggleCompact...

FILE: src/server/db/index.ts
  type User (line 191) | interface User {
  type Instance (line 198) | interface Instance {
  type Integration (line 211) | interface Integration {
  type MatchModeType (line 226) | type MatchModeType = (typeof MatchMode)[keyof typeof MatchMode]
  type CrossSeedConfig (line 228) | interface CrossSeedConfig {
  type CrossSeedIndexer (line 254) | interface CrossSeedIndexer {
  type CrossSeedSearchee (line 263) | interface CrossSeedSearchee {
  type CrossSeedDecision (line 275) | interface CrossSeedDecision {
  type BlocklistTypeValue (line 312) | type BlocklistTypeValue = (typeof BlocklistType)[keyof typeof BlocklistT...
  type TransferStats (line 314) | interface TransferStats {
  function cleanupExpiredSessions (line 322) | function cleanupExpiredSessions() {
  constant AUTH_DISABLED (line 330) | const AUTH_DISABLED = process.env.DISABLE_AUTH === 'true'
  constant REGISTRATION_DISABLED (line 331) | const REGISTRATION_DISABLED = process.env.DISABLE_REGISTRATION === 'true'
  function generateSecurePassword (line 342) | function generateSecurePassword(): string {
  function initDefaultAdmin (line 361) | async function initDefaultAdmin() {
  function clearDefaultCredentials (line 373) | function clearDefaultCredentials() {

FILE: src/server/middleware/auth.ts
  type AuthUser (line 5) | interface AuthUser {
  type ContextVariableMap (line 11) | interface ContextVariableMap {

FILE: src/server/routes/auth.ts
  constant SESSION_DURATION (line 11) | const SESSION_DURATION = 7 * 24 * 60 * 60
  function validatePassword (line 13) | function validatePassword(password: string): string | null {

FILE: src/server/routes/crossSeed.ts
  function userOwnsInstance (line 32) | function userOwnsInstance(userId: number, instanceId: number): boolean {
  function parseJsonArray (line 39) | function parseJsonArray<T>(json: string | null, guard: (v: unknown) => v...
  function parseIndexerIds (line 49) | function parseIndexerIds(json: string | null): number[] {
  function parseBlocklist (line 53) | function parseBlocklist(json: string | null): string[] {
  function validateLinkDir (line 103) | function validateLinkDir(dir: string): { valid: boolean; writable: boole...
  function validateConfig (line 113) | function validateConfig(body: Record<string, unknown>): { valid: boolean...

FILE: src/server/routes/files.ts
  constant DOWNLOADS_PATH (line 9) | const DOWNLOADS_PATH = process.env.DOWNLOADS_PATH
  function isPathSafe (line 14) | function isPathSafe(requestedPath: string): string | null {
  function sanitizeFilename (line 22) | function sanitizeFilename(name: string): string {
  type FileEntry (line 26) | interface FileEntry {
  method pull (line 168) | async pull(controller) {
  method cancel (line 185) | cancel() {

FILE: src/server/routes/instances.ts
  type InstanceBackoff (line 12) | interface InstanceBackoff {
  constant BACKOFF_DELAYS (line 18) | const BACKOFF_DELAYS = [10000, 15000, 30000]
  constant INITIAL_TIMEOUT (line 19) | const INITIAL_TIMEOUT = 3000
  type InstanceResponse (line 23) | interface InstanceResponse {
  function toResponse (line 34) | function toResponse(i: Instance): InstanceResponse {
  type TorrentInfo (line 55) | interface TorrentInfo {
  type TransferInfo (line 61) | interface TransferInfo {
  type SyncMaindata (line 66) | interface SyncMaindata {
  type InstanceStats (line 74) | interface InstanceStats {
  function fetchInstanceStats (line 90) | async function fetchInstanceStats(instance: Instance): Promise<InstanceS...

FILE: src/server/routes/integrations.ts
  type IntegrationResponse (line 13) | interface IntegrationResponse {
  function toResponse (line 21) | function toResponse(i: Integration): IntegrationResponse {
  function getUserIntegration (line 88) | function getUserIntegration(userId: number, integrationId: number) {
  function fetchProwlarrApi (line 132) | async function fetchProwlarrApi(integration: Integration, endpoint: stri...

FILE: src/server/routes/proxy.ts
  type QbtSession (line 12) | interface QbtSession {
  constant SESSION_TTL (line 19) | const SESSION_TTL = 30 * 60 * 1000
  function loginToQbt (line 21) | async function loginToQbt(instance: Instance): Promise<string | null> {
  function getQbtSession (line 44) | async function getQbtSession(instance: Instance): Promise<string | null> {
  function clearQbtSession (line 55) | function clearQbtSession(instanceId: number) {
  function getAgentUrl (line 139) | function getAgentUrl(instance: Instance): string {

FILE: src/server/routes/stats.ts
  constant PERIODS (line 11) | const PERIODS: Record<string, number> = {
  type InstancePeriodStats (line 24) | interface InstancePeriodStats extends PeriodStats {

FILE: src/server/routes/tools.ts
  type Torrent (line 12) | interface Torrent {
  type Tracker (line 19) | interface Tracker {
  type OrphanResult (line 25) | interface OrphanResult {
  function qbtRequest (line 35) | async function qbtRequest<T>(instance: Instance, cookie: string | null, ...

FILE: src/server/utils/crossSeedCache.ts
  function getDir (line 7) | function getDir(type: 'cache' | 'output'): string {
  function getCacheDir (line 17) | function getCacheDir(): string {
  function getOutputDir (line 21) | function getOutputDir(): string {
  constant VALID_HASH_REGEX (line 25) | const VALID_HASH_REGEX = /^[a-fA-F0-9]+$/
  function sanitizeHash (line 27) | function sanitizeHash(hash: string): string {
  function getTorrentCachePath (line 34) | function getTorrentCachePath(instanceId: number, infoHash: string): stri...
  function getOutputPath (line 41) | function getOutputPath(instanceId: number, name: string, infoHash: strin...
  function cacheTorrent (line 49) | function cacheTorrent(instanceId: number, infoHash: string, data: Buffer...
  function getCachedTorrent (line 55) | function getCachedTorrent(instanceId: number, infoHash: string): Buffer ...
  function hasCachedTorrent (line 61) | function hasCachedTorrent(instanceId: number, infoHash: string): boolean {
  function saveTorrentToOutput (line 65) | function saveTorrentToOutput(instanceId: number, name: string, infoHash:...
  function clearTorrentsInDir (line 72) | function clearTorrentsInDir(dir: string): number {
  function clearCacheForInstance (line 79) | function clearCacheForInstance(instanceId: number): number {
  function clearOutputForInstance (line 85) | function clearOutputForInstance(instanceId: number): number {
  function getTorrentFiles (line 91) | function getTorrentFiles(dir: string): string[] {
  function getCacheStats (line 96) | function getCacheStats(instanceId: number): { count: number; totalSize: ...
  function getOutputStats (line 112) | function getOutputStats(instanceId: number): { count: number; files: str...
  function _resetCachePaths (line 117) | function _resetCachePaths(): void {

FILE: src/server/utils/crossSeedMatcher.ts
  type FileInfo (line 4) | interface FileInfo {
  type Searchee (line 9) | interface Searchee {
  type MatchResult (line 19) | interface MatchResult {
  type PreFilterResult (line 28) | interface PreFilterResult {
  constant RESOLUTION_REGEX (line 33) | const RESOLUTION_REGEX = /\b(2160p|1080p|1080i|720p|576p|576i|480p|480i|...
  constant RELEASE_GROUP_REGEX (line 34) | const RELEASE_GROUP_REGEX =
  constant ANIME_GROUP_REGEX (line 36) | const ANIME_GROUP_REGEX = /^\s*\[(?<group>.+?)\]/i
  constant SOURCE_REGEX (line 37) | const SOURCE_REGEX = /\b(AMZN|NF|NETFLIX|DSNP|HULU|ATVP|PCOK|PMTP|HBO|HM...
  constant PROPER_REPACK_REGEX (line 38) | const PROPER_REPACK_REGEX = /\b(PROPER|REPACK|RERIP|REAL)\d?\b/i
  constant VIDEO_EXTENSIONS (line 39) | const VIDEO_EXTENSIONS = /\.(mkv|mp4|avi|m4v|ts|wmv|webm)$/i
  constant SEASON_REGEX (line 40) | const SEASON_REGEX = /^(?<title>.+?)[[(_.\s-]+(?<season>S(?:eason)?\s*\d...
  constant EP_REGEX (line 41) | const EP_REGEX =
  constant VIDEO_EXTS (line 43) | const VIDEO_EXTS = ['.mkv', '.mp4', '.avi', '.ts', '.m4v', '.mov', '.wmv...
  constant BAD_GROUP_PARSE_REGEX (line 44) | const BAD_GROUP_PARSE_REGEX =
  constant PARSE_BLOCKLIST_REGEX (line 46) | const PARSE_BLOCKLIST_REGEX = /^(?<blocklistType>.+?):(?<blocklistValue>...
  function extractResolution (line 48) | function extractResolution(name: string): string | null {
  function stripExtension (line 56) | function stripExtension(name: string): string {
  function extractReleaseGroup (line 60) | function extractReleaseGroup(name: string): string | null {
  function extractAnimeGroup (line 69) | function extractAnimeGroup(name: string): string | null {
  function extractSource (line 74) | function extractSource(name: string): string | null {
  function hasProperRepack (line 83) | function hasProperRepack(name: string): boolean {
  function checkMatch (line 87) | function checkMatch(
  function resolutionMatches (line 99) | function resolutionMatches(sourceName: string, candidateName: string): P...
  function releaseGroupMatches (line 103) | function releaseGroupMatches(sourceName: string, candidateName: string):...
  function sourceMatches (line 121) | function sourceMatches(sourceName: string, candidateName: string): PreFi...
  function properRepackMatches (line 125) | function properRepackMatches(sourceName: string, candidateName: string):...
  function compareFileTrees (line 135) | function compareFileTrees(candidateFiles: FileInfo[], searcheeFiles: Fil...
  function compareFileTreesIgnoringNames (line 139) | function compareFileTreesIgnoringNames(candidateFiles: FileInfo[], searc...
  function matchTorrentsBySizes (line 153) | function matchTorrentsBySizes(searcheeFiles: FileInfo[], candidateFiles:...
  function fuzzySizeMatch (line 198) | function fuzzySizeMatch(sourceSize: number, candidateSize: number, toler...
  function preFilterCandidate (line 202) | function preFilterCandidate(
  function isSeasonPack (line 229) | function isSeasonPack(title: string): boolean {
  function isSingleEpisode (line 233) | function isSingleEpisode(title: string, files: FileInfo[]): boolean {
  function shouldRejectSeasonEpisodeMismatch (line 239) | function shouldRejectSeasonEpisodeMismatch(
  function parseBlocklistEntry (line 250) | function parseBlocklistEntry(entry: string): { blocklistType: BlocklistT...
  function findBlockedStringInRelease (line 264) | function findBlockedStringInRelease(searchee: Searchee, blocklist: strin...

FILE: src/server/utils/crossSeedScheduler.ts
  type ScheduledInstance (line 5) | interface ScheduledInstance {
  constant CHECK_INTERVAL_MS (line 18) | const CHECK_INTERVAL_MS = 60 * 1000
  type SchedulerStatus (line 20) | interface SchedulerStatus {
  function getInstanceUserId (line 32) | function getInstanceUserId(instanceId: number): number | null {
  function calculateNextRun (line 37) | function calculateNextRun(lastRun: number | null, intervalHours: number)...
  function scheduleNextRun (line 44) | function scheduleNextRun(instanceId: number, userId: number, intervalHou...
  function runScheduledScan (line 50) | async function runScheduledScan(instanceId: number): Promise<void> {
  function scheduleInstance (line 96) | function scheduleInstance(instanceId: number, userId: number, nextRunTim...
  function loadEnabledConfigs (line 119) | function loadEnabledConfigs(): void {
  function checkMissedScans (line 144) | function checkMissedScans(): void {
  function startScheduler (line 165) | function startScheduler(): void {
  function stopScheduler (line 172) | function stopScheduler(): void {
  function updateInstanceSchedule (line 189) | function updateInstanceSchedule(instanceId: number, enabled: boolean): v...
  function triggerManualScan (line 215) | async function triggerManualScan(
  function configToStatus (line 270) | function configToStatus(config: CrossSeedConfig & { label: string }): Sc...
  function getSchedulerStatus (line 285) | function getSchedulerStatus(): SchedulerStatus[] {
  function getInstanceStatus (line 295) | function getInstanceStatus(instanceId: number): SchedulerStatus | null {
  function isInstanceRunning (line 305) | function isInstanceRunning(instanceId: number): boolean {
  function stopScan (line 309) | function stopScan(instanceId: number): boolean {

FILE: src/server/utils/crossSeedWorker.ts
  constant DEFAULT_DELAY_SECONDS (line 29) | const DEFAULT_DELAY_SECONDS = 30
  function wait (line 31) | function wait(ms: number): Promise<void> {
  type CandidateFileInfo (line 35) | interface CandidateFileInfo {
  type CandidateFilesWithRoot (line 41) | interface CandidateFilesWithRoot {
  function createHardlinks (line 46) | async function createHardlinks(
  function canCreateHardlink (line 96) | async function canCreateHardlink(sourcePath: string, destDir: string): P...
  type ScanOptions (line 110) | interface ScanOptions {
  type ScanResult (line 118) | interface ScanResult {
  type QbtTorrent (line 131) | interface QbtTorrent {
  type QbtFile (line 144) | interface QbtFile {
  type QbtVersion (line 152) | interface QbtVersion {
  constant RESUME_SLEEP_MS (line 158) | const RESUME_SLEEP_MS = 15 * 1000
  constant RESUME_ERROR_SLEEP_MS (line 159) | const RESUME_ERROR_SLEEP_MS = 5 * 60 * 1000
  constant RESUME_TIMEOUT_MS (line 160) | const RESUME_TIMEOUT_MS = 60 * 60 * 1000
  function qbtFetch (line 162) | async function qbtFetch(instance: Instance, cookie: string | null, endpo...
  function qbtRequest (line 173) | async function qbtRequest<T>(instance: Instance, cookie: string | null, ...
  function qbtRequestText (line 178) | async function qbtRequestText(instance: Instance, cookie: string | null,...
  constant DEFAULT_QBT_VERSION (line 183) | const DEFAULT_QBT_VERSION: QbtVersion = { major: 4, minor: 0, patch: 0 }
  function getQbtVersion (line 185) | async function getQbtVersion(instance: Instance, cookie: string | null):...
  function getTorrentInfo (line 196) | async function getTorrentInfo(
  function qbtPost (line 216) | async function qbtPost(instance: Instance, cookie: string | null, endpoi...
  function recheckTorrent (line 232) | async function recheckTorrent(
  function resumeInjection (line 243) | async function resumeInjection(
  function addTorrentToQbt (line 286) | async function addTorrentToQbt(
  type TorrentInfo (line 348) | interface TorrentInfo {
  function parseTorrentInfo (line 354) | function parseTorrentInfo(torrentData: Buffer): TorrentInfo | null {
  function parseFileSizesFromTorrent (line 363) | function parseFileSizesFromTorrent(torrentData: Buffer): FileInfo[] | nu...
  function parseFilesWithPathsFromTorrent (line 382) | function parseFilesWithPathsFromTorrent(torrentData: Buffer): CandidateF...
  type BencodeValue (line 408) | type BencodeValue = number | Buffer | string | BencodeValue[] | { [key: ...
  constant MAX_BENCODE_DEPTH (line 410) | const MAX_BENCODE_DEPTH = 100
  constant MAX_BENCODE_ITERATIONS (line 411) | const MAX_BENCODE_ITERATIONS = 100000
  function decodeBencode (line 413) | function decodeBencode(buffer: Buffer): BencodeValue {
  function getInfoHashFromTorrent (line 485) | function getInfoHashFromTorrent(torrentData: Buffer): string | null {
  function encodeBencode (line 499) | function encodeBencode(data: BencodeValue): Buffer {
  function upsertSearchee (line 536) | function upsertSearchee(
  function runCrossSeedScan (line 559) | async function runCrossSeedScan(options: ScanOptions): Promise<ScanResul...

FILE: src/server/utils/crypto.ts
  constant SALT_PATH (line 5) | const SALT_PATH = process.env.SALT_PATH || './data/.salt'
  function getKey (line 9) | function getKey(): Buffer {
  function getOrCreateSalt (line 25) | function getOrCreateSalt(): Buffer {
  function hashPassword (line 35) | async function hashPassword(password: string): Promise<string> {
  function verifyPassword (line 39) | async function verifyPassword(password: string, hash: string): Promise<b...
  function encrypt (line 43) | function encrypt(text: string): string {
  function decrypt (line 51) | function decrypt(encrypted: string): string {
  function generateSessionId (line 78) | function generateSessionId(): string {

FILE: src/server/utils/fetch.ts
  type FetchOptions (line 3) | type FetchOptions = RequestInit & {
  function fetchWithTls (line 7) | async function fetchWithTls(url: string, options: RequestInit = {}): Pro...

FILE: src/server/utils/logger.ts
  type LogEntry (line 1) | interface LogEntry {
  constant MAX_LOG_ENTRIES (line 7) | const MAX_LOG_ENTRIES = 500
  function addToBuffer (line 12) | function addToBuffer(level: LogEntry['level'], msg: string) {
  function getLogs (line 35) | function getLogs(filter?: string, limit = 100): LogEntry[] {
  function clearLogs (line 43) | function clearLogs(): void {

FILE: src/server/utils/qbt.ts
  type QbtInstance (line 5) | interface QbtInstance {
  type QbtLoginResult (line 12) | type QbtLoginResult =
  function loginToQbt (line 24) | async function loginToQbt(instance: QbtInstance, timeout?: number): Prom...
  function testQbtConnection (line 66) | async function testQbtConnection(
  function testStoredQbtInstance (line 117) | async function testStoredQbtInstance(instance: QbtInstance): Promise<Qbt...
  type SyncMaindata (line 143) | interface SyncMaindata {
  function fetchInstanceTransferStats (line 150) | async function fetchInstanceTransferStats(

FILE: src/server/utils/rateLimit.ts
  type RateLimitEntry (line 1) | interface RateLimitEntry {
  constant WINDOW_MS (line 8) | const WINDOW_MS = 60 * 1000
  constant MAX_ATTEMPTS (line 9) | const MAX_ATTEMPTS = 5
  function checkRateLimit (line 11) | function checkRateLimit(key: string): { allowed: boolean; retryAfter?: n...
  function resetRateLimit (line 28) | function resetRateLimit(key: string): void {

FILE: src/server/utils/statsRecorder.ts
  constant RECORD_INTERVAL_MS (line 5) | const RECORD_INTERVAL_MS = 5 * 60 * 1000
  constant PRUNE_AFTER_DAYS (line 6) | const PRUNE_AFTER_DAYS = 365
  function recordStats (line 10) | async function recordStats(): Promise<void> {
  function pruneOldStats (line 29) | function pruneOldStats(): void {
  function startStatsRecorder (line 37) | function startStatsRecorder(): void {
  function stopStatsRecorder (line 47) | function stopStatsRecorder(): void {
  type PeriodStats (line 55) | interface PeriodStats {
  function getStatsForPeriod (line 62) | function getStatsForPeriod(instanceId: number, periodSeconds: number): P...

FILE: src/server/utils/torznab.ts
  constant RATE_LIMIT_SNOOZE_MS (line 6) | const RATE_LIMIT_SNOOZE_MS = 60 * 60 * 1000
  constant ERROR_SNOOZE_MS (line 7) | const ERROR_SNOOZE_MS = 10 * 60 * 1000
  type TorznabIndexer (line 9) | interface TorznabIndexer {
  type TorznabResult (line 17) | interface TorznabResult {
  type TorznabXmlResult (line 28) | interface TorznabXmlResult {
  type TorznabXmlResponse (line 37) | interface TorznabXmlResponse {
  function getTorznabIndexers (line 43) | async function getTorznabIndexers(prowlarrUrl: string, apiKey: string): ...
  function getIndexerStatus (line 69) | function getIndexerStatus(integrationId: number, indexerId: number): Cro...
  function updateIndexerStatus (line 78) | function updateIndexerStatus(
  function isIndexerAvailable (line 98) | function isIndexerAvailable(integrationId: number, indexerId: number): b...
  function clearIndexerStatus (line 106) | function clearIndexerStatus(integrationId: number, indexerId: number): v...
  function handleResponseError (line 114) | function handleResponseError(
  function parseTorznabResults (line 137) | function parseTorznabResults(xml: TorznabXmlResponse, indexer: TorznabIn...
  function searchTorznab (line 161) | async function searchTorznab(
  function searchAllIndexers (line 219) | async function searchAllIndexers(
  constant SNATCH_RETRIES (line 262) | const SNATCH_RETRIES = 4
  constant SNATCH_DELAY_MS (line 263) | const SNATCH_DELAY_MS = 60 * 1000
  function snatchOnce (line 265) | async function snatchOnce(link: string): Promise<Buffer | { error: strin...
  function downloadTorrentDirect (line 297) | async function downloadTorrentDirect(

FILE: src/server/utils/url.ts
  constant CLOUD_METADATA (line 1) | const CLOUD_METADATA = ['169.254.169.254', 'metadata.google.internal', '...
  function isUrlAllowed (line 3) | function isUrlAllowed(urlString: string): { allowed: boolean; reason?: s...
  function validateUrl (line 24) | function validateUrl(urlString: string): void {

FILE: src/themes/index.ts
  type Theme (line 1) | interface Theme {
  function getThemeById (line 131) | function getThemeById(id: string): Theme {

FILE: src/types/preferences.ts
  type QBittorrentPreferences (line 1) | interface QBittorrentPreferences {

FILE: src/types/qbittorrent.ts
  type TorrentState (line 1) | type TorrentState =
  type Torrent (line 24) | interface Torrent {
  type TransferInfo (line 50) | interface TransferInfo {
  type SyncServerState (line 61) | interface SyncServerState {
  type SyncMaindata (line 69) | interface SyncMaindata {
  type TorrentFilter (line 73) | type TorrentFilter =

FILE: src/types/rss.ts
  type RSSArticle (line 1) | interface RSSArticle {
  type RSSFeedData (line 11) | interface RSSFeedData {
  type RSSItems (line 21) | type RSSItems = {
  type RSSRule (line 25) | interface RSSRule {
  type RSSRules (line 49) | type RSSRules = Record<string, RSSRule>
  type MatchingArticles (line 51) | interface MatchingArticles {

FILE: src/types/torrentDetails.ts
  type TorrentProperties (line 1) | interface TorrentProperties {
  type Tracker (line 42) | interface Tracker {
  type Peer (line 53) | interface Peer {
  type PeersResponse (line 71) | interface PeersResponse {
  type TorrentFile (line 78) | interface TorrentFile {
  type WebSeed (line 89) | interface WebSeed {

FILE: src/types/views.ts
  type CustomView (line 4) | interface CustomView {
  type CustomViewsStorage (line 21) | interface CustomViewsStorage {

FILE: src/utils/colorUtils.ts
  function generateThemeColors (line 7) | function generateThemeColors(
  function isValidHex (line 46) | function isValidHex(hex: string): boolean {

FILE: src/utils/customViews.ts
  constant STORAGE_KEY (line 6) | const STORAGE_KEY = 'customViews'
  constant DEFAULT_STORAGE (line 8) | const DEFAULT_STORAGE: CustomViewsStorage = {
  function loadCustomViews (line 13) | function loadCustomViews(): CustomViewsStorage {
  function saveCustomViews (line 30) | function saveCustomViews(storage: CustomViewsStorage): void {
  function createView (line 34) | function createView(
  function viewsAreEqual (line 67) | function viewsAreEqual(

FILE: src/utils/dateSettings.ts
  function loadHideAddedTime (line 1) | function loadHideAddedTime(): boolean {
  function saveHideAddedTime (line 5) | function saveHideAddedTime(hide: boolean): void {

FILE: src/utils/fileTree.ts
  type FileTreeNode (line 3) | interface FileTreeNode {
  function buildFileTree (line 15) | function buildFileTree(files: TorrentFile[]): FileTreeNode[] {
  function getPriorityLabel (line 61) | function getPriorityLabel(priority: number): 'skip' | 'normal' | 'high' ...
  function calculateFolderStats (line 68) | function calculateFolderStats(nodes: FileTreeNode[], files: TorrentFile[...
  function sortNodes (line 97) | function sortNodes(nodes: FileTreeNode[]): void {
  function flattenVisibleNodes (line 107) | function flattenVisibleNodes(
  function getInitialExpanded (line 122) | function getInitialExpanded(nodes: FileTreeNode[]): Set<string> {

FILE: src/utils/format.ts
  function formatSpeed (line 1) | function formatSpeed(bytes: number, showZero = true): string {
  function formatSize (line 8) | function formatSize(bytes: number): string {
  function formatCompactSpeed (line 16) | function formatCompactSpeed(bytes: number): string {
  function formatCompactSize (line 23) | function formatCompactSize(bytes: number): string {
  function formatEta (line 30) | function formatEta(seconds: number): string {
  function formatDate (line 38) | function formatDate(timestamp: number): string {
  function formatDuration (line 49) | function formatDuration(seconds: number): string {
  function formatRelativeTime (line 61) | function formatRelativeTime(timestamp: number): string {
  function formatRelativeDate (line 72) | function formatRelativeDate(timestamp: number): string {
  function normalizeSearch (line 83) | function normalizeSearch(str: string): string {
  function formatCountdown (line 87) | function formatCountdown(timestamp: number | null, fallback = '—'): stri...

FILE: src/utils/markdown.tsx
  function renderInline (line 3) | function renderInline(text: string): ReactNode[] {
  function renderMarkdown (line 67) | function renderMarkdown(markdown: string): ReactNode[] {

FILE: src/utils/pagination.ts
  constant PER_PAGE_OPTIONS (line 1) | const PER_PAGE_OPTIONS = [25, 50, 100, 200] as const

FILE: src/utils/ratioThresholds.ts
  constant DEFAULT_THRESHOLD (line 1) | const DEFAULT_THRESHOLD = 1.0
  function loadRatioThreshold (line 3) | function loadRatioThreshold(): number {
  function saveRatioThreshold (line 12) | function saveRatioThreshold(threshold: number): void {

FILE: src/utils/search.ts
  constant PATTERNS (line 1) | const PATTERNS = [
  function extractTags (line 9) | function extractTags(titles: string[]): { tag: string; count: number }[] {
  type SortKey (line 30) | type SortKey = 'seeders' | 'size' | 'age'
  function sortResults (line 32) | function sortResults<T extends { seeders?: number; size: number; publish...
  function filterResults (line 50) | function filterResults<T extends { title: string }>(results: T[], filter...

FILE: vite.config.ts
  method manualChunks (line 15) | manualChunks(id) {
Condensed preview — 183 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,469K chars).
[
  {
    "path": ".dockerignore",
    "chars": 33,
    "preview": "node_modules\ndist\n.git\n*.md\ndocs\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "chars": 96,
    "preview": "# These are supported funding model platforms\n\ngithub: [Maciejonos]\nbuy_me_a_coffee: maciejonos\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "chars": 834,
    "preview": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Describe the b"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "chars": 595,
    "preview": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Is your fea"
  },
  {
    "path": ".github/workflows/docker.yml",
    "chars": 2137,
    "preview": "name: Docker\n\non:\n  push:\n    tags: ['v*']\n  workflow_dispatch:\n\nenv:\n  REGISTRY: ghcr.io\n  IMAGE_NAME: ${{ github.repos"
  },
  {
    "path": ".github/workflows/docs.yml",
    "chars": 818,
    "preview": "name: Docs\n\non:\n  push:\n    branches: [master]\n    paths:\n      - 'docs/**'\n      - '.github/workflows/docs.yml'\n  workf"
  },
  {
    "path": ".github/workflows/tests.yml",
    "chars": 591,
    "preview": "name: CI\n\non:\n  push:\n    branches: [master]\n  pull_request:\n    branches: [master]\n\njobs:\n  ci:\n    runs-on: ubuntu-lat"
  },
  {
    "path": ".gitignore",
    "chars": 267,
    "preview": "# local data\nnode_modules\ndist\n*.local\n*.log\nCLAUDE.md\ndocker-compose.yml\ndata/\n*.env\n\n#enforce package manager and runt"
  },
  {
    "path": ".npmrc",
    "chars": 19,
    "preview": "engine-strict=true\n"
  },
  {
    "path": ".prettierrc",
    "chars": 120,
    "preview": "{\n\t\"useTabs\": true,\n\t\"tabWidth\": 2,\n\t\"semi\": false,\n\t\"singleQuote\": true,\n\t\"trailingComma\": \"es5\",\n\t\"printWidth\": 120\n}\n"
  },
  {
    "path": "Dockerfile",
    "chars": 522,
    "preview": "FROM oven/bun:alpine AS builder\nWORKDIR /app\nCOPY package.json bun.lock ./\nRUN bun install --frozen-lockfile\nCOPY . .\nRU"
  },
  {
    "path": "LICENSE",
    "chars": 1063,
    "preview": "MIT License\n\nCopyright (c) 2026 Maciej\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof "
  },
  {
    "path": "README.md",
    "chars": 4796,
    "preview": "<div align=\"center\">\n <img width=\"200\" height=\"200\" alt=\"logo\" src=\"https://github.com/user-attachments/assets/431cf92d-"
  },
  {
    "path": "__tests__/__mocks__/bun-sqlite.ts",
    "chars": 214,
    "preview": "import { vi } from 'vitest'\n\nexport class Database {\n\texec = vi.fn()\n\trun = vi.fn(() => ({ changes: 0, lastInsertRowid: "
  },
  {
    "path": "__tests__/api/auth.test.ts",
    "chars": 5067,
    "preview": "import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'\nimport { register, login, logout, getMe, change"
  },
  {
    "path": "__tests__/api/crossSeed.test.ts",
    "chars": 8845,
    "preview": "import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'\nimport {\n\tgetCrossSeedConfig,\n\tupdateCrossSeedC"
  },
  {
    "path": "__tests__/api/files.test.ts",
    "chars": 6553,
    "preview": "import { describe, it, expect, vi, beforeEach } from 'vitest'\nimport {\n    listFiles,\n    getDownloadUrl,\n    checkWrita"
  },
  {
    "path": "__tests__/api/instances.test.ts",
    "chars": 5450,
    "preview": "import { describe, it, expect, vi, beforeEach } from 'vitest'\nimport {\n    getInstances,\n    createInstance,\n    updateI"
  },
  {
    "path": "__tests__/api/integrations.test.ts",
    "chars": 8486,
    "preview": "import { describe, it, expect, vi, beforeEach } from 'vitest'\nimport {\n    getIntegrations,\n    createIntegration,\n    d"
  },
  {
    "path": "__tests__/api/qbittorrent.test.ts",
    "chars": 16216,
    "preview": "import { describe, it, expect, vi, beforeEach } from 'vitest'\nimport {\n    getTorrents,\n    getTransferInfo,\n    getSync"
  },
  {
    "path": "__tests__/hooks/useInstance.test.tsx",
    "chars": 1483,
    "preview": "import { describe, it, expect, vi } from 'vitest'\nimport { renderHook } from '@testing-library/react'\nimport React from "
  },
  {
    "path": "__tests__/hooks/usePagination.test.tsx",
    "chars": 1361,
    "preview": "import { describe, it, expect, vi } from 'vitest'\nimport { renderHook } from '@testing-library/react'\nimport React from "
  },
  {
    "path": "__tests__/reporter.ts",
    "chars": 14041,
    "preview": "import type { Reporter, Vitest } from \"vitest/node\";\nimport pc from \"picocolors\";\nimport path from \"node:path\";\n\ntype Ta"
  },
  {
    "path": "__tests__/server/crossSeedCache.test.ts",
    "chars": 4934,
    "preview": "import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'\nimport { existsSync, rmSync } "
  },
  {
    "path": "__tests__/server/crossSeedMatcher.test.ts",
    "chars": 9689,
    "preview": "import { describe, it, expect, vi } from 'vitest'\n\nvi.mock('../../src/server/db', () => ({\n\tdb: {\n\t\texec: vi.fn(),\n\t\trun"
  },
  {
    "path": "__tests__/server/crossSeedScheduler.test.ts",
    "chars": 3533,
    "preview": "import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'\n\nvi.mock('../../src/server/db', () ="
  },
  {
    "path": "__tests__/server/crossSeedWorker.test.ts",
    "chars": 25562,
    "preview": "import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'\n\nconst { state, db, fsMocks } = vi.hoisted(() ="
  },
  {
    "path": "__tests__/server/fetch.test.ts",
    "chars": 4180,
    "preview": "import { describe, it, expect, vi, beforeEach } from 'vitest'\nimport { fetchWithTls } from '../../src/server/utils/fetch"
  },
  {
    "path": "__tests__/server/logger.test.ts",
    "chars": 2580,
    "preview": "import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'\nimport { log } from '../../src/server/utils/log"
  },
  {
    "path": "__tests__/server/rateLimit.test.ts",
    "chars": 4766,
    "preview": "import { describe, it, expect, beforeEach, vi } from 'vitest'\nimport { checkRateLimit, resetRateLimit } from '../../src/"
  },
  {
    "path": "__tests__/server/url.test.ts",
    "chars": 4976,
    "preview": "import { describe, it, expect } from 'vitest'\nimport { isUrlAllowed, validateUrl } from '../../src/server/utils/url'\n\nde"
  },
  {
    "path": "__tests__/themes/themes.test.ts",
    "chars": 5205,
    "preview": "import { describe, it, expect } from 'vitest'\nimport { themes, getThemeById } from '../../src/themes/index'\n\ndescribe('t"
  },
  {
    "path": "__tests__/utils/fileTree.test.ts",
    "chars": 10991,
    "preview": "import { describe, it, expect } from 'vitest'\nimport { buildFileTree, flattenVisibleNodes, getInitialExpanded, type File"
  },
  {
    "path": "__tests__/utils/format.test.ts",
    "chars": 10951,
    "preview": "import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'\nimport {\n    formatSpeed,\n    formatSize,\n    f"
  },
  {
    "path": "__tests__/utils/pagination.test.ts",
    "chars": 1045,
    "preview": "import { describe, it, expect } from 'vitest'\nimport { PER_PAGE_OPTIONS } from '../../src/utils/pagination'\n\ndescribe('p"
  },
  {
    "path": "__tests__/utils/ratioThresholds.test.ts",
    "chars": 2573,
    "preview": "import { describe, it, expect, beforeEach, vi } from 'vitest'\nimport { loadRatioThreshold, saveRatioThreshold } from '.."
  },
  {
    "path": "__tests__/utils/search.test.ts",
    "chars": 6210,
    "preview": "import { describe, it, expect } from 'vitest'\nimport { extractTags, sortResults, filterResults } from '../../src/utils/s"
  },
  {
    "path": "docs/.vitepress/config.ts",
    "chars": 999,
    "preview": "import { defineConfig } from 'vitepress'\n\nexport default defineConfig({\n\ttitle: 'qbitwebui',\n\tdescription: 'Modern web i"
  },
  {
    "path": "docs/.vitepress/theme/custom.css",
    "chars": 1586,
    "preview": ":root {\n    --vp-c-brand-1: #0d7a6e;\n    --vp-c-brand-2: #0f665c;\n    --vp-c-brand-3: #15803d;\n    --vp-c-brand-soft: rg"
  },
  {
    "path": "docs/.vitepress/theme/index.ts",
    "chars": 93,
    "preview": "import DefaultTheme from 'vitepress/theme'\nimport './custom.css'\n\nexport default DefaultTheme"
  },
  {
    "path": "docs/guide/configuration.md",
    "chars": 2308,
    "preview": "# Configuration\n\nAll configuration is done through environment variables.\n\n## Required\n\n| Variable | Description |\n|----"
  },
  {
    "path": "docs/guide/docker.md",
    "chars": 7685,
    "preview": "# Docker Deployment\n\n## Images\n\n| Image | Description |\n|-------|-------------|\n| `ghcr.io/maciejonos/qbitwebui` | Main "
  },
  {
    "path": "docs/guide/features.md",
    "chars": 5798,
    "preview": "# Features\n\n## Multi-Instance Dashboard\n\nManage multiple qBittorrent instances from one interface:\n\n- Overview cards sho"
  },
  {
    "path": "docs/guide/getting-started.md",
    "chars": 2114,
    "preview": "# Getting Started\n\n## Requirements\n\n- Docker (recommended) or Bun runtime\n- A running qBittorrent instance with WebUI en"
  },
  {
    "path": "docs/guide/network-agent/index.md",
    "chars": 5723,
    "preview": "# Network Agent\n\nA lightweight companion service that provides network diagnostics from your qBittorrent host's perspect"
  },
  {
    "path": "docs/index.md",
    "chars": 772,
    "preview": "---\nlayout: home\nhero:\n  name: qbitwebui\n  text: Modern qBittorrent Web UI\n  actions:\n    - theme: brand\n      text: Get"
  },
  {
    "path": "eslint.config.js",
    "chars": 624,
    "preview": "import js from '@eslint/js'\nimport globals from 'globals'\nimport reactHooks from 'eslint-plugin-react-hooks'\nimport reac"
  },
  {
    "path": "index.html",
    "chars": 358,
    "preview": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/"
  },
  {
    "path": "net-agent/Dockerfile",
    "chars": 843,
    "preview": "FROM golang:1.22-alpine AS builder\n\nWORKDIR /build\nCOPY go.mod .\nCOPY main.go .\n\nRUN CGO_ENABLED=0 go build -ldflags=\"-s"
  },
  {
    "path": "net-agent/README.md",
    "chars": 1849,
    "preview": "# net-agent\n\nLightweight network utility agent for qbitwebui. Runs alongside qBittorrent to provide network diagnostics "
  },
  {
    "path": "net-agent/go.mod",
    "chars": 59,
    "preview": "module github.com/mac-torreon/qbitwebui/net-agent\n\ngo 1.22\n"
  },
  {
    "path": "net-agent/main.go",
    "chars": 7353,
    "preview": "package main\n\nimport (\n\t\"crypto/tls\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strings\"\n\t\"time"
  },
  {
    "path": "package.json",
    "chars": 2004,
    "preview": "{\n  \"name\": \"qbitwebui\",\n  \"private\": true,\n  \"version\": \"2.43.0\",\n  \"type\": \"module\",\n  \"packageManager\": \"bun@1.3.2\",\n"
  },
  {
    "path": "src/App.tsx",
    "chars": 7448,
    "preview": "import { useState, useEffect, useCallback, lazy, Suspense } from 'react'\nimport { QueryClient, QueryClientProvider } fro"
  },
  {
    "path": "src/api/auth.ts",
    "chars": 1589,
    "preview": "export interface User {\n\tid: number\n\tusername: string\n}\n\nexport async function register(username: string, password: stri"
  },
  {
    "path": "src/api/crossSeed.ts",
    "chars": 5706,
    "preview": "export type MatchMode = 'strict' | 'flexible'\n\nexport interface CrossSeedConfig {\n\tinstance_id: number\n\tenabled: boolean"
  },
  {
    "path": "src/api/files.ts",
    "chars": 2274,
    "preview": "export interface FileEntry {\n\tname: string\n\tsize: number\n\tisDirectory: boolean\n\tmodified: number\n}\n\nexport async functio"
  },
  {
    "path": "src/api/instances.ts",
    "chars": 1810,
    "preview": "export interface Instance {\n\tid: number\n\tlabel: string\n\turl: string\n\tqbt_username: string | null\n\tskip_auth: boolean\n\tag"
  },
  {
    "path": "src/api/integrations.ts",
    "chars": 3855,
    "preview": "export interface Integration {\n\tid: number\n\ttype: string\n\tlabel: string\n\turl: string\n\tcreated_at: number\n}\n\nexport inter"
  },
  {
    "path": "src/api/netAgent.ts",
    "chars": 2660,
    "preview": "export interface IpInfo {\n\tip: string\n\tcity: string\n\tregion: string\n\tcountry: string\n\tloc: string\n\torg: string\n\tpostal: "
  },
  {
    "path": "src/api/qbittorrent.ts",
    "chars": 16567,
    "preview": "import JSZip from 'jszip'\nimport type { Torrent, TorrentFilter, TransferInfo, SyncMaindata } from '../types/qbittorrent'"
  },
  {
    "path": "src/api/stats.ts",
    "chars": 596,
    "preview": "export interface PeriodStats {\n\tinstanceId: number\n\tinstanceLabel: string\n\tuploaded: number\n\tdownloaded: number\n\thasData"
  },
  {
    "path": "src/components/AddTorrentModal.tsx",
    "chars": 11677,
    "preview": "import { useState, useRef } from 'react'\nimport { Plus, X, Upload, CheckCircle, Check } from 'lucide-react'\nimport { use"
  },
  {
    "path": "src/components/AuthForm.tsx",
    "chars": 7340,
    "preview": "import { useState, useEffect } from 'react'\nimport { Download } from 'lucide-react'\nimport { register, login, type User "
  },
  {
    "path": "src/components/CategoryTagManager.tsx",
    "chars": 10758,
    "preview": "import { useState } from 'react'\nimport { Settings, X, Check, Pencil, Trash2 } from 'lucide-react'\nimport {\n\tuseCategori"
  },
  {
    "path": "src/components/ContextMenu.tsx",
    "chars": 9663,
    "preview": "import { useState, useRef, useEffect } from 'react'\nimport { ChevronRight } from 'lucide-react'\nimport {\n\tuseCategories,"
  },
  {
    "path": "src/components/CrossSeedManager.tsx",
    "chars": 17856,
    "preview": "import { type Instance } from '../api/instances'\nimport { formatSize, formatCountdown } from '../utils/format'\nimport { "
  },
  {
    "path": "src/components/DateSettingsPopup.tsx",
    "chars": 1325,
    "preview": "import { useEffect, useRef } from 'react'\nimport { Checkbox } from './ui'\n\ninterface Props {\n\tanchor: HTMLElement\n\thideT"
  },
  {
    "path": "src/components/FileBrowser.tsx",
    "chars": 21215,
    "preview": "import { useState, useEffect, useCallback } from 'react'\nimport { ChevronLeft, RefreshCw, Folder, File, Download, Check,"
  },
  {
    "path": "src/components/FilterBar.tsx",
    "chars": 14000,
    "preview": "import { useState, useRef, useCallback, type FC } from 'react'\nimport { createPortal } from 'react-dom'\nimport {\n\tLayout"
  },
  {
    "path": "src/components/Header.tsx",
    "chars": 6173,
    "preview": "import { useState } from 'react'\nimport { Check, Info, User, Lock, LogOut } from 'lucide-react'\nimport { ThemeSwitcher }"
  },
  {
    "path": "src/components/InstanceManager.tsx",
    "chars": 41128,
    "preview": "import { useState, useEffect, useCallback } from 'react'\nimport {\n\tSearch,\n\tFolderOpen,\n\tTrash2,\n\tRss,\n\tFileText,\n\tArrow"
  },
  {
    "path": "src/components/Layout.tsx",
    "chars": 829,
    "preview": "import type { ReactNode } from 'react'\nimport { Header } from './Header'\nimport { StatusBar } from './StatusBar'\n\ntype T"
  },
  {
    "path": "src/components/LogViewer.tsx",
    "chars": 14310,
    "preview": "import { useState, useEffect, useRef, useCallback, useMemo } from 'react'\nimport { Loader2, Server, FileText } from 'luc"
  },
  {
    "path": "src/components/NetworkTools.tsx",
    "chars": 24621,
    "preview": "import { useState, useEffect, useRef } from 'react'\nimport {\n\tGlobe,\n\tGauge,\n\tServer,\n\tNetwork,\n\tLoader2,\n\tMapPin,\n\tBuil"
  },
  {
    "path": "src/components/OrphanManager.tsx",
    "chars": 9786,
    "preview": "import { useState, useMemo } from 'react'\nimport { Check, Server } from 'lucide-react'\nimport { type Instance } from '.."
  },
  {
    "path": "src/components/RSSManager.tsx",
    "chars": 33356,
    "preview": "import { useState } from 'react'\nimport { Plus, ChevronDown, ChevronRight, Rss, RefreshCw, X } from 'lucide-react'\nimpor"
  },
  {
    "path": "src/components/RatioThresholdPopup.tsx",
    "chars": 2471,
    "preview": "import { useState, useEffect, useRef } from 'react'\n\ninterface Props {\n\tanchor: HTMLElement\n\tthreshold: number\n\tonSave: "
  },
  {
    "path": "src/components/SearchPanel.tsx",
    "chars": 39374,
    "preview": "import { useState, useEffect, Fragment } from 'react'\nimport { Plus, Trash2, ChevronDown, Filter, X } from 'lucide-react"
  },
  {
    "path": "src/components/SettingsPanel.tsx",
    "chars": 10931,
    "preview": "import { useState, useEffect, useMemo, type ReactNode } from 'react'\nimport { Settings, X } from 'lucide-react'\nimport t"
  },
  {
    "path": "src/components/Statistics.tsx",
    "chars": 3545,
    "preview": "import { ArrowDown, ArrowUp, AlertCircle } from 'lucide-react'\nimport { formatSize } from '../utils/format'\nimport { Sel"
  },
  {
    "path": "src/components/StatusBar.tsx",
    "chars": 10051,
    "preview": "import { useState, useRef, useEffect, useCallback } from 'react'\nimport {\n\tChevronDown,\n\tArrowDown,\n\tArrowUp,\n\tZap,\n\tChe"
  },
  {
    "path": "src/components/ThemeManager.tsx",
    "chars": 13541,
    "preview": "import { useState, useMemo, useRef, useEffect } from 'react'\nimport { createPortal } from 'react-dom'\nimport { ChevronLe"
  },
  {
    "path": "src/components/ThemeSwitcher.tsx",
    "chars": 4274,
    "preview": "import { useState, useRef, useEffect } from 'react'\nimport { ChevronDown, Settings, Check } from 'lucide-react'\nimport {"
  },
  {
    "path": "src/components/TorrentDetailsPanel.tsx",
    "chars": 33340,
    "preview": "import { useState, useEffect, useCallback, useRef, useMemo } from 'react'\nimport {\n\tSettings,\n\tArrowRightLeft,\n\tUsers,\n\t"
  },
  {
    "path": "src/components/TorrentList.tsx",
    "chars": 28687,
    "preview": "import { useState, useMemo, useEffect, useCallback, lazy, Suspense } from 'react'\nimport {\n\tChevronUp,\n\tChevronDown,\n\tPl"
  },
  {
    "path": "src/components/TorrentRow.tsx",
    "chars": 12068,
    "preview": "import type { ReactNode } from 'react'\nimport { Check } from 'lucide-react'\nimport type { Torrent, TorrentState } from '"
  },
  {
    "path": "src/components/ViewSelector.tsx",
    "chars": 7653,
    "preview": "import { useState, useRef, useCallback } from 'react'\nimport { Save, Trash2, Pencil, Check, X, Eye } from 'lucide-react'"
  },
  {
    "path": "src/components/columns.ts",
    "chars": 1842,
    "preview": "export type SortKey =\n\t| 'name'\n\t| 'size'\n\t| 'progress'\n\t| 'downloaded'\n\t| 'uploaded'\n\t| 'dlspeed'\n\t| 'upspeed'\n\t| 'rati"
  },
  {
    "path": "src/components/settings/AdvancedTab.tsx",
    "chars": 35809,
    "preview": "import { AlertTriangle } from 'lucide-react'\nimport type { QBittorrentPreferences } from '../../types/preferences'\nimpor"
  },
  {
    "path": "src/components/settings/BehaviorTab.tsx",
    "chars": 6589,
    "preview": "import { FileText } from 'lucide-react'\nimport type { QBittorrentPreferences } from '../../types/preferences'\nimport { T"
  },
  {
    "path": "src/components/settings/BitTorrentTab.tsx",
    "chars": 13223,
    "preview": "import type { QBittorrentPreferences } from '../../types/preferences'\nimport { Select, Checkbox } from '../ui'\n\ninterfac"
  },
  {
    "path": "src/components/settings/ConnectionTab.tsx",
    "chars": 12652,
    "preview": "import type { QBittorrentPreferences } from '../../types/preferences'\nimport { Select, Checkbox } from '../ui'\n\ninterfac"
  },
  {
    "path": "src/components/settings/DownloadsTab.tsx",
    "chars": 15685,
    "preview": "import type { QBittorrentPreferences } from '../../types/preferences'\nimport { Toggle, Select, Checkbox } from '../ui'\n\n"
  },
  {
    "path": "src/components/settings/RSSTab.tsx",
    "chars": 4389,
    "preview": "import type { QBittorrentPreferences } from '../../types/preferences'\nimport { Checkbox } from '../ui'\n\ninterface Props "
  },
  {
    "path": "src/components/settings/SpeedTab.tsx",
    "chars": 7807,
    "preview": "import { ArrowDown, ArrowUp, Clock } from 'lucide-react'\nimport type { QBittorrentPreferences } from '../../types/prefer"
  },
  {
    "path": "src/components/settings/WebUITab.tsx",
    "chars": 15822,
    "preview": "import { AlertTriangle } from 'lucide-react'\nimport type { QBittorrentPreferences } from '../../types/preferences'\nimpor"
  },
  {
    "path": "src/components/settings/index.ts",
    "chars": 340,
    "preview": "export { BehaviorTab } from './BehaviorTab'\nexport { DownloadsTab } from './DownloadsTab'\nexport { ConnectionTab } from "
  },
  {
    "path": "src/components/ui/Checkbox.tsx",
    "chars": 952,
    "preview": "import type { ReactNode } from 'react'\nimport { Check } from 'lucide-react'\n\nexport function Checkbox({\n\tchecked,\n\tonCha"
  },
  {
    "path": "src/components/ui/MultiSelect.tsx",
    "chars": 2954,
    "preview": "import { useState, useEffect, useRef } from 'react'\nimport { ChevronDown } from 'lucide-react'\nimport { Checkbox } from "
  },
  {
    "path": "src/components/ui/Select.tsx",
    "chars": 2293,
    "preview": "import { useState, useEffect, useRef } from 'react'\nimport { ChevronDown } from 'lucide-react'\n\ninterface SelectProps<T "
  },
  {
    "path": "src/components/ui/Toggle.tsx",
    "chars": 660,
    "preview": "export function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {\n\treturn (\n\t\t<butto"
  },
  {
    "path": "src/components/ui/index.ts",
    "chars": 150,
    "preview": "export { Toggle } from './Toggle'\nexport { Checkbox } from './Checkbox'\nexport { Select } from './Select'\nexport { Multi"
  },
  {
    "path": "src/contexts/InstanceProvider.tsx",
    "chars": 423,
    "preview": "import { useMemo, type ReactNode } from 'react'\nimport type { Instance } from '../api/instances'\nimport { InstanceContex"
  },
  {
    "path": "src/contexts/PaginationProvider.tsx",
    "chars": 863,
    "preview": "import { useState, type ReactNode } from 'react'\nimport { PaginationContext } from './paginationContext'\n\nexport functio"
  },
  {
    "path": "src/contexts/ThemeContext.ts",
    "chars": 384,
    "preview": "import { createContext } from 'react'\nimport type { Theme } from '../themes'\n\nexport interface ThemeContextValue {\n\tthem"
  },
  {
    "path": "src/contexts/ThemeProvider.tsx",
    "chars": 3072,
    "preview": "import { useEffect, useState, type ReactNode } from 'react'\nimport { themes, type Theme } from '../themes'\nimport { Them"
  },
  {
    "path": "src/contexts/instanceContext.ts",
    "chars": 224,
    "preview": "import { createContext } from 'react'\nimport type { Instance } from '../api/instances'\n\ninterface InstanceContextValue {"
  },
  {
    "path": "src/contexts/paginationContext.ts",
    "chars": 351,
    "preview": "import { createContext } from 'react'\n\nexport interface PaginationContextValue {\n\tpage: number\n\tperPage: number\n\ttotalIt"
  },
  {
    "path": "src/hooks/useClickOutside.ts",
    "chars": 451,
    "preview": "import { useEffect, type RefObject } from 'react'\n\nexport function useClickOutside(ref: RefObject<HTMLElement | null>, h"
  },
  {
    "path": "src/hooks/useCrossSeed.ts",
    "chars": 6464,
    "preview": "import { useState, useEffect, useRef } from 'react'\nimport { type Instance } from '../api/instances'\nimport { getIntegra"
  },
  {
    "path": "src/hooks/useInstance.ts",
    "chars": 351,
    "preview": "import { useContext } from 'react'\nimport { InstanceContext } from '../contexts/instanceContext'\nimport type { Instance "
  },
  {
    "path": "src/hooks/usePagination.ts",
    "chars": 276,
    "preview": "import { useContext } from 'react'\nimport { PaginationContext } from '../contexts/paginationContext'\n\nexport function us"
  },
  {
    "path": "src/hooks/useRSSManager.ts",
    "chars": 14140,
    "preview": "import { useState, useEffect, useCallback, useRef } from 'react'\nimport { type Instance } from '../api/instances'\nimport"
  },
  {
    "path": "src/hooks/useStats.ts",
    "chars": 2737,
    "preview": "import { useState, useEffect } from 'react'\nimport { getStats, type PeriodStats } from '../api/stats'\n\nexport const PERI"
  },
  {
    "path": "src/hooks/useSyncMaindata.ts",
    "chars": 355,
    "preview": "import { useQuery } from '@tanstack/react-query'\nimport { getSyncMaindata } from '../api/qbittorrent'\nimport { useInstan"
  },
  {
    "path": "src/hooks/useTheme.ts",
    "chars": 313,
    "preview": "import { useContext } from 'react'\nimport { ThemeContext } from '../contexts/ThemeContext'\n\nexport function useTheme() {"
  },
  {
    "path": "src/hooks/useTorrentDetails.ts",
    "chars": 3240,
    "preview": "import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'\nimport * as api from '../api/qbittorrent'\n"
  },
  {
    "path": "src/hooks/useTorrents.ts",
    "chars": 7092,
    "preview": "import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'\nimport * as api from '../api/qbittorrent'\n"
  },
  {
    "path": "src/hooks/useTransferInfo.ts",
    "chars": 355,
    "preview": "import { useQuery } from '@tanstack/react-query'\nimport { getTransferInfo } from '../api/qbittorrent'\nimport { useInstan"
  },
  {
    "path": "src/hooks/useUpdateCheck.ts",
    "chars": 1193,
    "preview": "import { useQuery } from '@tanstack/react-query'\n\ndeclare const __APP_VERSION__: string\n\ninterface GitHubRelease {\n\ttag_"
  },
  {
    "path": "src/index.css",
    "chars": 2785,
    "preview": "@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600"
  },
  {
    "path": "src/main.tsx",
    "chars": 225,
    "preview": "import { StrictMode } from 'react'\nimport { createRoot } from 'react-dom/client'\nimport './index.css'\nimport App from '."
  },
  {
    "path": "src/mobile/MobileApp.tsx",
    "chars": 14862,
    "preview": "import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react'\nimport { Download, Plus, Wrench, Zap, U"
  },
  {
    "path": "src/mobile/MobileCrossSeedManager.tsx",
    "chars": 16576,
    "preview": "import { useState, type ReactNode } from 'react'\nimport { ChevronDown, ChevronLeft } from 'lucide-react'\nimport { type I"
  },
  {
    "path": "src/mobile/MobileFileBrowser.tsx",
    "chars": 21131,
    "preview": "import { useState, useEffect, useCallback } from 'react'\nimport {\n\tChevronLeft,\n\tRefreshCw,\n\tFolderOpen,\n\tFolder,\n\tFile,"
  },
  {
    "path": "src/mobile/MobileInstancePicker.tsx",
    "chars": 3604,
    "preview": "import { useState } from 'react'\nimport { ChevronDown, LayoutGrid, Server, Check } from 'lucide-react'\nimport type { Ins"
  },
  {
    "path": "src/mobile/MobileLogViewer.tsx",
    "chars": 15225,
    "preview": "import { useState, useEffect, useRef, useCallback, useMemo } from 'react'\nimport { ChevronLeft, SlidersHorizontal, Refre"
  },
  {
    "path": "src/mobile/MobileNetworkTools.tsx",
    "chars": 28304,
    "preview": "import { useState, useEffect, useRef } from 'react'\nimport {\n\tGlobe,\n\tGauge,\n\tServer,\n\tNetwork,\n\tLoader2,\n\tMapPin,\n\tBuil"
  },
  {
    "path": "src/mobile/MobileOrphanManager.tsx",
    "chars": 11593,
    "preview": "import { useState } from 'react'\nimport { Check, CheckCircle, ChevronLeft, HardDrive, Search } from 'lucide-react'\nimpor"
  },
  {
    "path": "src/mobile/MobileRSSManager.tsx",
    "chars": 27857,
    "preview": "import { useState } from 'react'\nimport { ChevronDown, ChevronLeft, ChevronRight, RefreshCw, Rss, X } from 'lucide-react"
  },
  {
    "path": "src/mobile/MobileSearchPanel.tsx",
    "chars": 40910,
    "preview": "import { useState, useEffect, useRef } from 'react'\nimport { ChevronLeft, ChevronDown, Search, X, Check, Plus, Trash2, A"
  },
  {
    "path": "src/mobile/MobileStatistics.tsx",
    "chars": 4749,
    "preview": "import type { ReactNode } from 'react'\nimport { ChevronLeft, ArrowDown, ArrowUp, AlertCircle } from 'lucide-react'\nimpor"
  },
  {
    "path": "src/mobile/MobileStats.tsx",
    "chars": 5462,
    "preview": "import { useQueries } from '@tanstack/react-query'\nimport { ArrowDown, ArrowUp } from 'lucide-react'\nimport * as api fro"
  },
  {
    "path": "src/mobile/MobileThemeManager.tsx",
    "chars": 13769,
    "preview": "import { useState, useMemo, useRef, useEffect } from 'react'\nimport { Drawer } from 'vaul'\nimport { HexColorPicker } fro"
  },
  {
    "path": "src/mobile/MobileThemeSwitcher.tsx",
    "chars": 4055,
    "preview": "import { useState, useRef, useEffect } from 'react'\nimport { Palette, Settings, Check } from 'lucide-react'\nimport { use"
  },
  {
    "path": "src/mobile/MobileTools.tsx",
    "chars": 11384,
    "preview": "import { useState, useEffect, lazy, Suspense, type ReactNode } from 'react'\nimport {\n\tSearch,\n\tFolderOpen,\n\tAlertTriangl"
  },
  {
    "path": "src/mobile/MobileTorrentDetail.tsx",
    "chars": 21626,
    "preview": "import { useState, useEffect } from 'react'\nimport { createPortal } from 'react-dom'\nimport { useQuery, useMutation, use"
  },
  {
    "path": "src/mobile/MobileTorrentList.tsx",
    "chars": 19396,
    "preview": "import { useState, useMemo, useRef, useEffect } from 'react'\nimport {\n\tChevronDown,\n\tArrowDown,\n\tArrowUp,\n\tPause,\n\tAlert"
  },
  {
    "path": "src/server/db/index.ts",
    "chars": 10979,
    "preview": "import { Database } from 'bun:sqlite'\nimport { randomBytes } from 'crypto'\n\nconst dbPath = process.env.DATABASE_PATH || "
  },
  {
    "path": "src/server/index.ts",
    "chars": 2287,
    "preview": "import { Hono } from 'hono'\nimport { serveStatic } from 'hono/bun'\nimport { cors } from 'hono/cors'\nimport { AUTH_DISABL"
  },
  {
    "path": "src/server/middleware/auth.ts",
    "chars": 1177,
    "preview": "import { createMiddleware } from 'hono/factory'\nimport { getCookie } from 'hono/cookie'\nimport { db, type User, AUTH_DIS"
  },
  {
    "path": "src/server/routes/auth.ts",
    "chars": 4654,
    "preview": "import { Hono } from 'hono'\nimport { setCookie, deleteCookie, getCookie } from 'hono/cookie'\nimport { db, type User, REG"
  },
  {
    "path": "src/server/routes/crossSeed.ts",
    "chars": 13630,
    "preview": "import { Hono } from 'hono'\nimport {\n\tdb,\n\ttype CrossSeedConfig,\n\ttype CrossSeedSearchee,\n\ttype CrossSeedDecision,\n\ttype"
  },
  {
    "path": "src/server/routes/files.ts",
    "chars": 10091,
    "preview": "import { Hono } from 'hono'\nimport { authMiddleware } from '../middleware/auth'\nimport { readdir, stat, rm, rename, cp, "
  },
  {
    "path": "src/server/routes/instances.ts",
    "chars": 10789,
    "preview": "import { Hono } from 'hono'\nimport { db, type Instance } from '../db'\nimport { encrypt } from '../utils/crypto'\nimport {"
  },
  {
    "path": "src/server/routes/integrations.ts",
    "chars": 9161,
    "preview": "import { Hono } from 'hono'\nimport { db, type Integration } from '../db'\nimport { encrypt, decrypt } from '../utils/cryp"
  },
  {
    "path": "src/server/routes/proxy.ts",
    "chars": 5532,
    "preview": "import { Hono } from 'hono'\nimport { db, type Instance } from '../db'\nimport { loginToQbt as qbtLogin } from '../utils/q"
  },
  {
    "path": "src/server/routes/stats.ts",
    "chars": 1796,
    "preview": "import { Hono } from 'hono'\nimport { db, type Instance } from '../db'\nimport { authMiddleware } from '../middleware/auth"
  },
  {
    "path": "src/server/routes/tools.ts",
    "chars": 3417,
    "preview": "import { Hono } from 'hono'\nimport { db, type Instance } from '../db'\nimport { loginToQbt } from '../utils/qbt'\nimport {"
  },
  {
    "path": "src/server/utils/crossSeedCache.ts",
    "chars": 3977,
    "preview": "import { mkdirSync, existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync } from 'fs'\nimport { join"
  },
  {
    "path": "src/server/utils/crossSeedMatcher.ts",
    "chars": 10835,
    "preview": "import { CrossSeedDecisionType, BlocklistType, type BlocklistTypeValue } from '../db'\nimport { dirname } from 'path'\n\nex"
  },
  {
    "path": "src/server/utils/crossSeedScheduler.ts",
    "chars": 9525,
    "preview": "import { db, type CrossSeedConfig } from '../db'\nimport { log } from './logger'\nimport { runCrossSeedScan, type ScanResu"
  },
  {
    "path": "src/server/utils/crossSeedWorker.ts",
    "chars": 28612,
    "preview": "import { createHash } from 'crypto'\nimport { constants as fsConstants } from 'fs'\nimport { link, mkdir, stat, access } f"
  },
  {
    "path": "src/server/utils/crypto.ts",
    "chars": 2502,
    "preview": "import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync } from 'crypto'\nimport { existsSync, readFileSync, wr"
  },
  {
    "path": "src/server/utils/fetch.ts",
    "chars": 821,
    "preview": "const allowSelfSigned = process.env.ALLOW_SELF_SIGNED_CERTS === 'true'\n\ntype FetchOptions = RequestInit & {\n\ttls?: { rej"
  },
  {
    "path": "src/server/utils/logger.ts",
    "chars": 1051,
    "preview": "export interface LogEntry {\n\ttimestamp: string\n\tlevel: 'INFO' | 'WARN' | 'ERROR'\n\tmessage: string\n}\n\nconst MAX_LOG_ENTRI"
  },
  {
    "path": "src/server/utils/qbt.ts",
    "chars": 4753,
    "preview": "import { decrypt } from './crypto'\nimport { fetchWithTls } from './fetch'\nimport { log } from './logger'\n\ninterface QbtI"
  },
  {
    "path": "src/server/utils/rateLimit.ts",
    "chars": 830,
    "preview": "interface RateLimitEntry {\n\tcount: number\n\tresetAt: number\n}\n\nconst limits = new Map<string, RateLimitEntry>()\n\nconst WI"
  },
  {
    "path": "src/server/utils/statsRecorder.ts",
    "chars": 3171,
    "preview": "import { db, type Instance, type TransferStats } from '../db'\nimport { log } from './logger'\nimport { fetchInstanceTrans"
  },
  {
    "path": "src/server/utils/torznab.ts",
    "chars": 10040,
    "preview": "import xml2js from 'xml2js'\nimport { fetchWithTls } from './fetch'\nimport { log } from './logger'\nimport { db, IndexerSt"
  },
  {
    "path": "src/server/utils/url.ts",
    "chars": 852,
    "preview": "const CLOUD_METADATA = ['169.254.169.254', 'metadata.google.internal', 'metadata.aws.internal', '169.254.170.2']\n\nexport"
  },
  {
    "path": "src/themes/index.ts",
    "chars": 2623,
    "preview": "export interface Theme {\n\tid: string\n\tname: string\n\tcolors: {\n\t\tbgPrimary: string\n\t\tbgSecondary: string\n\t\tbgTertiary: st"
  },
  {
    "path": "src/types/preferences.ts",
    "chars": 6605,
    "preview": "export interface QBittorrentPreferences {\n\tlocale: string\n\tconfirm_torrent_deletion: boolean\n\tstatus_bar_external_ip: bo"
  },
  {
    "path": "src/types/qbittorrent.ts",
    "chars": 1419,
    "preview": "export type TorrentState =\n\t| 'error'\n\t| 'missingFiles'\n\t| 'uploading'\n\t| 'pausedUP'\n\t| 'stoppedUP'\n\t| 'queuedUP'\n\t| 'st"
  },
  {
    "path": "src/types/rss.ts",
    "chars": 1003,
    "preview": "export interface RSSArticle {\n\tid: string\n\ttitle: string\n\ttorrentURL?: string\n\tlink?: string\n\tdescription?: string\n\tdate"
  },
  {
    "path": "src/types/torrentDetails.ts",
    "chars": 1662,
    "preview": "export interface TorrentProperties {\n\tsave_path: string\n\tdownload_path: string\n\tcreation_date: number\n\tpiece_size: numbe"
  },
  {
    "path": "src/types/views.ts",
    "chars": 541,
    "preview": "import type { TorrentFilter } from './qbittorrent'\nimport type { SortKey } from '../components/columns'\n\nexport interfac"
  },
  {
    "path": "src/utils/colorUtils.ts",
    "chars": 1222,
    "preview": "import { colord, extend } from 'colord'\nimport mixPlugin from 'colord/plugins/mix'\nimport type { Theme } from '../themes"
  },
  {
    "path": "src/utils/customViews.ts",
    "chars": 2889,
    "preview": "import type { CustomView, CustomViewsStorage } from '../types/views'\nimport type { TorrentFilter } from '../types/qbitto"
  },
  {
    "path": "src/utils/dateSettings.ts",
    "chars": 219,
    "preview": "export function loadHideAddedTime(): boolean {\n\treturn localStorage.getItem('hideAddedTime') === 'true'\n}\n\nexport functi"
  },
  {
    "path": "src/utils/fileTree.ts",
    "chars": 3550,
    "preview": "import type { TorrentFile } from '../types/torrentDetails'\n\nexport interface FileTreeNode {\n\tname: string\n\tpath: string\n"
  },
  {
    "path": "src/utils/format.ts",
    "chars": 3580,
    "preview": "export function formatSpeed(bytes: number, showZero = true): string {\n\tif (bytes === 0 && !showZero) return '—'\n\tif (byt"
  },
  {
    "path": "src/utils/markdown.tsx",
    "chars": 3561,
    "preview": "import type { ReactNode } from 'react'\n\nfunction renderInline(text: string): ReactNode[] {\n\tconst nodes: ReactNode[] = ["
  },
  {
    "path": "src/utils/pagination.ts",
    "chars": 60,
    "preview": "export const PER_PAGE_OPTIONS = [25, 50, 100, 200] as const\n"
  },
  {
    "path": "src/utils/ratioThresholds.ts",
    "chars": 393,
    "preview": "const DEFAULT_THRESHOLD = 1.0\n\nexport function loadRatioThreshold(): number {\n\tconst stored = localStorage.getItem('rati"
  },
  {
    "path": "src/utils/search.ts",
    "chars": 1592,
    "preview": "const PATTERNS = [\n\t/\\b(2160p|1080p|720p|480p|4K|UHD)\\b/gi,\n\t/\\b(x264|x265|HEVC|AVC|H\\.?264|H\\.?265|AV1)\\b/gi,\n\t/\\b(BluR"
  },
  {
    "path": "tsconfig.app.json",
    "chars": 760,
    "preview": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.app.tsbuildinfo\",\n    \"target\": \"ES2022\",\n"
  },
  {
    "path": "tsconfig.json",
    "chars": 152,
    "preview": "{\n\t\"files\": [],\n\t\"references\": [\n\t\t{ \"path\": \"./tsconfig.app.json\" },\n\t\t{ \"path\": \"./tsconfig.node.json\" },\n\t\t{ \"path\": "
  },
  {
    "path": "tsconfig.node.json",
    "chars": 653,
    "preview": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.node.tsbuildinfo\",\n    \"target\": \"ES2023\","
  },
  {
    "path": "tsconfig.server.json",
    "chars": 576,
    "preview": "{\n\t\"compilerOptions\": {\n\t\t\"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.server.tsbuildinfo\",\n\t\t\"target\": \"ES2022\",\n\t\t"
  },
  {
    "path": "vite.config.ts",
    "chars": 1305,
    "preview": "import { defineConfig } from 'vite'\nimport react from '@vitejs/plugin-react'\nimport tailwindcss from '@tailwindcss/vite'"
  },
  {
    "path": "vitest.config.ts",
    "chars": 741,
    "preview": "import { defineConfig } from 'vitest/config'\nimport { loadEnv } from 'vite'\nimport react from '@vitejs/plugin-react'\nimp"
  }
]

About this extraction

This page contains the full source code of the Maciejonos/qbitwebui GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 183 files (1.2 MB), approximately 346.3k tokens, and a symbol index with 786 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!