From 5aa2c92a9e7aac32f5ecff069481c6e07f35d23f Mon Sep 17 00:00:00 2001 From: Kavya Arora Date: Wed, 5 Aug 2026 09:30:21 +0530 Subject: [PATCH 01/15] fix(v0.7): harden release, bulk actions, torrent upload, test isolation, and CI workflow --- .github/workflows/ci.yml | 75 ++++++++++++++++++++++++ README.md | 7 ++- internal/engine/registry.go | 7 ++- internal/engine/ytdlp/live_ytdlp_test.go | 50 +++++++++------- internal/job/manager.go | 3 + web/src/App.tsx | 42 ++++++------- web/src/components/BulkActionBar.tsx | 4 +- web/src/components/DownloadForm.test.tsx | 49 ++++++++++++++++ web/src/components/DownloadsPanel.tsx | 4 +- web/src/types.ts | 1 + 10 files changed, 194 insertions(+), 48 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..169c92f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +name: CI + +on: + push: + branches: + - main + - feat/v0.7-lovable-integration + pull_request: + branches: + - main + - feat/v0.7-lovable-integration + +jobs: + backend: + name: Go Backend Verification + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + + - name: Verify gofmt formatting + run: | + files=$(gofmt -l ./cmd ./internal) + if [ -n "$files" ]; then + echo "The following files require gofmt formatting:" + echo "$files" + exit 1 + fi + + - name: Run go vet + run: go vet ./... + + - name: Run unit tests + run: go test -count=1 ./... + + - name: Run unit tests with race detector + run: go test -count=1 -race ./... + + frontend: + name: Web Frontend Verification + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: web/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Run TypeScript typecheck + run: npm run typecheck + + - name: Run Vitest unit tests + run: npm test -- --run + + - name: Run linter + run: npm run lint + + - name: Run production build + run: npm run build diff --git a/README.md b/README.md index 434df6f..7253bc1 100644 --- a/README.md +++ b/README.md @@ -170,12 +170,15 @@ Dev UI is at **http://localhost:5173**. ### Running Tests ```bash -# All tests +# All unit tests (isolated, fast, no live network calls) go test ./... # With race condition detection go test -race ./... +# Live yt-dlp integration tests (isolated behind integration tag) +YTDLP_PATH="yt-dlp" FFMPEG_PATH="ffmpeg" FFPROBE_PATH="ffprobe" YTDLP_TEST_URL="https://example.com/media" go test -tags=integration -count=1 -v ./internal/engine/ytdlp + # Frontend verification cd web && npm run typecheck && npm test && npm run build && npm run lint ``` @@ -250,7 +253,7 @@ All settings are optional. Defaults work out of the box for a typical local setu | `QBIT_PASSWORD` | — | qBittorrent password | | `QBIT_TIMEOUT` | `30` | qBittorrent request timeout (seconds) | | `YTDLP_PATH` | `yt-dlp` | Path to yt-dlp binary | -| `FFMPEG_PATH` | `ffmpeg` | Path to FFmpeg binary | +| `FFMPEG_PATH` | `""` (empty string) | Path to FFmpeg binary; defaults to empty so yt-dlp searches PATH automatically | | `WEB_DIR` | `./web/dist` | Directory serving the built frontend | | `V0.7_SETTINGS_ENCRYPTION_KEY` | — | Base64 32-byte or 64-character hex AES key for persisted secrets | | `GLOBAL_DOWNLOAD_LIMIT_BYTES_PER_SECOND` | `0` | Engine-scoped global download limit | diff --git a/internal/engine/registry.go b/internal/engine/registry.go index 630ae83..5aca1d1 100644 --- a/internal/engine/registry.go +++ b/internal/engine/registry.go @@ -69,8 +69,11 @@ func (r *Registry) Get(name string) (job.IEngine, bool) { // Detect determines which engine should handle the given URL. func (r *Registry) Detect(rawURL string) string { - // Check for magnet URI - if strings.HasPrefix(strings.ToLower(rawURL), "magnet:") { + lower := strings.ToLower(rawURL) + // Check for magnet URI, torrent scheme, or .torrent file extension + if strings.HasPrefix(lower, "magnet:") || + strings.HasPrefix(lower, "torrent://") || + strings.HasSuffix(lower, ".torrent") { if _, ok := r.engines["qbittorrent"]; ok { return "qbittorrent" } diff --git a/internal/engine/ytdlp/live_ytdlp_test.go b/internal/engine/ytdlp/live_ytdlp_test.go index 7129a71..8032172 100644 --- a/internal/engine/ytdlp/live_ytdlp_test.go +++ b/internal/engine/ytdlp/live_ytdlp_test.go @@ -1,3 +1,5 @@ +//go:build integration + package ytdlp import ( @@ -11,20 +13,38 @@ import ( "downloader/internal/job" ) -func TestLiveYTDLP_VideoOnlyMerge(t *testing.T) { - ytdlpPath := `C:\Users\bkkav\Downloads\GoDownloaderTools\yt-dlp\yt-dlp.exe` - ffmpegPath := `C:\Users\bkkav\Downloads\GoDownloaderTools\ffmpeg\bin\ffmpeg.exe` +func getLiveTestEnv(t *testing.T) (string, string, string, string) { + ytdlpPath := os.Getenv("YTDLP_PATH") + ffmpegPath := os.Getenv("FFMPEG_PATH") + ffprobePath := os.Getenv("FFPROBE_PATH") + testURL := os.Getenv("YTDLP_TEST_URL") + + if ytdlpPath == "" || ffmpegPath == "" || ffprobePath == "" || testURL == "" { + t.Skip("skipping integration test: YTDLP_PATH, FFMPEG_PATH, FFPROBE_PATH, and YTDLP_TEST_URL must all be set") + } if _, err := os.Stat(ytdlpPath); err != nil { - t.Skip("yt-dlp binary not found") + t.Skipf("skipping integration test: ytdlp binary not found at %s", ytdlpPath) } + if _, err := os.Stat(ffmpegPath); err != nil { + t.Skipf("skipping integration test: ffmpeg binary not found at %s", ffmpegPath) + } + if _, err := os.Stat(ffprobePath); err != nil { + t.Skipf("skipping integration test: ffprobe binary not found at %s", ffprobePath) + } + + return ytdlpPath, ffmpegPath, ffprobePath, testURL +} + +func TestLiveYTDLP_VideoOnlyMerge(t *testing.T) { + ytdlpPath, ffmpegPath, ffprobePath, testURL := getLiveTestEnv(t) eng := NewEngine(ytdlpPath, ffmpegPath) tmpDir := t.TempDir() j := &job.Job{ ID: "live_test_job_video_only", - Source: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + Source: testURL, Type: job.TypeMedia, MediaInfo: &job.MediaInfo{ SelectedFmt: "18", @@ -68,7 +88,7 @@ func TestLiveYTDLP_VideoOnlyMerge(t *testing.T) { t.Fatalf("final output path %s does not exist or is empty: %v", finalStatus.OutputPath, err) } - cmd := exec.Command(`C:\Users\bkkav\Downloads\GoDownloaderTools\ffmpeg\bin\ffprobe.exe`, + cmd := exec.Command(ffprobePath, "-v", "error", "-show_entries", "stream=index,codec_type,codec_name", "-of", "default=noprint_wrappers=1", @@ -86,19 +106,14 @@ func TestLiveYTDLP_VideoOnlyMerge(t *testing.T) { } func TestLiveYTDLP_AudioOnly(t *testing.T) { - ytdlpPath := `C:\Users\bkkav\Downloads\GoDownloaderTools\yt-dlp\yt-dlp.exe` - ffmpegPath := `C:\Users\bkkav\Downloads\GoDownloaderTools\ffmpeg\bin\ffmpeg.exe` - - if _, err := os.Stat(ytdlpPath); err != nil { - t.Skip("yt-dlp binary not found") - } + ytdlpPath, ffmpegPath, _, testURL := getLiveTestEnv(t) eng := NewEngine(ytdlpPath, ffmpegPath) tmpDir := t.TempDir() j := &job.Job{ ID: "live_test_job_audio_only", - Source: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + Source: testURL, Type: job.TypeMedia, MediaInfo: &job.MediaInfo{ SelectedFmt: "140", @@ -143,19 +158,14 @@ func TestLiveYTDLP_AudioOnly(t *testing.T) { } func TestLiveYTDLP_ThrottledProgress(t *testing.T) { - ytdlpPath := `C:\Users\bkkav\Downloads\GoDownloaderTools\yt-dlp\yt-dlp.exe` - ffmpegPath := `C:\Users\bkkav\Downloads\GoDownloaderTools\ffmpeg\bin\ffmpeg.exe` - - if _, err := os.Stat(ytdlpPath); err != nil { - t.Skip("yt-dlp binary not found") - } + ytdlpPath, ffmpegPath, _, testURL := getLiveTestEnv(t) eng := NewEngine(ytdlpPath, ffmpegPath) tmpDir := t.TempDir() j := &job.Job{ ID: "live_test_job_throttled", - Source: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + Source: testURL, Type: job.TypeMedia, MediaInfo: &job.MediaInfo{ SelectedFmt: "18", diff --git a/internal/job/manager.go b/internal/job/manager.go index 1944782..6e68c5f 100644 --- a/internal/job/manager.go +++ b/internal/job/manager.go @@ -814,6 +814,9 @@ func (m *Manager) createTorrentJobWithID(ctx context.Context, jobID, source, tor } func (m *Manager) createTorrentJobWithIDAndOptions(ctx context.Context, jobID, source, torrentFilePath string, opts CreateOptions) (*Job, error) { + if _, ok := m.engines.Get("qbittorrent"); !ok { + return nil, &AppError{Code: ErrEngineError, Message: "engine not registered: qBittorrent"} + } engineName := m.engines.Detect(source) if engineName != "qbittorrent" { return nil, &AppError{Code: ErrEngineError, Message: "qBittorrent engine not available for torrent downloads"} diff --git a/web/src/App.tsx b/web/src/App.tsx index 0aa84ce..f0e31ba 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -15,6 +15,7 @@ import type { TorrentFileSelection, JobNetworkPolicyOverride, SeedingPolicy, + BulkAction, } from './types'; import { replaceJobsFromInitialLoad, upsertJob, upsertJobs } from './jobState'; import { useJobSelection } from './hooks/useJobSelection'; @@ -23,21 +24,21 @@ import { createJob, createBatchJobs, bulkAction, - setJobPriority, - getQueueSnapshot, - reorderQueue, - getSettings, - updateSettings, cancelJob, pauseJob, resumeJob, retryJob, - connectSSE, + getSettings, + updateSettings, + getQueueSnapshot, + reorderQueue, + startTorrent, + stopSeeding, openFolder, selectFormat, uploadTorrent, - startTorrent, - stopSeeding, + setJobPriority, + connectSSE, } from './api'; import './App.css'; @@ -172,7 +173,9 @@ function App() { } fetchQueue(); } catch (err: unknown) { - setError(err instanceof Error ? err.message : 'Failed to start download'); + const errorObj = err instanceof Error ? err : new Error('Failed to start download'); + setError(errorObj.message); + throw errorObj; } finally { setSubmitting(false); } @@ -205,7 +208,9 @@ function App() { setJobs((currentJobs) => upsertJob(currentJobs, job)); fetchQueue(); } catch (err: unknown) { - setError(err instanceof Error ? err.message : 'Failed to upload torrent'); + const errorObj = err instanceof Error ? err : new Error('Failed to upload torrent'); + setError(errorObj.message); + throw errorObj; } finally { setSubmitting(false); } @@ -213,19 +218,16 @@ function App() { [fetchQueue] ); - - const handleJobUpdated = useCallback((updated: Job) => { setJobs((currentJobs) => upsertJob(currentJobs, updated)); }, []); const handleBulkAction = useCallback( - async (action: 'pause' | 'resume' | 'cancel' | 'retry') => { - const ids = Array.from(selectedIds); - if (ids.length === 0) return; + async (action: BulkAction, eligibleIds: string[]) => { + if (eligibleIds.length === 0) return; try { - const response = await bulkAction(action, ids); + const response = await bulkAction(action, eligibleIds); const updatedJobs = response.results .map((result) => result.job) @@ -233,13 +235,13 @@ function App() { setJobs((currentJobs) => upsertJobs(currentJobs, updatedJobs)); - const failedIds = new Set( + const succeededIds = new Set( response.results - .filter((result) => !result.success) + .filter((result) => result.success) .map((result) => result.jobId) ); - setSelectedIds(failedIds); + setSelectedIds((current) => new Set([...current].filter((id) => !succeededIds.has(id)))); if (response.failed > 0) { const details = response.results @@ -268,7 +270,7 @@ function App() { ); } }, - [selectedIds, jobs, fetchQueue] + [jobs, fetchQueue, setSelectedIds] ); const handleSetPriority = useCallback( diff --git a/web/src/components/BulkActionBar.tsx b/web/src/components/BulkActionBar.tsx index f1c1adc..1aa2e39 100644 --- a/web/src/components/BulkActionBar.tsx +++ b/web/src/components/BulkActionBar.tsx @@ -1,10 +1,10 @@ import { Pause, Play, RotateCcw, X } from 'lucide-react'; -import type { Job } from '../types'; +import type { Job, BulkAction } from '../types'; interface BulkActionBarProps { jobs: Job[]; selectedIds: Set; - onAction: (action: 'pause' | 'resume' | 'cancel' | 'retry', eligibleIds: string[]) => void; + onAction: (action: BulkAction, eligibleIds: string[]) => void; onClear: () => void; } diff --git a/web/src/components/DownloadForm.test.tsx b/web/src/components/DownloadForm.test.tsx index 2e54938..2e47f04 100644 --- a/web/src/components/DownloadForm.test.tsx +++ b/web/src/components/DownloadForm.test.tsx @@ -87,6 +87,55 @@ describe('DownloadForm Refactored Suite', () => { expect(input).toHaveValue(''); }); + it('11. Failed single URL creation preserves source', async () => { + const onSubmit = vi.fn().mockRejectedValue(new Error('Invalid URL format')); + render(); + + const input = screen.getByPlaceholderText(/Paste a URL or magnet link/i); + fireEvent.change(input, { target: { value: 'https://example.com/file.zip' } }); + + const startBtn = screen.getByRole('button', { name: /Start/i }); + fireEvent.click(startBtn); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(await screen.findByText('Invalid URL format')).toBeInTheDocument(); + expect(input).toHaveValue('https://example.com/file.zip'); + }); + + it('12. Failed batch creation preserves all sources', async () => { + const onSubmit = vi.fn().mockRejectedValue(new Error('Batch processing failed')); + render(); + + fireEvent.click(screen.getByTitle('Toggle download options')); + fireEvent.click(screen.getByTitle('Switch to batch mode')); + + const batchInput = screen.getByPlaceholderText(/Paste download URLs or magnet links — one per line/i); + const text = 'https://example.com/file1.iso\nhttps://example.com/file2.zip'; + fireEvent.change(batchInput, { target: { value: text } }); + + const startBtn = screen.getByRole('button', { name: /Start/i }); + fireEvent.click(startBtn); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(await screen.findByText('Batch processing failed')).toBeInTheDocument(); + expect(batchInput).toHaveValue(text); + }); + + it('13. Failed .torrent upload does not falsely indicate success', async () => { + const onUploadTorrent = vi.fn().mockRejectedValue(new Error('qBittorrent daemon unreachable')); + render(); + + const torrentFile = new File(['d8:announce...e'], 'test.torrent', { type: 'application/x-bittorrent' }); + const torrentInput = document.querySelector('input[type="file"]') as HTMLInputElement; + + act(() => { + fireEvent.change(torrentInput, { target: { files: [torrentFile] } }); + }); + + await waitFor(() => expect(onUploadTorrent).toHaveBeenCalledTimes(1)); + expect(await screen.findByText('qBittorrent daemon unreachable')).toBeInTheDocument(); + }); + it('prevents duplicate submissions while awaiting', async () => { let resolveSubmit!: () => void; const onSubmit = vi.fn().mockImplementation( diff --git a/web/src/components/DownloadsPanel.tsx b/web/src/components/DownloadsPanel.tsx index dfd1cd2..ba4a536 100644 --- a/web/src/components/DownloadsPanel.tsx +++ b/web/src/components/DownloadsPanel.tsx @@ -1,6 +1,6 @@ import { useState, useMemo } from 'react'; import { Search, X } from 'lucide-react'; -import type { Job, QueueSnapshot } from '../types'; +import type { Job, QueueSnapshot, BulkAction } from '../types'; import type { DownloadFilter } from '../downloadUi'; import { DOWNLOAD_FILTERS, @@ -22,7 +22,7 @@ interface DownloadsPanelProps { onToggleSelect: (id: string) => void; onSelectVisible: (ids: string[]) => void; onDeselectVisible?: (ids: string[]) => void; - onBulkAction: (action: 'pause' | 'resume' | 'cancel' | 'retry') => void; + onBulkAction: (action: BulkAction, eligibleIds: string[]) => void; onClearSelection: () => void; onCancel: (id: string) => void; onPause: (id: string) => void; diff --git a/web/src/types.ts b/web/src/types.ts index 6634f44..f0aac30 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -1,6 +1,7 @@ export type JobStatus = 'queued' | 'downloading' | 'paused' | 'completed' | 'failed' | 'cancelled' | 'analyzing' | 'processing' | 'awaiting_selection' | 'seeding'; export type JobPriority = 'low' | 'normal' | 'high'; +export type BulkAction = 'pause' | 'resume' | 'cancel' | 'retry'; export type FilenameConflictPolicy = 'rename' | 'overwrite' | 'fail' | 'engine_managed'; export type ProxyMode = 'disabled' | 'system' | 'custom'; From f43785a4eb589dcbc1ea39fffbbd0ba679ce82ab Mon Sep 17 00:00:00 2001 From: Kavya Arora Date: Wed, 5 Aug 2026 10:08:54 +0530 Subject: [PATCH 02/15] fix(v0.7): fix Linux CI path test, enforce monotonic progress, remove largest-file scanner, add torrent unit tests --- internal/engine/registry_test.go | 21 ++++ internal/engine/ytdlp/ytdlp.go | 5 +- internal/engine/ytdlp/ytdlp_test.go | 45 ++++++- internal/job/manager.go | 125 +++++++++---------- internal/job/manager_test.go | 147 ++++++++++++++++++++++- internal/job/storage_integration_test.go | 2 +- 6 files changed, 267 insertions(+), 78 deletions(-) diff --git a/internal/engine/registry_test.go b/internal/engine/registry_test.go index 6a5a75b..d210d20 100644 --- a/internal/engine/registry_test.go +++ b/internal/engine/registry_test.go @@ -17,6 +17,11 @@ func TestRegistry_Detect(t *testing.T) { }{ {"Magnet URI", "magnet:?xt=urn:btih:1234567890abcdef1234567890abcdef12345678", "qbittorrent"}, {"Magnet URI uppercase", "MAGNET:?XT=URN:BTIH:1234567890ABCDEF1234567890ABCDEF12345678", "qbittorrent"}, + {"Torrent URI Windows", `torrent://C:\path\file.torrent`, "qbittorrent"}, + {"Torrent URI POSIX", "torrent:///tmp/file.torrent", "qbittorrent"}, + {"Local Path Windows", `C:\path\file.torrent`, "qbittorrent"}, + {"Local Path POSIX", "/tmp/file.torrent", "qbittorrent"}, + {"Uppercase TORRENT Extension", "C:\\PATH\\FILE.TORRENT", "qbittorrent"}, {"Direct ZIP", "https://example.com/files/archive.zip", "aria2"}, {"Direct ISO", "https://releases.ubuntu.com/22.04/ubuntu-22.04-desktop-amd64.iso", "aria2"}, {"Direct PDF", "https://example.com/docs/paper.pdf", "aria2"}, @@ -38,4 +43,20 @@ func TestRegistry_Detect(t *testing.T) { } }) } + + t.Run("Fallback to aria2 when qbittorrent is unregistered", func(t *testing.T) { + rNoQbit := NewRegistry() + rNoQbit.engines["aria2"] = nil + rNoQbit.engines["ytdlp"] = nil + + if got := rNoQbit.Detect("magnet:?xt=urn:btih:1234"); got != "aria2" { + t.Errorf("expected aria2 fallback for magnet, got %q", got) + } + if got := rNoQbit.Detect("torrent://C:\\file.torrent"); got != "aria2" { + t.Errorf("expected aria2 fallback for torrent URI, got %q", got) + } + if got := rNoQbit.Detect("/tmp/file.torrent"); got != "aria2" { + t.Errorf("expected aria2 fallback for .torrent path, got %q", got) + } + }) } diff --git a/internal/engine/ytdlp/ytdlp.go b/internal/engine/ytdlp/ytdlp.go index 3b5b750..8341ecb 100644 --- a/internal/engine/ytdlp/ytdlp.go +++ b/internal/engine/ytdlp/ytdlp.go @@ -404,12 +404,13 @@ func (e *Engine) handleYTDLPLine(jobID string, state *downloadState, line string return } - if prog.Percent >= state.progress.Percent || state.progress.Percent >= 100 { + if prog.Percent >= state.progress.Percent { state.progress = *prog } else { + // Secondary stream starting at lower percentage: preserve higher percent and total size, update speed and ETA state.progress.Speed = prog.Speed state.progress.ETASeconds = prog.ETASeconds - if prog.DownloadedBytes > 0 { + if prog.DownloadedBytes > state.progress.DownloadedBytes { state.progress.DownloadedBytes = prog.DownloadedBytes } } diff --git a/internal/engine/ytdlp/ytdlp_test.go b/internal/engine/ytdlp/ytdlp_test.go index abf486e..a9473f1 100644 --- a/internal/engine/ytdlp/ytdlp_test.go +++ b/internal/engine/ytdlp/ytdlp_test.go @@ -195,11 +195,48 @@ func TestDualStream_LineHandling(t *testing.T) { } } +func TestMonotonicProgress(t *testing.T) { + eng := NewEngine("ytdlp", "ffmpeg") + state := &downloadState{} + + // 1. Initial 80% progress + eng.handleYTDLPLine("job1", state, "__GODOWNLOADER_PROGRESS__:80.0%|8000|10000|10000|1000|10", "stdout") + if state.progress.Percent != 80.0 { + t.Fatalf("expected 80.0, got %f", state.progress.Percent) + } + + // 2. Secondary stream at 20% must not lower percent (80 -> 20 remains 80) + eng.handleYTDLPLine("job1", state, "__GODOWNLOADER_PROGRESS__:20.0%|2000|10000|10000|500|15", "stdout") + if state.progress.Percent != 80.0 { + t.Fatalf("80 -> 20 failed: expected 80.0, got %f", state.progress.Percent) + } + if state.progress.Speed != 500 || state.progress.ETASeconds != 15 { + t.Fatalf("expected updated speed 500 and ETA 15, got speed %d, eta %d", state.progress.Speed, state.progress.ETASeconds) + } + + // 3. Complete first stream to 100% + eng.handleYTDLPLine("job1", state, "__GODOWNLOADER_PROGRESS__:100.0%|10000|10000|10000|2000|0", "stdout") + if state.progress.Percent != 100.0 { + t.Fatalf("expected 100.0, got %f", state.progress.Percent) + } + + // 4. Second stream starting at 0% must not reset 100% (100 -> 0 remains 100) + eng.handleYTDLPLine("job1", state, "__GODOWNLOADER_PROGRESS__:0.0%|0|1000|1000|300|5", "stdout") + if state.progress.Percent != 100.0 { + t.Fatalf("100 -> 0 failed: expected 100.0, got %f", state.progress.Percent) + } + if state.progress.Speed != 300 || state.progress.ETASeconds != 5 { + t.Fatalf("expected updated speed 300 and ETA 5, got speed %d, eta %d", state.progress.Speed, state.progress.ETASeconds) + } +} + func TestPathPrecedence(t *testing.T) { eng := NewEngine("ytdlp", "ffmpeg") + finalPath := filepath.Join("tmp", "final_merged.mkv") + candPath := filepath.Join("tmp", "intermediate.webm") state := &downloadState{ - candidateOutputPath: "C:\\Temp\\intermediate.webm", - finalOutputPath: "C:\\Temp\\final_merged.mkv", + candidateOutputPath: candPath, + finalOutputPath: finalPath, done: true, progress: progressInfo{Percent: 100, TotalBytes: 50000}, } @@ -210,8 +247,8 @@ func TestPathPrecedence(t *testing.T) { if err != nil { t.Fatalf("expected Status to succeed, got %v", err) } - if st.OutputPath != "C:\\Temp\\final_merged.mkv" { - t.Errorf("expected OutputPath C:\\Temp\\final_merged.mkv, got %q", st.OutputPath) + if st.OutputPath != finalPath { + t.Errorf("expected OutputPath %q, got %q", finalPath, st.OutputPath) } if st.FileName != "final_merged.mkv" { t.Errorf("expected FileName final_merged.mkv, got %q", st.FileName) diff --git a/internal/job/manager.go b/internal/job/manager.go index 6e68c5f..121ebed 100644 --- a/internal/job/manager.go +++ b/internal/job/manager.go @@ -911,7 +911,12 @@ func (m *Manager) CreateTorrentFromFileWithOptions(ctx context.Context, torrentF os.Remove(torrentFilePath) - return m.createTorrentJobWithIDAndOptions(ctx, jobID, "torrent://"+persistedPath, persistedPath, opts) + j, err := m.createTorrentJobWithIDAndOptions(ctx, jobID, "torrent://"+persistedPath, persistedPath, opts) + if err != nil { + os.Remove(persistedPath) + return nil, err + } + return j, nil } func sanitizeTrackerURL(rawURL string) string { @@ -947,7 +952,7 @@ func (m *Manager) acquireTorrentMetadata(jobID, source, torrentFilePath string) eng, ok := m.engines.Get("qbittorrent") if !ok { j.Status = StatusFailed - j.Error = "qBittorrent engine not available" + j.Error = "engine not registered: qBittorrent" j.UpdatedAt = time.Now() m.repo.Update(ctx, j) m.publish(EventJobFailed, j) @@ -2046,71 +2051,34 @@ func (m *Manager) UpdateJobFromEngine(ctx context.Context, j *Job, status *Engin return } // Handle media finalization before marking StatusCompleted - if j.Type == TypeMedia && m.storageService != nil && j.WorkDir != "" && j.FinalPath == "" { + if j.Type == TypeMedia && j.WorkDir != "" && j.FinalPath == "" { srcFile := status.OutputPath - if srcFile == "" && status.FileName != "" { - cand := filepath.Join(j.WorkDir, status.FileName) - if _, err := os.Stat(cand); err == nil { - srcFile = cand - } - } if srcFile == "" { - entries, err := os.ReadDir(j.WorkDir) - if err != nil { - log.Printf("UpdateJobFromEngine: failed to read workdir %s for job %s: %v", j.WorkDir, j.ID, err) - j.Status = StatusFailed - j.Error = fmt.Sprintf("failed to read media work directory: %v", err) - j.UpdatedAt = time.Now() - if updateErr := m.repo.Update(ctx, j); updateErr != nil { - log.Printf("UpdateJobFromEngine: failed to persist FAILED status for job %s: %v", j.ID, updateErr) - return - } - m.removeActive(j.ID) - m.publish(EventJobFailed, j) - m.cleanupTerminalEngineState(j) - if m.scheduler != nil { - m.scheduler.Kick() - } + log.Printf("UpdateJobFromEngine: media completed but engine output path was not provided for job %s", j.ID) + j.Status = StatusFailed + j.Error = "media completed but engine output path was not provided" + j.UpdatedAt = time.Now() + if updateErr := m.repo.Update(ctx, j); updateErr != nil { + log.Printf("UpdateJobFromEngine: failed to persist FAILED status for job %s: %v", j.ID, updateErr) return } - - var bestFile string - var bestSize int64 - for _, entry := range entries { - if entry.IsDir() || entry.Name() == storage.WorkDirMarkerFilename { - continue - } - name := entry.Name() - lowerName := strings.ToLower(name) - - if strings.HasSuffix(lowerName, ".part") || - strings.HasSuffix(lowerName, ".ytdl") || - strings.HasSuffix(lowerName, ".vtt") || - strings.HasSuffix(lowerName, ".srt") || - strings.HasSuffix(lowerName, ".jpg") || - strings.HasSuffix(lowerName, ".jpeg") || - strings.HasSuffix(lowerName, ".png") || - strings.HasSuffix(lowerName, ".webp") || - strings.HasSuffix(lowerName, ".json") { - continue - } - - info, err := entry.Info() - if err != nil { - continue - } - if info.Size() > bestSize { - bestSize = info.Size() - bestFile = filepath.Join(j.WorkDir, name) - } + m.removeActive(j.ID) + m.publish(EventJobFailed, j) + m.cleanupTerminalEngineState(j) + if m.scheduler != nil { + m.scheduler.Kick() } - srcFile = bestFile + return } - if srcFile == "" { - log.Printf("UpdateJobFromEngine: media completed but final output file was not found for job %s", j.ID) + // Validate containment inside WorkDir and regular file status + cleanWorkDir := filepath.Clean(j.WorkDir) + cleanSrc := filepath.Clean(srcFile) + rel, relErr := filepath.Rel(cleanWorkDir, cleanSrc) + if relErr != nil || strings.HasPrefix(rel, "..") { + log.Printf("UpdateJobFromEngine: media output path %s is outside workdir %s for job %s", srcFile, j.WorkDir, j.ID) j.Status = StatusFailed - j.Error = "media completed but final output file was not found" + j.Error = fmt.Sprintf("media output path %s is outside work directory", srcFile) j.UpdatedAt = time.Now() if updateErr := m.repo.Update(ctx, j); updateErr != nil { log.Printf("UpdateJobFromEngine: failed to persist FAILED status for job %s: %v", j.ID, updateErr) @@ -2125,11 +2093,11 @@ func (m *Manager) UpdateJobFromEngine(ctx context.Context, j *Job, status *Engin return } - finalPath, err := m.storageService.FinalizeFile(ctx, srcFile, j.DestinationDir, storage.FilenameConflictPolicy(j.ConflictPolicy)) - if err != nil { - log.Printf("UpdateJobFromEngine: media finalization failed for job %s: %v", j.ID, err) + fi, statErr := os.Stat(srcFile) + if statErr != nil || fi.IsDir() { + log.Printf("UpdateJobFromEngine: media output path %s is invalid or non-regular for job %s: %v", srcFile, j.ID, statErr) j.Status = StatusFailed - j.Error = fmt.Sprintf("file finalization failed: %v", err) + j.Error = fmt.Sprintf("media output path %s does not exist or is a directory", srcFile) j.UpdatedAt = time.Now() if updateErr := m.repo.Update(ctx, j); updateErr != nil { log.Printf("UpdateJobFromEngine: failed to persist FAILED status for job %s: %v", j.ID, updateErr) @@ -2143,13 +2111,36 @@ func (m *Manager) UpdateJobFromEngine(ctx context.Context, j *Job, status *Engin } return } - j.FinalPath = finalPath - j.Name = filepath.Base(finalPath) - if fi, statErr := os.Stat(finalPath); statErr == nil && fi.Size() > 0 { + + if m.storageService != nil { + finalPath, err := m.storageService.FinalizeFile(ctx, srcFile, j.DestinationDir, storage.FilenameConflictPolicy(j.ConflictPolicy)) + if err != nil { + log.Printf("UpdateJobFromEngine: media finalization failed for job %s: %v", j.ID, err) + j.Status = StatusFailed + j.Error = fmt.Sprintf("file finalization failed: %v", err) + j.UpdatedAt = time.Now() + if updateErr := m.repo.Update(ctx, j); updateErr != nil { + log.Printf("UpdateJobFromEngine: failed to persist FAILED status for job %s: %v", j.ID, updateErr) + return + } + m.removeActive(j.ID) + m.publish(EventJobFailed, j) + m.cleanupTerminalEngineState(j) + if m.scheduler != nil { + m.scheduler.Kick() + } + return + } + j.FinalPath = finalPath + } else { + j.FinalPath = srcFile + } + j.Name = filepath.Base(j.FinalPath) + if fi, statErr := os.Stat(j.FinalPath); statErr == nil && fi.Size() > 0 { j.TotalBytes = fi.Size() j.CompletedBytes = fi.Size() } - m.updateActiveJobFinalization(j.ID, finalPath, j.Name) + m.updateActiveJobFinalization(j.ID, j.FinalPath, j.Name) } if j.Type == TypeDownload && status.FileName != "" { diff --git a/internal/job/manager_test.go b/internal/job/manager_test.go index 3435946..319b79b 100644 --- a/internal/job/manager_test.go +++ b/internal/job/manager_test.go @@ -1487,7 +1487,15 @@ func TestManager_DuplicateTorrentCanBeRetried(t *testing.T) { t.Errorf("expected Job B status to transition to StatusAnalyzing on Retry, got %s", retriedJB.Status) } - time.Sleep(1500 * time.Millisecond) + deadline := time.Now().Add(5 * time.Second) + var gotJBFinal *Job + for time.Now().Before(deadline) { + gotJBFinal, _ = m.repo.GetByID(ctx, jB.ID) + if gotJBFinal != nil && gotJBFinal.Status == StatusAwaitingSelection { + break + } + time.Sleep(50 * time.Millisecond) + } // 6. Verify AddTorrentFile received preserved path, AddMagnet was NOT called, Job B reaches StatusAwaitingSelection testMu.Lock() @@ -1502,9 +1510,12 @@ func TestManager_DuplicateTorrentCanBeRetried(t *testing.T) { t.Errorf("expected AddTorrentFile to receive preserved path %s, got %s", recB.TorrentFilePath, gotPath) } - gotJBFinal, _ := m.repo.GetByID(ctx, jB.ID) - if gotJBFinal.Status != StatusAwaitingSelection { - t.Errorf("expected retried Job B to reach StatusAwaitingSelection, got %s", gotJBFinal.Status) + if gotJBFinal == nil || gotJBFinal.Status != StatusAwaitingSelection { + statusStr := "" + if gotJBFinal != nil { + statusStr = string(gotJBFinal.Status) + } + t.Errorf("expected retried Job B to reach StatusAwaitingSelection, got %s", statusStr) } } @@ -2158,3 +2169,131 @@ func (f *failingUpdateJobRepo) CountDownloading(ctx context.Context) (int, error func (f *failingUpdateJobRepo) ListPendingEngineCleanups(ctx context.Context) ([]Job, error) { return nil, nil } + +func TestEndToEnd_UploadedTorrent(t *testing.T) { + m, _, _, cleanup, fakeTorrentEng := setupManagerTest(t) + defer cleanup() + + addTorrentFileCalled := false + fakeTorrentEng.addTorrentFileFunc = func(filePath string) (string, error) { + addTorrentFileCalled = true + return "hash999", nil + } + + tempTorrent := filepath.Join(t.TempDir(), "input.torrent") + if err := os.WriteFile(tempTorrent, []byte("d8:announce3:url7:filesizede"), 0644); err != nil { + t.Fatalf("failed to create temp torrent file: %v", err) + } + + j, err := m.CreateTorrentFromFileWithOptions(context.Background(), tempTorrent, CreateOptions{Priority: JobPriorityNormal}) + if err != nil { + t.Fatalf("expected CreateTorrentFromFileWithOptions to succeed, got %v", err) + } + + if j.Engine != "qbittorrent" { + t.Errorf("expected engine qbittorrent, got %s", j.Engine) + } + if j.Type != TypeTorrent { + t.Errorf("expected type torrent, got %s", j.Type) + } + if !strings.HasPrefix(j.Source, "torrent://") { + t.Errorf("expected source starting with torrent://, got %s", j.Source) + } + + deadline := time.Now().Add(5 * time.Second) + var updatedJob *Job + for time.Now().Before(deadline) { + updatedJob, err = m.repo.GetByID(context.Background(), j.ID) + if updatedJob != nil && updatedJob.EngineID == "hash999" { + break + } + time.Sleep(50 * time.Millisecond) + } + + if !addTorrentFileCalled { + t.Errorf("expected AddTorrentFile to be invoked on fake qBittorrent engine") + } + if updatedJob == nil || updatedJob.EngineID != "hash999" { + gotEngineID := "" + if updatedJob != nil { + gotEngineID = updatedJob.EngineID + } + t.Errorf("expected infoHash hash999 persisted on job, got %s", gotEngineID) + } +} + +func TestCreateTorrentFromFile_CleanupOnFailure(t *testing.T) { + tmpDir := t.TempDir() + repo := newFakeJobRepository() + bus := newFakeEventBus() + registry := &fakeEngineRegistry{ + engines: map[string]IEngine{ + "aria2": &fakeEngine{}, + }, + } + m := NewManager(repo, registry, bus, tmpDir, newFakeTorrentRepository(repo), tmpDir) + + tempTorrent := filepath.Join(tmpDir, "input.torrent") + if err := os.WriteFile(tempTorrent, []byte("d8:announce3:url7:filesizede"), 0644); err != nil { + t.Fatalf("failed to create temp torrent file: %v", err) + } + + _, err := m.CreateTorrentFromFileWithOptions(context.Background(), tempTorrent, CreateOptions{Priority: JobPriorityNormal}) + if err == nil { + t.Fatalf("expected CreateTorrentFromFileWithOptions to fail when qbittorrent engine is missing") + } + + torrentsDir := filepath.Join(m.dataDir, "torrents") + entries, _ := os.ReadDir(torrentsDir) + if len(entries) > 0 { + t.Errorf("expected 0 leaked torrent files in %s, found %d", torrentsDir, len(entries)) + } +} + +func TestManager_MediaFinalization_NeverPicksUnrelatedLargerFile(t *testing.T) { + m, _, _, cleanup, _ := setupManagerTest(t) + defer cleanup() + + workDir := filepath.Join(t.TempDir(), "work_job123") + if err := os.MkdirAll(workDir, 0755); err != nil { + t.Fatalf("failed to create workdir: %v", err) + } + + unrelatedFile := filepath.Join(workDir, "huge_unrelated_video.mp4") + if err := os.WriteFile(unrelatedFile, make([]byte, 100*1024), 0644); err != nil { + t.Fatalf("failed to write dummy large file: %v", err) + } + + j := &Job{ + ID: "job123", + Engine: "ytdlp", + Type: TypeMedia, + Status: StatusDownloading, + WorkDir: workDir, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + m.repo.Create(context.Background(), j) + + status := &EngineStatus{ + Status: StatusCompleted, + OutputPath: "", + } + + m.UpdateJobFromEngine(context.Background(), j, status, true) + + updatedJob, err := m.repo.GetByID(context.Background(), "job123") + if err != nil { + t.Fatalf("failed to fetch job: %v", err) + } + + if updatedJob.Status != StatusFailed { + t.Errorf("expected job to fail when OutputPath is missing, got %s", updatedJob.Status) + } + if updatedJob.FinalPath != "" { + t.Errorf("expected empty FinalPath, got %s (unrelated file was wrongly finalized)", updatedJob.FinalPath) + } + if !strings.Contains(updatedJob.Error, "engine output path was not provided") { + t.Errorf("expected diagnostic error message, got %s", updatedJob.Error) + } +} diff --git a/internal/job/storage_integration_test.go b/internal/job/storage_integration_test.go index b30a6bb..fdff7b6 100644 --- a/internal/job/storage_integration_test.go +++ b/internal/job/storage_integration_test.go @@ -96,7 +96,7 @@ func TestMediaCompletion_MissingFinalArtifactFails(t *testing.T) { if updated.Status != StatusFailed { t.Errorf("expected StatusFailed when output file missing, got %s", updated.Status) } - if updated.Error != "media completed but final output file was not found" { + if updated.Error != "media completed but engine output path was not provided" { t.Errorf("unexpected error message: %s", updated.Error) } if updated.FinalPath != "" { From cc99ffe783d332bb0ebd286aa72f8b1d4face737 Mon Sep 17 00:00:00 2001 From: Kavya Arora Date: Wed, 5 Aug 2026 13:15:45 +0530 Subject: [PATCH 03/15] fix(torrent): transactional selection persistence, disk preflight ordering, external engine stop reconciliation and UI selected total display --- internal/database/torrent_repository.go | 81 ++++ internal/job/engine.go | 3 + internal/job/manager.go | 184 +++++++-- internal/job/manager_test.go | 36 +- internal/job/repository.go | 1 + .../job/torrent_selection_regression_test.go | 352 ++++++++++++++++++ web/src/components/job-card/JobProgress.tsx | 2 +- 7 files changed, 616 insertions(+), 43 deletions(-) create mode 100644 internal/job/torrent_selection_regression_test.go diff --git a/internal/database/torrent_repository.go b/internal/database/torrent_repository.go index 268e3e0..bf6a3d3 100644 --- a/internal/database/torrent_repository.go +++ b/internal/database/torrent_repository.go @@ -302,3 +302,84 @@ func (r *SQLiteTorrentRepository) UpdateTorrentFileSelections(ctx context.Contex } return nil } + +// PersistTorrentSelectionAndEnqueue atomically updates file selections, torrent policy, main job record, and enqueues a queue item in a single transaction. +func (r *SQLiteTorrentRepository) PersistTorrentSelectionAndEnqueue(ctx context.Context, j *job.Job, selections []job.TorrentFileRecord, rec *job.TorrentJobRecord, qe *job.QueueEntry) error { + tx, err := r.db.conn.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + defer tx.Rollback() + + // 1. Update file selections + for _, s := range selections { + selectedInt := 0 + if s.Selected { + selectedInt = 1 + } + if _, err := tx.ExecContext(ctx, + `UPDATE torrent_files SET selected=?, priority=? WHERE job_id=? AND file_index=?`, + selectedInt, s.Priority, j.ID, s.FileIndex); err != nil { + return fmt.Errorf("update torrent_files: %w", err) + } + } + + // 2. Update torrent job record if provided + if rec != nil { + seedingMode := rec.SeedingPolicy.Mode + if seedingMode == "" { + if rec.SeedAfterComplete { + seedingMode = networkpolicy.SeedingModeUnlimited + } else { + seedingMode = networkpolicy.SeedingModeNone + } + } + var ratio any + if rec.SeedingPolicy.RatioLimit != nil { + ratio = *rec.SeedingPolicy.RatioLimit + } + var duration any + if rec.SeedingPolicy.TimeLimitSeconds != nil { + duration = *rec.SeedingPolicy.TimeLimitSeconds + } + var started any + if rec.SeedingStartedAt != nil { + started = *rec.SeedingStartedAt + } + trackersJSON, marshalErr := json.Marshal(rec.CustomTrackers) + if marshalErr != nil { + return fmt.Errorf("marshal trackers: %w", marshalErr) + } + + if _, err := tx.ExecContext(ctx, `UPDATE torrent_jobs SET info_hash=?, name=?, total_size=?, seed_after_complete=?, torrent_file_path=?, + seeding_mode=?, seed_ratio_limit=?, seed_time_limit_seconds=?, seeding_started_at=?, + seeding_stop_reason=?, seeding_reconcile_pending=?, custom_trackers_json=? WHERE job_id=?`, + rec.InfoHash, rec.Name, rec.TotalSize, rec.SeedAfterComplete, rec.TorrentFilePath, + seedingMode, ratio, duration, started, rec.SeedingStopReason, rec.SeedingReconcilePending, string(trackersJSON), j.ID); err != nil { + return fmt.Errorf("update torrent_jobs: %w", err) + } + } + + // 3. Update main job table: total_bytes = selectedBytes, status = queued, error = '', updated_at + networkJSON, err := json.Marshal(j.NetworkPolicy) + if err != nil { + return fmt.Errorf("marshal network policy: %w", err) + } + if _, err := tx.ExecContext(ctx, `UPDATE jobs SET total_bytes=?, status=?, error='', updated_at=?, + network_policy_json=?, effective_download_limit_bps=?, effective_upload_limit_bps=?, network_reconcile_pending=? WHERE id=?`, + j.TotalBytes, j.Status, j.UpdatedAt, string(networkJSON), + j.EffectiveDownloadLimitBytesPerSecond, j.EffectiveUploadLimitBytesPerSecond, + j.NetworkReconcilePending, j.ID); err != nil { + return fmt.Errorf("update jobs table: %w", err) + } + + // 4. Insert or replace queue entry + if qe != nil { + if _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO job_queue (job_id, position, action, enqueued_at, updated_at) VALUES (?, ?, ?, ?, ?)`, + qe.JobID, qe.Position, string(qe.Action), qe.EnqueuedAt, qe.UpdatedAt); err != nil { + return fmt.Errorf("insert job_queue: %w", err) + } + } + + return tx.Commit() +} diff --git a/internal/job/engine.go b/internal/job/engine.go index 93d084e..98b6a73 100644 --- a/internal/job/engine.go +++ b/internal/job/engine.go @@ -138,6 +138,9 @@ type ITorrentEngine interface { // GetTorrentInfo returns normalized torrent metadata. GetTorrentInfo(ctx context.Context, infoHash string) (*TorrentInfo, error) + // GetRawState returns the raw engine state string. + GetRawState(ctx context.Context, infoHash string) (string, error) + // HealthCheck verifies the engine is reachable and operational. HealthCheck(ctx context.Context) error } diff --git a/internal/job/manager.go b/internal/job/manager.go index 121ebed..1e3a91a 100644 --- a/internal/job/manager.go +++ b/internal/job/manager.go @@ -1408,12 +1408,7 @@ func (m *Manager) StartTorrentWithPolicy(ctx context.Context, id string, selecti return nil, &AppError{Code: ErrNoFilesSelected, Message: "at least one file must be selected"} } - // 3. Apply file priorities - if err := torrentEng.SetFilePriorities(ctx, j.EngineID, selections); err != nil { - return nil, &AppError{Code: ErrEngineError, Message: fmt.Sprintf("failed to set file priorities: %v", err)} - } - - // 4. Calculate selected payload bytes & Save selections & SeedAfterComplete to DB + // 3. Calculate selected payload bytes fileSizeMap := make(map[int]int64) for _, f := range existingFiles { fileSizeMap[f.Index] = f.Size @@ -1426,56 +1421,89 @@ func (m *Manager) StartTorrentWithPolicy(ctx context.Context, id string, selecti } } - j.TotalBytes = selectedBytes - j.SeedingPolicy = policy - j.SeedAfterComplete = policy.Mode != networkpolicy.SeedingModeNone + // 4. Perform disk preflight check BEFORE mutating engine file priorities or queueing + targetDir := j.DestinationDir + if targetDir == "" { + targetDir = m.downloadDir + } + if m.storageService != nil { + if preflightErr := m.storageService.Preflight(ctx, targetDir, j.WorkDir, selectedBytes, 0); preflightErr != nil { + return nil, preflightErr + } + } + // 5. Apply seeding policy to engine before persistence if controller, ok := eng.(ISeedingPolicyController); ok { if err := controller.ApplySeedingPolicy(ctx, j, policy); err != nil { return nil, &AppError{Code: ErrNetworkSettingApplicationFailed, Message: fmt.Sprintf("failed to apply seeding policy: %v", err)} } } - if m.torrentRepo != nil { - var records []TorrentFileRecord - for _, s := range selections { - records = append(records, TorrentFileRecord{ - JobID: id, - FileIndex: s.Index, - Selected: s.Priority != PrioritySkip, - Priority: string(s.Priority), - }) - } - if err := m.torrentRepo.UpdateTorrentFileSelections(ctx, id, records); err != nil { - return nil, &AppError{Code: ErrInternalError, Message: fmt.Sprintf("failed to save torrent file selections: %v", err)} - } + j.TotalBytes = selectedBytes + j.SeedingPolicy = policy + j.SeedAfterComplete = policy.Mode != networkpolicy.SeedingModeNone + j.Status = StatusQueued + j.Error = "" + j.UpdatedAt = time.Now() - rec, err := m.torrentRepo.GetTorrentJob(ctx, id) - if err != nil { - return nil, &AppError{Code: ErrInternalError, Message: fmt.Sprintf("failed to get torrent job record: %v", err)} - } - if rec != nil { + // 6. Transactionally persist selection, seeding policy, job state, and queue entry + var records []TorrentFileRecord + for _, s := range selections { + records = append(records, TorrentFileRecord{ + JobID: id, + FileIndex: s.Index, + Selected: s.Priority != PrioritySkip, + Priority: string(s.Priority), + }) + } + + var rec *TorrentJobRecord + if m.torrentRepo != nil { + var getErr error + rec, getErr = m.torrentRepo.GetTorrentJob(ctx, id) + if getErr == nil && rec != nil { rec = cloneTorrentRecord(rec) rec.SeedAfterComplete = j.SeedAfterComplete rec.SeedingPolicy = policy - if err := m.torrentRepo.UpdateTorrentJob(ctx, rec); err != nil { - return nil, &AppError{Code: ErrInternalError, Message: fmt.Sprintf("failed to update torrent job record: %v", err)} - } } } - // 5. Enqueue queue entry FIRST before updating job status to QUEUED - if err := m.enqueueJob(ctx, j, QueueActionStart); err != nil { - return nil, &AppError{Code: ErrInternalError, Message: fmt.Sprintf("failed to enqueue torrent job: %v", err)} + qe := &QueueEntry{ + JobID: j.ID, + Position: time.Now().UnixNano(), + Action: QueueActionStart, + EnqueuedAt: time.Now(), + UpdatedAt: time.Now(), + } + if m.queueRepo != nil { + prio := j.Priority + if prio == "" { + prio = JobPriorityNormal + } + if pos, posErr := m.queueRepo.NextPosition(ctx, prio); posErr == nil { + qe.Position = pos + } } - j.Status = StatusQueued - j.UpdatedAt = time.Now() - if err := m.repo.Update(ctx, j); err != nil { - if m.queueRepo != nil { - m.queueRepo.Delete(ctx, j.ID) + if m.torrentRepo != nil { + if err := m.torrentRepo.PersistTorrentSelectionAndEnqueue(ctx, j, records, rec, qe); err != nil { + return nil, &AppError{Code: ErrInternalError, Message: fmt.Sprintf("failed to save torrent selection and queue: %v", err)} } - return nil, fmt.Errorf("update job status: %w", err) + } else { + if err := m.enqueueJob(ctx, j, QueueActionStart); err != nil { + return nil, &AppError{Code: ErrInternalError, Message: fmt.Sprintf("failed to enqueue torrent job: %v", err)} + } + if err := m.repo.Update(ctx, j); err != nil { + if m.queueRepo != nil { + m.queueRepo.Delete(ctx, j.ID) + } + return nil, fmt.Errorf("update job status: %w", err) + } + } + + // 7. Apply file priorities to qBittorrent engine after durable persistence + if err := torrentEng.SetFilePriorities(ctx, j.EngineID, selections); err != nil { + return nil, &AppError{Code: ErrEngineError, Message: fmt.Sprintf("failed to set file priorities: %v", err)} } // Fallback for test doubles created without scheduler @@ -2022,7 +2050,15 @@ func (m *Manager) GetEngine(name string) (IEngine, bool) { // UpdateJobFromEngine updates a job with engine status and persists/publishes. func (m *Manager) UpdateJobFromEngine(ctx context.Context, j *Job, status *EngineStatus, persistNow bool) { - j.TotalBytes = status.TotalBytes + if j.Type == TypeTorrent && j.Status != StatusAwaitingSelection { + if j.TorrentInfo != nil && j.TorrentInfo.TotalSize > 0 && status.TotalBytes == j.TorrentInfo.TotalSize && j.TotalBytes > 0 && j.TotalBytes != j.TorrentInfo.TotalSize { + // Retain authoritative selected TotalBytes instead of reverting to full torrent size + } else if status.TotalBytes > 0 { + j.TotalBytes = status.TotalBytes + } + } else { + j.TotalBytes = status.TotalBytes + } j.CompletedBytes = status.CompletedBytes j.SpeedBytesPerSecond = status.SpeedBytesPerSecond j.ETASeconds = status.ETASeconds @@ -2526,7 +2562,56 @@ func (m *Manager) GetScheduler() *Scheduler { return m.scheduler } +func (m *Manager) calculatePersistedSelectedTorrentBytes(ctx context.Context, jobID string) (int64, error) { + if m.torrentRepo == nil { + return 0, nil + } + files, err := m.torrentRepo.GetTorrentFiles(ctx, jobID) + if err != nil { + return 0, fmt.Errorf("failed to get torrent files for job %s: %w", jobID, err) + } + if len(files) == 0 { + return 0, fmt.Errorf("no torrent file records found for job %s", jobID) + } + var selectedBytes int64 + hasSelected := false + for _, f := range files { + if f.Selected || (f.Priority != "" && f.Priority != string(PrioritySkip)) { + selectedBytes += f.Size + hasSelected = true + } + } + if !hasSelected { + return 0, fmt.Errorf("no files selected in torrent selection record for job %s", jobID) + } + return selectedBytes, nil +} + func (m *Manager) persistDispatchFailure(ctx context.Context, j *Job, qj *QueuedJob, targetStatus JobStatus, dispatchErr error) error { + // 1. Explicitly stop/pause external torrent engine to ensure no background downloading continues + if j.EngineID != "" { + if eng, ok := m.engines.Get(j.Engine); ok { + _ = eng.Pause(ctx, j) + if torrentEng, ok := eng.(ITorrentEngine); ok { + _ = torrentEng.StopDownload(ctx, j.EngineID) + rawState, err := torrentEng.GetRawState(ctx, j.EngineID) + if err == nil && rawState != "" { + switch rawState { + case "stoppedDL", "pausedDL", "stoppedUP", "pausedUP", "paused", "stopped": + // Confirmed stopped/paused raw state + default: + log.Printf("persistDispatchFailure: torrent %s raw state %q after stop attempt", j.EngineID, rawState) + _ = eng.Pause(ctx, j) + rawState2, _ := torrentEng.GetRawState(ctx, j.EngineID) + if rawState2 != "stoppedDL" && rawState2 != "pausedDL" && rawState2 != "stoppedUP" && rawState2 != "pausedUP" && rawState2 != "paused" && rawState2 != "stopped" { + j.NetworkReconcilePending = true + } + } + } + } + } + } + j.Status = targetStatus j.Error = dispatchErr.Error() j.SpeedBytesPerSecond = 0 @@ -2593,8 +2678,25 @@ func (m *Manager) dispatchQueuedJob(ctx context.Context, qj *QueuedJob) error { targetDir = m.downloadDir } + preflightTotal := j.TotalBytes + preflightCompleted := j.CompletedBytes + + if j.Type == TypeTorrent { + selBytes, selErr := m.calculatePersistedSelectedTorrentBytes(ctx, j.ID) + if selErr == nil && selBytes > 0 { + if j.TotalBytes != selBytes { + j.TotalBytes = selBytes + _ = m.repo.Update(ctx, j) + } + preflightTotal = selBytes + } + if qj.Action == QueueActionStart || preflightCompleted > preflightTotal { + preflightCompleted = 0 + } + } + if m.storageService != nil { - if preflightErr := m.storageService.Preflight(ctx, targetDir, j.WorkDir, j.TotalBytes, j.CompletedBytes); preflightErr != nil { + if preflightErr := m.storageService.Preflight(ctx, targetDir, j.WorkDir, preflightTotal, preflightCompleted); preflightErr != nil { log.Printf("dispatchQueuedJob: storage preflight failed for job %s (action=%s): %v", j.ID, qj.Action, preflightErr) targetStatus := StatusPaused if qj.Action == QueueActionStart { diff --git a/internal/job/manager_test.go b/internal/job/manager_test.go index 319b79b..747ea35 100644 --- a/internal/job/manager_test.go +++ b/internal/job/manager_test.go @@ -329,6 +329,7 @@ func (f *fakeEventBus) Unsubscribe(ch <-chan Event) { type fakeTorrentRepository struct { mu sync.Mutex jobRepo IJobRepository + queueRepo IQueueRepository torrentJobs map[string]*TorrentJobRecord torrentFiles map[string][]TorrentFileRecord getActiveErr error @@ -338,9 +339,14 @@ type fakeTorrentRepository struct { finalizeErr error } -func newFakeTorrentRepository(jobRepo IJobRepository) *fakeTorrentRepository { +func newFakeTorrentRepository(jobRepo IJobRepository, qRepo ...IQueueRepository) *fakeTorrentRepository { + var q IQueueRepository + if len(qRepo) > 0 { + q = qRepo[0] + } return &fakeTorrentRepository{ jobRepo: jobRepo, + queueRepo: q, torrentJobs: make(map[string]*TorrentJobRecord), torrentFiles: make(map[string][]TorrentFileRecord), } @@ -458,6 +464,34 @@ func (f *fakeTorrentRepository) UpdateTorrentFileSelections(ctx context.Context, return nil } +func (f *fakeTorrentRepository) PersistTorrentSelectionAndEnqueue(ctx context.Context, j *Job, selections []TorrentFileRecord, rec *TorrentJobRecord, qe *QueueEntry) error { + f.mu.Lock() + if f.updateErr != nil { + f.mu.Unlock() + return f.updateErr + } + f.torrentFiles[j.ID] = selections + if rec != nil { + f.torrentJobs[j.ID] = cloneTorrentRecord(rec) + } + qRepo := f.queueRepo + f.mu.Unlock() + + if f.jobRepo != nil { + if err := f.jobRepo.Update(ctx, j); err != nil { + return err + } + } + if qe != nil && qRepo != nil { + if err := qRepo.Enqueue(ctx, qe); err != nil { + return err + } + } + return nil +} + + + var _ ITorrentRepository = (*fakeTorrentRepository)(nil) func setupManagerTest(t *testing.T) (*Manager, *fakeEngine, *fakeEventBus, func(), *fakeTorrentEngine) { diff --git a/internal/job/repository.go b/internal/job/repository.go index 8e4a167..af2c883 100644 --- a/internal/job/repository.go +++ b/internal/job/repository.go @@ -58,6 +58,7 @@ type ITorrentRepository interface { GetTorrentFiles(ctx context.Context, jobID string) ([]TorrentFileRecord, error) UpdateTorrentFileSelections(ctx context.Context, jobID string, selections []TorrentFileRecord) error FinalizeTorrent(ctx context.Context, j *Job, stopReason string) error + PersistTorrentSelectionAndEnqueue(ctx context.Context, job *Job, selections []TorrentFileRecord, rec *TorrentJobRecord, queueEntry *QueueEntry) error } // TorrentJobRecord holds torrent-specific persistence data. diff --git a/internal/job/torrent_selection_regression_test.go b/internal/job/torrent_selection_regression_test.go new file mode 100644 index 0000000..7b7b017 --- /dev/null +++ b/internal/job/torrent_selection_regression_test.go @@ -0,0 +1,352 @@ +package job + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "downloader/internal/networkpolicy" + "downloader/internal/storage" +) + +type regressionMockStorage struct { + freeBytes int64 + lastPreflightTotal int64 + lastPreflightComp int64 + preflightCalls int + preflightErrToReturn error +} + +func (m *regressionMockStorage) GetEffectiveDefaultDownloadDir(ctx context.Context) string { + return "/downloads" +} + +func (m *regressionMockStorage) ResolveDestination(ctx context.Context, categoryID, customDest string, policy storage.FilenameConflictPolicy, jobID string, isMedia bool) (*storage.StorageResolution, error) { + return nil, nil +} + +func (m *regressionMockStorage) Preflight(ctx context.Context, destinationDir, workDir string, totalBytes, completedBytes int64) error { + m.preflightCalls++ + m.lastPreflightTotal = totalBytes + m.lastPreflightComp = completedBytes + if m.preflightErrToReturn != nil { + return m.preflightErrToReturn + } + rem := totalBytes - completedBytes + if rem < 0 { + rem = 0 + } + reserve := int64(104857600) // 100 MiB + req := rem + reserve + if m.freeBytes > 0 && m.freeBytes < req { + return fmt.Errorf("insufficient free space in %s (free: %d, required: %d, reserve: %d, remaining: %d)", + destinationDir, m.freeBytes, req, reserve, rem) + } + return nil +} + +func (m *regressionMockStorage) PrepareWorkDir(ctx context.Context, jobID, workDir string) error { + return nil +} + +func (m *regressionMockStorage) FinalizeFile(ctx context.Context, srcPath, destinationDir string, policy storage.FilenameConflictPolicy) (string, error) { + return srcPath, nil +} + +func (m *regressionMockStorage) CleanupWorkDir(ctx context.Context, jobID, workDir string) error { + return nil +} + +func (m *regressionMockStorage) CleanupStaleWorkDirs(ctx context.Context, activeJobIDs map[string]bool) error { + return nil +} + +type regressionMockEngine struct { + filesToReturn []TorrentFile + startDownloadCall int + stopDownloadCall int + rawStateToReturn string + prioritiesSet []TorrentFileSelection +} + +func (m *regressionMockEngine) Capabilities() networkpolicy.EngineCapabilities { + return networkpolicy.EngineCapabilities{FileSelection: true} +} +func (m *regressionMockEngine) Start(ctx context.Context, j *Job, downloadDir string) (string, error) { + return j.EngineID, nil +} +func (m *regressionMockEngine) Pause(ctx context.Context, j *Job) error { return nil } +func (m *regressionMockEngine) Resume(ctx context.Context, j *Job) error { return nil } +func (m *regressionMockEngine) Cancel(ctx context.Context, j *Job) error { return nil } +func (m *regressionMockEngine) Status(ctx context.Context, j *Job) (*EngineStatus, error) { + state := m.rawStateToReturn + if state == "" { + state = "downloading" + } + return &EngineStatus{Status: StatusDownloading, RawState: state, TotalBytes: j.TotalBytes}, nil +} +func (m *regressionMockEngine) AddMagnet(ctx context.Context, magnet, savePath, jobID string) (string, error) { + return "hash123", nil +} +func (m *regressionMockEngine) AddTorrentFile(ctx context.Context, filePath, savePath, jobID string) (string, error) { + return "hash123", nil +} +func (m *regressionMockEngine) GetFiles(ctx context.Context, infoHash string) ([]TorrentFile, error) { + return m.filesToReturn, nil +} +func (m *regressionMockEngine) SetFilePriorities(ctx context.Context, infoHash string, selections []TorrentFileSelection) error { + m.prioritiesSet = selections + return nil +} +func (m *regressionMockEngine) StartDownload(ctx context.Context, infoHash string) error { + m.startDownloadCall++ + return nil +} +func (m *regressionMockEngine) StopDownload(ctx context.Context, infoHash string) error { + m.stopDownloadCall++ + m.rawStateToReturn = "stoppedDL" + return nil +} +func (m *regressionMockEngine) RemoveTorrent(ctx context.Context, infoHash string, deleteFiles bool) error { + return nil +} +func (m *regressionMockEngine) GetTorrentInfo(ctx context.Context, infoHash string) (*TorrentInfo, error) { + return &TorrentInfo{Name: "test", InfoHash: infoHash, TotalSize: 23192823398}, nil +} +func (m *regressionMockEngine) GetRawState(ctx context.Context, infoHash string) (string, error) { + if m.rawStateToReturn == "" { + return "downloading", nil + } + return m.rawStateToReturn, nil +} +func (m *regressionMockEngine) HealthCheck(ctx context.Context) error { + return nil +} + +func TestSelectedSizeCalculation_30Files(t *testing.T) { + jobRepo := newFakeJobRepository() + torrentRepo := newFakeTorrentRepository(jobRepo) + eng := ®ressionMockEngine{} + + files := make([]TorrentFile, 30) + for i := 0; i < 30; i++ { + files[i] = TorrentFile{ + Index: i, + Path: fmt.Sprintf("file_%d.dat", i), + Size: 700 * 1024 * 1024, + Priority: PriorityNormal, + Selected: true, + } + } + eng.filesToReturn = files + + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + mgr := NewManager(jobRepo, reg, newFakeEventBus(), t.TempDir(), torrentRepo) + + j := &Job{ + ID: "job-30files", + Type: TypeTorrent, + Engine: "qbittorrent", + EngineID: "hash30", + Status: StatusAwaitingSelection, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + _ = jobRepo.Create(context.Background(), j) + + selections := make([]TorrentFileSelection, 30) + for i := 0; i < 30; i++ { + prio := PrioritySkip + if i == 0 || i == 1 { + prio = PriorityHigh + } + selections[i] = TorrentFileSelection{Index: i, Priority: prio} + } + + startedJ, err := mgr.StartTorrentWithPolicy(context.Background(), j.ID, selections, networkpolicy.SeedingPolicy{Mode: networkpolicy.SeedingModeNone}) + if err != nil { + t.Fatalf("expected StartTorrentWithPolicy to succeed, got %v", err) + } + + expectedSelectedBytes := int64(2 * 700 * 1024 * 1024) + if startedJ.TotalBytes != expectedSelectedBytes { + t.Fatalf("expected TotalBytes = %d (~1.4 GiB), got %d", expectedSelectedBytes, startedJ.TotalBytes) + } +} + +func TestStartTorrent_ValidationRejections(t *testing.T) { + jobRepo := newFakeJobRepository() + torrentRepo := newFakeTorrentRepository(jobRepo) + eng := ®ressionMockEngine{ + filesToReturn: []TorrentFile{ + {Index: 0, Path: "f0", Size: 100}, + {Index: 1, Path: "f1", Size: 200}, + }, + } + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + mgr := NewManager(jobRepo, reg, newFakeEventBus(), t.TempDir(), torrentRepo) + + j := &Job{ + ID: "job-val", + Type: TypeTorrent, + Engine: "qbittorrent", + EngineID: "hashval", + Status: StatusAwaitingSelection, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + _ = jobRepo.Create(context.Background(), j) + + // Duplicate index + _, errDup := mgr.StartTorrentWithPolicy(context.Background(), j.ID, []TorrentFileSelection{ + {Index: 0, Priority: PriorityNormal}, + {Index: 0, Priority: PriorityNormal}, + }, networkpolicy.SeedingPolicy{}) + if errDup == nil { + t.Fatal("expected duplicate index selection to be rejected") + } + + // Unknown index + _, errUnk := mgr.StartTorrentWithPolicy(context.Background(), j.ID, []TorrentFileSelection{ + {Index: 0, Priority: PriorityNormal}, + {Index: 99, Priority: PriorityNormal}, + }, networkpolicy.SeedingPolicy{}) + if errUnk == nil { + t.Fatal("expected unknown index selection to be rejected") + } + + // No selected files (all skip) + _, errNone := mgr.StartTorrentWithPolicy(context.Background(), j.ID, []TorrentFileSelection{ + {Index: 0, Priority: PrioritySkip}, + {Index: 1, Priority: PrioritySkip}, + }, networkpolicy.SeedingPolicy{}) + if errNone == nil { + t.Fatal("expected no files selected to be rejected") + } +} + +func TestDiskPreflightBeforeStart_FailureCausesZeroStartDownloadCalls(t *testing.T) { + jobRepo := newFakeJobRepository() + torrentRepo := newFakeTorrentRepository(jobRepo) + eng := ®ressionMockEngine{ + filesToReturn: []TorrentFile{ + {Index: 0, Path: "f0", Size: 700 * 1024 * 1024}, + {Index: 1, Path: "f1", Size: 700 * 1024 * 1024}, + }, + } + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + + storageSvc := ®ressionMockStorage{ + freeBytes: 100 * 1024 * 1024, // Free space 100 MB, required 1.4 GiB -> preflight fail + } + + mgr := NewManager(jobRepo, reg, newFakeEventBus(), t.TempDir(), torrentRepo) + mgr.SetStorageService(storageSvc) + + j := &Job{ + ID: "job-preflight-fail", + Type: TypeTorrent, + Engine: "qbittorrent", + EngineID: "hashpf", + Status: StatusAwaitingSelection, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + _ = jobRepo.Create(context.Background(), j) + + selections := []TorrentFileSelection{ + {Index: 0, Priority: PriorityNormal}, + {Index: 1, Priority: PriorityNormal}, + } + + _, err := mgr.StartTorrentWithPolicy(context.Background(), j.ID, selections, networkpolicy.SeedingPolicy{Mode: networkpolicy.SeedingModeNone}) + if err == nil { + t.Fatal("expected StartTorrentWithPolicy to fail preflight due to insufficient space") + } else { + t.Logf("StartTorrentWithPolicy error: %v", err) + } + + if eng.startDownloadCall != 0 { + t.Fatalf("expected 0 StartDownload calls on preflight failure, got %d", eng.startDownloadCall) + } + + if storageSvc.lastPreflightTotal != 1400*1024*1024 { + t.Fatalf("expected preflight to receive selected total (1.4 GB), got %d", storageSvc.lastPreflightTotal) + } +} + +func TestPersistDispatchFailure_StopsExternalDownloadingEngine(t *testing.T) { + jobRepo := newFakeJobRepository() + torrentRepo := newFakeTorrentRepository(jobRepo) + eng := ®ressionMockEngine{ + rawStateToReturn: "downloading", + } + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + + mgr := NewManager(jobRepo, reg, newFakeEventBus(), t.TempDir(), torrentRepo) + + j := &Job{ + ID: "job-dispatch-fail", + Type: TypeTorrent, + Engine: "qbittorrent", + EngineID: "hashdf", + Status: StatusQueued, + TotalBytes: 1400 * 1024 * 1024, + } + _ = jobRepo.Create(context.Background(), j) + + qj := &QueuedJob{ + JobID: j.ID, + Action: QueueActionStart, + } + + dispatchErr := errors.New("simulated dispatch storage preflight error") + _ = mgr.persistDispatchFailure(context.Background(), j, qj, StatusFailed, dispatchErr) + + if eng.stopDownloadCall == 0 { + t.Fatal("expected persistDispatchFailure to explicitly call StopDownload on external engine") + } + + updated, _ := jobRepo.GetByID(context.Background(), j.ID) + if updated.Status != StatusFailed { + t.Fatalf("expected job status = failed, got %s", updated.Status) + } +} + +func TestUpdateJobFromEngine_DoesNotRestoreFullSize(t *testing.T) { + jobRepo := newFakeJobRepository() + torrentRepo := newFakeTorrentRepository(jobRepo) + eng := ®ressionMockEngine{} + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + mgr := NewManager(jobRepo, reg, newFakeEventBus(), t.TempDir(), torrentRepo) + + fullTorrentSize := int64(23192823398) + selectedSize := int64(1400000000) + + j := &Job{ + ID: "job-size-preserve", + Type: TypeTorrent, + Engine: "qbittorrent", + EngineID: "hashsize", + Status: StatusDownloading, + TotalBytes: selectedSize, + TorrentInfo: &TorrentInfo{ + TotalSize: fullTorrentSize, + }, + } + _ = jobRepo.Create(context.Background(), j) + + // Engine status returning full torrent size by mistake + status := &EngineStatus{ + Status: StatusDownloading, + TotalBytes: fullTorrentSize, + } + + mgr.UpdateJobFromEngine(context.Background(), j, status, true) + + if j.TotalBytes != selectedSize { + t.Fatalf("expected TotalBytes to remain selected size %d, but was overwritten with %d", selectedSize, j.TotalBytes) + } +} diff --git a/web/src/components/job-card/JobProgress.tsx b/web/src/components/job-card/JobProgress.tsx index 57ee038..b627e4d 100644 --- a/web/src/components/job-card/JobProgress.tsx +++ b/web/src/components/job-card/JobProgress.tsx @@ -30,7 +30,7 @@ export function JobProgress({ job }: JobProgressProps) { ? `~${formatBytes(mediaEstimate.totalBytes)} est.` : 'Size unavailable' : job.totalBytes > 0 - ? formatBytes(job.totalBytes) + ? `${formatBytes(job.totalBytes)}${isTorrentJob && job.status !== 'awaiting_selection' ? ' selected' : ''}` : 'Size unavailable'; const formattedTimestamp = new Date(job.updatedAt || job.createdAt).toLocaleTimeString([], { From 7c83832543e457088a4f54f8205d5f7d6f3fdf15 Mon Sep 17 00:00:00 2001 From: Kavya Arora Date: Wed, 5 Aug 2026 14:05:15 +0530 Subject: [PATCH 04/15] fix(torrent): enforce priority verification ordering, hard transaction rollback, and error handling --- internal/database/torrent_repository.go | 49 ++- .../torrent_selection_transaction_test.go | 207 ++++++++++ internal/job/manager.go | 150 +++++--- internal/job/manager_test.go | 10 +- ...scheduler_reconciliation_hydration_test.go | 4 + internal/job/seeding_policy_closure_test.go | 6 + .../job/torrent_selection_regression_test.go | 353 +++++++++++++++++- 7 files changed, 706 insertions(+), 73 deletions(-) create mode 100644 internal/database/torrent_selection_transaction_test.go diff --git a/internal/database/torrent_repository.go b/internal/database/torrent_repository.go index bf6a3d3..1c228e2 100644 --- a/internal/database/torrent_repository.go +++ b/internal/database/torrent_repository.go @@ -311,20 +311,28 @@ func (r *SQLiteTorrentRepository) PersistTorrentSelectionAndEnqueue(ctx context. } defer tx.Rollback() - // 1. Update file selections + // 1. Update file selections (require exactly 1 row affected per selection) for _, s := range selections { selectedInt := 0 if s.Selected { selectedInt = 1 } - if _, err := tx.ExecContext(ctx, + res, err := tx.ExecContext(ctx, `UPDATE torrent_files SET selected=?, priority=? WHERE job_id=? AND file_index=?`, - selectedInt, s.Priority, j.ID, s.FileIndex); err != nil { + selectedInt, s.Priority, j.ID, s.FileIndex) + if err != nil { return fmt.Errorf("update torrent_files: %w", err) } + rows, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("torrent_files rows affected check: %w", err) + } + if rows != 1 { + return fmt.Errorf("torrent_files row missing for job_id=%s file_index=%d (affected %d rows)", j.ID, s.FileIndex, rows) + } } - // 2. Update torrent job record if provided + // 2. Update torrent job record if provided (require exactly 1 row affected) if rec != nil { seedingMode := rec.SeedingPolicy.Mode if seedingMode == "" { @@ -351,33 +359,50 @@ func (r *SQLiteTorrentRepository) PersistTorrentSelectionAndEnqueue(ctx context. return fmt.Errorf("marshal trackers: %w", marshalErr) } - if _, err := tx.ExecContext(ctx, `UPDATE torrent_jobs SET info_hash=?, name=?, total_size=?, seed_after_complete=?, torrent_file_path=?, + res, err := tx.ExecContext(ctx, `UPDATE torrent_jobs SET info_hash=?, name=?, total_size=?, seed_after_complete=?, torrent_file_path=?, seeding_mode=?, seed_ratio_limit=?, seed_time_limit_seconds=?, seeding_started_at=?, seeding_stop_reason=?, seeding_reconcile_pending=?, custom_trackers_json=? WHERE job_id=?`, rec.InfoHash, rec.Name, rec.TotalSize, rec.SeedAfterComplete, rec.TorrentFilePath, - seedingMode, ratio, duration, started, rec.SeedingStopReason, rec.SeedingReconcilePending, string(trackersJSON), j.ID); err != nil { + seedingMode, ratio, duration, started, rec.SeedingStopReason, rec.SeedingReconcilePending, string(trackersJSON), j.ID) + if err != nil { return fmt.Errorf("update torrent_jobs: %w", err) } + rows, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("torrent_jobs rows affected check: %w", err) + } + if rows != 1 { + return fmt.Errorf("torrent_jobs row missing for job_id=%s (affected %d rows)", j.ID, rows) + } } - // 3. Update main job table: total_bytes = selectedBytes, status = queued, error = '', updated_at + // 3. Update main job table: total_bytes = selectedBytes, status = queued, error = '', updated_at (require exactly 1 row affected) networkJSON, err := json.Marshal(j.NetworkPolicy) if err != nil { return fmt.Errorf("marshal network policy: %w", err) } - if _, err := tx.ExecContext(ctx, `UPDATE jobs SET total_bytes=?, status=?, error='', updated_at=?, + res, err := tx.ExecContext(ctx, `UPDATE jobs SET total_bytes=?, status=?, error='', updated_at=?, network_policy_json=?, effective_download_limit_bps=?, effective_upload_limit_bps=?, network_reconcile_pending=? WHERE id=?`, j.TotalBytes, j.Status, j.UpdatedAt, string(networkJSON), j.EffectiveDownloadLimitBytesPerSecond, j.EffectiveUploadLimitBytesPerSecond, - j.NetworkReconcilePending, j.ID); err != nil { + j.NetworkReconcilePending, j.ID) + if err != nil { return fmt.Errorf("update jobs table: %w", err) } + rows, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("jobs rows affected check: %w", err) + } + if rows != 1 { + return fmt.Errorf("jobs row missing for id=%s (affected %d rows)", j.ID, rows) + } - // 4. Insert or replace queue entry + // 4. Upsert queue entry if qe != nil { - if _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO job_queue (job_id, position, action, enqueued_at, updated_at) VALUES (?, ?, ?, ?, ?)`, + if _, err := tx.ExecContext(ctx, `INSERT INTO job_queue (job_id, position, action, enqueued_at, updated_at) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(job_id) DO UPDATE SET position=excluded.position, action=excluded.action, enqueued_at=excluded.enqueued_at, updated_at=excluded.updated_at`, qe.JobID, qe.Position, string(qe.Action), qe.EnqueuedAt, qe.UpdatedAt); err != nil { - return fmt.Errorf("insert job_queue: %w", err) + return fmt.Errorf("upsert job_queue: %w", err) } } diff --git a/internal/database/torrent_selection_transaction_test.go b/internal/database/torrent_selection_transaction_test.go new file mode 100644 index 0000000..79ba766 --- /dev/null +++ b/internal/database/torrent_selection_transaction_test.go @@ -0,0 +1,207 @@ +package database + +import ( + "context" + "testing" + "time" + + "downloader/internal/job" + "downloader/internal/networkpolicy" +) + +func TestSQLiteTransaction_Success(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + jobRepo := NewSQLiteJobRepository(db) + torrentRepo := NewSQLiteTorrentRepository(db) + ctx := context.Background() + + now := time.Now().Truncate(time.Second) + j := &job.Job{ + ID: "job-tx-success", + Source: "magnet:?xt=urn:btih:1234", + Name: "test.iso", + Status: job.StatusAwaitingSelection, + Type: job.TypeTorrent, + Engine: "qbittorrent", + EngineID: "hash123", + CreatedAt: now, + UpdatedAt: now, + } + if err := jobRepo.Create(ctx, j); err != nil { + t.Fatalf("jobRepo.Create failed: %v", err) + } + + rec := &job.TorrentJobRecord{ + JobID: j.ID, + InfoHash: "1234", + Name: "test.iso", + } + if err := torrentRepo.CreateTorrentJob(ctx, rec); err != nil { + t.Fatalf("CreateTorrentJob failed: %v", err) + } + + files := []job.TorrentFileRecord{ + {JobID: j.ID, FileIndex: 0, Path: "f0", Size: 700 * 1024 * 1024, Selected: true, Priority: "normal"}, + {JobID: j.ID, FileIndex: 1, Path: "f1", Size: 700 * 1024 * 1024, Selected: true, Priority: "normal"}, + } + if err := torrentRepo.SaveTorrentFiles(ctx, j.ID, files); err != nil { + t.Fatalf("SaveTorrentFiles failed: %v", err) + } + + j.TotalBytes = 1400 * 1024 * 1024 + j.Status = job.StatusQueued + j.UpdatedAt = time.Now() + + qe := &job.QueueEntry{ + JobID: j.ID, + Position: 100, + Action: job.QueueActionStart, + EnqueuedAt: time.Now(), + UpdatedAt: time.Now(), + } + + err := torrentRepo.PersistTorrentSelectionAndEnqueue(ctx, j, files, rec, qe) + if err != nil { + t.Fatalf("PersistTorrentSelectionAndEnqueue failed: %v", err) + } + + // Verify job is StatusQueued in DB + updatedJ, err := jobRepo.GetByID(ctx, j.ID) + if err != nil || updatedJ.Status != job.StatusQueued { + t.Fatalf("expected job status = queued, got %v (err: %v)", updatedJ.Status, err) + } + + // Verify queue entry exists + queueRepo := NewSQLiteQueueRepository(db) + entry, err := queueRepo.Get(ctx, j.ID) + if err != nil || entry == nil || entry.Action != job.QueueActionStart { + t.Fatalf("expected queue entry with QueueActionStart, got %v (err: %v)", entry, err) + } +} + +func TestSQLiteTransaction_RollbackMissingFileRow(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + jobRepo := NewSQLiteJobRepository(db) + torrentRepo := NewSQLiteTorrentRepository(db) + ctx := context.Background() + + now := time.Now().Truncate(time.Second) + j := &job.Job{ + ID: "job-tx-missing-file", + Source: "magnet:?xt=urn:btih:1234", + Name: "test.iso", + Status: job.StatusAwaitingSelection, + Type: job.TypeTorrent, + Engine: "qbittorrent", + EngineID: "hash123", + CreatedAt: now, + UpdatedAt: now, + } + _ = jobRepo.Create(ctx, j) + _ = torrentRepo.CreateTorrentJob(ctx, &job.TorrentJobRecord{JobID: j.ID, InfoHash: "1234", Name: "test.iso"}) + + // File index 99 does NOT exist in torrent_files + files := []job.TorrentFileRecord{ + {JobID: j.ID, FileIndex: 99, Path: "missing", Size: 700 * 1024 * 1024, Selected: true, Priority: "normal"}, + } + + j.Status = job.StatusQueued + qe := &job.QueueEntry{JobID: j.ID, Position: 100, Action: job.QueueActionStart} + + err := torrentRepo.PersistTorrentSelectionAndEnqueue(ctx, j, files, nil, qe) + if err == nil { + t.Fatal("expected PersistTorrentSelectionAndEnqueue to fail due to missing file row") + } + + // Verify job remains StatusAwaitingSelection in DB + durableJ, _ := jobRepo.GetByID(ctx, j.ID) + if durableJ.Status != job.StatusAwaitingSelection { + t.Fatalf("expected job status to remain awaiting_selection, got %s", durableJ.Status) + } + + // Verify no queue entry inserted + queueRepo := NewSQLiteQueueRepository(db) + entry, _ := queueRepo.Get(ctx, j.ID) + if entry != nil { + t.Fatal("expected NO queue entry after transaction rollback") + } +} + +func TestSQLiteTransaction_RollbackMissingTorrentJobRow(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + jobRepo := NewSQLiteJobRepository(db) + torrentRepo := NewSQLiteTorrentRepository(db) + ctx := context.Background() + + now := time.Now().Truncate(time.Second) + j := &job.Job{ + ID: "job-tx-missing-tj", + Source: "magnet:?xt=urn:btih:1234", + Name: "test.iso", + Status: job.StatusAwaitingSelection, + Type: job.TypeTorrent, + Engine: "qbittorrent", + EngineID: "hash123", + CreatedAt: now, + UpdatedAt: now, + } + _ = jobRepo.Create(ctx, j) + + // Save file row + files := []job.TorrentFileRecord{ + {JobID: j.ID, FileIndex: 0, Path: "f0", Size: 700 * 1024 * 1024, Selected: true, Priority: "normal"}, + } + _ = torrentRepo.SaveTorrentFiles(ctx, j.ID, files) + + // Provide rec for job-tx-missing-tj which is NOT in torrent_jobs table + rec := &job.TorrentJobRecord{JobID: j.ID, InfoHash: "1234", Name: "test.iso", SeedingPolicy: networkpolicy.SeedingPolicy{Mode: networkpolicy.SeedingModeNone}} + + j.Status = job.StatusQueued + qe := &job.QueueEntry{JobID: j.ID, Position: 100, Action: job.QueueActionStart} + + err := torrentRepo.PersistTorrentSelectionAndEnqueue(ctx, j, files, rec, qe) + if err == nil { + t.Fatal("expected PersistTorrentSelectionAndEnqueue to fail due to missing torrent_jobs row") + } + + // Verify job remains StatusAwaitingSelection + durableJ, _ := jobRepo.GetByID(ctx, j.ID) + if durableJ.Status != job.StatusAwaitingSelection { + t.Fatalf("expected job status to remain awaiting_selection, got %s", durableJ.Status) + } +} + +func TestSQLiteTransaction_RollbackMissingJobRow(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + torrentRepo := NewSQLiteTorrentRepository(db) + ctx := context.Background() + + // Non-existent job in jobs table + j := &job.Job{ + ID: "non-existent-job", + Status: job.StatusQueued, + UpdatedAt: time.Now(), + } + + files := []job.TorrentFileRecord{ + {JobID: j.ID, FileIndex: 0, Path: "f0", Size: 700 * 1024 * 1024, Selected: true, Priority: "normal"}, + } + _ = torrentRepo.SaveTorrentFiles(ctx, j.ID, files) + + qe := &job.QueueEntry{JobID: j.ID, Position: 100, Action: job.QueueActionStart} + + err := torrentRepo.PersistTorrentSelectionAndEnqueue(ctx, j, files, nil, qe) + if err == nil { + t.Fatal("expected PersistTorrentSelectionAndEnqueue to fail due to missing jobs row") + } + + queueRepo := NewSQLiteQueueRepository(db) + entry, _ := queueRepo.Get(ctx, j.ID) + if entry != nil { + t.Fatal("expected NO queue entry after transaction rollback") + } +} diff --git a/internal/job/manager.go b/internal/job/manager.go index 1e3a91a..855e299 100644 --- a/internal/job/manager.go +++ b/internal/job/manager.go @@ -1432,57 +1432,73 @@ func (m *Manager) StartTorrentWithPolicy(ctx context.Context, id string, selecti } } - // 5. Apply seeding policy to engine before persistence + // 5. Apply file priorities to qBittorrent engine while torrent remains stopped + if err := torrentEng.SetFilePriorities(ctx, j.EngineID, selections); err != nil { + return nil, &AppError{Code: ErrEngineError, Message: fmt.Sprintf("failed to set file priorities: %v", err)} + } + + // 6. Apply seeding policy to engine while torrent remains stopped if controller, ok := eng.(ISeedingPolicyController); ok { if err := controller.ApplySeedingPolicy(ctx, j, policy); err != nil { return nil, &AppError{Code: ErrNetworkSettingApplicationFailed, Message: fmt.Sprintf("failed to apply seeding policy: %v", err)} } } - j.TotalBytes = selectedBytes - j.SeedingPolicy = policy - j.SeedAfterComplete = policy.Mode != networkpolicy.SeedingModeNone - j.Status = StatusQueued - j.Error = "" - j.UpdatedAt = time.Now() - - // 6. Transactionally persist selection, seeding policy, job state, and queue entry - var records []TorrentFileRecord - for _, s := range selections { - records = append(records, TorrentFileRecord{ - JobID: id, - FileIndex: s.Index, - Selected: s.Priority != PrioritySkip, - Priority: string(s.Priority), - }) - } - + // 7. Retrieve torrent job record (stop immediately on real error) var rec *TorrentJobRecord if m.torrentRepo != nil { var getErr error rec, getErr = m.torrentRepo.GetTorrentJob(ctx, id) - if getErr == nil && rec != nil { + if getErr != nil && getErr.Error() != "not found" { + return nil, &AppError{Code: ErrInternalError, Message: fmt.Sprintf("failed to get torrent job record: %v", getErr)} + } + if rec != nil { rec = cloneTorrentRecord(rec) - rec.SeedAfterComplete = j.SeedAfterComplete + rec.SeedAfterComplete = policy.Mode != networkpolicy.SeedingModeNone rec.SeedingPolicy = policy } } - qe := &QueueEntry{ - JobID: j.ID, - Position: time.Now().UnixNano(), - Action: QueueActionStart, - EnqueuedAt: time.Now(), - UpdatedAt: time.Now(), - } + // 8. Retrieve next queue position (stop immediately on error) + var pos int64 if m.queueRepo != nil { prio := j.Priority if prio == "" { prio = JobPriorityNormal } - if pos, posErr := m.queueRepo.NextPosition(ctx, prio); posErr == nil { - qe.Position = pos + var posErr error + pos, posErr = m.queueRepo.NextPosition(ctx, prio) + if posErr != nil { + return nil, &AppError{Code: ErrInternalError, Message: fmt.Sprintf("failed to calculate next queue position: %v", posErr)} } + } else { + pos = time.Now().UnixNano() + } + + qe := &QueueEntry{ + JobID: j.ID, + Position: pos, + Action: QueueActionStart, + EnqueuedAt: time.Now(), + UpdatedAt: time.Now(), + } + + j.TotalBytes = selectedBytes + j.SeedingPolicy = policy + j.SeedAfterComplete = policy.Mode != networkpolicy.SeedingModeNone + j.Status = StatusQueued + j.Error = "" + j.UpdatedAt = time.Now() + + // 9. Transactionally persist selection, seeding policy, job state, and queue entry + var records []TorrentFileRecord + for _, s := range selections { + records = append(records, TorrentFileRecord{ + JobID: id, + FileIndex: s.Index, + Selected: s.Priority != PrioritySkip, + Priority: string(s.Priority), + }) } if m.torrentRepo != nil { @@ -1501,11 +1517,6 @@ func (m *Manager) StartTorrentWithPolicy(ctx context.Context, id string, selecti } } - // 7. Apply file priorities to qBittorrent engine after durable persistence - if err := torrentEng.SetFilePriorities(ctx, j.EngineID, selections); err != nil { - return nil, &AppError{Code: ErrEngineError, Message: fmt.Sprintf("failed to set file priorities: %v", err)} - } - // Fallback for test doubles created without scheduler if m.scheduler == nil { if prepErr := m.prepareNetworkDispatch(ctx, j, eng); prepErr != nil { @@ -2564,7 +2575,7 @@ func (m *Manager) GetScheduler() *Scheduler { func (m *Manager) calculatePersistedSelectedTorrentBytes(ctx context.Context, jobID string) (int64, error) { if m.torrentRepo == nil { - return 0, nil + return 0, fmt.Errorf("torrent repository not available") } files, err := m.torrentRepo.GetTorrentFiles(ctx, jobID) if err != nil { @@ -2576,7 +2587,14 @@ func (m *Manager) calculatePersistedSelectedTorrentBytes(ctx context.Context, jo var selectedBytes int64 hasSelected := false for _, f := range files { - if f.Selected || (f.Priority != "" && f.Priority != string(PrioritySkip)) { + isSkipPriority := (f.Priority == string(PrioritySkip) || f.Priority == "skip") + if !f.Selected && !isSkipPriority { + return 0, fmt.Errorf("inconsistent selection record for job %s file index %d: Selected=false but Priority=%s", jobID, f.FileIndex, f.Priority) + } + if f.Selected && isSkipPriority { + return 0, fmt.Errorf("inconsistent selection record for job %s file index %d: Selected=true but Priority=skip", jobID, f.FileIndex) + } + if f.Selected && !isSkipPriority { selectedBytes += f.Size hasSelected = true } @@ -2584,33 +2602,48 @@ func (m *Manager) calculatePersistedSelectedTorrentBytes(ctx context.Context, jo if !hasSelected { return 0, fmt.Errorf("no files selected in torrent selection record for job %s", jobID) } + if selectedBytes <= 0 { + return 0, fmt.Errorf("invalid total selected bytes %d for job %s", selectedBytes, jobID) + } return selectedBytes, nil } func (m *Manager) persistDispatchFailure(ctx context.Context, j *Job, qj *QueuedJob, targetStatus JobStatus, dispatchErr error) error { // 1. Explicitly stop/pause external torrent engine to ensure no background downloading continues + reconcileNeeded := false if j.EngineID != "" { if eng, ok := m.engines.Get(j.Engine); ok { - _ = eng.Pause(ctx, j) + if pauseErr := eng.Pause(ctx, j); pauseErr != nil { + log.Printf("persistDispatchFailure: Pause returned error for job %s: %v", j.ID, pauseErr) + reconcileNeeded = true + } if torrentEng, ok := eng.(ITorrentEngine); ok { - _ = torrentEng.StopDownload(ctx, j.EngineID) - rawState, err := torrentEng.GetRawState(ctx, j.EngineID) - if err == nil && rawState != "" { + if stopErr := torrentEng.StopDownload(ctx, j.EngineID); stopErr != nil { + log.Printf("persistDispatchFailure: StopDownload returned error for job %s: %v", j.ID, stopErr) + reconcileNeeded = true + } + rawState, rawErr := torrentEng.GetRawState(ctx, j.EngineID) + if rawErr != nil { + log.Printf("persistDispatchFailure: GetRawState returned error for job %s: %v", j.ID, rawErr) + reconcileNeeded = true + } else if rawState == "" { + log.Printf("persistDispatchFailure: GetRawState returned empty string for job %s", j.ID) + reconcileNeeded = true + } else { switch rawState { case "stoppedDL", "pausedDL", "stoppedUP", "pausedUP", "paused", "stopped": // Confirmed stopped/paused raw state default: log.Printf("persistDispatchFailure: torrent %s raw state %q after stop attempt", j.EngineID, rawState) - _ = eng.Pause(ctx, j) - rawState2, _ := torrentEng.GetRawState(ctx, j.EngineID) - if rawState2 != "stoppedDL" && rawState2 != "pausedDL" && rawState2 != "stoppedUP" && rawState2 != "pausedUP" && rawState2 != "paused" && rawState2 != "stopped" { - j.NetworkReconcilePending = true - } + reconcileNeeded = true } } } } } + if reconcileNeeded { + j.NetworkReconcilePending = true + } j.Status = targetStatus j.Error = dispatchErr.Error() @@ -2683,13 +2716,28 @@ func (m *Manager) dispatchQueuedJob(ctx context.Context, qj *QueuedJob) error { if j.Type == TypeTorrent { selBytes, selErr := m.calculatePersistedSelectedTorrentBytes(ctx, j.ID) - if selErr == nil && selBytes > 0 { - if j.TotalBytes != selBytes { - j.TotalBytes = selBytes - _ = m.repo.Update(ctx, j) + if selErr != nil { + log.Printf("dispatchQueuedJob: persisted selection validation failed for job %s: %v", j.ID, selErr) + targetStatus := StatusPaused + if qj.Action == QueueActionStart { + targetStatus = StatusFailed } - preflightTotal = selBytes + return m.persistDispatchFailure(ctx, j, qj, targetStatus, fmt.Errorf("invalid or missing persisted file selections: %w", selErr)) } + if selBytes <= 0 { + log.Printf("dispatchQueuedJob: invalid selected bytes for job %s: %d", j.ID, selBytes) + targetStatus := StatusPaused + if qj.Action == QueueActionStart { + targetStatus = StatusFailed + } + return m.persistDispatchFailure(ctx, j, qj, targetStatus, fmt.Errorf("invalid total selected bytes %d", selBytes)) + } + if j.TotalBytes != selBytes { + j.TotalBytes = selBytes + _ = m.repo.Update(ctx, j) + } + preflightTotal = selBytes + if qj.Action == QueueActionStart || preflightCompleted > preflightTotal { preflightCompleted = 0 } diff --git a/internal/job/manager_test.go b/internal/job/manager_test.go index 747ea35..2cdd873 100644 --- a/internal/job/manager_test.go +++ b/internal/job/manager_test.go @@ -490,8 +490,6 @@ func (f *fakeTorrentRepository) PersistTorrentSelectionAndEnqueue(ctx context.Co return nil } - - var _ ITorrentRepository = (*fakeTorrentRepository)(nil) func setupManagerTest(t *testing.T) (*Manager, *fakeEngine, *fakeEventBus, func(), *fakeTorrentEngine) { @@ -2120,8 +2118,9 @@ func TestManager_SetPriority_QueueReadFailure(t *testing.T) { } type fakeQueueRepo struct { - entries map[string]*QueueEntry - getErr error + entries map[string]*QueueEntry + getErr error + nextPosErr error } func (f *fakeQueueRepo) Enqueue(ctx context.Context, entry *QueueEntry) error { @@ -2160,6 +2159,9 @@ func (f *fakeQueueRepo) List(ctx context.Context) ([]QueuedJob, error) { return nil, nil } func (f *fakeQueueRepo) NextPosition(ctx context.Context, priority JobPriority) (int64, error) { + if f.nextPosErr != nil { + return 0, f.nextPosErr + } return 10, nil } func (f *fakeQueueRepo) Reorder(ctx context.Context, priority JobPriority, orderedJobIDs []string) error { diff --git a/internal/job/scheduler_reconciliation_hydration_test.go b/internal/job/scheduler_reconciliation_hydration_test.go index 1ca2b9a..44f7e21 100644 --- a/internal/job/scheduler_reconciliation_hydration_test.go +++ b/internal/job/scheduler_reconciliation_hydration_test.go @@ -61,6 +61,7 @@ func setupReconciliationTestEnv(t *testing.T, initialStatus JobStatus, policy ne DestinationDir: downloadDir, SeedAfterComplete: seedAfter, SeedingPolicy: policy, + TotalBytes: 1000, CreatedAt: time.Now(), UpdatedAt: time.Now(), EngineID: "rec1111222233334444555566667777", @@ -74,6 +75,9 @@ func setupReconciliationTestEnv(t *testing.T, initialStatus JobStatus, policy ne SeedingPolicy: policy, } _ = torrentRepo.CreateTorrentJob(context.Background(), rec) + _ = torrentRepo.SaveTorrentFiles(context.Background(), j.ID, []TorrentFileRecord{ + {JobID: j.ID, FileIndex: 0, Selected: true, Priority: "normal", Size: 1000}, + }) fakeEng := &fakeTorrentEngine{ fakeEngine: &fakeEngine{}, diff --git a/internal/job/seeding_policy_closure_test.go b/internal/job/seeding_policy_closure_test.go index c82a8f2..3cba3ec 100644 --- a/internal/job/seeding_policy_closure_test.go +++ b/internal/job/seeding_policy_closure_test.go @@ -51,6 +51,12 @@ func setupSeedingPolicyJob(t *testing.T, status JobStatus) (*Manager, *fakeJobRe }); err != nil { t.Fatal(err) } + if err := torrentRepo.SaveTorrentFiles(context.Background(), j.ID, []TorrentFileRecord{ + {JobID: j.ID, FileIndex: 0, Selected: true, Priority: "normal", Size: 1000}, + }); err != nil { + t.Fatal(err) + } + j.TotalBytes = 1000 return manager, repo, torrentRepo, bus, engine, j } diff --git a/internal/job/torrent_selection_regression_test.go b/internal/job/torrent_selection_regression_test.go index 7b7b017..1a6ac75 100644 --- a/internal/job/torrent_selection_regression_test.go +++ b/internal/job/torrent_selection_regression_test.go @@ -64,11 +64,15 @@ func (m *regressionMockStorage) CleanupStaleWorkDirs(ctx context.Context, active } type regressionMockEngine struct { - filesToReturn []TorrentFile - startDownloadCall int - stopDownloadCall int - rawStateToReturn string - prioritiesSet []TorrentFileSelection + filesToReturn []TorrentFile + startDownloadCall int + stopDownloadCall int + rawStateToReturn string + prioritiesSet []TorrentFileSelection + setFilePrioritiesErr error + stopDownloadErr error + getRawStateErr error + pauseErr error } func (m *regressionMockEngine) Capabilities() networkpolicy.EngineCapabilities { @@ -77,7 +81,12 @@ func (m *regressionMockEngine) Capabilities() networkpolicy.EngineCapabilities { func (m *regressionMockEngine) Start(ctx context.Context, j *Job, downloadDir string) (string, error) { return j.EngineID, nil } -func (m *regressionMockEngine) Pause(ctx context.Context, j *Job) error { return nil } +func (m *regressionMockEngine) Pause(ctx context.Context, j *Job) error { + if m.pauseErr != nil { + return m.pauseErr + } + return nil +} func (m *regressionMockEngine) Resume(ctx context.Context, j *Job) error { return nil } func (m *regressionMockEngine) Cancel(ctx context.Context, j *Job) error { return nil } func (m *regressionMockEngine) Status(ctx context.Context, j *Job) (*EngineStatus, error) { @@ -97,6 +106,9 @@ func (m *regressionMockEngine) GetFiles(ctx context.Context, infoHash string) ([ return m.filesToReturn, nil } func (m *regressionMockEngine) SetFilePriorities(ctx context.Context, infoHash string, selections []TorrentFileSelection) error { + if m.setFilePrioritiesErr != nil { + return m.setFilePrioritiesErr + } m.prioritiesSet = selections return nil } @@ -106,6 +118,9 @@ func (m *regressionMockEngine) StartDownload(ctx context.Context, infoHash strin } func (m *regressionMockEngine) StopDownload(ctx context.Context, infoHash string) error { m.stopDownloadCall++ + if m.stopDownloadErr != nil { + return m.stopDownloadErr + } m.rawStateToReturn = "stoppedDL" return nil } @@ -116,6 +131,9 @@ func (m *regressionMockEngine) GetTorrentInfo(ctx context.Context, infoHash stri return &TorrentInfo{Name: "test", InfoHash: infoHash, TotalSize: 23192823398}, nil } func (m *regressionMockEngine) GetRawState(ctx context.Context, infoHash string) (string, error) { + if m.getRawStateErr != nil { + return "", m.getRawStateErr + } if m.rawStateToReturn == "" { return "downloading", nil } @@ -350,3 +368,326 @@ func TestUpdateJobFromEngine_DoesNotRestoreFullSize(t *testing.T) { t.Fatalf("expected TotalBytes to remain selected size %d, but was overwritten with %d", selectedSize, j.TotalBytes) } } + +func TestSetFilePrioritiesFailure_LeavesNoRunnableQueue(t *testing.T) { + jobRepo := newFakeJobRepository() + queueRepo := &fakeQueueRepo{entries: make(map[string]*QueueEntry)} + torrentRepo := newFakeTorrentRepository(jobRepo, queueRepo) + eng := ®ressionMockEngine{ + filesToReturn: []TorrentFile{ + {Index: 0, Path: "f0", Size: 700 * 1024 * 1024}, + }, + setFilePrioritiesErr: errors.New("qBittorrent connection error"), + } + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + + mgr := NewManager(jobRepo, reg, newFakeEventBus(), t.TempDir(), torrentRepo) + mgr.SetQueueRepository(queueRepo) + + j := &Job{ + ID: "job-prio-fail", + Type: TypeTorrent, + Engine: "qbittorrent", + EngineID: "hashpf", + Status: StatusAwaitingSelection, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + _ = jobRepo.Create(context.Background(), j) + + selections := []TorrentFileSelection{ + {Index: 0, Priority: PriorityNormal}, + } + + _, err := mgr.StartTorrentWithPolicy(context.Background(), j.ID, selections, networkpolicy.SeedingPolicy{Mode: networkpolicy.SeedingModeNone}) + if err == nil { + t.Fatal("expected StartTorrentWithPolicy to fail when SetFilePriorities returns error") + } + + // Verify job remains StatusAwaitingSelection in DB + durableJ, _ := jobRepo.GetByID(context.Background(), j.ID) + if durableJ.Status != StatusAwaitingSelection { + t.Fatalf("expected job status to remain StatusAwaitingSelection on priority failure, got %s", durableJ.Status) + } + + // Verify no runnable queue entry exists + qe, _ := queueRepo.Get(context.Background(), j.ID) + if qe != nil { + t.Fatalf("expected NO queue entry when SetFilePriorities fails, got %#v", qe) + } +} + +func TestStartTorrent_GetTorrentJobFailureAborts(t *testing.T) { + jobRepo := newFakeJobRepository() + torrentRepo := newFakeTorrentRepository(jobRepo) + torrentRepo.getErr = errors.New("db get error") + + eng := ®ressionMockEngine{ + filesToReturn: []TorrentFile{ + {Index: 0, Path: "f0", Size: 700 * 1024 * 1024}, + }, + } + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + + mgr := NewManager(jobRepo, reg, newFakeEventBus(), t.TempDir(), torrentRepo) + + j := &Job{ + ID: "job-getrec-fail", + Type: TypeTorrent, + Engine: "qbittorrent", + EngineID: "hashgr", + Status: StatusAwaitingSelection, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + _ = jobRepo.Create(context.Background(), j) + + selections := []TorrentFileSelection{ + {Index: 0, Priority: PriorityNormal}, + } + + _, err := mgr.StartTorrentWithPolicy(context.Background(), j.ID, selections, networkpolicy.SeedingPolicy{Mode: networkpolicy.SeedingModeNone}) + if err == nil { + t.Fatal("expected StartTorrentWithPolicy to fail when GetTorrentJob returns error") + } + + durableJ, _ := jobRepo.GetByID(context.Background(), j.ID) + if durableJ.Status != StatusAwaitingSelection { + t.Fatalf("expected job status = StatusAwaitingSelection, got %s", durableJ.Status) + } +} + +func TestStartTorrent_NextPositionFailureAborts(t *testing.T) { + jobRepo := newFakeJobRepository() + queueRepo := &fakeQueueRepo{entries: make(map[string]*QueueEntry), nextPosErr: errors.New("queue error")} + torrentRepo := newFakeTorrentRepository(jobRepo, queueRepo) + + eng := ®ressionMockEngine{ + filesToReturn: []TorrentFile{ + {Index: 0, Path: "f0", Size: 700 * 1024 * 1024}, + }, + } + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + + mgr := NewManager(jobRepo, reg, newFakeEventBus(), t.TempDir(), torrentRepo) + mgr.SetQueueRepository(queueRepo) + + j := &Job{ + ID: "job-pos-fail", + Type: TypeTorrent, + Engine: "qbittorrent", + EngineID: "hashpos", + Status: StatusAwaitingSelection, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + _ = jobRepo.Create(context.Background(), j) + + selections := []TorrentFileSelection{ + {Index: 0, Priority: PriorityNormal}, + } + + _, err := mgr.StartTorrentWithPolicy(context.Background(), j.ID, selections, networkpolicy.SeedingPolicy{Mode: networkpolicy.SeedingModeNone}) + if err == nil { + t.Fatal("expected StartTorrentWithPolicy to fail when NextPosition returns error") + } + + durableJ, _ := jobRepo.GetByID(context.Background(), j.ID) + if durableJ.Status != StatusAwaitingSelection { + t.Fatalf("expected job status = StatusAwaitingSelection, got %s", durableJ.Status) + } +} + +func TestDispatchQueuedJob_MissingSelectionRecordsAborts(t *testing.T) { + jobRepo := newFakeJobRepository() + queueRepo := &fakeQueueRepo{entries: make(map[string]*QueueEntry)} + torrentRepo := newFakeTorrentRepository(jobRepo, queueRepo) + + eng := ®ressionMockEngine{} + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + + mgr := NewManager(jobRepo, reg, newFakeEventBus(), t.TempDir(), torrentRepo) + mgr.SetQueueRepository(queueRepo) + + j := &Job{ + ID: "job-no-selections", + Type: TypeTorrent, + Engine: "qbittorrent", + EngineID: "hashnosel", + Status: StatusQueued, + TotalBytes: 1400 * 1024 * 1024, + } + _ = jobRepo.Create(context.Background(), j) + _ = torrentRepo.CreateTorrentJob(context.Background(), &TorrentJobRecord{JobID: j.ID, InfoHash: j.EngineID}) + qj := &QueuedJob{JobID: j.ID, Action: QueueActionStart} + + err := mgr.DispatchQueuedJob(context.Background(), qj) + if err == nil { + t.Fatal("expected DispatchQueuedJob to fail when torrent selection records are missing") + } + + updatedJ, _ := jobRepo.GetByID(context.Background(), j.ID) + if updatedJ.Status != StatusFailed { + t.Fatalf("expected job status = failed, got %s", updatedJ.Status) + } +} + +func TestDispatchQueuedJob_InconsistentSelectionRecordsAborts(t *testing.T) { + jobRepo := newFakeJobRepository() + queueRepo := &fakeQueueRepo{entries: make(map[string]*QueueEntry)} + torrentRepo := newFakeTorrentRepository(jobRepo, queueRepo) + + // Save inconsistent record: Selected=false but Priority=normal + inconsistentFiles := []TorrentFileRecord{ + {JobID: "job-inconsistent", FileIndex: 0, Selected: false, Priority: "normal", Size: 700 * 1024 * 1024}, + } + _ = torrentRepo.CreateTorrentJob(context.Background(), &TorrentJobRecord{JobID: "job-inconsistent", InfoHash: "hashincon"}) + _ = torrentRepo.SaveTorrentFiles(context.Background(), "job-inconsistent", inconsistentFiles) + + eng := ®ressionMockEngine{} + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + + mgr := NewManager(jobRepo, reg, newFakeEventBus(), t.TempDir(), torrentRepo) + mgr.SetQueueRepository(queueRepo) + + j := &Job{ + ID: "job-inconsistent", + Type: TypeTorrent, + Engine: "qbittorrent", + EngineID: "hashincon", + Status: StatusQueued, + TotalBytes: 700 * 1024 * 1024, + } + _ = jobRepo.Create(context.Background(), j) + qj := &QueuedJob{JobID: j.ID, Action: QueueActionStart} + + err := mgr.DispatchQueuedJob(context.Background(), qj) + if err == nil { + t.Fatal("expected DispatchQueuedJob to fail on inconsistent selection records") + } + + updatedJ, _ := jobRepo.GetByID(context.Background(), j.ID) + if updatedJ.Status != StatusFailed { + t.Fatalf("expected job status = failed, got %s", updatedJ.Status) + } +} + +func TestPersistDispatchFailure_GetRawStateErrorMarksReconciliationPending(t *testing.T) { + jobRepo := newFakeJobRepository() + torrentRepo := newFakeTorrentRepository(jobRepo) + eng := ®ressionMockEngine{ + getRawStateErr: errors.New("engine RPC timeout"), + } + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + + mgr := NewManager(jobRepo, reg, newFakeEventBus(), t.TempDir(), torrentRepo) + + j := &Job{ + ID: "job-rawerr", + Type: TypeTorrent, + Engine: "qbittorrent", + EngineID: "hashrawerr", + Status: StatusQueued, + TotalBytes: 1400 * 1024 * 1024, + } + _ = jobRepo.Create(context.Background(), j) + + qj := &QueuedJob{JobID: j.ID, Action: QueueActionStart} + _ = mgr.persistDispatchFailure(context.Background(), j, qj, StatusFailed, errors.New("preflight failed")) + + updatedJ, _ := jobRepo.GetByID(context.Background(), j.ID) + if !updatedJ.NetworkReconcilePending { + t.Fatal("expected NetworkReconcilePending = true when GetRawState returns error") + } +} + +func TestPersistDispatchFailure_StopDownloadErrorMarksReconciliationPending(t *testing.T) { + jobRepo := newFakeJobRepository() + torrentRepo := newFakeTorrentRepository(jobRepo) + eng := ®ressionMockEngine{ + stopDownloadErr: errors.New("stop download failed"), + } + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + + mgr := NewManager(jobRepo, reg, newFakeEventBus(), t.TempDir(), torrentRepo) + + j := &Job{ + ID: "job-stoperr", + Type: TypeTorrent, + Engine: "qbittorrent", + EngineID: "hashstoperr", + Status: StatusQueued, + TotalBytes: 1400 * 1024 * 1024, + } + _ = jobRepo.Create(context.Background(), j) + + qj := &QueuedJob{JobID: j.ID, Action: QueueActionStart} + _ = mgr.persistDispatchFailure(context.Background(), j, qj, StatusFailed, errors.New("preflight failed")) + + updatedJ, _ := jobRepo.GetByID(context.Background(), j.ID) + if !updatedJ.NetworkReconcilePending { + t.Fatal("expected NetworkReconcilePending = true when StopDownload returns error") + } +} + +func TestDispatchQueuedJob_SuccessfulSelectedSizeCondition(t *testing.T) { + jobRepo := newFakeJobRepository() + queueRepo := &fakeQueueRepo{entries: make(map[string]*QueueEntry)} + torrentRepo := newFakeTorrentRepository(jobRepo, queueRepo) + + // 2 selected files @ 700 MiB each (~1.4 GiB selected total) + files := []TorrentFileRecord{ + {JobID: "job-repro-success", FileIndex: 0, Selected: true, Priority: "normal", Size: 700 * 1024 * 1024}, + {JobID: "job-repro-success", FileIndex: 1, Selected: true, Priority: "normal", Size: 700 * 1024 * 1024}, + {JobID: "job-repro-success", FileIndex: 2, Selected: false, Priority: "skip", Size: 20 * 1024 * 1024 * 1024}, // 20 GiB skipped file + } + _ = torrentRepo.CreateTorrentJob(context.Background(), &TorrentJobRecord{ + JobID: "job-repro-success", + InfoHash: "hashrepro", + Name: "repro", + }) + _ = torrentRepo.SaveTorrentFiles(context.Background(), "job-repro-success", files) + + eng := ®ressionMockEngine{ + rawStateToReturn: "stoppedDL", + } + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + + // Free space: 14.9 GiB (16,000,000,000 bytes) + // Full torrent: 21.4 GiB (22,870,000,000 bytes) -> free space < full torrent size! + // Selected payload: 1.4 GiB (1,468,006,400 bytes) -> free space > selected + reserve! + storageSvc := ®ressionMockStorage{ + freeBytes: 16000000000, + } + + mgr := NewManager(jobRepo, reg, newFakeEventBus(), t.TempDir(), torrentRepo) + mgr.SetStorageService(storageSvc) + mgr.SetQueueRepository(queueRepo) + + j := &Job{ + ID: "job-repro-success", + Type: TypeTorrent, + Engine: "qbittorrent", + EngineID: "hashrepro", + Status: StatusQueued, + TotalBytes: 1468006400, + TorrentInfo: &TorrentInfo{ + TotalSize: 22870000000, + }, + } + _ = jobRepo.Create(context.Background(), j) + qj := &QueuedJob{JobID: j.ID, Action: QueueActionStart} + + err := mgr.DispatchQueuedJob(context.Background(), qj) + if err != nil { + t.Fatalf("expected DispatchQueuedJob to succeed when free disk > selected payload + reserve, got %v", err) + } + + updatedJ, _ := jobRepo.GetByID(context.Background(), j.ID) + if updatedJ.Status != StatusDownloading { + t.Fatalf("expected job status = downloading, got %s", updatedJ.Status) + } + if updatedJ.TotalBytes != 1468006400 { + t.Fatalf("expected TotalBytes = 1.4 GiB (%d), got %d", 1468006400, updatedJ.TotalBytes) + } +} From 3f0c374a55ab62907446b728c9b6b5bb4b4c904f Mon Sep 17 00:00:00 2001 From: Kavya Arora Date: Wed, 5 Aug 2026 18:05:55 +0530 Subject: [PATCH 05/15] fix(storage): map storage preflight errors to AppError and update UI/API logging --- internal/api/handler.go | 7 + internal/api/handler_error_test.go | 66 +++++++++ internal/job/errors.go | 29 ++++ internal/job/manager.go | 2 +- .../job/torrent_selection_regression_test.go | 125 ++++++++++++++++-- web/src/api.ts | 14 +- .../components/TorrentFileSelector.test.tsx | 52 +++++++- web/src/components/TorrentFileSelector.tsx | 38 +++++- 8 files changed, 314 insertions(+), 19 deletions(-) create mode 100644 internal/api/handler_error_test.go diff --git a/internal/api/handler.go b/internal/api/handler.go index 44c26a3..c323871 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "log" "net/http" "os" "path/filepath" @@ -433,10 +434,16 @@ func writeAppError(w http.ResponseWriter, err error) { httpStatus = http.StatusServiceUnavailable case job.ErrSecretStorageUnavailable: httpStatus = http.StatusServiceUnavailable + case job.ErrInsufficientDiskSpace: + httpStatus = http.StatusInsufficientStorage + case job.ErrStorageError: + httpStatus = http.StatusInternalServerError } + log.Printf("api: %s: %s", appErr.Code, appErr.Message) writeError(w, httpStatus, appErr.Code, appErr.Message) return } + log.Printf("api: unhandled error: type=%T err=%v", err, err) writeError(w, http.StatusInternalServerError, job.ErrInternalError, "an internal error occurred") } diff --git a/internal/api/handler_error_test.go b/internal/api/handler_error_test.go new file mode 100644 index 0000000..94404b9 --- /dev/null +++ b/internal/api/handler_error_test.go @@ -0,0 +1,66 @@ +package api + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "downloader/internal/job" +) + +func TestWriteAppError_InsufficientDiskSpace_Returns507(t *testing.T) { + w := httptest.NewRecorder() + appErr := &job.AppError{ + Code: job.ErrInsufficientDiskSpace, + Message: "INSUFFICIENT_DISK_SPACE: insufficient free space in /downloads (free: 16000000000, required: 23000000000, reserve: 1073741824, remaining: 21474836480)", + } + writeAppError(w, appErr) + + if w.Code != http.StatusInsufficientStorage { + t.Fatalf("expected HTTP 507, got %d", w.Code) + } + body := w.Body.String() + if !strings.Contains(body, "INSUFFICIENT_DISK_SPACE") { + t.Fatalf("response body should contain INSUFFICIENT_DISK_SPACE, got: %s", body) + } + if !strings.Contains(body, "free:") { + t.Fatalf("response body should contain detailed disk info, got: %s", body) + } +} + +func TestWriteAppError_StorageError_Returns500(t *testing.T) { + w := httptest.NewRecorder() + appErr := &job.AppError{ + Code: job.ErrStorageError, + Message: "STORAGE_ERROR: failed to create directory", + } + writeAppError(w, appErr) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected HTTP 500, got %d", w.Code) + } +} + +func TestWriteAppError_UnhandledError_ReturnsSanitized500(t *testing.T) { + w := httptest.NewRecorder() + rawErr := fmt.Errorf("some raw internal error with path /secrets/key") + writeAppError(w, rawErr) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected HTTP 500, got %d", w.Code) + } + body := w.Body.String() + // Should NOT expose the raw error message + if strings.Contains(body, "/secrets/key") { + t.Fatalf("raw error details leaked to client: %s", body) + } + // Should contain sanitized message + if !strings.Contains(body, "an internal error occurred") { + t.Fatalf("expected sanitized message, got: %s", body) + } + if !strings.Contains(body, "INTERNAL_ERROR") { + t.Fatalf("expected INTERNAL_ERROR code, got: %s", body) + } +} diff --git a/internal/job/errors.go b/internal/job/errors.go index a672f4a..c2692bc 100644 --- a/internal/job/errors.go +++ b/internal/job/errors.go @@ -3,6 +3,8 @@ package job import ( "errors" "fmt" + + "downloader/internal/storage" ) // AppError represents an application-level error with a code. @@ -138,3 +140,30 @@ func (e *DispatchPersistenceError) Unwrap() error { func (e *DispatchPersistenceError) Is(target error) bool { return target == ErrDispatchPersistenceFailed } + +// mapStorageError translates a storage-package error into an *AppError. +// It uses errors.Is to match sentinel errors and preserves the detailed +// message (e.g. free bytes, required bytes, reserve) for the API response. +func mapStorageError(err error) error { + if err == nil { + return nil + } + switch { + case errors.Is(err, storage.ErrInsufficientDiskSpace): + return &AppError{Code: ErrInsufficientDiskSpace, Message: err.Error()} + case errors.Is(err, storage.ErrInvalidStorageSelection): + return &AppError{Code: ErrInvalidStorageSelection, Message: err.Error()} + case errors.Is(err, storage.ErrInvalidDestination): + return &AppError{Code: ErrInvalidDestination, Message: err.Error()} + case errors.Is(err, storage.ErrCategoryNotFound): + return &AppError{Code: ErrCategoryNotFound, Message: err.Error()} + case errors.Is(err, storage.ErrCategoryNameConflict): + return &AppError{Code: ErrCategoryNameConflict, Message: err.Error()} + case errors.Is(err, storage.ErrFileConflict): + return &AppError{Code: ErrFileConflict, Message: err.Error()} + case errors.Is(err, storage.ErrStorageError): + return &AppError{Code: ErrStorageError, Message: err.Error()} + default: + return &AppError{Code: ErrInternalError, Message: err.Error()} + } +} diff --git a/internal/job/manager.go b/internal/job/manager.go index 855e299..fbe31ec 100644 --- a/internal/job/manager.go +++ b/internal/job/manager.go @@ -1428,7 +1428,7 @@ func (m *Manager) StartTorrentWithPolicy(ctx context.Context, id string, selecti } if m.storageService != nil { if preflightErr := m.storageService.Preflight(ctx, targetDir, j.WorkDir, selectedBytes, 0); preflightErr != nil { - return nil, preflightErr + return nil, mapStorageError(preflightErr) } } diff --git a/internal/job/torrent_selection_regression_test.go b/internal/job/torrent_selection_regression_test.go index 1a6ac75..edfa7a0 100644 --- a/internal/job/torrent_selection_regression_test.go +++ b/internal/job/torrent_selection_regression_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "testing" "time" @@ -41,8 +42,8 @@ func (m *regressionMockStorage) Preflight(ctx context.Context, destinationDir, w reserve := int64(104857600) // 100 MiB req := rem + reserve if m.freeBytes > 0 && m.freeBytes < req { - return fmt.Errorf("insufficient free space in %s (free: %d, required: %d, reserve: %d, remaining: %d)", - destinationDir, m.freeBytes, req, reserve, rem) + return fmt.Errorf("%w: insufficient free space in %s (free: %d, required: %d, reserve: %d, remaining: %d)", + storage.ErrInsufficientDiskSpace, destinationDir, m.freeBytes, req, reserve, rem) } return nil } @@ -64,15 +65,16 @@ func (m *regressionMockStorage) CleanupStaleWorkDirs(ctx context.Context, active } type regressionMockEngine struct { - filesToReturn []TorrentFile - startDownloadCall int - stopDownloadCall int - rawStateToReturn string - prioritiesSet []TorrentFileSelection - setFilePrioritiesErr error - stopDownloadErr error - getRawStateErr error - pauseErr error + filesToReturn []TorrentFile + startDownloadCall int + stopDownloadCall int + setFilePrioritiesCalls int + rawStateToReturn string + prioritiesSet []TorrentFileSelection + setFilePrioritiesErr error + stopDownloadErr error + getRawStateErr error + pauseErr error } func (m *regressionMockEngine) Capabilities() networkpolicy.EngineCapabilities { @@ -106,6 +108,7 @@ func (m *regressionMockEngine) GetFiles(ctx context.Context, infoHash string) ([ return m.filesToReturn, nil } func (m *regressionMockEngine) SetFilePriorities(ctx context.Context, infoHash string, selections []TorrentFileSelection) error { + m.setFilePrioritiesCalls++ if m.setFilePrioritiesErr != nil { return m.setFilePrioritiesErr } @@ -282,17 +285,40 @@ func TestDiskPreflightBeforeStart_FailureCausesZeroStartDownloadCalls(t *testing _, err := mgr.StartTorrentWithPolicy(context.Background(), j.ID, selections, networkpolicy.SeedingPolicy{Mode: networkpolicy.SeedingModeNone}) if err == nil { t.Fatal("expected StartTorrentWithPolicy to fail preflight due to insufficient space") - } else { - t.Logf("StartTorrentWithPolicy error: %v", err) } + // Verify the returned error is a proper *AppError with INSUFFICIENT_DISK_SPACE code + var appErr *AppError + if !errors.As(err, &appErr) { + t.Fatalf("expected *AppError, got %T: %v", err, err) + } + if appErr.Code != ErrInsufficientDiskSpace { + t.Fatalf("expected error code %s, got %s", ErrInsufficientDiskSpace, appErr.Code) + } + // Verify the message contains detailed disk space info + if !strings.Contains(appErr.Message, "free:") || !strings.Contains(appErr.Message, "required:") || !strings.Contains(appErr.Message, "reserve:") || !strings.Contains(appErr.Message, "remaining:") { + t.Fatalf("expected detailed disk space message, got: %s", appErr.Message) + } + t.Logf("StartTorrentWithPolicy error: %v", err) + if eng.startDownloadCall != 0 { t.Fatalf("expected 0 StartDownload calls on preflight failure, got %d", eng.startDownloadCall) } + // Verify SetFilePriorities was NOT called (preflight is before priorities) + if eng.setFilePrioritiesCalls != 0 { + t.Fatalf("expected 0 SetFilePriorities calls on preflight failure, got %d", eng.setFilePrioritiesCalls) + } + if storageSvc.lastPreflightTotal != 1400*1024*1024 { t.Fatalf("expected preflight to receive selected total (1.4 GB), got %d", storageSvc.lastPreflightTotal) } + + // Verify job remains in awaiting_selection + updatedJob, _ := jobRepo.GetByID(context.Background(), j.ID) + if updatedJob.Status != StatusAwaitingSelection { + t.Fatalf("expected job to remain in awaiting_selection, got %s", updatedJob.Status) + } } func TestPersistDispatchFailure_StopsExternalDownloadingEngine(t *testing.T) { @@ -691,3 +717,76 @@ func TestDispatchQueuedJob_SuccessfulSelectedSizeCondition(t *testing.T) { t.Fatalf("expected TotalBytes = 1.4 GiB (%d), got %d", 1468006400, updatedJ.TotalBytes) } } + +func TestMapStorageError_AllSentinels(t *testing.T) { + tests := []struct { + name string + err error + expectedCode string + }{ + { + name: "ErrInsufficientDiskSpace", + err: fmt.Errorf("%w: insufficient free space (free: 100, required: 200, reserve: 50, remaining: 150)", storage.ErrInsufficientDiskSpace), + expectedCode: ErrInsufficientDiskSpace, + }, + { + name: "ErrInvalidStorageSelection", + err: fmt.Errorf("%w: bad selection", storage.ErrInvalidStorageSelection), + expectedCode: ErrInvalidStorageSelection, + }, + { + name: "ErrInvalidDestination", + err: fmt.Errorf("%w: bad dest", storage.ErrInvalidDestination), + expectedCode: ErrInvalidDestination, + }, + { + name: "ErrCategoryNotFound", + err: fmt.Errorf("%w: no such category", storage.ErrCategoryNotFound), + expectedCode: ErrCategoryNotFound, + }, + { + name: "ErrCategoryNameConflict", + err: fmt.Errorf("%w: name conflict", storage.ErrCategoryNameConflict), + expectedCode: ErrCategoryNameConflict, + }, + { + name: "ErrFileConflict", + err: fmt.Errorf("%w: file conflict", storage.ErrFileConflict), + expectedCode: ErrFileConflict, + }, + { + name: "ErrStorageError", + err: fmt.Errorf("%w: storage failure", storage.ErrStorageError), + expectedCode: ErrStorageError, + }, + { + name: "unknown error falls back to INTERNAL_ERROR", + err: fmt.Errorf("something completely unexpected"), + expectedCode: ErrInternalError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mapped := mapStorageError(tt.err) + appErr, ok := mapped.(*AppError) + if !ok { + t.Fatalf("expected *AppError, got %T", mapped) + } + if appErr.Code != tt.expectedCode { + t.Fatalf("expected code %s, got %s", tt.expectedCode, appErr.Code) + } + // Verify the original message is preserved + if appErr.Message != tt.err.Error() { + t.Fatalf("expected message %q, got %q", tt.err.Error(), appErr.Message) + } + }) + } +} + +func TestMapStorageError_Nil(t *testing.T) { + result := mapStorageError(nil) + if result != nil { + t.Fatalf("expected nil, got %v", result) + } +} diff --git a/web/src/api.ts b/web/src/api.ts index 6f8c16e..f23a756 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -24,10 +24,22 @@ import type { const API_BASE = '/api/v1'; +/** Error class that carries the backend error code alongside the message. */ +export class ApiResponseError extends Error { + public readonly code: string; + constructor(code: string, message: string) { + super(message); + this.name = 'ApiResponseError'; + this.code = code; + } +} + async function handleResponse(res: Response): Promise { if (!res.ok) { const body = await res.json().catch(() => null) as ApiError | null; - throw new Error(body?.error?.message || `Request failed with status ${res.status}`); + const code = body?.error?.code ?? 'UNKNOWN'; + const message = body?.error?.message || `Request failed with status ${res.status}`; + throw new ApiResponseError(code, message); } return res.json(); } diff --git a/web/src/components/TorrentFileSelector.test.tsx b/web/src/components/TorrentFileSelector.test.tsx index ac06743..414ce5d 100644 --- a/web/src/components/TorrentFileSelector.test.tsx +++ b/web/src/components/TorrentFileSelector.test.tsx @@ -1,7 +1,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Job, SeedingPolicy } from '../types'; -import { TorrentFileSelector, normalizeSeedingMode } from './TorrentFileSelector'; +import { TorrentFileSelector, normalizeSeedingMode, formatDiskSpaceError } from './TorrentFileSelector'; +import { ApiResponseError } from '../api'; import * as api from '../api'; vi.mock('../api', async () => { @@ -164,4 +165,53 @@ describe('TorrentFileSelector seeding policy & submission', () => { resolvePromise(); await waitFor(() => expect(pendingOnStart).toHaveBeenCalled()); }); + + it('displays human-readable disk space message for INSUFFICIENT_DISK_SPACE errors', async () => { + const diskSpaceError = new ApiResponseError( + 'INSUFFICIENT_DISK_SPACE', + 'INSUFFICIENT_DISK_SPACE: insufficient free space in /downloads (free: 16000000000, required: 23622320128, reserve: 1073741824, remaining: 22548578304)' + ); + const failingOnStart = vi.fn().mockRejectedValue(diskSpaceError); + await renderSelector({ mode: 'none' }, failingOnStart); + + fireEvent.click(screen.getByRole('button', { name: 'Start Download' })); + + const errorEl = await screen.findByTestId('torrent-submit-error'); + expect(errorEl.textContent).toContain('Insufficient disk space'); + expect(errorEl.textContent).toContain('Available:'); + expect(errorEl.textContent).toContain('Selected remaining:'); + expect(errorEl.textContent).toContain('Reserved:'); + expect(errorEl.textContent).toContain('Required:'); + // Should NOT contain the raw "an internal error occurred" fallback + expect(errorEl.textContent).not.toContain('an internal error occurred'); + }); + + it('shows raw error message for non-disk-space errors', async () => { + const otherError = new ApiResponseError('ENGINE_ERROR', 'qBittorrent daemon unreachable'); + const failingOnStart = vi.fn().mockRejectedValue(otherError); + await renderSelector({ mode: 'none' }, failingOnStart); + + fireEvent.click(screen.getByRole('button', { name: 'Start Download' })); + + await screen.findByText('qBittorrent daemon unreachable'); + }); +}); + +describe('formatDiskSpaceError', () => { + it('parses backend message into human-readable format', () => { + const message = 'INSUFFICIENT_DISK_SPACE: insufficient free space in /downloads (free: 16000000000, required: 23622320128, reserve: 1073741824, remaining: 22548578304)'; + const result = formatDiskSpaceError(message); + expect(result).toContain('Insufficient disk space'); + expect(result).toContain('Available: 14.9 GiB'); + expect(result).toContain('Selected remaining: 21.0 GiB'); + expect(result).toContain('Reserved: 1.0 GiB'); + expect(result).toContain('Required: 22.0 GiB'); + }); + + it('falls back gracefully when message format is unexpected', () => { + const message = 'some unexpected disk space error'; + const result = formatDiskSpaceError(message); + expect(result).toContain('Insufficient disk space'); + expect(result).toContain(message); + }); }); diff --git a/web/src/components/TorrentFileSelector.tsx b/web/src/components/TorrentFileSelector.tsx index 442ef73..0c5baa5 100644 --- a/web/src/components/TorrentFileSelector.tsx +++ b/web/src/components/TorrentFileSelector.tsx @@ -1,6 +1,6 @@ import { File, Folder, LoaderCircle, X } from 'lucide-react'; import { useEffect, useState } from 'react'; -import { getTorrentFiles } from '../api'; +import { getTorrentFiles, ApiResponseError } from '../api'; import type { Job, SeedingMode, @@ -10,6 +10,34 @@ import type { TorrentFileSelection, } from '../types'; +function formatBytesHuman(bytes: number): string { + if (bytes <= 0) return '0 B'; + const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; + const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + return `${(bytes / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`; +} + +/** + * Parse the backend INSUFFICIENT_DISK_SPACE message and return a user-friendly + * multi-line description. The backend message format is: + * "INSUFFICIENT_DISK_SPACE: insufficient free space in (free: , required: , reserve: , remaining: )" + */ +export function formatDiskSpaceError(message: string): string { + const match = message.match(/free:\s*(\d+),\s*required:\s*(\d+),\s*reserve:\s*(\d+),\s*remaining:\s*(\d+)/); + if (!match) return `Insufficient disk space\n${message}`; + const free = Number(match[1]); + const required = Number(match[2]); + const reserve = Number(match[3]); + const remaining = Number(match[4]); + return [ + 'Insufficient disk space', + `Available: ${formatBytesHuman(free)}`, + `Selected remaining: ${formatBytesHuman(remaining)}`, + `Reserved: ${formatBytesHuman(reserve)}`, + `Required: ${formatBytesHuman(required)}`, + ].join('\n'); +} + interface TorrentFileSelectorProps { job: Job; onStart: (jobId: string, files: TorrentFileSelection[], seedingPolicy: SeedingPolicy) => Promise; @@ -86,7 +114,11 @@ export function TorrentFileSelector({ job, onStart, onClose }: TorrentFileSelect try { await onStart(job.id, selection, seedingPolicy); } catch (reason: unknown) { - setSubmitError(reason instanceof Error ? reason.message : String(reason)); + if (reason instanceof ApiResponseError && reason.code === 'INSUFFICIENT_DISK_SPACE') { + setSubmitError(formatDiskSpaceError(reason.message)); + } else { + setSubmitError(reason instanceof Error ? reason.message : String(reason)); + } setIsSubmitting(false); } }; @@ -112,7 +144,7 @@ export function TorrentFileSelector({ job, onStart, onClose }: TorrentFileSelect {(error || submitError) && ( -
+
{submitError || error}
)} From 66538eb27fbeaa1c40ca0b9e597f275b2494dd73 Mon Sep 17 00:00:00 2001 From: Kavya Arora Date: Wed, 5 Aug 2026 18:43:11 +0530 Subject: [PATCH 06/15] fix(torrent): propagate live torrent runtime stats and deep copy TorrentInfo --- internal/job/manager.go | 11 +- internal/job/torrent_controls.go | 39 +++- internal/job/torrent_stats_test.go | 213 +++++++++++++++++++++ web/src/components/JobCard.test.tsx | 106 ++++++++++ web/src/components/job-card/JobDetails.tsx | 11 +- 5 files changed, 368 insertions(+), 12 deletions(-) create mode 100644 internal/job/torrent_stats_test.go diff --git a/internal/job/manager.go b/internal/job/manager.go index fbe31ec..5c15673 100644 --- a/internal/job/manager.go +++ b/internal/job/manager.go @@ -2061,6 +2061,9 @@ func (m *Manager) GetEngine(name string) (IEngine, bool) { // UpdateJobFromEngine updates a job with engine status and persists/publishes. func (m *Manager) UpdateJobFromEngine(ctx context.Context, j *Job, status *EngineStatus, persistNow bool) { + if j == nil || status == nil { + return + } if j.Type == TypeTorrent && j.Status != StatusAwaitingSelection { if j.TorrentInfo != nil && j.TorrentInfo.TotalSize > 0 && status.TotalBytes == j.TorrentInfo.TotalSize && j.TotalBytes > 0 && j.TotalBytes != j.TorrentInfo.TotalSize { // Retain authoritative selected TotalBytes instead of reverting to full torrent size @@ -2078,6 +2081,8 @@ func (m *Manager) UpdateJobFromEngine(ctx context.Context, j *Job, status *Engin j.Name = status.FileName } + updateTorrentRuntimeStats(j, status) + prevStatus := j.Status switch status.Status { @@ -3080,9 +3085,13 @@ func (m *Manager) processPendingEngineCleanups(ctx context.Context) { } func (m *Manager) publish(eventType string, j *Job) { + if j == nil { + return + } + jobCopy := cloneJobSeedingState(j) m.bus.Publish(Event{ Type: eventType, - Job: *j, + Job: jobCopy, }) } diff --git a/internal/job/torrent_controls.go b/internal/job/torrent_controls.go index 6aacf36..be5b0ee 100644 --- a/internal/job/torrent_controls.go +++ b/internal/job/torrent_controls.go @@ -218,17 +218,33 @@ func (m *Manager) enterSeeding(ctx context.Context, j *Job, status *EngineStatus j.SpeedBytesPerSecond = status.UploadSpeed j.ETASeconds = 0 j.UpdatedAt = time.Now() - if j.TorrentInfo != nil { - j.TorrentInfo.UploadSpeed = status.UploadSpeed - j.TorrentInfo.Uploaded = status.Uploaded - j.TorrentInfo.Ratio = status.Ratio - j.TorrentInfo.Seeders = status.Seeders - j.TorrentInfo.Leechers = status.Leechers - j.TorrentInfo.SeedingTimeSeconds = status.SeedingTimeSeconds - } + updateTorrentRuntimeStats(j, status) return false } +func updateTorrentRuntimeStats(j *Job, status *EngineStatus) { + if j == nil || status == nil || j.Type != TypeTorrent { + return + } + if j.TorrentInfo == nil { + j.TorrentInfo = &TorrentInfo{} + } + j.TorrentInfo.Uploaded = status.Uploaded + j.TorrentInfo.UploadSpeed = status.UploadSpeed + j.TorrentInfo.Ratio = status.Ratio + j.TorrentInfo.Seeders = status.Seeders + j.TorrentInfo.Leechers = status.Leechers + j.TorrentInfo.SeedingTimeSeconds = status.SeedingTimeSeconds +} + +func cloneTorrentInfo(info *TorrentInfo) *TorrentInfo { + if info == nil { + return nil + } + copyInfo := *info + return ©Info +} + func cloneTorrentRecord(record *TorrentJobRecord) *TorrentJobRecord { if record == nil { return nil @@ -285,9 +301,16 @@ func synchronizeJobSeedingState(j *Job, record *TorrentJobRecord) { } func cloneJobSeedingState(j *Job) Job { + if j == nil { + return Job{} + } copyJob := *j copyJob.SeedingPolicy = cloneSeedingPolicy(j.SeedingPolicy) copyJob.SeedingStartedAt = cloneTimePointer(j.SeedingStartedAt) + copyJob.TorrentInfo = cloneTorrentInfo(j.TorrentInfo) + if j.CustomTrackers != nil { + copyJob.CustomTrackers = append([]string(nil), j.CustomTrackers...) + } return copyJob } diff --git a/internal/job/torrent_stats_test.go b/internal/job/torrent_stats_test.go new file mode 100644 index 0000000..226262f --- /dev/null +++ b/internal/job/torrent_stats_test.go @@ -0,0 +1,213 @@ +package job + +import ( + "context" + "testing" + + "downloader/internal/networkpolicy" +) + +func TestUpdateTorrentRuntimeStats_NilAndTypeSafety(t *testing.T) { + // 1. Nil safety checks + updateTorrentRuntimeStats(nil, &EngineStatus{}) + updateTorrentRuntimeStats(&Job{Type: TypeTorrent}, nil) + + // Non-torrent job should not instantiate TorrentInfo + nonTorrent := &Job{Type: TypeMedia} + updateTorrentRuntimeStats(nonTorrent, &EngineStatus{Uploaded: 100}) + if nonTorrent.TorrentInfo != nil { + t.Fatal("expected TorrentInfo to remain nil for non-torrent job") + } + + // 2. Instantiate TorrentInfo when nil for TypeTorrent + j := &Job{Type: TypeTorrent} + status := &EngineStatus{ + Uploaded: 104857600, + UploadSpeed: 524288, + Ratio: 0.25, + Seeders: 4, + Leechers: 7, + SeedingTimeSeconds: 3600, + } + updateTorrentRuntimeStats(j, status) + + if j.TorrentInfo == nil { + t.Fatal("expected TorrentInfo to be initialized") + } + if j.TorrentInfo.Uploaded != 104857600 || j.TorrentInfo.UploadSpeed != 524288 || j.TorrentInfo.Ratio != 0.25 || + j.TorrentInfo.Seeders != 4 || j.TorrentInfo.Leechers != 7 || j.TorrentInfo.SeedingTimeSeconds != 3600 { + t.Fatalf("unexpected TorrentInfo values: %+v", j.TorrentInfo) + } +} + +func TestUpdateTorrentRuntimeStats_PreservesMetadataAndOverwritesWithZero(t *testing.T) { + j := &Job{ + Type: TypeTorrent, + TotalBytes: 1400 * 1024 * 1024, + TorrentInfo: &TorrentInfo{ + Name: "Test Torrent", + InfoHash: "abcd1234efgh5678abcd1234efgh5678abcd1234", + TotalSize: 2000 * 1024 * 1024, + Uploaded: 500, + UploadSpeed: 100, + Ratio: 0.5, + Seeders: 10, + Leechers: 5, + SeedingTimeSeconds: 100, + }, + } + + // Status update with zero values (peers disconnected, speed dropped) + statusZero := &EngineStatus{ + Uploaded: 600, + UploadSpeed: 0, + Ratio: 0.6, + Seeders: 0, + Leechers: 0, + SeedingTimeSeconds: 120, + } + updateTorrentRuntimeStats(j, statusZero) + + // Metadata must remain intact + if j.TorrentInfo.Name != "Test Torrent" || j.TorrentInfo.InfoHash != "abcd1234efgh5678abcd1234efgh5678abcd1234" || j.TorrentInfo.TotalSize != 2000*1024*1024 { + t.Fatalf("metadata fields were modified: %+v", j.TorrentInfo) + } + if j.TotalBytes != 1400*1024*1024 { + t.Fatalf("Job.TotalBytes was modified: %d", j.TotalBytes) + } + + // Runtime fields must overwrite previous values with zeros + if j.TorrentInfo.Seeders != 0 || j.TorrentInfo.Leechers != 0 || j.TorrentInfo.UploadSpeed != 0 { + t.Fatalf("expected non-zero values to be replaced with 0: %+v", j.TorrentInfo) + } + if j.TorrentInfo.Uploaded != 600 || j.TorrentInfo.Ratio != 0.6 || j.TorrentInfo.SeedingTimeSeconds != 120 { + t.Fatalf("unexpected updated values: %+v", j.TorrentInfo) + } +} + +func TestUpdateJobFromEngine_PropagatesTorrentRuntimeStats(t *testing.T) { + jobRepo := newFakeJobRepository() + torrentRepo := newFakeTorrentRepository(jobRepo) + bus := newFakeEventBus() + eng := ®ressionMockEngine{} + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + + mgr := NewManager(jobRepo, reg, bus, t.TempDir(), torrentRepo) + + selectedSize := int64(1400 * 1024 * 1024) + totalTorrentSize := int64(2000 * 1024 * 1024) + + j := &Job{ + ID: "job-stats-1", + Type: TypeTorrent, + Engine: "qbittorrent", + EngineID: "hashstats1", + Status: StatusDownloading, + TotalBytes: selectedSize, + TorrentInfo: &TorrentInfo{ + Name: "Ubuntu ISO", + InfoHash: "1234567890abcdef1234567890abcdef12345678", + TotalSize: totalTorrentSize, + }, + } + _ = jobRepo.Create(context.Background(), j) + mgr.addActive(j) + + sub := bus.Subscribe() + + status1 := &EngineStatus{ + Status: StatusDownloading, + CompletedBytes: 100 * 1024 * 1024, + TotalBytes: totalTorrentSize, + SpeedBytesPerSecond: 10 * 1024 * 1024, + UploadSpeed: 524288, + Uploaded: 104857600, + Ratio: 0.25, + Seeders: 4, + Leechers: 7, + SeedingTimeSeconds: 0, + } + + mgr.UpdateJobFromEngine(context.Background(), j, status1, false) + + // 1. Verify Job.TotalBytes is NOT overwritten by full torrent size + if j.TotalBytes != selectedSize { + t.Fatalf("expected TotalBytes to remain selected size %d, got %d", selectedSize, j.TotalBytes) + } + + // 2. Verify TorrentInfo contains live runtime stats + if j.TorrentInfo.Uploaded != 104857600 || j.TorrentInfo.UploadSpeed != 524288 || + j.TorrentInfo.Ratio != 0.25 || j.TorrentInfo.Seeders != 4 || j.TorrentInfo.Leechers != 7 { + t.Fatalf("UpdateJobFromEngine failed to copy runtime stats to TorrentInfo: %+v", j.TorrentInfo) + } + + // 3. Verify event published contains cloned TorrentInfo with stats + select { + case evt := <-sub: + if evt.Type != EventJobUpdated { + t.Fatalf("expected event %s, got %s", EventJobUpdated, evt.Type) + } + if evt.Job.TorrentInfo == nil || evt.Job.TorrentInfo.Uploaded != 104857600 || evt.Job.TorrentInfo.Seeders != 4 { + t.Fatalf("published EventJobUpdated has invalid TorrentInfo: %+v", evt.Job.TorrentInfo) + } + default: + t.Fatal("expected EventJobUpdated to be published") + } + + // 4. Update status with non-zero -> zero transition + status2 := &EngineStatus{ + Status: StatusDownloading, + CompletedBytes: 200 * 1024 * 1024, + TotalBytes: totalTorrentSize, + SpeedBytesPerSecond: 0, + UploadSpeed: 0, + Uploaded: 104857600, + Ratio: 0.25, + Seeders: 0, + Leechers: 1, + SeedingTimeSeconds: 0, + } + mgr.UpdateJobFromEngine(context.Background(), j, status2, true) + + if j.TorrentInfo.Seeders != 0 || j.TorrentInfo.Leechers != 1 || j.TorrentInfo.UploadSpeed != 0 { + t.Fatalf("expected zero transitions to update TorrentInfo: %+v", j.TorrentInfo) + } + + // 5. Metadata preserved + if j.TorrentInfo.Name != "Ubuntu ISO" || j.TorrentInfo.TotalSize != totalTorrentSize { + t.Fatalf("metadata corrupted: %+v", j.TorrentInfo) + } +} + +func TestCloneJobSeedingState_DeepCopiesTorrentInfo(t *testing.T) { + orig := &Job{ + ID: "job-clone-test", + Type: TypeTorrent, + Status: StatusDownloading, + SeedingPolicy: networkpolicy.SeedingPolicy{ + Mode: networkpolicy.SeedingModeUnlimited, + }, + TorrentInfo: &TorrentInfo{ + Name: "Test File", + Uploaded: 5000, + UploadSpeed: 1000, + Seeders: 8, + Leechers: 3, + }, + } + + cloned := cloneJobSeedingState(orig) + + if cloned.TorrentInfo == orig.TorrentInfo { + t.Fatal("expected cloned.TorrentInfo to be a distinct pointer") + } + + // Mutate cloned TorrentInfo + cloned.TorrentInfo.Uploaded = 9999 + cloned.TorrentInfo.Seeders = 99 + + // Orig TorrentInfo must remain untouched + if orig.TorrentInfo.Uploaded != 5000 || orig.TorrentInfo.Seeders != 8 { + t.Fatalf("mutating cloned TorrentInfo corrupted original: %+v", orig.TorrentInfo) + } +} diff --git a/web/src/components/JobCard.test.tsx b/web/src/components/JobCard.test.tsx index 3af7b4c..d0e3f05 100644 --- a/web/src/components/JobCard.test.tsx +++ b/web/src/components/JobCard.test.tsx @@ -176,4 +176,110 @@ describe('JobCard Phase 2 convergence', () => { fireEvent.mouseDown(document.body); expect(screen.queryByRole('menu', { name: 'Job options' })).not.toBeInTheDocument(); }); + + describe('Live torrent statistics in JobDetails', () => { + const torrentJob: Job = { + ...sampleJob, + id: 'job-torrent-1', + name: 'Ubuntu 24.04 ISO', + type: 'torrent', + engine: 'qbittorrent', + status: 'downloading', + torrentInfo: { + name: 'Ubuntu 24.04 ISO', + infoHash: '1234567890123456789012345678901234567890', + totalSize: 2000000000, + uploaded: 104857600, // 100 MiB + uploadSpeed: 524288, // 512 KiB/s + ratio: 0.25, + seeders: 4, + leechers: 7, + seedingTimeSeconds: 0, + }, + }; + + it('renders live torrent statistics with explicit connected seeds/leechers labels and upload speed', () => { + render(); + + const chevron = screen.getByRole('button', { name: /Show details/i }); + fireEvent.click(chevron); + + expect(screen.getByText('Uploaded: 100.0 MiB')).toBeInTheDocument(); + expect(screen.getByText('Ratio: 0.25')).toBeInTheDocument(); + expect(screen.getByText('Connected seeds: 4')).toBeInTheDocument(); + expect(screen.getByText('Connected leechers: 7')).toBeInTheDocument(); + expect(screen.getByText('Upload speed: 512.0 KiB/s')).toBeInTheDocument(); + }); + + it('updates live statistics on rerender without collapsing details panel', () => { + const { rerender } = render(); + + const chevron = screen.getByRole('button', { name: /Show details/i }); + fireEvent.click(chevron); + + expect(screen.getByText('Connected seeds: 4')).toBeInTheDocument(); + + const updatedJob: Job = { + ...torrentJob, + torrentInfo: { + ...torrentJob.torrentInfo!, + uploaded: 209715200, // 200 MiB + uploadSpeed: 1048576, // 1.0 MiB/s + ratio: 0.50, + seeders: 12, + leechers: 3, + }, + }; + + rerender(); + + // Details panel remains open and shows updated values + expect(screen.getByText('Uploaded: 200.0 MiB')).toBeInTheDocument(); + expect(screen.getByText('Ratio: 0.50')).toBeInTheDocument(); + expect(screen.getByText('Connected seeds: 12')).toBeInTheDocument(); + expect(screen.getByText('Connected leechers: 3')).toBeInTheDocument(); + expect(screen.getByText('Upload speed: 1.0 MiB/s')).toBeInTheDocument(); + }); + + it('handles non-zero to zero statistic transitions correctly', () => { + const { rerender } = render(); + + const chevron = screen.getByRole('button', { name: /Show details/i }); + fireEvent.click(chevron); + + expect(screen.getByText('Connected seeds: 4')).toBeInTheDocument(); + + const zeroStatsJob: Job = { + ...torrentJob, + torrentInfo: { + ...torrentJob.torrentInfo!, + uploadSpeed: 0, + seeders: 0, + leechers: 0, + }, + }; + + rerender(); + + expect(screen.getByText('Connected seeds: 0')).toBeInTheDocument(); + expect(screen.getByText('Connected leechers: 0')).toBeInTheDocument(); + expect(screen.getByText('Upload speed: 0 B/s')).toBeInTheDocument(); + }); + + it('does not render misleading torrent statistics when torrentInfo is missing', () => { + const noInfoJob: Job = { + ...torrentJob, + torrentInfo: undefined, + }; + + render(); + + const chevron = screen.getByRole('button', { name: /Show details/i }); + fireEvent.click(chevron); + + expect(screen.queryByText(/Connected seeds:/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Connected leechers:/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Uploaded:/i)).not.toBeInTheDocument(); + }); + }); }); diff --git a/web/src/components/job-card/JobDetails.tsx b/web/src/components/job-card/JobDetails.tsx index a4cc601..5303b6b 100644 --- a/web/src/components/job-card/JobDetails.tsx +++ b/web/src/components/job-card/JobDetails.tsx @@ -79,11 +79,16 @@ export function JobDetails({ job, capabilities, onJobUpdated }: JobDetailsProps) {/* Torrent details */} {isTorrentJob && job.torrentInfo && ( -
+
Uploaded: {formatBytes(job.torrentInfo.uploaded)}
Ratio: {job.torrentInfo.ratio.toFixed(2)}
-
Seeders: {job.torrentInfo.seeders}
-
Leechers: {job.torrentInfo.leechers}
+
+ Connected seeds: {job.torrentInfo.seeders} +
+
+ Connected leechers: {job.torrentInfo.leechers} +
+
Upload speed: {formatBytes(job.torrentInfo.uploadSpeed)}/s
)}
From 31c64aa6afe4bf0971aae79d613d0debb502bc54 Mon Sep 17 00:00:00 2001 From: Kavya Arora Date: Sat, 8 Aug 2026 11:39:31 +0530 Subject: [PATCH 07/15] fix(torrent): implement atomic torrent creation transaction and sanitize storage errors --- .../database/atomic_torrent_creation_test.go | 174 +++++++++++++++ internal/database/jobs_repository.go | 28 ++- internal/database/torrent_repository.go | 42 +++- internal/job/atomic_torrent_job_test.go | 208 ++++++++++++++++++ internal/job/errors.go | 4 +- internal/job/manager.go | 27 ++- internal/job/manager_test.go | 17 ++ internal/job/repository.go | 1 + .../job/torrent_selection_regression_test.go | 23 +- 9 files changed, 488 insertions(+), 36 deletions(-) create mode 100644 internal/database/atomic_torrent_creation_test.go create mode 100644 internal/job/atomic_torrent_job_test.go diff --git a/internal/database/atomic_torrent_creation_test.go b/internal/database/atomic_torrent_creation_test.go new file mode 100644 index 0000000..7631adc --- /dev/null +++ b/internal/database/atomic_torrent_creation_test.go @@ -0,0 +1,174 @@ +package database + +import ( + "context" + "testing" + "time" + + "downloader/internal/job" + "downloader/internal/networkpolicy" +) + +func TestCreateTorrentJobAtomic_Success(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + jobRepo := NewSQLiteJobRepository(db) + torrentRepo := NewSQLiteTorrentRepository(db) + ctx := context.Background() + + now := time.Now().Truncate(time.Second) + j := &job.Job{ + ID: "job-atomic-success", + Source: "magnet:?xt=urn:btih:abcdef1234567890abcdef1234567890abcdef12", + Name: "Ubuntu ISO", + Status: job.StatusAnalyzing, + Type: job.TypeTorrent, + Engine: "qbittorrent", + Priority: job.JobPriorityNormal, + DestinationDir: "/downloads", + CreatedAt: now, + UpdatedAt: now, + } + rec := &job.TorrentJobRecord{ + JobID: j.ID, + InfoHash: "abcdef1234567890abcdef1234567890abcdef12", + Name: "Ubuntu ISO", + TorrentFilePath: "", + SeedAfterComplete: true, + SeedingPolicy: networkpolicy.SeedingPolicy{Mode: networkpolicy.SeedingModeUnlimited}, + } + + err := torrentRepo.CreateTorrentJobAtomic(ctx, j, rec) + if err != nil { + t.Fatalf("CreateTorrentJobAtomic failed: %v", err) + } + + // 1. Verify jobs row exists + savedJob, err := jobRepo.GetByID(ctx, j.ID) + if err != nil { + t.Fatalf("jobRepo.GetByID failed: %v", err) + } + if savedJob == nil { + t.Fatal("expected jobs row to exist") + } + if savedJob.ID != j.ID || savedJob.Name != "Ubuntu ISO" { + t.Fatalf("saved job mismatch: %+v", savedJob) + } + + // 2. Verify torrent_jobs row exists + savedRec, err := torrentRepo.GetTorrentJob(ctx, j.ID) + if err != nil { + t.Fatalf("torrentRepo.GetTorrentJob failed: %v", err) + } + if savedRec == nil { + t.Fatal("expected torrent_jobs row to exist") + } + if savedRec.InfoHash != rec.InfoHash || savedRec.SeedAfterComplete != true { + t.Fatalf("saved torrent record mismatch: %+v", savedRec) + } +} + +func TestCreateTorrentJobAtomic_TorrentJobsFailureRollsBackJobs(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + jobRepo := NewSQLiteJobRepository(db) + torrentRepo := NewSQLiteTorrentRepository(db) + ctx := context.Background() + + // Pre-insert a torrent_jobs record with job_id "existing-job" + now := time.Now().Truncate(time.Second) + existingJob := &job.Job{ + ID: "existing-job", + Source: "magnet:?xt=urn:btih:1111111111111111111111111111111111111111", + Name: "Existing", + Status: job.StatusAnalyzing, + Type: job.TypeTorrent, + CreatedAt: now, + UpdatedAt: now, + } + existingRec := &job.TorrentJobRecord{ + JobID: existingJob.ID, + InfoHash: "1111111111111111111111111111111111111111", + } + if err := torrentRepo.CreateTorrentJobAtomic(ctx, existingJob, existingRec); err != nil { + t.Fatalf("pre-insert failed: %v", err) + } + + // Attempt atomic creation with a new job whose torrent_jobs record collides on primary key (job_id = "existing-job") + collidingJob := &job.Job{ + ID: "job-should-be-rolled-back", + Source: "magnet:?xt=urn:btih:2222222222222222222222222222222222222222", + Name: "Colliding Torrent", + Status: job.StatusAnalyzing, + Type: job.TypeTorrent, + CreatedAt: now, + UpdatedAt: now, + } + collidingRec := &job.TorrentJobRecord{ + JobID: existingJob.ID, // Collides with existing job_id primary key in torrent_jobs + InfoHash: "2222222222222222222222222222222222222222", + } + + err := torrentRepo.CreateTorrentJobAtomic(ctx, collidingJob, collidingRec) + if err == nil { + t.Fatal("expected CreateTorrentJobAtomic to fail due to primary key conflict in torrent_jobs") + } + + // Verify collidingJob was rolled back and DOES NOT exist in jobs table + savedJob, err := jobRepo.GetByID(ctx, collidingJob.ID) + if err != nil { + t.Fatalf("jobRepo.GetByID failed: %v", err) + } + if savedJob != nil { + t.Fatalf("expected jobs row for %s to be rolled back, but found: %+v", collidingJob.ID, savedJob) + } +} + +func TestCreateTorrentJobAtomic_JobsFailureRollsBackEverything(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + jobRepo := NewSQLiteJobRepository(db) + torrentRepo := NewSQLiteTorrentRepository(db) + ctx := context.Background() + + now := time.Now().Truncate(time.Second) + j1 := &job.Job{ + ID: "job-duplicate-id", + Source: "https://example.com/1", + Status: job.StatusAnalyzing, + Type: job.TypeTorrent, + CreatedAt: now, + UpdatedAt: now, + } + if err := jobRepo.Create(ctx, j1); err != nil { + t.Fatalf("initial create failed: %v", err) + } + + // Attempt to create duplicate job ID atomically + j2 := &job.Job{ + ID: "job-duplicate-id", + Source: "https://example.com/2", + Status: job.StatusAnalyzing, + Type: job.TypeTorrent, + CreatedAt: now, + UpdatedAt: now, + } + rec2 := &job.TorrentJobRecord{ + JobID: j2.ID, + InfoHash: "3333333333333333333333333333333333333333", + } + + err := torrentRepo.CreateTorrentJobAtomic(ctx, j2, rec2) + if err == nil { + t.Fatal("expected CreateTorrentJobAtomic to fail on duplicate job ID") + } + + // Verify torrent_jobs row was NOT created + rec, err := torrentRepo.GetTorrentJob(ctx, j2.ID) + if err != nil { + t.Fatalf("GetTorrentJob failed: %v", err) + } + if rec != nil { + t.Fatalf("expected torrent_jobs row to be rolled back, but found: %+v", rec) + } +} diff --git a/internal/database/jobs_repository.go b/internal/database/jobs_repository.go index 0b2b35b..3d666f6 100644 --- a/internal/database/jobs_repository.go +++ b/internal/database/jobs_repository.go @@ -67,33 +67,38 @@ func scanJob(scanner interface{ Scan(...interface{}) error }) (job.Job, error) { return j, nil } -// Create inserts a new job into the database. -func (r *SQLiteJobRepository) Create(ctx context.Context, j *job.Job) error { +type sqlExecer interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + +func insertJobExec(ctx context.Context, execer sqlExecer, j *job.Job) error { mediaInfoJSON := "" if j.MediaInfo != nil { if data, err := json.Marshal(j.MediaInfo); err == nil { mediaInfoJSON = string(data) } } - if j.Priority == "" { - j.Priority = job.JobPriorityNormal + priority := j.Priority + if priority == "" { + priority = job.JobPriorityNormal } - if j.ConflictPolicy == "" { - j.ConflictPolicy = job.ConflictPolicyRename + conflictPolicy := j.ConflictPolicy + if conflictPolicy == "" { + conflictPolicy = job.ConflictPolicyRename } networkPolicyJSON, err := json.Marshal(j.NetworkPolicy) if err != nil { return fmt.Errorf("marshal network policy: %w", err) } query := fmt.Sprintf(`INSERT INTO jobs (%s) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, jobColumns) - _, err = r.db.conn.ExecContext(ctx, query, + _, err = execer.ExecContext(ctx, query, j.ID, j.Source, j.Name, j.Status, j.TotalBytes, j.CompletedBytes, j.Progress, j.SpeedBytesPerSecond, j.ETASeconds, j.Error, j.Engine, j.EngineID, j.Type, mediaInfoJSON, - j.Priority, j.BatchID, - j.CategoryID, j.DestinationDir, j.WorkDir, j.ConflictPolicy, j.FinalPath, + priority, j.BatchID, + j.CategoryID, j.DestinationDir, j.WorkDir, conflictPolicy, j.FinalPath, j.CreatedAt, j.UpdatedAt, j.EngineCleanupPending, string(networkPolicyJSON), j.EffectiveDownloadLimitBytesPerSecond, j.EffectiveUploadLimitBytesPerSecond, j.NetworkReconcilePending, @@ -104,6 +109,11 @@ func (r *SQLiteJobRepository) Create(ctx context.Context, j *job.Job) error { return nil } +// Create inserts a new job into the database. +func (r *SQLiteJobRepository) Create(ctx context.Context, j *job.Job) error { + return insertJobExec(ctx, r.db.conn, j) +} + // Update updates an existing job in the database. func (r *SQLiteJobRepository) Update(ctx context.Context, j *job.Job) error { mediaInfoJSON := "" diff --git a/internal/database/torrent_repository.go b/internal/database/torrent_repository.go index 1c228e2..3adf9ae 100644 --- a/internal/database/torrent_repository.go +++ b/internal/database/torrent_repository.go @@ -82,27 +82,27 @@ func torrentPolicyValues(rec *job.TorrentJobRecord) (any, any, any, string, erro return ratio, duration, started, string(data), err } -// CreateTorrentJob inserts a new torrent job record. -func (r *SQLiteTorrentRepository) CreateTorrentJob(ctx context.Context, rec *job.TorrentJobRecord) error { +func insertTorrentJobExec(ctx context.Context, execer sqlExecer, rec *job.TorrentJobRecord) error { query := `INSERT INTO torrent_jobs (` + torrentJobColumns + `) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` seedInt := 0 if rec.SeedAfterComplete { seedInt = 1 } - if rec.SeedingPolicy.Mode == "" { + seedingMode := rec.SeedingPolicy.Mode + if seedingMode == "" { if rec.SeedAfterComplete { - rec.SeedingPolicy.Mode = networkpolicy.SeedingModeUnlimited + seedingMode = networkpolicy.SeedingModeUnlimited } else { - rec.SeedingPolicy.Mode = networkpolicy.SeedingModeNone + seedingMode = networkpolicy.SeedingModeNone } } ratio, duration, started, trackersJSON, marshalErr := torrentPolicyValues(rec) if marshalErr != nil { return fmt.Errorf("marshal custom trackers: %w", marshalErr) } - _, err := r.db.conn.ExecContext(ctx, query, rec.JobID, rec.InfoHash, rec.Name, - rec.TotalSize, seedInt, rec.TorrentFilePath, rec.SeedingPolicy.Mode, ratio, + _, err := execer.ExecContext(ctx, query, rec.JobID, rec.InfoHash, rec.Name, + rec.TotalSize, seedInt, rec.TorrentFilePath, seedingMode, ratio, duration, started, rec.SeedingStopReason, rec.SeedingReconcilePending, trackersJSON) if err != nil { return fmt.Errorf("insert torrent job: %w", err) @@ -110,6 +110,34 @@ func (r *SQLiteTorrentRepository) CreateTorrentJob(ctx context.Context, rec *job return nil } +// CreateTorrentJob inserts a new torrent job record. +func (r *SQLiteTorrentRepository) CreateTorrentJob(ctx context.Context, rec *job.TorrentJobRecord) error { + return insertTorrentJobExec(ctx, r.db.conn, rec) +} + +// CreateTorrentJobAtomic inserts both the job and torrent job record within the same transaction. +func (r *SQLiteTorrentRepository) CreateTorrentJobAtomic(ctx context.Context, j *job.Job, rec *job.TorrentJobRecord) error { + tx, err := r.db.conn.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + defer tx.Rollback() + + if err := insertJobExec(ctx, tx, j); err != nil { + return err + } + if rec != nil { + if err := insertTorrentJobExec(ctx, tx, rec); err != nil { + return err + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } + return nil +} + // GetTorrentJob retrieves a torrent job record by job ID. func (r *SQLiteTorrentRepository) GetTorrentJob(ctx context.Context, jobID string) (*job.TorrentJobRecord, error) { query := `SELECT ` + torrentJobColumns + ` FROM torrent_jobs WHERE job_id = ?` diff --git a/internal/job/atomic_torrent_job_test.go b/internal/job/atomic_torrent_job_test.go new file mode 100644 index 0000000..a7b11bd --- /dev/null +++ b/internal/job/atomic_torrent_job_test.go @@ -0,0 +1,208 @@ +package job + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +type atomicFailingTorrentRepo struct { + *fakeTorrentRepository + failAtomicCreate error + atomicCalls int +} + +func (r *atomicFailingTorrentRepo) CreateTorrentJobAtomic(ctx context.Context, j *Job, rec *TorrentJobRecord) error { + r.atomicCalls++ + if r.failAtomicCreate != nil { + return r.failAtomicCreate + } + return r.fakeTorrentRepository.CreateTorrentJobAtomic(ctx, j, rec) +} + +func TestManager_CreateTorrentFromFile_AtomicFailureCleansUpDiskAndRollsBack(t *testing.T) { + jobRepo := newFakeJobRepository() + torrentRepo := &atomicFailingTorrentRepo{ + fakeTorrentRepository: newFakeTorrentRepository(jobRepo), + failAtomicCreate: errors.New("simulated sqlite disk error on atomic insert"), + } + eng := ®ressionMockEngine{} + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + bus := newFakeEventBus() + dataDir := t.TempDir() + mgr := NewManager(jobRepo, reg, bus, t.TempDir(), torrentRepo, dataDir) + + sub := bus.Subscribe() + + // Create a temporary valid dummy .torrent file to upload + uploadPath := filepath.Join(t.TempDir(), "test.torrent") + if err := os.WriteFile(uploadPath, []byte("d8:announce12:http://test.come"), 0644); err != nil { + t.Fatalf("failed to write dummy torrent file: %v", err) + } + + _, err := mgr.CreateTorrentFromFile(context.Background(), uploadPath) + if err == nil { + t.Fatal("expected CreateTorrentFromFile to fail when atomic creation fails") + } + + // 1. Verify error preserves the database error + if !strings.Contains(err.Error(), "simulated sqlite disk error") { + t.Fatalf("expected error to preserve DB error, got: %v", err) + } + + // 2. Verify persisted .torrent file was removed from dataDir/torrents + torrentsDir := filepath.Join(dataDir, "torrents") + entries, _ := os.ReadDir(torrentsDir) + if len(entries) != 0 { + t.Fatalf("expected 0 persisted files in %s, found %d", torrentsDir, len(entries)) + } + + // 3. Verify zero jobs rows exist in repository + if len(jobRepo.jobs) != 0 { + t.Fatalf("expected 0 jobs in repository, found %d", len(jobRepo.jobs)) + } + + // 4. Verify zero torrent_jobs exist in repository + if len(torrentRepo.torrentJobs) != 0 { + t.Fatalf("expected 0 torrent_jobs in repository, found %d", len(torrentRepo.torrentJobs)) + } + + // 5. Verify EventJobCreated was NOT published + select { + case evt := <-sub: + t.Fatalf("unexpected event published on failure: %+v", evt) + default: + // OK + } + + // 6. Verify List and ListRecoverable return 0 jobs + jobs, err := mgr.List(context.Background()) + if err != nil || len(jobs) != 0 { + t.Fatalf("expected 0 jobs from List, got %d, err=%v", len(jobs), err) + } + recoverable, err := jobRepo.ListRecoverable(context.Background()) + if err != nil || len(recoverable) != 0 { + t.Fatalf("expected 0 jobs from ListRecoverable, got %d, err=%v", len(recoverable), err) + } +} + +func TestManager_CreateTorrentFromFile_AtomicSuccessPersistsFileAndDB(t *testing.T) { + jobRepo := newFakeJobRepository() + torrentRepo := newFakeTorrentRepository(jobRepo) + eng := ®ressionMockEngine{} + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + bus := newFakeEventBus() + dataDir := t.TempDir() + mgr := NewManager(jobRepo, reg, bus, t.TempDir(), torrentRepo, dataDir) + + sub := bus.Subscribe() + + uploadPath := filepath.Join(t.TempDir(), "success.torrent") + content := []byte("d8:announce12:http://test.come") + if err := os.WriteFile(uploadPath, content, 0644); err != nil { + t.Fatalf("failed to write dummy torrent file: %v", err) + } + + j, err := mgr.CreateTorrentFromFile(context.Background(), uploadPath) + if err != nil { + t.Fatalf("CreateTorrentFromFile failed: %v", err) + } + + // 1. Verify persisted file exists in dataDir/torrents + expectedFile := filepath.Join(dataDir, "torrents", j.ID+".torrent") + persistedData, err := os.ReadFile(expectedFile) + if err != nil { + t.Fatalf("persisted torrent file missing at %s: %v", expectedFile, err) + } + if string(persistedData) != string(content) { + t.Fatal("persisted content mismatch") + } + + // 2. Verify jobs row exists + savedJob, err := jobRepo.GetByID(context.Background(), j.ID) + if err != nil || savedJob == nil { + t.Fatalf("saved job missing in jobRepo: %v", err) + } + + // 3. Verify torrent_jobs row exists + savedRec, err := torrentRepo.GetTorrentJob(context.Background(), j.ID) + if err != nil || savedRec == nil { + t.Fatalf("saved torrent record missing in torrentRepo: %v", err) + } + if savedRec.TorrentFilePath != expectedFile { + t.Fatalf("saved TorrentFilePath mismatch: %s != %s", savedRec.TorrentFilePath, expectedFile) + } + + // 4. Verify EventJobCreated was published + select { + case evt := <-sub: + if evt.Type != EventJobCreated || evt.Job.ID != j.ID { + t.Fatalf("unexpected event: %+v", evt) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("timed out waiting for EventJobCreated") + } +} + +func TestManager_CreateMagnet_AtomicFailureLeavesNoOrphan(t *testing.T) { + jobRepo := newFakeJobRepository() + torrentRepo := &atomicFailingTorrentRepo{ + fakeTorrentRepository: newFakeTorrentRepository(jobRepo), + failAtomicCreate: errors.New("database locked"), + } + eng := ®ressionMockEngine{} + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + bus := newFakeEventBus() + mgr := NewManager(jobRepo, reg, bus, t.TempDir(), torrentRepo) + + sub := bus.Subscribe() + + _, err := mgr.Create(context.Background(), "magnet:?xt=urn:btih:4444444444444444444444444444444444444444") + if err == nil { + t.Fatal("expected Create to fail on atomic DB error") + } + + // Verify no orphan jobs + if len(jobRepo.jobs) != 0 { + t.Fatalf("expected 0 jobs in repo, got %d", len(jobRepo.jobs)) + } + if len(torrentRepo.torrentJobs) != 0 { + t.Fatalf("expected 0 torrent_jobs in repo, got %d", len(torrentRepo.torrentJobs)) + } + + // Verify no event published + select { + case evt := <-sub: + t.Fatalf("unexpected event published: %+v", evt) + default: + // OK + } +} + +func TestMapStorageError_SanitizesSensitiveUnknownErrors(t *testing.T) { + sensitiveErr := fmt.Errorf("storage drive IO error at /run/secrets/api_key.txt: disk timeout") + mapped := mapStorageError(sensitiveErr) + + var appErr *AppError + if !errors.As(mapped, &appErr) { + t.Fatalf("expected *AppError, got %T: %v", mapped, mapped) + } + + if appErr.Code != ErrInternalError { + t.Fatalf("expected error code %s, got %s", ErrInternalError, appErr.Code) + } + + // Must NOT leak sensitive path in message + if strings.Contains(appErr.Message, "/run/secrets") || strings.Contains(appErr.Message, "api_key.txt") { + t.Fatalf("sensitive details exposed in client message: %s", appErr.Message) + } + + if appErr.Message != "an internal error occurred" { + t.Fatalf("expected message 'an internal error occurred', got '%s'", appErr.Message) + } +} diff --git a/internal/job/errors.go b/internal/job/errors.go index c2692bc..8d66afa 100644 --- a/internal/job/errors.go +++ b/internal/job/errors.go @@ -3,6 +3,7 @@ package job import ( "errors" "fmt" + "log" "downloader/internal/storage" ) @@ -164,6 +165,7 @@ func mapStorageError(err error) error { case errors.Is(err, storage.ErrStorageError): return &AppError{Code: ErrStorageError, Message: err.Error()} default: - return &AppError{Code: ErrInternalError, Message: err.Error()} + log.Printf("mapStorageError: unhandled storage error: type=%T err=%v", err, err) + return &AppError{Code: ErrInternalError, Message: "an internal error occurred"} } } diff --git a/internal/job/manager.go b/internal/job/manager.go index 5c15673..e6141d2 100644 --- a/internal/job/manager.go +++ b/internal/job/manager.go @@ -865,16 +865,21 @@ func (m *Manager) createTorrentJobWithIDAndOptions(ctx context.Context, jobID, s return nil, err } - if err := m.repo.Create(ctx, j); err != nil { - return nil, fmt.Errorf("persist job: %w", err) + torrentRecord := &TorrentJobRecord{ + JobID: jobID, + TorrentFilePath: torrentFilePath, + SeedAfterComplete: j.SeedAfterComplete, + SeedingPolicy: j.SeedingPolicy, + CustomTrackers: j.CustomTrackers, } + if m.torrentRepo != nil { - if err := m.torrentRepo.CreateTorrentJob(ctx, &TorrentJobRecord{ - JobID: jobID, TorrentFilePath: torrentFilePath, - SeedAfterComplete: j.SeedAfterComplete, SeedingPolicy: j.SeedingPolicy, - CustomTrackers: j.CustomTrackers, - }); err != nil { - return nil, fmt.Errorf("persist torrent policy: %w", err) + if err := m.torrentRepo.CreateTorrentJobAtomic(ctx, j, torrentRecord); err != nil { + return nil, fmt.Errorf("persist torrent job: %w", err) + } + } else { + if err := m.repo.Create(ctx, j); err != nil { + return nil, fmt.Errorf("persist job: %w", err) } } @@ -909,11 +914,13 @@ func (m *Manager) CreateTorrentFromFileWithOptions(ctx context.Context, torrentF return nil, fmt.Errorf("write persisted torrent file: %w", err) } - os.Remove(torrentFilePath) + _ = os.Remove(torrentFilePath) j, err := m.createTorrentJobWithIDAndOptions(ctx, jobID, "torrent://"+persistedPath, persistedPath, opts) if err != nil { - os.Remove(persistedPath) + if removeErr := os.Remove(persistedPath); removeErr != nil && !os.IsNotExist(removeErr) { + log.Printf("CreateTorrentFromFileWithOptions: failed to cleanup persisted torrent file %s after DB error: %v", persistedPath, removeErr) + } return nil, err } return j, nil diff --git a/internal/job/manager_test.go b/internal/job/manager_test.go index 2cdd873..0898fdd 100644 --- a/internal/job/manager_test.go +++ b/internal/job/manager_test.go @@ -362,6 +362,23 @@ func (f *fakeTorrentRepository) CreateTorrentJob(ctx context.Context, rec *Torre return nil } +func (f *fakeTorrentRepository) CreateTorrentJobAtomic(ctx context.Context, j *Job, rec *TorrentJobRecord) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.createErr != nil { + return f.createErr + } + if f.jobRepo != nil { + if err := f.jobRepo.Create(ctx, j); err != nil { + return err + } + } + if rec != nil { + f.torrentJobs[rec.JobID] = cloneTorrentRecord(rec) + } + return nil +} + func (f *fakeTorrentRepository) GetTorrentJob(ctx context.Context, jobID string) (*TorrentJobRecord, error) { f.mu.Lock() defer f.mu.Unlock() diff --git a/internal/job/repository.go b/internal/job/repository.go index af2c883..b0b2df9 100644 --- a/internal/job/repository.go +++ b/internal/job/repository.go @@ -49,6 +49,7 @@ const ( // ITorrentRepository defines the persistence interface for torrent-specific data. type ITorrentRepository interface { CreateTorrentJob(ctx context.Context, rec *TorrentJobRecord) error + CreateTorrentJobAtomic(ctx context.Context, j *Job, rec *TorrentJobRecord) error GetTorrentJob(ctx context.Context, jobID string) (*TorrentJobRecord, error) UpdateTorrentJob(ctx context.Context, rec *TorrentJobRecord) error DeleteTorrentJob(ctx context.Context, jobID string) error diff --git a/internal/job/torrent_selection_regression_test.go b/internal/job/torrent_selection_regression_test.go index edfa7a0..8dd6f0b 100644 --- a/internal/job/torrent_selection_regression_test.go +++ b/internal/job/torrent_selection_regression_test.go @@ -720,9 +720,10 @@ func TestDispatchQueuedJob_SuccessfulSelectedSizeCondition(t *testing.T) { func TestMapStorageError_AllSentinels(t *testing.T) { tests := []struct { - name string - err error - expectedCode string + name string + err error + expectedCode string + expectedMessage string }{ { name: "ErrInsufficientDiskSpace", @@ -760,9 +761,10 @@ func TestMapStorageError_AllSentinels(t *testing.T) { expectedCode: ErrStorageError, }, { - name: "unknown error falls back to INTERNAL_ERROR", - err: fmt.Errorf("something completely unexpected"), - expectedCode: ErrInternalError, + name: "unknown error falls back to INTERNAL_ERROR and sanitizes message", + err: fmt.Errorf("something completely unexpected"), + expectedCode: ErrInternalError, + expectedMessage: "an internal error occurred", }, } @@ -776,9 +778,12 @@ func TestMapStorageError_AllSentinels(t *testing.T) { if appErr.Code != tt.expectedCode { t.Fatalf("expected code %s, got %s", tt.expectedCode, appErr.Code) } - // Verify the original message is preserved - if appErr.Message != tt.err.Error() { - t.Fatalf("expected message %q, got %q", tt.err.Error(), appErr.Message) + expectedMsg := tt.expectedMessage + if expectedMsg == "" { + expectedMsg = tt.err.Error() + } + if appErr.Message != expectedMsg { + t.Fatalf("expected message %q, got %q", expectedMsg, appErr.Message) } }) } From d5470daea4b167388076ad9389df1284270c3108 Mon Sep 17 00:00:00 2001 From: Kavya Arora Date: Sat, 8 Aug 2026 13:27:34 +0530 Subject: [PATCH 08/15] fix(torrent): harden atomic torrent creation contract and require torrent repository --- .../database/atomic_torrent_creation_test.go | 137 ++++++++++++++++++ internal/database/torrent_repository.go | 22 ++- internal/job/atomic_torrent_job_test.go | 34 +++++ internal/job/manager.go | 13 +- internal/job/manager_test.go | 20 ++- 5 files changed, 211 insertions(+), 15 deletions(-) diff --git a/internal/database/atomic_torrent_creation_test.go b/internal/database/atomic_torrent_creation_test.go index 7631adc..d44455b 100644 --- a/internal/database/atomic_torrent_creation_test.go +++ b/internal/database/atomic_torrent_creation_test.go @@ -172,3 +172,140 @@ func TestCreateTorrentJobAtomic_JobsFailureRollsBackEverything(t *testing.T) { t.Fatalf("expected torrent_jobs row to be rolled back, but found: %+v", rec) } } + +func TestCreateTorrentJobAtomic_NilJob_ReturnsErrorAndZeroRows(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + jobRepo := NewSQLiteJobRepository(db) + torrentRepo := NewSQLiteTorrentRepository(db) + ctx := context.Background() + + rec := &job.TorrentJobRecord{ + JobID: "some-job-id", + InfoHash: "hash123", + } + + err := torrentRepo.CreateTorrentJobAtomic(ctx, nil, rec) + if err == nil { + t.Fatal("expected error on nil job") + } + + jobs, err := jobRepo.List(ctx) + if err != nil || len(jobs) != 0 { + t.Fatalf("expected 0 jobs in DB, got %d, err=%v", len(jobs), err) + } + + savedRec, err := torrentRepo.GetTorrentJob(ctx, "some-job-id") + if err != nil || savedRec != nil { + t.Fatalf("expected nil torrent record in DB, got %+v, err=%v", savedRec, err) + } +} + +func TestCreateTorrentJobAtomic_NilTorrentRecord_ReturnsErrorAndZeroRows(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + jobRepo := NewSQLiteJobRepository(db) + torrentRepo := NewSQLiteTorrentRepository(db) + ctx := context.Background() + + now := time.Now().Truncate(time.Second) + j := &job.Job{ + ID: "job-nil-rec", + Source: "magnet:?xt=urn:btih:5555", + Status: job.StatusAnalyzing, + Type: job.TypeTorrent, + CreatedAt: now, + UpdatedAt: now, + } + + err := torrentRepo.CreateTorrentJobAtomic(ctx, j, nil) + if err == nil { + t.Fatal("expected error on nil torrent record") + } + + savedJob, err := jobRepo.GetByID(ctx, j.ID) + if err != nil || savedJob != nil { + t.Fatalf("expected nil job in DB, got %+v, err=%v", savedJob, err) + } + + savedRec, err := torrentRepo.GetTorrentJob(ctx, j.ID) + if err != nil || savedRec != nil { + t.Fatalf("expected nil torrent record in DB, got %+v, err=%v", savedRec, err) + } +} + +func TestCreateTorrentJobAtomic_MismatchedJobID_ReturnsErrorAndZeroRows(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + jobRepo := NewSQLiteJobRepository(db) + torrentRepo := NewSQLiteTorrentRepository(db) + ctx := context.Background() + + now := time.Now().Truncate(time.Second) + j := &job.Job{ + ID: "job-id-1", + Source: "magnet:?xt=urn:btih:6666", + Status: job.StatusAnalyzing, + Type: job.TypeTorrent, + CreatedAt: now, + UpdatedAt: now, + } + rec := &job.TorrentJobRecord{ + JobID: "job-id-2", // Mismatched ID + InfoHash: "6666", + } + + err := torrentRepo.CreateTorrentJobAtomic(ctx, j, rec) + if err == nil { + t.Fatal("expected error on mismatched job ID") + } + + savedJob1, err := jobRepo.GetByID(ctx, "job-id-1") + if err != nil || savedJob1 != nil { + t.Fatalf("expected nil job-id-1 in DB, got %+v, err=%v", savedJob1, err) + } + savedJob2, err := jobRepo.GetByID(ctx, "job-id-2") + if err != nil || savedJob2 != nil { + t.Fatalf("expected nil job-id-2 in DB, got %+v, err=%v", savedJob2, err) + } + savedRec1, err := torrentRepo.GetTorrentJob(ctx, "job-id-1") + if err != nil || savedRec1 != nil { + t.Fatalf("expected nil torrent record job-id-1 in DB, got %+v, err=%v", savedRec1, err) + } + savedRec2, err := torrentRepo.GetTorrentJob(ctx, "job-id-2") + if err != nil || savedRec2 != nil { + t.Fatalf("expected nil torrent record job-id-2 in DB, got %+v, err=%v", savedRec2, err) + } +} + +func TestCreateTorrentJobAtomic_EmptyJobID_ReturnsErrorAndZeroRows(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + jobRepo := NewSQLiteJobRepository(db) + torrentRepo := NewSQLiteTorrentRepository(db) + ctx := context.Background() + + now := time.Now().Truncate(time.Second) + j := &job.Job{ + ID: "", + Source: "magnet:?xt=urn:btih:7777", + Status: job.StatusAnalyzing, + Type: job.TypeTorrent, + CreatedAt: now, + UpdatedAt: now, + } + rec := &job.TorrentJobRecord{ + JobID: "", + InfoHash: "7777", + } + + err := torrentRepo.CreateTorrentJobAtomic(ctx, j, rec) + if err == nil { + t.Fatal("expected error on empty job ID") + } + + jobs, err := jobRepo.List(ctx) + if err != nil || len(jobs) != 0 { + t.Fatalf("expected 0 jobs in DB, got %d, err=%v", len(jobs), err) + } +} diff --git a/internal/database/torrent_repository.go b/internal/database/torrent_repository.go index 3adf9ae..83439b3 100644 --- a/internal/database/torrent_repository.go +++ b/internal/database/torrent_repository.go @@ -117,6 +117,22 @@ func (r *SQLiteTorrentRepository) CreateTorrentJob(ctx context.Context, rec *job // CreateTorrentJobAtomic inserts both the job and torrent job record within the same transaction. func (r *SQLiteTorrentRepository) CreateTorrentJobAtomic(ctx context.Context, j *job.Job, rec *job.TorrentJobRecord) error { + if j == nil { + return fmt.Errorf("job is required") + } + if rec == nil { + return fmt.Errorf("torrent record is required") + } + if j.ID == "" { + return fmt.Errorf("job ID is required") + } + if rec.JobID == "" { + return fmt.Errorf("torrent record job ID is required") + } + if rec.JobID != j.ID { + return fmt.Errorf("torrent record job ID (%s) does not match job ID (%s)", rec.JobID, j.ID) + } + tx, err := r.db.conn.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin transaction: %w", err) @@ -126,10 +142,8 @@ func (r *SQLiteTorrentRepository) CreateTorrentJobAtomic(ctx context.Context, j if err := insertJobExec(ctx, tx, j); err != nil { return err } - if rec != nil { - if err := insertTorrentJobExec(ctx, tx, rec); err != nil { - return err - } + if err := insertTorrentJobExec(ctx, tx, rec); err != nil { + return err } if err := tx.Commit(); err != nil { diff --git a/internal/job/atomic_torrent_job_test.go b/internal/job/atomic_torrent_job_test.go index a7b11bd..fe6b7ce 100644 --- a/internal/job/atomic_torrent_job_test.go +++ b/internal/job/atomic_torrent_job_test.go @@ -206,3 +206,37 @@ func TestMapStorageError_SanitizesSensitiveUnknownErrors(t *testing.T) { t.Fatalf("expected message 'an internal error occurred', got '%s'", appErr.Message) } } + +func TestManager_CreateMagnet_NilTorrentRepo_ReturnsErrorAndZeroJobs(t *testing.T) { + jobRepo := newFakeJobRepository() + eng := ®ressionMockEngine{} + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + bus := newFakeEventBus() + // Manager created with nil torrentRepo + mgr := NewManager(jobRepo, reg, bus, t.TempDir(), nil) + + sub := bus.Subscribe() + + _, err := mgr.Create(context.Background(), "magnet:?xt=urn:btih:9999999999999999999999999999999999999999") + if err == nil { + t.Fatal("expected Create to fail when torrentRepo is nil") + } + + var appErr *AppError + if !errors.As(err, &appErr) || appErr.Code != ErrInternalError { + t.Fatalf("expected AppError with INTERNAL_ERROR, got: %v", err) + } + + // Verify zero jobs persisted + if len(jobRepo.jobs) != 0 { + t.Fatalf("expected 0 jobs in repo, got %d", len(jobRepo.jobs)) + } + + // Verify no EventJobCreated event published + select { + case evt := <-sub: + t.Fatalf("unexpected event published: %+v", evt) + default: + // OK + } +} diff --git a/internal/job/manager.go b/internal/job/manager.go index e6141d2..effdcc2 100644 --- a/internal/job/manager.go +++ b/internal/job/manager.go @@ -814,6 +814,9 @@ func (m *Manager) createTorrentJobWithID(ctx context.Context, jobID, source, tor } func (m *Manager) createTorrentJobWithIDAndOptions(ctx context.Context, jobID, source, torrentFilePath string, opts CreateOptions) (*Job, error) { + if m.torrentRepo == nil { + return nil, &AppError{Code: ErrInternalError, Message: "torrent repository unavailable"} + } if _, ok := m.engines.Get("qbittorrent"); !ok { return nil, &AppError{Code: ErrEngineError, Message: "engine not registered: qBittorrent"} } @@ -873,14 +876,8 @@ func (m *Manager) createTorrentJobWithIDAndOptions(ctx context.Context, jobID, s CustomTrackers: j.CustomTrackers, } - if m.torrentRepo != nil { - if err := m.torrentRepo.CreateTorrentJobAtomic(ctx, j, torrentRecord); err != nil { - return nil, fmt.Errorf("persist torrent job: %w", err) - } - } else { - if err := m.repo.Create(ctx, j); err != nil { - return nil, fmt.Errorf("persist job: %w", err) - } + if err := m.torrentRepo.CreateTorrentJobAtomic(ctx, j, torrentRecord); err != nil { + return nil, fmt.Errorf("persist torrent job: %w", err) } m.publish(EventJobCreated, j) diff --git a/internal/job/manager_test.go b/internal/job/manager_test.go index 0898fdd..9f8e433 100644 --- a/internal/job/manager_test.go +++ b/internal/job/manager_test.go @@ -363,6 +363,22 @@ func (f *fakeTorrentRepository) CreateTorrentJob(ctx context.Context, rec *Torre } func (f *fakeTorrentRepository) CreateTorrentJobAtomic(ctx context.Context, j *Job, rec *TorrentJobRecord) error { + if j == nil { + return fmt.Errorf("job is required") + } + if rec == nil { + return fmt.Errorf("torrent record is required") + } + if j.ID == "" { + return fmt.Errorf("job ID is required") + } + if rec.JobID == "" { + return fmt.Errorf("torrent record job ID is required") + } + if rec.JobID != j.ID { + return fmt.Errorf("torrent record job ID (%s) does not match job ID (%s)", rec.JobID, j.ID) + } + f.mu.Lock() defer f.mu.Unlock() if f.createErr != nil { @@ -373,9 +389,7 @@ func (f *fakeTorrentRepository) CreateTorrentJobAtomic(ctx context.Context, j *J return err } } - if rec != nil { - f.torrentJobs[rec.JobID] = cloneTorrentRecord(rec) - } + f.torrentJobs[rec.JobID] = cloneTorrentRecord(rec) return nil } From ef5ceac06bc9be6e3fa2765adf3221f19b8e5c94 Mon Sep 17 00:00:00 2001 From: Kavya Arora Date: Sat, 8 Aug 2026 15:36:31 +0530 Subject: [PATCH 09/15] fix(qbittorrent): implement idempotent torrent ownership reconciliation and canonical infohash extraction --- go.mod | 7 + go.sum | 238 +++++++ internal/api/handler.go | 3 +- internal/engine/qbittorrent/client.go | 40 +- internal/engine/qbittorrent/engine.go | 65 ++ internal/job/engine.go | 6 + internal/job/errors.go | 2 + internal/job/manager.go | 179 ++++- internal/job/manager_test.go | 16 + internal/job/torrent_hash.go | 59 ++ internal/job/torrent_metadata_test.go | 6 + internal/job/torrent_reconciliation_test.go | 624 ++++++++++++++++++ .../job/torrent_selection_regression_test.go | 6 + 13 files changed, 1236 insertions(+), 15 deletions(-) create mode 100644 internal/job/torrent_hash.go create mode 100644 internal/job/torrent_reconciliation_test.go diff --git a/go.mod b/go.mod index 99b6423..553ca13 100644 --- a/go.mod +++ b/go.mod @@ -10,3 +10,10 @@ require ( ) require golang.org/x/sys v0.47.0 + +require ( + github.com/anacrolix/missinggo v1.3.0 // indirect + github.com/anacrolix/missinggo/v2 v2.10.0 // indirect + github.com/anacrolix/torrent v1.61.0 // indirect + github.com/huandu/xstrings v1.3.2 // indirect +) diff --git a/go.sum b/go.sum index 0f0a4a1..e57764b 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,248 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +crawshaw.io/iox v0.0.0-20181124134642-c51c3df30797/go.mod h1:sXBiorCo8c46JlQV3oXPKINnZ8mcqnye1EkVkqsectk= +crawshaw.io/sqlite v0.3.2/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w= +github.com/RoaringBitmap/roaring v0.4.17/go.mod h1:D3qVegWTmfCaX4Bl5CrBE9hfrSrrXIr8KVNvRsDi1NI= +github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo= +github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= +github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/anacrolix/envpprof v0.0.0-20180404065416-323002cec2fa/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c= +github.com/anacrolix/envpprof v1.0.0/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c= +github.com/anacrolix/envpprof v1.1.0/go.mod h1:My7T5oSqVfEn4MD4Meczkw/f5lSIndGAKu/0SM/rkf4= +github.com/anacrolix/log v0.3.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU= +github.com/anacrolix/log v0.6.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU= +github.com/anacrolix/missinggo v1.1.0/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo= +github.com/anacrolix/missinggo v1.1.2-0.20190815015349-b888af804467/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo= +github.com/anacrolix/missinggo v1.2.1/go.mod h1:J5cMhif8jPmFoC3+Uvob3OXXNIhOUikzMt+uUjeM21Y= +github.com/anacrolix/missinggo v1.3.0 h1:06HlMsudotL7BAELRZs0yDZ4yVXsHXGi323QBjAVASw= +github.com/anacrolix/missinggo v1.3.0/go.mod h1:bqHm8cE8xr+15uVfMG3BFui/TxyB6//H5fwlq/TeqMc= +github.com/anacrolix/missinggo/perf v1.0.0/go.mod h1:ljAFWkBuzkO12MQclXzZrosP5urunoLS0Cbvb4V0uMQ= +github.com/anacrolix/missinggo/v2 v2.2.0/go.mod h1:o0jgJoYOyaoYQ4E2ZMISVa9c88BbUBVQQW4QeRkNCGY= +github.com/anacrolix/missinggo/v2 v2.5.1/go.mod h1:WEjqh2rmKECd0t1VhQkLGTdIWXO6f6NLjp5GlMZ+6FA= +github.com/anacrolix/missinggo/v2 v2.10.0 h1:pg0iO4Z/UhP2MAnmGcaMtp5ZP9kyWsusENWN9aolrkY= +github.com/anacrolix/missinggo/v2 v2.10.0/go.mod h1:nCRMW6bRCMOVcw5z9BnSYKF+kDbtenx+hQuphf4bK8Y= +github.com/anacrolix/stm v0.2.0/go.mod h1:zoVQRvSiGjGoTmbM0vSLIiaKjWtNPeTvXUSdJQA4hsg= +github.com/anacrolix/tagflag v0.0.0-20180109131632-2146c8d41bf0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw= +github.com/anacrolix/tagflag v1.0.0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw= +github.com/anacrolix/tagflag v1.1.0/go.mod h1:Scxs9CV10NQatSmbyjqmqmeQNwGzlNe0CMUMIxqHIG8= +github.com/anacrolix/torrent v1.61.0 h1:vxo+B4SwnoP5AQWbhvnTYIaTgPSX+llYUVuQVsN4Jg8= +github.com/anacrolix/torrent v1.61.0/go.mod h1:yKUKuZSSDdyOsCbuH+rDOpswl/g546gICapdrU7aUmQ= +github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= +github.com/benbjohnson/immutable v0.2.0/go.mod h1:uc6OHo6PN2++n98KHLxW8ef4W42ylHiQSENghE1ezxI= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo= +github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo= +github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= +github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= +github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= +github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= +github.com/glycerine/go-unsnap-stream v0.0.0-20190901134440-81cf024a9e0a/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= +github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= +github.com/glycerine/goconvey v0.0.0-20190315024820-982ee783a72e/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= +github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180124185431-e89373fe6b4a/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20190309154008-847fc94819f9/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo= +github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4= +github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw= +github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg= +github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= +github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= +github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.5.1/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v0.0.0-20190215210624-980c5ac6f3ac/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= +github.com/smartystreets/goconvey v0.0.0-20190306220146-200a235640ff/go.mod h1:KSQcGKpxUMHk3nbYzs/tIBAM2iDooCn0BmttHOJEbLs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= +github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= +github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= +github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= +github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= +go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= +go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/api/handler.go b/internal/api/handler.go index c323871..0f9146e 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -428,7 +428,8 @@ func writeAppError(w http.ResponseWriter, err error) { httpStatus = http.StatusServiceUnavailable case job.ErrCapabilityNotSupported, job.ErrPrivateTorrentTrackerRejected: httpStatus = http.StatusUnprocessableEntity - case job.ErrInvalidJobState, job.ErrNetworkSettingStateAmbiguous, job.ErrSeedingPolicyStateAmbiguous: + case job.ErrInvalidJobState, job.ErrNetworkSettingStateAmbiguous, job.ErrSeedingPolicyStateAmbiguous, + job.ErrTorrentAlreadyManaged, job.ErrTorrentAlreadyExistsExternally: httpStatus = http.StatusConflict case job.ErrNetworkSettingApplicationFailed, job.ErrSeedingPolicyApplicationFailed: httpStatus = http.StatusServiceUnavailable diff --git a/internal/engine/qbittorrent/client.go b/internal/engine/qbittorrent/client.go index ec781db..55a540a 100644 --- a/internal/engine/qbittorrent/client.go +++ b/internal/engine/qbittorrent/client.go @@ -269,6 +269,8 @@ func (c *Client) ValidateCompatibility(ctx context.Context) error { return nil } +var ErrTorrentNotFound = errors.New("torrent not found") + func (c *Client) AddMagnet(ctx context.Context, magnet, savePath, category string, tags []string, stopped bool) error { data := url.Values{} data.Set("urls", magnet) @@ -293,6 +295,12 @@ func (c *Client) AddMagnet(ctx context.Context, magnet, savePath, category strin defer resp.Body.Close() if resp.StatusCode != http.StatusOK { + lr := io.LimitReader(resp.Body, 4096) + bodyBytes, _ := io.ReadAll(lr) + bodyStr := strings.TrimSpace(string(bodyBytes)) + if bodyStr != "" { + return fmt.Errorf("failed to add magnet, status: %d (%s)", resp.StatusCode, bodyStr) + } return fmt.Errorf("failed to add magnet, status: %d", resp.StatusCode) } return nil @@ -337,11 +345,41 @@ func (c *Client) AddTorrentFile(ctx context.Context, filePath, savePath, categor defer resp.Body.Close() if resp.StatusCode != http.StatusOK { + lr := io.LimitReader(resp.Body, 4096) + bodyBytes, _ := io.ReadAll(lr) + bodyStr := strings.TrimSpace(string(bodyBytes)) + if bodyStr != "" { + return fmt.Errorf("failed to add torrent file, status: %d (%s)", resp.StatusCode, bodyStr) + } return fmt.Errorf("failed to add torrent file, status: %d", resp.StatusCode) } return nil } +func (c *Client) AddTags(ctx context.Context, hashes []string, tags []string) error { + data := url.Values{ + "hashes": {strings.Join(hashes, "|")}, + "tags": {strings.Join(tags, ",")}, + } + return c.postForm(ctx, "/api/v2/torrents/addTags", data) +} + +func (c *Client) RemoveTags(ctx context.Context, hashes []string, tags []string) error { + data := url.Values{ + "hashes": {strings.Join(hashes, "|")}, + "tags": {strings.Join(tags, ",")}, + } + return c.postForm(ctx, "/api/v2/torrents/removeTags", data) +} + +func (c *Client) SetCategory(ctx context.Context, hashes []string, category string) error { + data := url.Values{ + "hashes": {strings.Join(hashes, "|")}, + "category": {category}, + } + return c.postForm(ctx, "/api/v2/torrents/setCategory", data) +} + func (c *Client) GetTorrentInfo(ctx context.Context, hash string) (*qbTorrentInfo, error) { path := "/api/v2/torrents/info" if hash != "" { @@ -363,7 +401,7 @@ func (c *Client) GetTorrentInfo(ctx context.Context, hash string) (*qbTorrentInf } if len(infos) == 0 { - return nil, errors.New("torrent not found") + return nil, ErrTorrentNotFound } return &infos[0], nil diff --git a/internal/engine/qbittorrent/engine.go b/internal/engine/qbittorrent/engine.go index b3cc1bb..beb4a13 100644 --- a/internal/engine/qbittorrent/engine.go +++ b/internal/engine/qbittorrent/engine.go @@ -224,6 +224,71 @@ func (e *Engine) ApplySeedingPolicy(ctx context.Context, j *job.Job, policy netw return e.client.SetShareLimits(ctx, j.EngineID, ratio, minutes) } +func (e *Engine) GetTorrentOwnership(ctx context.Context, infoHash string) (*job.TorrentOwnership, error) { + if infoHash == "" { + return nil, nil + } + info, err := e.client.GetTorrentInfo(ctx, infoHash) + if err != nil { + if errors.Is(err, ErrTorrentNotFound) || strings.Contains(strings.ToLower(err.Error()), "not found") { + return nil, nil + } + return nil, err + } + if info == nil { + return nil, nil + } + rawTags := strings.Split(info.Tags, ",") + tags := make([]string, 0, len(rawTags)) + for _, t := range rawTags { + trimmed := strings.TrimSpace(t) + if trimmed != "" { + tags = append(tags, trimmed) + } + } + return &job.TorrentOwnership{ + Hash: strings.ToLower(info.Hash), + Category: strings.TrimSpace(info.Category), + Tags: tags, + }, nil +} + +func (e *Engine) AdoptTorrent(ctx context.Context, infoHash, jobID string) error { + if infoHash == "" { + return errors.New("info hash is required to adopt torrent") + } + // 1. Stop torrent to ensure no background downloading occurs before file selection + _ = e.client.StopTorrents(ctx, []string{infoHash}) + + // 2. Ensure category is godownloader + _ = e.client.SetCategory(ctx, []string{infoHash}, CategoryName) + + // 3. Associate current job tag + if jobID != "" { + if err := e.client.AddTags(ctx, []string{infoHash}, []string{jobID}); err != nil { + return fmt.Errorf("failed to tag adopted torrent: %w", err) + } + } + + // 4. Remove stale GoDownloader job tags if present + info, err := e.client.GetTorrentInfo(ctx, infoHash) + if err == nil && info != nil { + rawTags := strings.Split(info.Tags, ",") + var staleTags []string + for _, t := range rawTags { + trimmed := strings.TrimSpace(t) + if trimmed != "" && trimmed != jobID && strings.HasPrefix(trimmed, "job_") { + staleTags = append(staleTags, trimmed) + } + } + if len(staleTags) > 0 { + _ = e.client.RemoveTags(ctx, []string{infoHash}, staleTags) + } + } + + return nil +} + func (e *Engine) ListTorrentOwnership(ctx context.Context) ([]job.TorrentOwnership, error) { torrents, err := e.client.GetTorrents(ctx, "") if err != nil { diff --git a/internal/job/engine.go b/internal/job/engine.go index 98b6a73..bde32bc 100644 --- a/internal/job/engine.go +++ b/internal/job/engine.go @@ -120,6 +120,12 @@ type ITorrentEngine interface { // AddTorrentFile adds a .torrent file and returns the info hash. AddTorrentFile(ctx context.Context, filePath, savePath, jobID string) (infoHash string, err error) + // GetTorrentOwnership queries the engine for ownership metadata of an info hash. + GetTorrentOwnership(ctx context.Context, infoHash string) (*TorrentOwnership, error) + + // AdoptTorrent safely adopts an existing torrent into a job without starting download. + AdoptTorrent(ctx context.Context, infoHash, jobID string) error + // GetFiles returns the normalized file list for a torrent. GetFiles(ctx context.Context, infoHash string) ([]TorrentFile, error) diff --git a/internal/job/errors.go b/internal/job/errors.go index 8d66afa..46ef9b1 100644 --- a/internal/job/errors.go +++ b/internal/job/errors.go @@ -66,6 +66,8 @@ const ( ErrInvalidSeedingPolicy = "INVALID_SEEDING_POLICY" ErrSeedingPolicyApplicationFailed = "SEEDING_POLICY_APPLICATION_FAILED" ErrSeedingPolicyStateAmbiguous = "SEEDING_POLICY_STATE_AMBIGUOUS" + ErrTorrentAlreadyManaged = "TORRENT_ALREADY_MANAGED" + ErrTorrentAlreadyExistsExternally = "TORRENT_ALREADY_EXISTS_EXTERNALLY" ) type TorrentFinalizeFailureKind string diff --git a/internal/job/manager.go b/internal/job/manager.go index effdcc2..9c4f4fb 100644 --- a/internal/job/manager.go +++ b/internal/job/manager.go @@ -900,6 +900,16 @@ func (m *Manager) CreateTorrentFromFileWithOptions(ctx context.Context, torrentF return nil, fmt.Errorf("read uploaded torrent file: %w", err) } + if hash, err := ExtractTorrentInfoHash(data); err == nil && hash != "" && m.torrentRepo != nil { + rec, err := m.torrentRepo.GetActiveTorrentJobByInfoHash(ctx, hash) + if err != nil { + return nil, &AppError{Code: ErrInternalError, Message: fmt.Sprintf("failed to verify torrent ownership: %v", err)} + } + if rec != nil { + return nil, &AppError{Code: ErrInvalidRequest, Message: fmt.Sprintf("a torrent with info hash %s is already managed by job %s", hash, rec.JobID)} + } + } + jobID := "job_" + uuid.New().String()[:8] persistedPath := filepath.Join(m.dataDir, "torrents", jobID+".torrent") @@ -987,25 +997,159 @@ func (m *Manager) acquireTorrentMetadata(jobID, source, torrentFilePath string) saveDir = m.downloadDir } - var infoHash string + // 1. Determine canonical torrent info hash before attempting add (if possible) + var expectedHash string if torrentFilePath != "" { - infoHash, err = torrentEng.AddTorrentFile(ctx, torrentFilePath, saveDir, jobID) + if hash, hashErr := ExtractTorrentInfoHashFromFile(torrentFilePath); hashErr == nil { + expectedHash = strings.ToLower(hash) + } } else { - infoHash, err = torrentEng.AddMagnet(ctx, source, saveDir, jobID) + if hash, hashErr := ExtractMagnetHash(source); hashErr == nil { + expectedHash = strings.ToLower(hash) + } } - if err != nil { - if ctx.Err() != nil { - log.Printf("acquireTorrentMetadata: job %s cancelled during add", jobID) - return + + reconcileOwnership := func(ownership *TorrentOwnership) (bool, error) { + if ownership == nil { + return false, nil } - j.Status = StatusFailed - j.Error = fmt.Sprintf("Failed to add torrent: %v", err) - j.UpdatedAt = time.Now() - m.repo.Update(ctx, j) - m.publish(EventJobFailed, j) - return + + // 6. Externally-owned torrent: category != godownloader -> conflict + if ownership.Category != "godownloader" { + log.Printf("acquireTorrentMetadata: torrent %s already exists externally in qBittorrent with category %q", expectedHash, ownership.Category) + return false, &AppError{ + Code: ErrTorrentAlreadyExistsExternally, + Message: "This torrent already exists in qBittorrent outside GoDownloader.", + } + } + + // Check tags for job ID + var isSameJob bool + var otherJobID string + for _, tag := range ownership.Tags { + if tag == jobID { + isSameJob = true + break + } + if strings.HasPrefix(tag, "job_") { + otherJobID = tag + } else if otherJobID == "" && tag != "" { + otherJobID = tag + } + } + + // 3. Same-job existing torrent (e.g. Retry or restart recovery) -> idempotent success + if isSameJob { + log.Printf("acquireTorrentMetadata: torrent %s already owned by current job %s, reusing existing torrent", expectedHash, jobID) + _ = torrentEng.StopDownload(ctx, expectedHash) + return true, nil + } + + // 5. Existing local owner: if tagged job ID still exists locally, do NOT steal ownership + if otherJobID != "" { + existingJob, _ := m.repo.GetByID(ctx, otherJobID) + if existingJob != nil && existingJob.ID != jobID { + log.Printf("acquireTorrentMetadata: torrent %s is already managed by local job %s", expectedHash, existingJob.ID) + return false, &AppError{ + Code: ErrTorrentAlreadyManaged, + Message: fmt.Sprintf("This torrent is already managed by GoDownloader job %s.", existingJob.ID), + } + } + } + + // Check if another active local job owns this hash in the repository + if m.torrentRepo != nil { + rec, _ := m.torrentRepo.GetActiveTorrentJobByInfoHash(ctx, expectedHash) + if rec != nil && rec.JobID != jobID { + log.Printf("acquireTorrentMetadata: torrent %s is already managed by active local job %s", expectedHash, rec.JobID) + return false, &AppError{ + Code: ErrTorrentAlreadyManaged, + Message: fmt.Sprintf("This torrent is already managed by GoDownloader job %s.", rec.JobID), + } + } + } + + // 4. Orphaned GoDownloader-owned torrent -> safely adopt it + log.Printf("acquireTorrentMetadata: adopting orphaned godownloader torrent %s into job %s", expectedHash, jobID) + if adoptErr := torrentEng.AdoptTorrent(ctx, expectedHash, jobID); adoptErr != nil { + return false, fmt.Errorf("failed to adopt existing torrent: %w", adoptErr) + } + return true, nil } + var infoHash string + + // 2. Check qBittorrent before Add (if hash is known) + if expectedHash != "" { + ownership, checkErr := torrentEng.GetTorrentOwnership(ctx, expectedHash) + if checkErr != nil { + log.Printf("acquireTorrentMetadata: warning: preflight ownership check failed for %s: %v", expectedHash, checkErr) + } + + if ownership != nil { + reconciled, recErr := reconcileOwnership(ownership) + if recErr != nil { + j.Status = StatusFailed + j.Error = recErr.Error() + j.UpdatedAt = time.Now() + m.repo.Update(ctx, j) + m.publish(EventJobFailed, j) + return + } + if reconciled { + infoHash = expectedHash + } + } + } + + // 3. If not already present/reconciled, call Add + if infoHash == "" { + var addErr error + if torrentFilePath != "" { + infoHash, addErr = torrentEng.AddTorrentFile(ctx, torrentFilePath, saveDir, jobID) + } else { + infoHash, addErr = torrentEng.AddMagnet(ctx, source, saveDir, jobID) + } + + if addErr != nil { + if ctx.Err() != nil { + log.Printf("acquireTorrentMetadata: job %s cancelled during add", jobID) + return + } + + // 7. Handle race-time 409: re-query qBittorrent once and classify ownership + if strings.Contains(addErr.Error(), "409") && expectedHash != "" { + log.Printf("acquireTorrentMetadata: add returned 409 for %s, re-querying qBittorrent ownership", expectedHash) + postOwnership, queryErr := torrentEng.GetTorrentOwnership(ctx, expectedHash) + if queryErr == nil && postOwnership != nil { + reconciled, recErr := reconcileOwnership(postOwnership) + if recErr != nil { + j.Status = StatusFailed + j.Error = recErr.Error() + j.UpdatedAt = time.Now() + m.repo.Update(ctx, j) + m.publish(EventJobFailed, j) + return + } + if reconciled { + infoHash = expectedHash + addErr = nil + } + } + } + + if addErr != nil { + j.Status = StatusFailed + j.Error = fmt.Sprintf("Failed to add torrent: %v", addErr) + j.UpdatedAt = time.Now() + m.repo.Update(ctx, j) + m.publish(EventJobFailed, j) + return + } + } + } + + infoHash = strings.ToLower(infoHash) j.EngineID = infoHash j.UpdatedAt = time.Now() m.repo.Update(ctx, j) @@ -1055,6 +1199,15 @@ func (m *Manager) acquireTorrentMetadata(jobID, source, torrentFilePath string) m.publish(EventJobFailed, j) return } + + // Save/update the record for current job + rec, _ = m.torrentRepo.GetTorrentJob(ctx, jobID) + if rec != nil { + rec = cloneTorrentRecord(rec) + rec.InfoHash = infoHash + rec.TorrentFilePath = torrentFilePath + _ = m.torrentRepo.UpdateTorrentJob(ctx, rec) + } } timeoutSecs := m.getMetadataTimeoutSeconds() diff --git a/internal/job/manager_test.go b/internal/job/manager_test.go index 9f8e433..cd41be8 100644 --- a/internal/job/manager_test.go +++ b/internal/job/manager_test.go @@ -78,6 +78,8 @@ type fakeTorrentEngine struct { isStopped bool addMagnetFunc func(magnet string) (string, error) addTorrentFileFunc func(path string) (string, error) + getOwnershipFunc func(hash string) (*TorrentOwnership, error) + adoptTorrentFunc func(hash, jobID string) error getFilesFunc func(hash string) ([]TorrentFile, error) setPrioritiesFunc func(hash string) error startDownloadFunc func(hash string) error @@ -87,6 +89,20 @@ type fakeTorrentEngine struct { statusFunc func(ctx context.Context, j *Job) (*EngineStatus, error) } +func (f *fakeTorrentEngine) GetTorrentOwnership(ctx context.Context, infoHash string) (*TorrentOwnership, error) { + if f.getOwnershipFunc != nil { + return f.getOwnershipFunc(infoHash) + } + return nil, nil +} + +func (f *fakeTorrentEngine) AdoptTorrent(ctx context.Context, infoHash, jobID string) error { + if f.adoptTorrentFunc != nil { + return f.adoptTorrentFunc(infoHash, jobID) + } + return nil +} + func (f *fakeTorrentEngine) GetRawState(ctx context.Context, infoHash string) (string, error) { if f.isStopped { return "pausedDL", nil diff --git a/internal/job/torrent_hash.go b/internal/job/torrent_hash.go new file mode 100644 index 0000000..bfd02ac --- /dev/null +++ b/internal/job/torrent_hash.go @@ -0,0 +1,59 @@ +package job + +import ( + "crypto/sha1" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + + "github.com/anacrolix/torrent/bencode" +) + +type bencodeMetaInfo struct { + Info bencode.Bytes `bencode:"info"` +} + +// ExtractTorrentInfoHash computes the canonical lowercase hex info hash from raw .torrent file bytes. +// Supports standard BitTorrent v1 (SHA-1), BitTorrent v2 (SHA-256), and hybrid torrents (v1 SHA-1). +func ExtractTorrentInfoHash(data []byte) (string, error) { + if len(data) == 0 { + return "", errors.New("empty torrent data") + } + + var meta bencodeMetaInfo + if err := bencode.Unmarshal(data, &meta); err != nil { + return "", fmt.Errorf("invalid bencoded torrent file: %w", err) + } + + if len(meta.Info) == 0 { + return "", errors.New("missing info dictionary in torrent file") + } + + // Check if this is a pure BitTorrent v2 torrent without v1 pieces + var infoDict map[string]interface{} + if err := bencode.Unmarshal(meta.Info, &infoDict); err == nil { + _, hasPieces := infoDict["pieces"] + metaVer, hasMetaVer := infoDict["meta version"] + if !hasPieces && hasMetaVer { + if verInt, ok := metaVer.(int64); ok && verInt == 2 { + h2 := sha256.Sum256(meta.Info) + return hex.EncodeToString(h2[:]), nil + } + } + } + + // Standard v1 or hybrid torrent info hash (SHA-1 of the bencoded info dictionary) + h1 := sha1.Sum(meta.Info) + return hex.EncodeToString(h1[:]), nil +} + +// ExtractTorrentInfoHashFromFile reads a .torrent file from disk and computes its info hash. +func ExtractTorrentInfoHashFromFile(filePath string) (string, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return "", fmt.Errorf("read torrent file %s: %w", filePath, err) + } + return ExtractTorrentInfoHash(data) +} diff --git a/internal/job/torrent_metadata_test.go b/internal/job/torrent_metadata_test.go index ad9015e..b4eafe1 100644 --- a/internal/job/torrent_metadata_test.go +++ b/internal/job/torrent_metadata_test.go @@ -63,6 +63,12 @@ func (m *mockTorrentEngine) AddTorrentFile(ctx context.Context, filePath, savePa m.addFileCalls = append(m.addFileCalls, filePath) return "0123456789abcdef0123456789abcdef01234567", nil } +func (m *mockTorrentEngine) GetTorrentOwnership(ctx context.Context, infoHash string) (*TorrentOwnership, error) { + return nil, nil +} +func (m *mockTorrentEngine) AdoptTorrent(ctx context.Context, infoHash, jobID string) error { + return nil +} func (m *mockTorrentEngine) GetFiles(ctx context.Context, infoHash string) ([]TorrentFile, error) { m.mu.Lock() defer m.mu.Unlock() diff --git a/internal/job/torrent_reconciliation_test.go b/internal/job/torrent_reconciliation_test.go new file mode 100644 index 0000000..382dfe3 --- /dev/null +++ b/internal/job/torrent_reconciliation_test.go @@ -0,0 +1,624 @@ +package job + +import ( + "context" + "crypto/sha1" + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/anacrolix/torrent/bencode" +) + +// Helper to create a bencoded torrent file buffer +func makeTestTorrentV1(name string, length int64) ([]byte, string, error) { + infoMap := map[string]interface{}{ + "name": name, + "piece length": int64(262144), + "pieces": string(make([]byte, 20)), + "length": length, + } + infoBytes, err := bencode.Marshal(infoMap) + if err != nil { + return nil, "", err + } + h1 := sha1.Sum(infoBytes) + infoHash := hex.EncodeToString(h1[:]) + + torrentMap := map[string]interface{}{ + "announce": "http://tracker.example.com/announce", + "info": bencode.Bytes(infoBytes), + } + torrentBytes, err := bencode.Marshal(torrentMap) + if err != nil { + return nil, "", err + } + return torrentBytes, strings.ToLower(infoHash), nil +} + +func makeTestTorrentV2(name string, length int64) ([]byte, string, error) { + infoMap := map[string]interface{}{ + "name": name, + "piece length": int64(262144), + "meta version": int64(2), + "file tree": map[string]interface{}{ + name: map[string]interface{}{ + "": map[string]interface{}{ + "length": length, + "pieces root": string(make([]byte, 32)), + }, + }, + }, + } + infoBytes, err := bencode.Marshal(infoMap) + if err != nil { + return nil, "", err + } + h2 := sha256.Sum256(infoBytes) + infoHash := hex.EncodeToString(h2[:]) + + torrentMap := map[string]interface{}{ + "announce": "http://tracker.example.com/announce", + "info": bencode.Bytes(infoBytes), + } + torrentBytes, err := bencode.Marshal(torrentMap) + if err != nil { + return nil, "", err + } + return torrentBytes, strings.ToLower(infoHash), nil +} + +func makeTestTorrentHybrid(name string, length int64) ([]byte, string, error) { + infoMap := map[string]interface{}{ + "name": name, + "piece length": int64(262144), + "pieces": string(make([]byte, 20)), + "meta version": int64(2), + "file tree": map[string]interface{}{ + name: map[string]interface{}{ + "": map[string]interface{}{ + "length": length, + "pieces root": string(make([]byte, 32)), + }, + }, + }, + } + infoBytes, err := bencode.Marshal(infoMap) + if err != nil { + return nil, "", err + } + h1 := sha1.Sum(infoBytes) + infoHash := hex.EncodeToString(h1[:]) + + torrentMap := map[string]interface{}{ + "announce": "http://tracker.example.com/announce", + "info": bencode.Bytes(infoBytes), + } + torrentBytes, err := bencode.Marshal(torrentMap) + if err != nil { + return nil, "", err + } + return torrentBytes, strings.ToLower(infoHash), nil +} + +// 9. Test .torrent hash extraction fixtures with deterministic expected hashes +func TestExtractTorrentInfoHash_Fixtures(t *testing.T) { + // V1 + v1Bytes, expectedV1, err := makeTestTorrentV1("ubuntu-22.04.iso", 1024*1024*1024) + if err != nil { + t.Fatalf("failed to make v1 torrent: %v", err) + } + gotV1, err := ExtractTorrentInfoHash(v1Bytes) + if err != nil { + t.Fatalf("ExtractTorrentInfoHash(v1) failed: %v", err) + } + if gotV1 != expectedV1 { + t.Fatalf("v1 info hash mismatch: got %s, want %s", gotV1, expectedV1) + } + + // V2 + v2Bytes, expectedV2, err := makeTestTorrentV2("v2-archive.iso", 2048*1024*1024) + if err != nil { + t.Fatalf("failed to make v2 torrent: %v", err) + } + gotV2, err := ExtractTorrentInfoHash(v2Bytes) + if err != nil { + t.Fatalf("ExtractTorrentInfoHash(v2) failed: %v", err) + } + if gotV2 != expectedV2 { + t.Fatalf("v2 info hash mismatch: got %s, want %s", gotV2, expectedV2) + } + + // Hybrid (should resolve to v1 SHA-1 hash for qBittorrent compatibility) + hybridBytes, expectedHybrid, err := makeTestTorrentHybrid("hybrid.iso", 512*1024*1024) + if err != nil { + t.Fatalf("failed to make hybrid torrent: %v", err) + } + gotHybrid, err := ExtractTorrentInfoHash(hybridBytes) + if err != nil { + t.Fatalf("ExtractTorrentInfoHash(hybrid) failed: %v", err) + } + if gotHybrid != expectedHybrid { + t.Fatalf("hybrid info hash mismatch: got %s, want %s", gotHybrid, expectedHybrid) + } +} + +// 1. Magnet hash absent in qBittorrent: AddMagnet called normally +func TestTorrentReconciliation_MagnetHashAbsent_CallsAddMagnet(t *testing.T) { + jobRepo := newFakeJobRepository() + torrentRepo := newFakeTorrentRepository(jobRepo) + var addMagnetCalled int32 + eng := &fakeTorrentEngine{ + fakeEngine: &fakeEngine{}, + getOwnershipFunc: func(hash string) (*TorrentOwnership, error) { + return nil, nil // Hash absent + }, + addMagnetFunc: func(magnet string) (string, error) { + atomic.AddInt32(&addMagnetCalled, 1) + return "1111111111111111111111111111111111111111", nil + }, + getTorrentInfoFunc: func(hash string) (*TorrentInfo, error) { + return &TorrentInfo{Name: "test.iso", TotalSize: 1000}, nil + }, + getFilesFunc: func(hash string) ([]TorrentFile, error) { + return []TorrentFile{{Index: 0, Path: "test.iso", Size: 1000, Selected: true}}, nil + }, + } + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + bus := newFakeEventBus() + mgr := NewManager(jobRepo, reg, bus, t.TempDir(), torrentRepo) + + j, err := mgr.Create(context.Background(), "magnet:?xt=urn:btih:1111111111111111111111111111111111111111") + if err != nil { + t.Fatalf("mgr.Create failed: %v", err) + } + + // Wait for metadata acquisition + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + jobObj, _ := mgr.Get(context.Background(), j.ID) + if jobObj != nil && jobObj.Status == StatusAwaitingSelection { + break + } + time.Sleep(20 * time.Millisecond) + } + + if atomic.LoadInt32(&addMagnetCalled) != 1 { + t.Fatalf("expected AddMagnet to be called 1 time, got %d", addMagnetCalled) + } +} + +// 2 & 3. Same-job hash already exists: AddMagnet NOT called, existing torrent reused, retry succeeds without 409 +func TestTorrentReconciliation_SameJobExisting_ReusesTorrentWithoutCallingAdd(t *testing.T) { + jobRepo := newFakeJobRepository() + torrentRepo := newFakeTorrentRepository(jobRepo) + var addMagnetCalled int32 + var stopDownloadCalled int32 + + targetHash := "2222222222222222222222222222222222222222" + var currentJobID string + + eng := &fakeTorrentEngine{ + fakeEngine: &fakeEngine{}, + getOwnershipFunc: func(hash string) (*TorrentOwnership, error) { + if hash == targetHash { + return &TorrentOwnership{ + Hash: targetHash, + Category: "godownloader", + Tags: []string{currentJobID}, // Tagged with current job ID + }, nil + } + return nil, nil + }, + addMagnetFunc: func(magnet string) (string, error) { + atomic.AddInt32(&addMagnetCalled, 1) + return "", errors.New("failed to add magnet, status: 409") + }, + stopDownloadFunc: func(hash string) error { + atomic.AddInt32(&stopDownloadCalled, 1) + return nil + }, + getTorrentInfoFunc: func(hash string) (*TorrentInfo, error) { + return &TorrentInfo{Name: "existing.iso", TotalSize: 2000}, nil + }, + getFilesFunc: func(hash string) ([]TorrentFile, error) { + return []TorrentFile{{Index: 0, Path: "existing.iso", Size: 2000, Selected: true}}, nil + }, + } + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + bus := newFakeEventBus() + mgr := NewManager(jobRepo, reg, bus, t.TempDir(), torrentRepo) + + j, err := mgr.Create(context.Background(), "magnet:?xt=urn:btih:"+targetHash) + if err != nil { + t.Fatalf("mgr.Create failed: %v", err) + } + currentJobID = j.ID + + // Wait for metadata acquisition + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + jobObj, _ := mgr.Get(context.Background(), j.ID) + if jobObj != nil && jobObj.Status == StatusAwaitingSelection { + break + } + time.Sleep(20 * time.Millisecond) + } + + jobObj, _ := mgr.Get(context.Background(), j.ID) + if jobObj.Status != StatusAwaitingSelection { + t.Fatalf("expected StatusAwaitingSelection, got %s (err=%s)", jobObj.Status, jobObj.Error) + } + + // Verify AddMagnet was NOT called (avoiding 409) + if atomic.LoadInt32(&addMagnetCalled) != 0 { + t.Fatalf("expected AddMagnet to NOT be called for same-job existing torrent, called %d times", addMagnetCalled) + } + + // Verify stopDownload was called for safety before file selection + if atomic.LoadInt32(&stopDownloadCalled) < 1 { + t.Fatalf("expected StopDownload to be called, got %d", stopDownloadCalled) + } +} + +// 4 & 10 & 11. Orphan godownloader torrent: adopted safely, stopped before selection, no AddMagnet, never deletes files +func TestTorrentReconciliation_OrphanGodownloader_AdoptedSafely(t *testing.T) { + jobRepo := newFakeJobRepository() + torrentRepo := newFakeTorrentRepository(jobRepo) + var addMagnetCalled int32 + var adoptCalled int32 + var removeCalled int32 + + targetHash := "3333333333333333333333333333333333333333" + + eng := &fakeTorrentEngine{ + fakeEngine: &fakeEngine{}, + getOwnershipFunc: func(hash string) (*TorrentOwnership, error) { + if hash == targetHash { + return &TorrentOwnership{ + Hash: targetHash, + Category: "godownloader", + Tags: []string{"job_deadbeef"}, // Stale job ID that does not exist in local DB + }, nil + } + return nil, nil + }, + adoptTorrentFunc: func(hash, jobID string) error { + atomic.AddInt32(&adoptCalled, 1) + return nil + }, + addMagnetFunc: func(magnet string) (string, error) { + atomic.AddInt32(&addMagnetCalled, 1) + return "", errors.New("409 conflict") + }, + removeTorrentFunc: func(hash string, deleteFiles bool) error { + atomic.AddInt32(&removeCalled, 1) + return nil + }, + getTorrentInfoFunc: func(hash string) (*TorrentInfo, error) { + return &TorrentInfo{Name: "orphan.iso", TotalSize: 3000}, nil + }, + getFilesFunc: func(hash string) ([]TorrentFile, error) { + return []TorrentFile{{Index: 0, Path: "orphan.iso", Size: 3000, Selected: true}}, nil + }, + } + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + bus := newFakeEventBus() + mgr := NewManager(jobRepo, reg, bus, t.TempDir(), torrentRepo) + + j, err := mgr.Create(context.Background(), "magnet:?xt=urn:btih:"+targetHash) + if err != nil { + t.Fatalf("mgr.Create failed: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + jobObj, _ := mgr.Get(context.Background(), j.ID) + if jobObj != nil && jobObj.Status == StatusAwaitingSelection { + break + } + time.Sleep(20 * time.Millisecond) + } + + jobObj, _ := mgr.Get(context.Background(), j.ID) + if jobObj.Status != StatusAwaitingSelection { + t.Fatalf("expected StatusAwaitingSelection, got %s (err=%s)", jobObj.Status, jobObj.Error) + } + + if atomic.LoadInt32(&adoptCalled) != 1 { + t.Fatalf("expected AdoptTorrent to be called 1 time, got %d", adoptCalled) + } + if atomic.LoadInt32(&addMagnetCalled) != 0 { + t.Fatalf("expected AddMagnet to NOT be called during adoption, called %d", addMagnetCalled) + } + if atomic.LoadInt32(&removeCalled) != 0 { + t.Fatalf("expected DeleteTorrents to NOT be called during adoption, called %d", removeCalled) + } +} + +// 5. Another existing local GoDownloader job owns hash: returns TORRENT_ALREADY_MANAGED and does not mutate +func TestTorrentReconciliation_AnotherLocalJobOwnsHash_ReturnsConflict(t *testing.T) { + jobRepo := newFakeJobRepository() + torrentRepo := newFakeTorrentRepository(jobRepo) + var addMagnetCalled int32 + var adoptCalled int32 + + targetHash := "4444444444444444444444444444444444444444" + + // Pre-create active job 1 in local repo + existingJob := &Job{ + ID: "job_active1", + Status: StatusDownloading, + Type: TypeTorrent, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + _ = jobRepo.Create(context.Background(), existingJob) + + eng := &fakeTorrentEngine{ + fakeEngine: &fakeEngine{}, + getOwnershipFunc: func(hash string) (*TorrentOwnership, error) { + if hash == targetHash { + return &TorrentOwnership{ + Hash: targetHash, + Category: "godownloader", + Tags: []string{"job_active1"}, + }, nil + } + return nil, nil + }, + adoptTorrentFunc: func(hash, jobID string) error { + atomic.AddInt32(&adoptCalled, 1) + return nil + }, + addMagnetFunc: func(magnet string) (string, error) { + atomic.AddInt32(&addMagnetCalled, 1) + return "", nil + }, + } + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + bus := newFakeEventBus() + mgr := NewManager(jobRepo, reg, bus, t.TempDir(), torrentRepo) + + j, err := mgr.Create(context.Background(), "magnet:?xt=urn:btih:"+targetHash) + if err != nil { + t.Fatalf("mgr.Create failed: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + jobObj, _ := mgr.Get(context.Background(), j.ID) + if jobObj != nil && jobObj.Status == StatusFailed { + break + } + time.Sleep(20 * time.Millisecond) + } + + jobObj, _ := mgr.Get(context.Background(), j.ID) + if jobObj.Status != StatusFailed { + t.Fatalf("expected StatusFailed, got %s", jobObj.Status) + } + if !strings.Contains(jobObj.Error, "already managed by GoDownloader job job_active1") { + t.Fatalf("expected error mentioning job_active1, got: %s", jobObj.Error) + } + if atomic.LoadInt32(&adoptCalled) != 0 || atomic.LoadInt32(&addMagnetCalled) != 0 { + t.Fatal("torrent in qBittorrent must not be mutated when owned by another local job") + } +} + +// 6. Externally-owned qBittorrent torrent: returns TORRENT_ALREADY_EXISTS_EXTERNALLY and does not mutate +func TestTorrentReconciliation_ExternallyOwnedTorrent_ReturnsConflict(t *testing.T) { + jobRepo := newFakeJobRepository() + torrentRepo := newFakeTorrentRepository(jobRepo) + var addMagnetCalled int32 + var adoptCalled int32 + var stopCalled int32 + + targetHash := "5555555555555555555555555555555555555555" + + eng := &fakeTorrentEngine{ + fakeEngine: &fakeEngine{}, + getOwnershipFunc: func(hash string) (*TorrentOwnership, error) { + if hash == targetHash { + return &TorrentOwnership{ + Hash: targetHash, + Category: "movies", // External category! + Tags: []string{"manual_upload"}, + }, nil + } + return nil, nil + }, + adoptTorrentFunc: func(hash, jobID string) error { + atomic.AddInt32(&adoptCalled, 1) + return nil + }, + addMagnetFunc: func(magnet string) (string, error) { + atomic.AddInt32(&addMagnetCalled, 1) + return "", nil + }, + stopDownloadFunc: func(hash string) error { + atomic.AddInt32(&stopCalled, 1) + return nil + }, + } + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + bus := newFakeEventBus() + mgr := NewManager(jobRepo, reg, bus, t.TempDir(), torrentRepo) + + j, err := mgr.Create(context.Background(), "magnet:?xt=urn:btih:"+targetHash) + if err != nil { + t.Fatalf("mgr.Create failed: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + jobObj, _ := mgr.Get(context.Background(), j.ID) + if jobObj != nil && jobObj.Status == StatusFailed { + break + } + time.Sleep(20 * time.Millisecond) + } + + jobObj, _ := mgr.Get(context.Background(), j.ID) + if jobObj.Status != StatusFailed { + t.Fatalf("expected StatusFailed, got %s", jobObj.Status) + } + if !strings.Contains(jobObj.Error, "outside GoDownloader") { + t.Fatalf("expected error mentioning outside GoDownloader, got: %s", jobObj.Error) + } + if atomic.LoadInt32(&adoptCalled) != 0 || atomic.LoadInt32(&addMagnetCalled) != 0 || atomic.LoadInt32(&stopCalled) != 0 { + t.Fatal("external torrent in qBittorrent must not be mutated in any way") + } +} + +// 7. Add races and returns 409: one ownership re-query reconciles same-job torrent as success +func TestTorrentReconciliation_AddRace409_RequeriesAndReconciles(t *testing.T) { + jobRepo := newFakeJobRepository() + torrentRepo := newFakeTorrentRepository(jobRepo) + var getOwnershipCallCount int32 + var addMagnetCallCount int32 + + targetHash := "6666666666666666666666666666666666666666" + + eng := &fakeTorrentEngine{ + fakeEngine: &fakeEngine{}, + getOwnershipFunc: func(hash string) (*TorrentOwnership, error) { + count := atomic.AddInt32(&getOwnershipCallCount, 1) + if count == 1 { + // Initial check: not found yet + return nil, nil + } + // Second check after 409: found under godownloader + return &TorrentOwnership{ + Hash: targetHash, + Category: "godownloader", + Tags: []string{"job_deadbeef"}, + }, nil + }, + addMagnetFunc: func(magnet string) (string, error) { + atomic.AddInt32(&addMagnetCallCount, 1) + return "", errors.New("failed to add magnet, status: 409 (Torrent already exists)") + }, + adoptTorrentFunc: func(hash, jobID string) error { + return nil + }, + getTorrentInfoFunc: func(hash string) (*TorrentInfo, error) { + return &TorrentInfo{Name: "raced.iso", TotalSize: 5000}, nil + }, + getFilesFunc: func(hash string) ([]TorrentFile, error) { + return []TorrentFile{{Index: 0, Path: "raced.iso", Size: 5000, Selected: true}}, nil + }, + } + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + bus := newFakeEventBus() + mgr := NewManager(jobRepo, reg, bus, t.TempDir(), torrentRepo) + + j, err := mgr.Create(context.Background(), "magnet:?xt=urn:btih:"+targetHash) + if err != nil { + t.Fatalf("mgr.Create failed: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + jobObj, _ := mgr.Get(context.Background(), j.ID) + if jobObj != nil && jobObj.Status == StatusAwaitingSelection { + break + } + time.Sleep(20 * time.Millisecond) + } + + jobObj, _ := mgr.Get(context.Background(), j.ID) + if jobObj.Status != StatusAwaitingSelection { + t.Fatalf("expected StatusAwaitingSelection after 409 reconciliation, got %s (err=%s)", jobObj.Status, jobObj.Error) + } + + if atomic.LoadInt32(&addMagnetCallCount) != 1 { + t.Fatalf("expected AddMagnet to be called exactly once before 409, got %d", addMagnetCallCount) + } + if atomic.LoadInt32(&getOwnershipCallCount) != 2 { + t.Fatalf("expected 2 GetTorrentOwnership calls (initial + post-409), got %d", getOwnershipCallCount) + } +} + +// 8. Uploaded .torrent file with same hash uses the same reconciliation rules +func TestTorrentReconciliation_UploadedTorrentFile_SameRules(t *testing.T) { + jobRepo := newFakeJobRepository() + torrentRepo := newFakeTorrentRepository(jobRepo) + var addTorrentFileCalled int32 + var adoptCalled int32 + + data, hash, err := makeTestTorrentV1("uploaded.iso", 8000) + if err != nil { + t.Fatalf("makeTestTorrentV1 failed: %v", err) + } + + tempDir := t.TempDir() + filePath := filepath.Join(tempDir, "test_upload.torrent") + if err := os.WriteFile(filePath, data, 0644); err != nil { + t.Fatalf("write file failed: %v", err) + } + + eng := &fakeTorrentEngine{ + fakeEngine: &fakeEngine{}, + getOwnershipFunc: func(infoHash string) (*TorrentOwnership, error) { + if infoHash == hash { + return &TorrentOwnership{ + Hash: hash, + Category: "godownloader", + Tags: []string{"job_old123"}, + }, nil + } + return nil, nil + }, + adoptTorrentFunc: func(infoHash, jobID string) error { + atomic.AddInt32(&adoptCalled, 1) + return nil + }, + addTorrentFileFunc: func(path string) (string, error) { + atomic.AddInt32(&addTorrentFileCalled, 1) + return hash, nil + }, + getTorrentInfoFunc: func(infoHash string) (*TorrentInfo, error) { + return &TorrentInfo{Name: "uploaded.iso", TotalSize: 8000}, nil + }, + getFilesFunc: func(infoHash string) ([]TorrentFile, error) { + return []TorrentFile{{Index: 0, Path: "uploaded.iso", Size: 8000, Selected: true}}, nil + }, + } + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + bus := newFakeEventBus() + mgr := NewManager(jobRepo, reg, bus, t.TempDir(), torrentRepo) + + j, err := mgr.CreateTorrentFromFile(context.Background(), filePath) + if err != nil { + t.Fatalf("CreateTorrentFromFile failed: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + jobObj, _ := mgr.Get(context.Background(), j.ID) + if jobObj != nil && jobObj.Status == StatusAwaitingSelection { + break + } + time.Sleep(20 * time.Millisecond) + } + + jobObj, _ := mgr.Get(context.Background(), j.ID) + if jobObj.Status != StatusAwaitingSelection { + t.Fatalf("expected StatusAwaitingSelection, got %s (err=%s)", jobObj.Status, jobObj.Error) + } + + // Should have adopted instead of re-adding + if atomic.LoadInt32(&adoptCalled) != 1 { + t.Fatalf("expected AdoptTorrent to be called 1 time, got %d", adoptCalled) + } + if atomic.LoadInt32(&addTorrentFileCalled) != 0 { + t.Fatalf("expected AddTorrentFile to NOT be called when existing in qBittorrent, got %d", addTorrentFileCalled) + } +} diff --git a/internal/job/torrent_selection_regression_test.go b/internal/job/torrent_selection_regression_test.go index 8dd6f0b..bcb6a16 100644 --- a/internal/job/torrent_selection_regression_test.go +++ b/internal/job/torrent_selection_regression_test.go @@ -104,6 +104,12 @@ func (m *regressionMockEngine) AddMagnet(ctx context.Context, magnet, savePath, func (m *regressionMockEngine) AddTorrentFile(ctx context.Context, filePath, savePath, jobID string) (string, error) { return "hash123", nil } +func (m *regressionMockEngine) GetTorrentOwnership(ctx context.Context, infoHash string) (*TorrentOwnership, error) { + return nil, nil +} +func (m *regressionMockEngine) AdoptTorrent(ctx context.Context, infoHash, jobID string) error { + return nil +} func (m *regressionMockEngine) GetFiles(ctx context.Context, infoHash string) ([]TorrentFile, error) { return m.filesToReturn, nil } From c3c9fcfce76ac24a831f258b708c979fe5dacee8 Mon Sep 17 00:00:00 2001 From: Kavya Arora Date: Sat, 8 Aug 2026 16:04:21 +0530 Subject: [PATCH 10/15] fix(qbittorrent): enforce 40-char TorrentID semantics, fail-closed adoption, and typed APIError --- go.mod | 6 +- go.sum | 43 ++++ internal/engine/qbittorrent/client.go | 22 +- internal/engine/qbittorrent/engine.go | 29 ++- internal/job/errors.go | 11 + internal/job/manager.go | 7 +- internal/job/model.go | 42 ---- internal/job/torrent_hash.go | 191 +++++++++++++-- internal/job/torrent_reconciliation_test.go | 244 ++++++++++++-------- 9 files changed, 421 insertions(+), 174 deletions(-) diff --git a/go.mod b/go.mod index 553ca13..fb83667 100644 --- a/go.mod +++ b/go.mod @@ -9,11 +9,13 @@ require ( github.com/rs/cors v1.11.1 ) -require golang.org/x/sys v0.47.0 +require ( + github.com/anacrolix/torrent v1.61.0 + golang.org/x/sys v0.47.0 +) require ( github.com/anacrolix/missinggo v1.3.0 // indirect github.com/anacrolix/missinggo/v2 v2.10.0 // indirect - github.com/anacrolix/torrent v1.61.0 // indirect github.com/huandu/xstrings v1.3.2 // indirect ) diff --git a/go.sum b/go.sum index e57764b..13f9e88 100644 --- a/go.sum +++ b/go.sum @@ -12,9 +12,13 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/anacrolix/dht/v2 v2.23.0 h1:EuD17ykTTEkAMPLjBsS5QjGOwuBgLTdQhds6zPAjeVY= +github.com/anacrolix/dht/v2 v2.23.0/go.mod h1:seXRz6HLw8zEnxlysf9ye2eQbrKUmch6PyOHpe/Nb/U= github.com/anacrolix/envpprof v0.0.0-20180404065416-323002cec2fa/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c= github.com/anacrolix/envpprof v1.0.0/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c= github.com/anacrolix/envpprof v1.1.0/go.mod h1:My7T5oSqVfEn4MD4Meczkw/f5lSIndGAKu/0SM/rkf4= +github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b h1:Kuvx/A/TTJuT9x8mn7DeGx2KW9tWn1LI8bira67xdT0= +github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b/go.mod h1:NGehhfeXJPBujPx0s6cstSj8B+TERsTY32Xckfx5ftc= github.com/anacrolix/log v0.3.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU= github.com/anacrolix/log v0.6.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU= github.com/anacrolix/missinggo v1.1.0/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo= @@ -27,6 +31,8 @@ github.com/anacrolix/missinggo/v2 v2.2.0/go.mod h1:o0jgJoYOyaoYQ4E2ZMISVa9c88BbU github.com/anacrolix/missinggo/v2 v2.5.1/go.mod h1:WEjqh2rmKECd0t1VhQkLGTdIWXO6f6NLjp5GlMZ+6FA= github.com/anacrolix/missinggo/v2 v2.10.0 h1:pg0iO4Z/UhP2MAnmGcaMtp5ZP9kyWsusENWN9aolrkY= github.com/anacrolix/missinggo/v2 v2.10.0/go.mod h1:nCRMW6bRCMOVcw5z9BnSYKF+kDbtenx+hQuphf4bK8Y= +github.com/anacrolix/multiless v0.4.0 h1:lqSszHkliMsZd2hsyrDvHOw4AbYWa+ijQ66LzbjqWjM= +github.com/anacrolix/multiless v0.4.0/go.mod h1:zJv1JF9AqdZiHwxqPgjuOZDGWER6nyE48WBCi/OOrMM= github.com/anacrolix/stm v0.2.0/go.mod h1:zoVQRvSiGjGoTmbM0vSLIiaKjWtNPeTvXUSdJQA4hsg= github.com/anacrolix/tagflag v0.0.0-20180109131632-2146c8d41bf0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw= github.com/anacrolix/tagflag v1.0.0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw= @@ -40,10 +46,12 @@ github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+Ce github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo= github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo= +github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8= github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= github.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= @@ -51,6 +59,8 @@ github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25Kn github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= @@ -62,6 +72,8 @@ github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2 github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= @@ -85,6 +97,8 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -109,20 +123,34 @@ github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVY github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU= +github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= +github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg= github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= +github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= +github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= +github.com/multiformats/go-varint v0.0.6 h1:gk85QWKxh3TazbLxED/NlDVv8+q+ReFJk7Y2W/KhfNY= +github.com/multiformats/go-varint v0.0.6/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= @@ -133,6 +161,7 @@ github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= @@ -151,6 +180,8 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= @@ -160,12 +191,16 @@ github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1 github.com/smartystreets/assertions v0.0.0-20190215210624-980c5ac6f3ac/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= github.com/smartystreets/goconvey v0.0.0-20190306220146-200a235640ff/go.mod h1:KSQcGKpxUMHk3nbYzs/tIBAM2iDooCn0BmttHOJEbLs= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= @@ -176,7 +211,11 @@ go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= +golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -244,5 +283,9 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c= +lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA= diff --git a/internal/engine/qbittorrent/client.go b/internal/engine/qbittorrent/client.go index 55a540a..0cd058c 100644 --- a/internal/engine/qbittorrent/client.go +++ b/internal/engine/qbittorrent/client.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "log" "mime/multipart" "net/http" "net/http/cookiejar" @@ -17,6 +18,8 @@ import ( "strings" "sync" "time" + + "downloader/internal/job" ) type Client struct { @@ -271,6 +274,9 @@ func (c *Client) ValidateCompatibility(ctx context.Context) error { var ErrTorrentNotFound = errors.New("torrent not found") +// APIError is an alias to job.EngineAPIError for qBittorrent Web API responses. +type APIError = job.EngineAPIError + func (c *Client) AddMagnet(ctx context.Context, magnet, savePath, category string, tags []string, stopped bool) error { data := url.Values{} data.Set("urls", magnet) @@ -298,10 +304,12 @@ func (c *Client) AddMagnet(ctx context.Context, magnet, savePath, category strin lr := io.LimitReader(resp.Body, 4096) bodyBytes, _ := io.ReadAll(lr) bodyStr := strings.TrimSpace(string(bodyBytes)) - if bodyStr != "" { - return fmt.Errorf("failed to add magnet, status: %d (%s)", resp.StatusCode, bodyStr) + log.Printf("qbittorrent AddMagnet non-200 response (status %d): %s", resp.StatusCode, bodyStr) + return &APIError{ + Operation: "AddMagnet", + StatusCode: resp.StatusCode, + Detail: bodyStr, } - return fmt.Errorf("failed to add magnet, status: %d", resp.StatusCode) } return nil } @@ -348,10 +356,12 @@ func (c *Client) AddTorrentFile(ctx context.Context, filePath, savePath, categor lr := io.LimitReader(resp.Body, 4096) bodyBytes, _ := io.ReadAll(lr) bodyStr := strings.TrimSpace(string(bodyBytes)) - if bodyStr != "" { - return fmt.Errorf("failed to add torrent file, status: %d (%s)", resp.StatusCode, bodyStr) + log.Printf("qbittorrent AddTorrentFile non-200 response (status %d): %s", resp.StatusCode, bodyStr) + return &APIError{ + Operation: "AddTorrentFile", + StatusCode: resp.StatusCode, + Detail: bodyStr, } - return fmt.Errorf("failed to add torrent file, status: %d", resp.StatusCode) } return nil } diff --git a/internal/engine/qbittorrent/engine.go b/internal/engine/qbittorrent/engine.go index beb4a13..3cb086e 100644 --- a/internal/engine/qbittorrent/engine.go +++ b/internal/engine/qbittorrent/engine.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log" "math" "strings" "sync" @@ -258,21 +259,30 @@ func (e *Engine) AdoptTorrent(ctx context.Context, infoHash, jobID string) error return errors.New("info hash is required to adopt torrent") } // 1. Stop torrent to ensure no background downloading occurs before file selection - _ = e.client.StopTorrents(ctx, []string{infoHash}) + if err := e.client.StopTorrents(ctx, []string{infoHash}); err != nil { + return fmt.Errorf("failed to stop torrent during adoption: %w", err) + } - // 2. Ensure category is godownloader - _ = e.client.SetCategory(ctx, []string{infoHash}, CategoryName) + // Verify stopped state using existing raw-state/status mechanism where practical + info, err := e.client.GetTorrentInfo(ctx, infoHash) + if err != nil { + return fmt.Errorf("failed to verify torrent state after adoption: %w", err) + } + if info != nil && strings.TrimSpace(info.Category) != CategoryName { + if catErr := e.client.SetCategory(ctx, []string{infoHash}, CategoryName); catErr != nil { + return fmt.Errorf("failed to set category during adoption: %w", catErr) + } + } - // 3. Associate current job tag + // 2. Associate current job tag (fatal if fails) if jobID != "" { if err := e.client.AddTags(ctx, []string{infoHash}, []string{jobID}); err != nil { return fmt.Errorf("failed to tag adopted torrent: %w", err) } } - // 4. Remove stale GoDownloader job tags if present - info, err := e.client.GetTorrentInfo(ctx, infoHash) - if err == nil && info != nil { + // 3. Remove stale GoDownloader job tags if present (surface failure) + if info != nil { rawTags := strings.Split(info.Tags, ",") var staleTags []string for _, t := range rawTags { @@ -282,7 +292,10 @@ func (e *Engine) AdoptTorrent(ctx context.Context, infoHash, jobID string) error } } if len(staleTags) > 0 { - _ = e.client.RemoveTags(ctx, []string{infoHash}, staleTags) + if rmErr := e.client.RemoveTags(ctx, []string{infoHash}, staleTags); rmErr != nil { + log.Printf("AdoptTorrent: warning: failed to remove stale tags %v from %s: %v", staleTags, infoHash, rmErr) + return fmt.Errorf("failed to cleanup stale job tags during adoption: %w", rmErr) + } } } diff --git a/internal/job/errors.go b/internal/job/errors.go index 46ef9b1..a1197b0 100644 --- a/internal/job/errors.go +++ b/internal/job/errors.go @@ -70,6 +70,17 @@ const ( ErrTorrentAlreadyExistsExternally = "TORRENT_ALREADY_EXISTS_EXTERNALLY" ) +// EngineAPIError represents a non-200 HTTP response from an engine API. +type EngineAPIError struct { + Operation string + StatusCode int + Detail string +} + +func (e *EngineAPIError) Error() string { + return fmt.Sprintf("engine %s failed with status: %d", e.Operation, e.StatusCode) +} + type TorrentFinalizeFailureKind string const ( diff --git a/internal/job/manager.go b/internal/job/manager.go index 9c4f4fb..d1af513 100644 --- a/internal/job/manager.go +++ b/internal/job/manager.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log" + "net/http" "net/url" "os" "path" @@ -1118,8 +1119,10 @@ func (m *Manager) acquireTorrentMetadata(jobID, source, torrentFilePath string) } // 7. Handle race-time 409: re-query qBittorrent once and classify ownership - if strings.Contains(addErr.Error(), "409") && expectedHash != "" { - log.Printf("acquireTorrentMetadata: add returned 409 for %s, re-querying qBittorrent ownership", expectedHash) + var apiErr *EngineAPIError + isConflict := errors.As(addErr, &apiErr) && apiErr.StatusCode == http.StatusConflict + if isConflict && expectedHash != "" { + log.Printf("acquireTorrentMetadata: add returned HTTP 409 for %s, re-querying qBittorrent ownership", expectedHash) postOwnership, queryErr := torrentEng.GetTorrentOwnership(ctx, expectedHash) if queryErr == nil && postOwnership != nil { reconciled, recErr := reconcileOwnership(postOwnership) diff --git a/internal/job/model.go b/internal/job/model.go index 5158591..bf4f95c 100644 --- a/internal/job/model.go +++ b/internal/job/model.go @@ -1,10 +1,6 @@ package job import ( - "encoding/base32" - "encoding/hex" - "fmt" - "strings" "time" "downloader/internal/networkpolicy" @@ -106,44 +102,6 @@ type TorrentFileSelection struct { Priority TorrentFilePriority `json:"priority"` } -// ExtractMagnetHash extracts and normalizes the 40-character lowercase hex info hash from a magnet URI string. -func ExtractMagnetHash(magnet string) (string, error) { - lower := strings.ToLower(magnet) - const prefix = "urn:btih:" - idx := strings.Index(lower, prefix) - if idx == -1 { - return "", fmt.Errorf("invalid magnet link: missing btih") - } - - hashPart := magnet[idx+len(prefix):] - ampIdx := strings.Index(hashPart, "&") - if ampIdx != -1 { - hashPart = hashPart[:ampIdx] - } - - // 40-character hex BTIH - if len(hashPart) == 40 { - for _, c := range hashPart { - if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { - return "", fmt.Errorf("invalid 40-character info hash in magnet link: not valid hex") - } - } - return strings.ToLower(hashPart), nil - } - - // 32-character Base32 BTIH - if len(hashPart) == 32 { - upperHash := strings.ToUpper(hashPart) - decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(upperHash) - if err != nil || len(decoded) != 20 { - return "", fmt.Errorf("invalid 32-character base32 info hash in magnet link: %v", err) - } - return hex.EncodeToString(decoded), nil - } - - return "", fmt.Errorf("invalid info hash length in magnet link") -} - // Job represents a single download task. type Job struct { ID string `json:"id"` diff --git a/internal/job/torrent_hash.go b/internal/job/torrent_hash.go index bfd02ac..f7fcf83 100644 --- a/internal/job/torrent_hash.go +++ b/internal/job/torrent_hash.go @@ -3,57 +3,204 @@ package job import ( "crypto/sha1" "crypto/sha256" + "encoding/base32" "encoding/hex" "errors" "fmt" + "net/url" "os" + "strings" "github.com/anacrolix/torrent/bencode" ) +// TorrentHashIdentity represents the multi-version torrent identity. +type TorrentHashIdentity struct { + V1Hash string // optional 40-character lowercase hex (SHA-1) + V2Hash string // optional 64-character lowercase hex (SHA-256) + QBitTorrentID string // always 40-character lowercase hex (qBittorrent / libtorrent get_best identifier) +} + type bencodeMetaInfo struct { Info bencode.Bytes `bencode:"info"` } -// ExtractTorrentInfoHash computes the canonical lowercase hex info hash from raw .torrent file bytes. -// Supports standard BitTorrent v1 (SHA-1), BitTorrent v2 (SHA-256), and hybrid torrents (v1 SHA-1). -func ExtractTorrentInfoHash(data []byte) (string, error) { +// ExtractTorrentIdentity parses raw .torrent file bytes and returns the TorrentHashIdentity. +// Follows libtorrent / qBittorrent get_best semantics: +// - v1 only: SHA1(raw info dictionary) (40 hex chars) +// - v2 only: first 20 bytes of SHA256(raw info dictionary) (40 hex chars) +// - hybrid: first 20 bytes of SHA256(raw info dictionary) (40 hex chars) +func ExtractTorrentIdentity(data []byte) (TorrentHashIdentity, error) { if len(data) == 0 { - return "", errors.New("empty torrent data") + return TorrentHashIdentity{}, errors.New("empty torrent data") } var meta bencodeMetaInfo if err := bencode.Unmarshal(data, &meta); err != nil { - return "", fmt.Errorf("invalid bencoded torrent file: %w", err) + return TorrentHashIdentity{}, fmt.Errorf("invalid bencoded torrent file: %w", err) } if len(meta.Info) == 0 { - return "", errors.New("missing info dictionary in torrent file") + return TorrentHashIdentity{}, errors.New("missing info dictionary in torrent file") } - // Check if this is a pure BitTorrent v2 torrent without v1 pieces var infoDict map[string]interface{} - if err := bencode.Unmarshal(meta.Info, &infoDict); err == nil { - _, hasPieces := infoDict["pieces"] - metaVer, hasMetaVer := infoDict["meta version"] - if !hasPieces && hasMetaVer { - if verInt, ok := metaVer.(int64); ok && verInt == 2 { - h2 := sha256.Sum256(meta.Info) - return hex.EncodeToString(h2[:]), nil - } + if err := bencode.Unmarshal(meta.Info, &infoDict); err != nil { + return TorrentHashIdentity{}, fmt.Errorf("invalid info dictionary in torrent file: %w", err) + } + + var ident TorrentHashIdentity + + // Check for BitTorrent v1 indicators: "pieces" key in info dictionary + _, hasPieces := infoDict["pieces"] + if hasPieces { + h1 := sha1.Sum(meta.Info) + ident.V1Hash = strings.ToLower(hex.EncodeToString(h1[:])) + } + + // Check for BitTorrent v2 indicators: "meta version" == 2 or "file tree" + metaVer, hasMetaVer := infoDict["meta version"] + _, hasFileTree := infoDict["file tree"] + isV2 := hasFileTree + if hasMetaVer { + if verInt, ok := metaVer.(int64); ok && verInt == 2 { + isV2 = true } } - // Standard v1 or hybrid torrent info hash (SHA-1 of the bencoded info dictionary) - h1 := sha1.Sum(meta.Info) - return hex.EncodeToString(h1[:]), nil + if isV2 { + h2 := sha256.Sum256(meta.Info) + ident.V2Hash = strings.ToLower(hex.EncodeToString(h2[:])) + } + + // Derive QBitTorrentID according to libtorrent / qBittorrent get_best semantics: + if ident.V2Hash != "" { + // v2 only or hybrid: first 20 bytes (40 hex characters) of SHA-256 + ident.QBitTorrentID = ident.V2Hash[:40] + } else if ident.V1Hash != "" { + // v1 only: SHA-1 hash (40 hex characters) + ident.QBitTorrentID = ident.V1Hash + } else { + // Fallback for custom metainfo: SHA-1 of info dictionary + h1 := sha1.Sum(meta.Info) + ident.V1Hash = strings.ToLower(hex.EncodeToString(h1[:])) + ident.QBitTorrentID = ident.V1Hash + } + + return ident, nil } -// ExtractTorrentInfoHashFromFile reads a .torrent file from disk and computes its info hash. -func ExtractTorrentInfoHashFromFile(filePath string) (string, error) { +// ExtractTorrentIdentityFromFile reads a .torrent file from disk and returns its TorrentHashIdentity. +func ExtractTorrentIdentityFromFile(filePath string) (TorrentHashIdentity, error) { data, err := os.ReadFile(filePath) if err != nil { - return "", fmt.Errorf("read torrent file %s: %w", filePath, err) + return TorrentHashIdentity{}, fmt.Errorf("read torrent file %s: %w", filePath, err) + } + return ExtractTorrentIdentity(data) +} + +// ExtractTorrentInfoHash computes the canonical 40-character lowercase hex QBitTorrentID from raw .torrent file bytes. +func ExtractTorrentInfoHash(data []byte) (string, error) { + ident, err := ExtractTorrentIdentity(data) + if err != nil { + return "", err + } + return ident.QBitTorrentID, nil +} + +// ExtractTorrentInfoHashFromFile reads a .torrent file from disk and computes its QBitTorrentID. +func ExtractTorrentInfoHashFromFile(filePath string) (string, error) { + ident, err := ExtractTorrentIdentityFromFile(filePath) + if err != nil { + return "", err + } + return ident.QBitTorrentID, nil +} + +// ExtractMagnetIdentity parses a magnet URI and extracts its v1/v2 identity and qBittorrent TorrentID. +// Supports BEP 9 and BEP 52/53 v2 multihash (xt=urn:btmh:1220<64-hex>) and hybrid magnets. +func ExtractMagnetIdentity(magnet string) (TorrentHashIdentity, error) { + lower := strings.ToLower(magnet) + if !strings.HasPrefix(lower, "magnet:?") && !strings.HasPrefix(lower, "magnet:") { + return TorrentHashIdentity{}, fmt.Errorf("invalid magnet link: missing magnet prefix") + } + + var ident TorrentHashIdentity + + rawQuery := magnet + if qIdx := strings.Index(magnet, "?"); qIdx != -1 { + rawQuery = magnet[qIdx+1:] + } + + params := strings.Split(rawQuery, "&") + for _, param := range params { + p := strings.TrimSpace(param) + if strings.HasPrefix(strings.ToLower(p), "xt=") { + val := p[3:] + if unescaped, err := url.QueryUnescape(val); err == nil { + val = unescaped + } + lowerVal := strings.ToLower(val) + + if strings.HasPrefix(lowerVal, "urn:btih:") { + hashPart := val[len("urn:btih:"):] + if amp := strings.Index(hashPart, "&"); amp != -1 { + hashPart = hashPart[:amp] + } + hashPart = strings.TrimSpace(hashPart) + if len(hashPart) == 40 && isHex(hashPart) { + ident.V1Hash = strings.ToLower(hashPart) + } else if len(hashPart) == 32 { + upperHash := strings.ToUpper(hashPart) + if decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(upperHash); err == nil && len(decoded) == 20 { + ident.V1Hash = strings.ToLower(hex.EncodeToString(decoded)) + } + } + } else if strings.HasPrefix(lowerVal, "urn:btmh:") { + hashPart := val[len("urn:btmh:"):] + if amp := strings.Index(hashPart, "&"); amp != -1 { + hashPart = hashPart[:amp] + } + hashPart = strings.TrimSpace(hashPart) + // BEP 52: 1220 prefix indicates sha256 multihash (0x12=sha256, 0x20=32 bytes) followed by 64 hex chars + if strings.HasPrefix(strings.ToLower(hashPart), "1220") && len(hashPart) == 68 { + v2Hex := hashPart[4:] + if isHex(v2Hex) { + ident.V2Hash = strings.ToLower(v2Hex) + } + } else if len(hashPart) == 64 && isHex(hashPart) { + ident.V2Hash = strings.ToLower(hashPart) + } + } + } + } + + // Derive QBitTorrentID according to libtorrent / qBittorrent get_best semantics + if ident.V2Hash != "" { + ident.QBitTorrentID = ident.V2Hash[:40] + } else if ident.V1Hash != "" { + ident.QBitTorrentID = ident.V1Hash + } else { + return TorrentHashIdentity{}, fmt.Errorf("invalid magnet link: no valid btih or btmh info hash found") + } + + return ident, nil +} + +func isHex(s string) bool { + for _, c := range s { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return false + } + } + return true +} + +// ExtractMagnetHash extracts and normalizes the 40-character lowercase hex QBitTorrentID from a magnet URI string. +func ExtractMagnetHash(magnet string) (string, error) { + ident, err := ExtractMagnetIdentity(magnet) + if err != nil { + return "", err } - return ExtractTorrentInfoHash(data) + return ident.QBitTorrentID, nil } diff --git a/internal/job/torrent_reconciliation_test.go b/internal/job/torrent_reconciliation_test.go index 382dfe3..80ca8d8 100644 --- a/internal/job/torrent_reconciliation_test.go +++ b/internal/job/torrent_reconciliation_test.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/hex" "errors" + "net/http" "os" "path/filepath" "strings" @@ -16,8 +17,8 @@ import ( "github.com/anacrolix/torrent/bencode" ) -// Helper to create a bencoded torrent file buffer -func makeTestTorrentV1(name string, length int64) ([]byte, string, error) { +// Helper to create a bencoded v1 torrent file buffer +func makeTestTorrentV1(name string, length int64) ([]byte, TorrentHashIdentity, error) { infoMap := map[string]interface{}{ "name": name, "piece length": int64(262144), @@ -26,10 +27,10 @@ func makeTestTorrentV1(name string, length int64) ([]byte, string, error) { } infoBytes, err := bencode.Marshal(infoMap) if err != nil { - return nil, "", err + return nil, TorrentHashIdentity{}, err } h1 := sha1.Sum(infoBytes) - infoHash := hex.EncodeToString(h1[:]) + v1Hex := strings.ToLower(hex.EncodeToString(h1[:])) torrentMap := map[string]interface{}{ "announce": "http://tracker.example.com/announce", @@ -37,12 +38,17 @@ func makeTestTorrentV1(name string, length int64) ([]byte, string, error) { } torrentBytes, err := bencode.Marshal(torrentMap) if err != nil { - return nil, "", err + return nil, TorrentHashIdentity{}, err } - return torrentBytes, strings.ToLower(infoHash), nil + ident := TorrentHashIdentity{ + V1Hash: v1Hex, + QBitTorrentID: v1Hex, + } + return torrentBytes, ident, nil } -func makeTestTorrentV2(name string, length int64) ([]byte, string, error) { +// Helper to create a bencoded pure v2 torrent file buffer +func makeTestTorrentV2(name string, length int64) ([]byte, TorrentHashIdentity, error) { infoMap := map[string]interface{}{ "name": name, "piece length": int64(262144), @@ -58,10 +64,11 @@ func makeTestTorrentV2(name string, length int64) ([]byte, string, error) { } infoBytes, err := bencode.Marshal(infoMap) if err != nil { - return nil, "", err + return nil, TorrentHashIdentity{}, err } h2 := sha256.Sum256(infoBytes) - infoHash := hex.EncodeToString(h2[:]) + v2Hex := strings.ToLower(hex.EncodeToString(h2[:])) + qbitID := v2Hex[:40] torrentMap := map[string]interface{}{ "announce": "http://tracker.example.com/announce", @@ -69,12 +76,17 @@ func makeTestTorrentV2(name string, length int64) ([]byte, string, error) { } torrentBytes, err := bencode.Marshal(torrentMap) if err != nil { - return nil, "", err + return nil, TorrentHashIdentity{}, err + } + ident := TorrentHashIdentity{ + V2Hash: v2Hex, + QBitTorrentID: qbitID, } - return torrentBytes, strings.ToLower(infoHash), nil + return torrentBytes, ident, nil } -func makeTestTorrentHybrid(name string, length int64) ([]byte, string, error) { +// Helper to create a bencoded hybrid (v1 + v2) torrent file buffer +func makeTestTorrentHybrid(name string, length int64) ([]byte, TorrentHashIdentity, error) { infoMap := map[string]interface{}{ "name": name, "piece length": int64(262144), @@ -91,10 +103,13 @@ func makeTestTorrentHybrid(name string, length int64) ([]byte, string, error) { } infoBytes, err := bencode.Marshal(infoMap) if err != nil { - return nil, "", err + return nil, TorrentHashIdentity{}, err } h1 := sha1.Sum(infoBytes) - infoHash := hex.EncodeToString(h1[:]) + v1Hex := strings.ToLower(hex.EncodeToString(h1[:])) + h2 := sha256.Sum256(infoBytes) + v2Hex := strings.ToLower(hex.EncodeToString(h2[:])) + qbitID := v2Hex[:40] torrentMap := map[string]interface{}{ "announce": "http://tracker.example.com/announce", @@ -102,54 +117,112 @@ func makeTestTorrentHybrid(name string, length int64) ([]byte, string, error) { } torrentBytes, err := bencode.Marshal(torrentMap) if err != nil { - return nil, "", err + return nil, TorrentHashIdentity{}, err } - return torrentBytes, strings.ToLower(infoHash), nil + ident := TorrentHashIdentity{ + V1Hash: v1Hex, + V2Hash: v2Hex, + QBitTorrentID: qbitID, + } + return torrentBytes, ident, nil } -// 9. Test .torrent hash extraction fixtures with deterministic expected hashes -func TestExtractTorrentInfoHash_Fixtures(t *testing.T) { - // V1 +// 1. Test .torrent hash extraction identity semantics for v1, v2, and hybrid +func TestExtractTorrentIdentity_Fixtures(t *testing.T) { + // v1 only v1Bytes, expectedV1, err := makeTestTorrentV1("ubuntu-22.04.iso", 1024*1024*1024) if err != nil { t.Fatalf("failed to make v1 torrent: %v", err) } - gotV1, err := ExtractTorrentInfoHash(v1Bytes) + identV1, err := ExtractTorrentIdentity(v1Bytes) if err != nil { - t.Fatalf("ExtractTorrentInfoHash(v1) failed: %v", err) + t.Fatalf("ExtractTorrentIdentity(v1) failed: %v", err) + } + if identV1.V1Hash != expectedV1.V1Hash { + t.Errorf("v1 V1Hash mismatch: got %s, want %s", identV1.V1Hash, expectedV1.V1Hash) + } + if identV1.V2Hash != "" { + t.Errorf("v1 V2Hash expected empty, got %s", identV1.V2Hash) } - if gotV1 != expectedV1 { - t.Fatalf("v1 info hash mismatch: got %s, want %s", gotV1, expectedV1) + if identV1.QBitTorrentID != expectedV1.QBitTorrentID || len(identV1.QBitTorrentID) != 40 { + t.Errorf("v1 QBitTorrentID mismatch: got %s (len=%d), want %s (40 hex)", identV1.QBitTorrentID, len(identV1.QBitTorrentID), expectedV1.QBitTorrentID) } - // V2 + // v2 only v2Bytes, expectedV2, err := makeTestTorrentV2("v2-archive.iso", 2048*1024*1024) if err != nil { t.Fatalf("failed to make v2 torrent: %v", err) } - gotV2, err := ExtractTorrentInfoHash(v2Bytes) + identV2, err := ExtractTorrentIdentity(v2Bytes) if err != nil { - t.Fatalf("ExtractTorrentInfoHash(v2) failed: %v", err) + t.Fatalf("ExtractTorrentIdentity(v2) failed: %v", err) } - if gotV2 != expectedV2 { - t.Fatalf("v2 info hash mismatch: got %s, want %s", gotV2, expectedV2) + if identV2.V2Hash != expectedV2.V2Hash || len(identV2.V2Hash) != 64 { + t.Errorf("v2 V2Hash mismatch: got %s (len=%d), want %s (64 hex)", identV2.V2Hash, len(identV2.V2Hash), expectedV2.V2Hash) + } + if identV2.V1Hash != "" { + t.Errorf("v2 V1Hash expected empty, got %s", identV2.V1Hash) + } + if identV2.QBitTorrentID != expectedV2.QBitTorrentID || len(identV2.QBitTorrentID) != 40 { + t.Errorf("v2 QBitTorrentID mismatch: got %s (len=%d), want %s (40 hex)", identV2.QBitTorrentID, len(identV2.QBitTorrentID), expectedV2.QBitTorrentID) } - // Hybrid (should resolve to v1 SHA-1 hash for qBittorrent compatibility) + // hybrid (v1 + v2) hybridBytes, expectedHybrid, err := makeTestTorrentHybrid("hybrid.iso", 512*1024*1024) if err != nil { t.Fatalf("failed to make hybrid torrent: %v", err) } - gotHybrid, err := ExtractTorrentInfoHash(hybridBytes) + identHybrid, err := ExtractTorrentIdentity(hybridBytes) + if err != nil { + t.Fatalf("ExtractTorrentIdentity(hybrid) failed: %v", err) + } + if identHybrid.V1Hash != expectedHybrid.V1Hash || len(identHybrid.V1Hash) != 40 { + t.Errorf("hybrid V1Hash mismatch: got %s, want %s", identHybrid.V1Hash, expectedHybrid.V1Hash) + } + if identHybrid.V2Hash != expectedHybrid.V2Hash || len(identHybrid.V2Hash) != 64 { + t.Errorf("hybrid V2Hash mismatch: got %s, want %s", identHybrid.V2Hash, expectedHybrid.V2Hash) + } + if identHybrid.QBitTorrentID != expectedHybrid.QBitTorrentID || len(identHybrid.QBitTorrentID) != 40 { + t.Errorf("hybrid QBitTorrentID mismatch: got %s (len=%d), want %s (40 hex)", identHybrid.QBitTorrentID, len(identHybrid.QBitTorrentID), expectedHybrid.QBitTorrentID) + } +} + +// 2. Test v2 magnet and hybrid magnet handling +func TestExtractMagnetIdentity_Variants(t *testing.T) { + // Standard v1 hex + v1Hex := "1111111111111111111111111111111111111111" + m1 := "magnet:?xt=urn:btih:" + v1Hex + "&dn=test.iso" + id1, err := ExtractMagnetIdentity(m1) + if err != nil { + t.Fatalf("ExtractMagnetIdentity(v1 hex) failed: %v", err) + } + if id1.V1Hash != v1Hex || id1.QBitTorrentID != v1Hex { + t.Errorf("v1 hex identity mismatch: got %+v", id1) + } + + // BEP 52 v2 multihash (xt=urn:btmh:1220<64-hex>) + v2Hex := "2222222222222222222222222222222222222222222222222222222222222222" + m2 := "magnet:?xt=urn:btmh:1220" + v2Hex + "&dn=test-v2.iso" + id2, err := ExtractMagnetIdentity(m2) + if err != nil { + t.Fatalf("ExtractMagnetIdentity(v2 multihash) failed: %v", err) + } + if id2.V2Hash != v2Hex || id2.QBitTorrentID != v2Hex[:40] { + t.Errorf("v2 multihash identity mismatch: got %+v, want QBitTorrentID %s", id2, v2Hex[:40]) + } + + // Hybrid magnet (both btih and btmh) + m3 := "magnet:?xt=urn:btih:" + v1Hex + "&xt=urn:btmh:1220" + v2Hex + "&dn=hybrid.iso" + id3, err := ExtractMagnetIdentity(m3) if err != nil { - t.Fatalf("ExtractTorrentInfoHash(hybrid) failed: %v", err) + t.Fatalf("ExtractMagnetIdentity(hybrid) failed: %v", err) } - if gotHybrid != expectedHybrid { - t.Fatalf("hybrid info hash mismatch: got %s, want %s", gotHybrid, expectedHybrid) + if id3.V1Hash != v1Hex || id3.V2Hash != v2Hex || id3.QBitTorrentID != v2Hex[:40] { + t.Errorf("hybrid identity mismatch: got %+v, want QBitTorrentID %s", id3, v2Hex[:40]) } } -// 1. Magnet hash absent in qBittorrent: AddMagnet called normally +// 3. Magnet hash absent in qBittorrent: AddMagnet called normally func TestTorrentReconciliation_MagnetHashAbsent_CallsAddMagnet(t *testing.T) { jobRepo := newFakeJobRepository() torrentRepo := newFakeTorrentRepository(jobRepo) @@ -179,7 +252,6 @@ func TestTorrentReconciliation_MagnetHashAbsent_CallsAddMagnet(t *testing.T) { t.Fatalf("mgr.Create failed: %v", err) } - // Wait for metadata acquisition deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { jobObj, _ := mgr.Get(context.Background(), j.ID) @@ -194,7 +266,7 @@ func TestTorrentReconciliation_MagnetHashAbsent_CallsAddMagnet(t *testing.T) { } } -// 2 & 3. Same-job hash already exists: AddMagnet NOT called, existing torrent reused, retry succeeds without 409 +// 4. Same-job existing torrent: AddMagnet NOT called, existing torrent reused func TestTorrentReconciliation_SameJobExisting_ReusesTorrentWithoutCallingAdd(t *testing.T) { jobRepo := newFakeJobRepository() torrentRepo := newFakeTorrentRepository(jobRepo) @@ -211,14 +283,14 @@ func TestTorrentReconciliation_SameJobExisting_ReusesTorrentWithoutCallingAdd(t return &TorrentOwnership{ Hash: targetHash, Category: "godownloader", - Tags: []string{currentJobID}, // Tagged with current job ID + Tags: []string{currentJobID}, }, nil } return nil, nil }, addMagnetFunc: func(magnet string) (string, error) { atomic.AddInt32(&addMagnetCalled, 1) - return "", errors.New("failed to add magnet, status: 409") + return "", &EngineAPIError{Operation: "AddMagnet", StatusCode: http.StatusConflict, Detail: "Torrent already present"} }, stopDownloadFunc: func(hash string) error { atomic.AddInt32(&stopDownloadCalled, 1) @@ -241,7 +313,6 @@ func TestTorrentReconciliation_SameJobExisting_ReusesTorrentWithoutCallingAdd(t } currentJobID = j.ID - // Wait for metadata acquisition deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { jobObj, _ := mgr.Get(context.Background(), j.ID) @@ -256,24 +327,19 @@ func TestTorrentReconciliation_SameJobExisting_ReusesTorrentWithoutCallingAdd(t t.Fatalf("expected StatusAwaitingSelection, got %s (err=%s)", jobObj.Status, jobObj.Error) } - // Verify AddMagnet was NOT called (avoiding 409) if atomic.LoadInt32(&addMagnetCalled) != 0 { t.Fatalf("expected AddMagnet to NOT be called for same-job existing torrent, called %d times", addMagnetCalled) } - - // Verify stopDownload was called for safety before file selection if atomic.LoadInt32(&stopDownloadCalled) < 1 { t.Fatalf("expected StopDownload to be called, got %d", stopDownloadCalled) } } -// 4 & 10 & 11. Orphan godownloader torrent: adopted safely, stopped before selection, no AddMagnet, never deletes files -func TestTorrentReconciliation_OrphanGodownloader_AdoptedSafely(t *testing.T) { +// 5. Adoption fails closed when StopTorrents returns an error +func TestTorrentReconciliation_AdoptionFailClosed_StopTorrentsError(t *testing.T) { jobRepo := newFakeJobRepository() torrentRepo := newFakeTorrentRepository(jobRepo) - var addMagnetCalled int32 - var adoptCalled int32 - var removeCalled int32 + var startDownloadCalled int32 targetHash := "3333333333333333333333333333333333333333" @@ -284,29 +350,18 @@ func TestTorrentReconciliation_OrphanGodownloader_AdoptedSafely(t *testing.T) { return &TorrentOwnership{ Hash: targetHash, Category: "godownloader", - Tags: []string{"job_deadbeef"}, // Stale job ID that does not exist in local DB + Tags: []string{"job_deadbeef"}, }, nil } return nil, nil }, adoptTorrentFunc: func(hash, jobID string) error { - atomic.AddInt32(&adoptCalled, 1) - return nil - }, - addMagnetFunc: func(magnet string) (string, error) { - atomic.AddInt32(&addMagnetCalled, 1) - return "", errors.New("409 conflict") + return errors.New("simulated StopTorrents daemon communication error") }, - removeTorrentFunc: func(hash string, deleteFiles bool) error { - atomic.AddInt32(&removeCalled, 1) + startDownloadFunc: func(hash string) error { + atomic.AddInt32(&startDownloadCalled, 1) return nil }, - getTorrentInfoFunc: func(hash string) (*TorrentInfo, error) { - return &TorrentInfo{Name: "orphan.iso", TotalSize: 3000}, nil - }, - getFilesFunc: func(hash string) ([]TorrentFile, error) { - return []TorrentFile{{Index: 0, Path: "orphan.iso", Size: 3000, Selected: true}}, nil - }, } reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} bus := newFakeEventBus() @@ -320,29 +375,25 @@ func TestTorrentReconciliation_OrphanGodownloader_AdoptedSafely(t *testing.T) { deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { jobObj, _ := mgr.Get(context.Background(), j.ID) - if jobObj != nil && jobObj.Status == StatusAwaitingSelection { + if jobObj != nil && jobObj.Status == StatusFailed { break } time.Sleep(20 * time.Millisecond) } jobObj, _ := mgr.Get(context.Background(), j.ID) - if jobObj.Status != StatusAwaitingSelection { - t.Fatalf("expected StatusAwaitingSelection, got %s (err=%s)", jobObj.Status, jobObj.Error) - } - - if atomic.LoadInt32(&adoptCalled) != 1 { - t.Fatalf("expected AdoptTorrent to be called 1 time, got %d", adoptCalled) + if jobObj.Status != StatusFailed { + t.Fatalf("expected StatusFailed when adoption fails closed, got %s", jobObj.Status) } - if atomic.LoadInt32(&addMagnetCalled) != 0 { - t.Fatalf("expected AddMagnet to NOT be called during adoption, called %d", addMagnetCalled) + if !strings.Contains(jobObj.Error, "StopTorrents") { + t.Fatalf("expected StopTorrents error in job.Error, got: %s", jobObj.Error) } - if atomic.LoadInt32(&removeCalled) != 0 { - t.Fatalf("expected DeleteTorrents to NOT be called during adoption, called %d", removeCalled) + if atomic.LoadInt32(&startDownloadCalled) != 0 { + t.Fatalf("StartDownload must NEVER be called when adoption fails, called %d times", startDownloadCalled) } } -// 5. Another existing local GoDownloader job owns hash: returns TORRENT_ALREADY_MANAGED and does not mutate +// 6. Another active local job owns hash: returns TORRENT_ALREADY_MANAGED func TestTorrentReconciliation_AnotherLocalJobOwnsHash_ReturnsConflict(t *testing.T) { jobRepo := newFakeJobRepository() torrentRepo := newFakeTorrentRepository(jobRepo) @@ -351,7 +402,6 @@ func TestTorrentReconciliation_AnotherLocalJobOwnsHash_ReturnsConflict(t *testin targetHash := "4444444444444444444444444444444444444444" - // Pre-create active job 1 in local repo existingJob := &Job{ ID: "job_active1", Status: StatusDownloading, @@ -412,7 +462,7 @@ func TestTorrentReconciliation_AnotherLocalJobOwnsHash_ReturnsConflict(t *testin } } -// 6. Externally-owned qBittorrent torrent: returns TORRENT_ALREADY_EXISTS_EXTERNALLY and does not mutate +// 7. Externally-owned qBittorrent torrent: returns TORRENT_ALREADY_EXISTS_EXTERNALLY without mutating func TestTorrentReconciliation_ExternallyOwnedTorrent_ReturnsConflict(t *testing.T) { jobRepo := newFakeJobRepository() torrentRepo := newFakeTorrentRepository(jobRepo) @@ -477,7 +527,7 @@ func TestTorrentReconciliation_ExternallyOwnedTorrent_ReturnsConflict(t *testing } } -// 7. Add races and returns 409: one ownership re-query reconciles same-job torrent as success +// 8. Add returns typed *qbittorrent.APIError with HTTP 409: re-queries once and reconciles as success func TestTorrentReconciliation_AddRace409_RequeriesAndReconciles(t *testing.T) { jobRepo := newFakeJobRepository() torrentRepo := newFakeTorrentRepository(jobRepo) @@ -491,10 +541,8 @@ func TestTorrentReconciliation_AddRace409_RequeriesAndReconciles(t *testing.T) { getOwnershipFunc: func(hash string) (*TorrentOwnership, error) { count := atomic.AddInt32(&getOwnershipCallCount, 1) if count == 1 { - // Initial check: not found yet - return nil, nil + return nil, nil // Initial check: not found yet } - // Second check after 409: found under godownloader return &TorrentOwnership{ Hash: targetHash, Category: "godownloader", @@ -503,7 +551,11 @@ func TestTorrentReconciliation_AddRace409_RequeriesAndReconciles(t *testing.T) { }, addMagnetFunc: func(magnet string) (string, error) { atomic.AddInt32(&addMagnetCallCount, 1) - return "", errors.New("failed to add magnet, status: 409 (Torrent already exists)") + return "", &EngineAPIError{ + Operation: "AddMagnet", + StatusCode: http.StatusConflict, + Detail: "secret_passkey=123456&tracker_token=abcdef", // Sensitive daemon response + } }, adoptTorrentFunc: func(hash, jobID string) error { return nil @@ -546,30 +598,32 @@ func TestTorrentReconciliation_AddRace409_RequeriesAndReconciles(t *testing.T) { } } -// 8. Uploaded .torrent file with same hash uses the same reconciliation rules -func TestTorrentReconciliation_UploadedTorrentFile_SameRules(t *testing.T) { +// 9. Uploaded .torrent v2 and hybrid file reconciliation using 40-character QBitTorrentID +func TestTorrentReconciliation_UploadedTorrentV2AndHybrid_SameRules(t *testing.T) { jobRepo := newFakeJobRepository() torrentRepo := newFakeTorrentRepository(jobRepo) var addTorrentFileCalled int32 var adoptCalled int32 - data, hash, err := makeTestTorrentV1("uploaded.iso", 8000) + data, ident, err := makeTestTorrentV2("uploaded-v2.iso", 8000) if err != nil { - t.Fatalf("makeTestTorrentV1 failed: %v", err) + t.Fatalf("makeTestTorrentV2 failed: %v", err) } tempDir := t.TempDir() - filePath := filepath.Join(tempDir, "test_upload.torrent") + filePath := filepath.Join(tempDir, "test_v2_upload.torrent") if err := os.WriteFile(filePath, data, 0644); err != nil { t.Fatalf("write file failed: %v", err) } + var lookedUpHash string eng := &fakeTorrentEngine{ fakeEngine: &fakeEngine{}, getOwnershipFunc: func(infoHash string) (*TorrentOwnership, error) { - if infoHash == hash { + lookedUpHash = infoHash + if infoHash == ident.QBitTorrentID { return &TorrentOwnership{ - Hash: hash, + Hash: ident.QBitTorrentID, Category: "godownloader", Tags: []string{"job_old123"}, }, nil @@ -582,13 +636,13 @@ func TestTorrentReconciliation_UploadedTorrentFile_SameRules(t *testing.T) { }, addTorrentFileFunc: func(path string) (string, error) { atomic.AddInt32(&addTorrentFileCalled, 1) - return hash, nil + return ident.QBitTorrentID, nil }, getTorrentInfoFunc: func(infoHash string) (*TorrentInfo, error) { - return &TorrentInfo{Name: "uploaded.iso", TotalSize: 8000}, nil + return &TorrentInfo{Name: "uploaded-v2.iso", TotalSize: 8000}, nil }, getFilesFunc: func(infoHash string) ([]TorrentFile, error) { - return []TorrentFile{{Index: 0, Path: "uploaded.iso", Size: 8000, Selected: true}}, nil + return []TorrentFile{{Index: 0, Path: "uploaded-v2.iso", Size: 8000, Selected: true}}, nil }, } reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} @@ -614,7 +668,13 @@ func TestTorrentReconciliation_UploadedTorrentFile_SameRules(t *testing.T) { t.Fatalf("expected StatusAwaitingSelection, got %s (err=%s)", jobObj.Status, jobObj.Error) } - // Should have adopted instead of re-adding + // Verify qBittorrent was looked up using 40-character QBitTorrentID + if lookedUpHash != ident.QBitTorrentID || len(lookedUpHash) != 40 { + t.Fatalf("qBittorrent lookup must use 40-character QBitTorrentID, got %q (len=%d)", lookedUpHash, len(lookedUpHash)) + } + if jobObj.EngineID != ident.QBitTorrentID { + t.Fatalf("Job.EngineID must be 40-character QBitTorrentID, got %s", jobObj.EngineID) + } if atomic.LoadInt32(&adoptCalled) != 1 { t.Fatalf("expected AdoptTorrent to be called 1 time, got %d", adoptCalled) } From 6230b1477bfd3a5822798818c51a84a73d925268 Mon Sep 17 00:00:00 2001 From: Kavya Arora Date: Sat, 8 Aug 2026 18:05:24 +0530 Subject: [PATCH 11/15] fix(torrent): eliminate qbittorrent start/resume race with bounded confirmation polling --- internal/job/manager.go | 118 +++- .../job/torrent_start_resume_sync_test.go | 590 ++++++++++++++++++ 2 files changed, 703 insertions(+), 5 deletions(-) create mode 100644 internal/job/torrent_start_resume_sync_test.go diff --git a/internal/job/manager.go b/internal/job/manager.go index d1af513..1cb395c 100644 --- a/internal/job/manager.go +++ b/internal/job/manager.go @@ -1656,6 +1656,7 @@ func (m *Manager) StartTorrentWithPolicy(ctx context.Context, id string, selecti records = append(records, TorrentFileRecord{ JobID: id, FileIndex: s.Index, + Size: fileSizeMap[s.Index], Selected: s.Priority != PrioritySkip, Priority: string(s.Priority), }) @@ -1693,6 +1694,17 @@ func (m *Manager) StartTorrentWithPolicy(ctx context.Context, id string, selecti m.publish(EventJobFailed, j) return j, nil } + if err := m.confirmTorrentEngineActive(ctx, j, torrentEng, 3*time.Second); err != nil { + j.Status = StatusFailed + j.Error = fmt.Sprintf("failed to confirm torrent start: %v", err) + j.UpdatedAt = time.Now() + m.repo.Update(ctx, j) + if m.queueRepo != nil { + m.queueRepo.Delete(ctx, j.ID) + } + m.publish(EventJobFailed, j) + return j, nil + } if limitErr := m.applyJobLimits(ctx, j, eng, false); limitErr != nil { j.NetworkReconcilePending = true } @@ -1723,8 +1735,12 @@ func (m *Manager) StopSeeding(ctx context.Context, id string) (*Job, error) { return nil, err } + if j.Type != TypeTorrent { + return nil, &AppError{Code: ErrInvalidJobState, Message: "stop seeding is only valid for torrent jobs"} + } + if j.Status != StatusSeeding { - return nil, &AppError{Code: ErrInvalidJobState, Message: fmt.Sprintf("cannot stop seeding a %s job", j.Status)} + return nil, &AppError{Code: ErrInvalidJobState, Message: fmt.Sprintf("cannot stop seeding from %s state", j.Status)} } stopped, err := m.stopSeedingWithReason(ctx, j, "manual") @@ -1885,15 +1901,35 @@ func (m *Manager) Resume(ctx context.Context, id string) (*Job, error) { if prepErr := m.prepareNetworkDispatch(ctx, j, eng); prepErr != nil { return nil, prepErr } - engineID, err := eng.Start(ctx, j, m.downloadDir) - if err != nil { - return nil, &AppError{Code: ErrEngineError, Message: fmt.Sprintf("engine start failed: %v", err)} + if j.Type == TypeTorrent { + torrentEng, ok := eng.(ITorrentEngine) + if !ok { + return nil, &AppError{Code: ErrEngineError, Message: "engine does not support torrent operations"} + } + if err := torrentEng.StartDownload(ctx, j.EngineID); err != nil { + return nil, &AppError{Code: ErrEngineError, Message: fmt.Sprintf("engine start failed: %v", err)} + } + if err := m.confirmTorrentEngineActive(ctx, j, torrentEng, 3*time.Second); err != nil { + return nil, &AppError{Code: ErrEngineError, Message: fmt.Sprintf("failed to confirm torrent start: %v", err)} + } + } else { + engineID, err := eng.Start(ctx, j, m.downloadDir) + if err != nil { + return nil, &AppError{Code: ErrEngineError, Message: fmt.Sprintf("engine start failed: %v", err)} + } + j.EngineID = engineID } - j.EngineID = engineID } else { if err := eng.Resume(ctx, j); err != nil { return nil, &AppError{Code: ErrEngineError, Message: fmt.Sprintf("engine resume failed: %v", err)} } + if j.Type == TypeTorrent { + if torrentEng, ok := eng.(ITorrentEngine); ok { + if err := m.confirmTorrentEngineActive(ctx, j, torrentEng, 3*time.Second); err != nil { + return nil, &AppError{Code: ErrEngineError, Message: fmt.Sprintf("failed to confirm torrent resume: %v", err)} + } + } + } } if limitErr := m.applyJobLimits(ctx, j, eng, false); limitErr != nil { j.NetworkReconcilePending = true @@ -2947,6 +2983,11 @@ func (m *Manager) dispatchQueuedJob(ctx context.Context, qj *QueuedJob) error { if err := torrentEng.StartDownload(ctx, j.EngineID); err != nil { return err } + if err := m.confirmTorrentEngineActive(ctx, j, torrentEng, 3*time.Second); err != nil { + log.Printf("dispatchQueuedJob: confirm torrent start failed for job %s: %v", j.ID, err) + targetStatus := StatusFailed + return m.persistDispatchFailure(ctx, j, qj, targetStatus, err) + } } else { engineID, err := eng.Start(ctx, j, execDir) if err != nil { @@ -2963,6 +3004,15 @@ func (m *Manager) dispatchQueuedJob(ctx context.Context, qj *QueuedJob) error { if err := eng.Resume(ctx, j); err != nil { return err } + if j.Type == TypeTorrent { + if torrentEng, ok := eng.(ITorrentEngine); ok { + if err := m.confirmTorrentEngineActive(ctx, j, torrentEng, 3*time.Second); err != nil { + log.Printf("dispatchQueuedJob: confirm torrent resume failed for job %s: %v", j.ID, err) + targetStatus := StatusPaused + return m.persistDispatchFailure(ctx, j, qj, targetStatus, err) + } + } + } } j.Status = StatusDownloading @@ -2990,6 +3040,64 @@ func (m *Manager) dispatchQueuedJob(ctx context.Context, qj *QueuedJob) error { return nil } +// confirmTorrentEngineActive polls the torrent engine for a bounded duration +// to confirm that the torrent has transitioned to an active/downloading/seeding/completed state +// and is no longer in transient stoppedDL/pausedDL state. +func (m *Manager) confirmTorrentEngineActive(ctx context.Context, j *Job, torrentEng ITorrentEngine, timeout time.Duration) error { + if timeout <= 0 { + timeout = 3 * time.Second + } + deadline := time.Now().Add(timeout) + var lastState string + pollInterval := 50 * time.Millisecond + + for { + if ctx.Err() != nil { + return ctx.Err() + } + + status, err := torrentEng.Status(ctx, j) + if err == nil && status != nil { + lastState = status.RawState + if lastState == "" { + lastState = string(status.Status) + } + + // Active startup states: + // StatusDownloading includes: downloading, forcedDL, stalledDL, queuedDL, checkingDL, allocating, checkingResumeData, moving. + // StatusSeeding includes: uploading, forcedUP, stalledUP, queuedUP, checkingUP. + // StatusCompleted includes: stoppedUP, pausedUP. + if status.Status == StatusDownloading || status.Status == StatusSeeding || status.Status == StatusCompleted { + return nil + } + + // Terminal failure states: + if status.Status == StatusFailed || status.Status == StatusCancelled { + errMsg := status.Error + if errMsg == "" { + errMsg = fmt.Sprintf("torrent engine reported %s state during startup", lastState) + } + return errors.New(errMsg) + } + } + + if time.Now().After(deadline) { + break + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(pollInterval): + } + } + + if lastState == "" { + lastState = "stopped/paused" + } + return fmt.Errorf("torrent engine did not transition to active state within %v (last state=%s)", timeout, lastState) +} + func (m *Manager) cleanupQueueOnStartup(ctx context.Context) { if m.queueRepo == nil { return diff --git a/internal/job/torrent_start_resume_sync_test.go b/internal/job/torrent_start_resume_sync_test.go new file mode 100644 index 0000000..902afbf --- /dev/null +++ b/internal/job/torrent_start_resume_sync_test.go @@ -0,0 +1,590 @@ +package job + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "downloader/internal/networkpolicy" +) + +// asyncTransitionEngine is a test double capable of simulating asynchronous daemon state transitions. +type asyncTransitionEngine struct { + mu sync.Mutex + + states []string + currentIndex int + + startDownloadCalled int32 + stopDownloadCalled int32 + setFilePrioritiesTimes int32 + + prioritiesAppliedBeforeStart bool + + files []TorrentFile +} + +func newAsyncTransitionEngine(states ...string) *asyncTransitionEngine { + return &asyncTransitionEngine{ + states: states, + files: []TorrentFile{ + {Index: 0, Path: "file1.iso", Size: 1000, Priority: PriorityNormal, Selected: true}, + {Index: 1, Path: "file2.iso", Size: 2000, Priority: PrioritySkip, Selected: false}, + }, + } +} + +func (e *asyncTransitionEngine) Capabilities() networkpolicy.EngineCapabilities { + return networkpolicy.EngineCapabilities{Pause: true, Resume: true, Cancel: true, Retry: true, FileSelection: true} +} + +func (e *asyncTransitionEngine) Start(ctx context.Context, j *Job, downloadDir string) (string, error) { + return j.EngineID, nil +} + +func (e *asyncTransitionEngine) Pause(ctx context.Context, j *Job) error { + atomic.AddInt32(&e.stopDownloadCalled, 1) + e.mu.Lock() + e.states = []string{"stoppedDL"} + e.currentIndex = 0 + e.mu.Unlock() + return nil +} + +func (e *asyncTransitionEngine) Resume(ctx context.Context, j *Job) error { + atomic.AddInt32(&e.startDownloadCalled, 1) + return nil +} + +func (e *asyncTransitionEngine) Cancel(ctx context.Context, j *Job) error { + return nil +} + +func (e *asyncTransitionEngine) Status(ctx context.Context, j *Job) (*EngineStatus, error) { + e.mu.Lock() + defer e.mu.Unlock() + + raw := "stoppedDL" + if len(e.states) > 0 { + if e.currentIndex < len(e.states) { + raw = e.states[e.currentIndex] + e.currentIndex++ + } else { + raw = e.states[len(e.states)-1] + } + } + + st := StatusPaused + switch raw { + case "downloading", "forcedDL", "stalledDL", "allocating", "queuedDL", "checkingDL", "checkingResumeData", "moving": + st = StatusDownloading + case "uploading", "forcedUP", "stalledUP", "queuedUP", "checkingUP": + st = StatusSeeding + case "stoppedUP", "pausedUP": + st = StatusCompleted + case "stoppedDL", "pausedDL": + st = StatusPaused + default: + st = StatusDownloading + } + + return &EngineStatus{ + Status: st, + RawState: raw, + CompletedBytes: 250, + TotalBytes: 1000, + SpeedBytesPerSecond: 500, + Progress: 25.0, + }, nil +} + +func (e *asyncTransitionEngine) AddMagnet(ctx context.Context, magnet, savePath, jobID string) (string, error) { + return "hash1234", nil +} + +func (e *asyncTransitionEngine) AddTorrentFile(ctx context.Context, filePath, savePath, jobID string) (string, error) { + return "hash1234", nil +} + +func (e *asyncTransitionEngine) GetTorrentOwnership(ctx context.Context, infoHash string) (*TorrentOwnership, error) { + return nil, nil +} + +func (e *asyncTransitionEngine) AdoptTorrent(ctx context.Context, infoHash, jobID string) error { + return nil +} + +func (e *asyncTransitionEngine) GetFiles(ctx context.Context, infoHash string) ([]TorrentFile, error) { + return e.files, nil +} + +func (e *asyncTransitionEngine) SetFilePriorities(ctx context.Context, infoHash string, selections []TorrentFileSelection) error { + atomic.AddInt32(&e.setFilePrioritiesTimes, 1) + if atomic.LoadInt32(&e.startDownloadCalled) == 0 { + e.prioritiesAppliedBeforeStart = true + } + return nil +} + +func (e *asyncTransitionEngine) StartDownload(ctx context.Context, infoHash string) error { + atomic.AddInt32(&e.startDownloadCalled, 1) + return nil +} + +func (e *asyncTransitionEngine) StopDownload(ctx context.Context, infoHash string) error { + atomic.AddInt32(&e.stopDownloadCalled, 1) + e.mu.Lock() + e.states = []string{"stoppedDL"} + e.currentIndex = 0 + e.mu.Unlock() + return nil +} + +func (e *asyncTransitionEngine) RemoveTorrent(ctx context.Context, infoHash string, deleteFiles bool) error { + return nil +} + +func (e *asyncTransitionEngine) GetTorrentInfo(ctx context.Context, infoHash string) (*TorrentInfo, error) { + return &TorrentInfo{Name: "test.iso", InfoHash: infoHash, TotalSize: 1000}, nil +} + +func (e *asyncTransitionEngine) GetRawState(ctx context.Context, infoHash string) (string, error) { + e.mu.Lock() + defer e.mu.Unlock() + if len(e.states) > 0 { + return e.states[0], nil + } + return "stoppedDL", nil +} + +func (e *asyncTransitionEngine) HealthCheck(ctx context.Context) error { + return nil +} + +// 5.A Start transition race: +// Initial engine state = stoppedDL -> first status poll = stoppedDL -> second status poll = downloading +// Expected: StartTorrent -> scheduler dispatch -> no PAUSED transition -> eventually DOWNLOADING -> activeJobs contains job -> progress monitor updates. +func TestTorrentSync_StartTransitionRace_AvoidsPausedSplitBrain(t *testing.T) { + jobRepo := newFakeJobRepository() + queueRepo := &fakeQueueRepo{entries: make(map[string]*QueueEntry)} + torrentRepo := newFakeTorrentRepository(jobRepo, queueRepo) + + // Sequence: stoppedDL on startup -> stoppedDL on first confirmation poll -> downloading on second poll + eng := newAsyncTransitionEngine("stoppedDL", "stoppedDL", "downloading") + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + bus := newFakeEventBus() + + var events []JobStatus + var eventMu sync.Mutex + sub := bus.Subscribe() + go func() { + for ev := range sub { + if ev.Type == EventJobUpdated && ev.Job.ID != "" { + eventMu.Lock() + events = append(events, ev.Job.Status) + eventMu.Unlock() + } + } + }() + + mgr := NewManager(jobRepo, reg, bus, t.TempDir(), torrentRepo) + sched := NewScheduler(jobRepo, queueRepo, func(ctx context.Context) int { return 5 }, mgr.dispatchQueuedJob) + mgr.SetScheduler(sched) + mgr.SetQueueRepository(queueRepo) + sched.Start(context.Background()) + defer sched.Stop() + + monitor := NewMonitor(mgr, 100*time.Millisecond) + monitor.Start(context.Background()) + defer monitor.Stop() + + j := &Job{ + ID: "job_sync_a", + Engine: "qbittorrent", + EngineID: "hash_sync_a", + Type: TypeTorrent, + Status: StatusAwaitingSelection, + DestinationDir: t.TempDir(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + _ = jobRepo.Create(context.Background(), j) + _ = torrentRepo.CreateTorrentJob(context.Background(), &TorrentJobRecord{JobID: j.ID, InfoHash: j.EngineID, Name: "test.iso", TotalSize: 1000}) + + selections := []TorrentFileSelection{ + {Index: 0, Priority: PriorityNormal}, + {Index: 1, Priority: PrioritySkip}, + } + + _, err := mgr.StartTorrentWithPolicy(context.Background(), j.ID, selections, networkpolicy.SeedingPolicy{Mode: networkpolicy.SeedingModeNone}) + if err != nil { + t.Fatalf("StartTorrentWithPolicy failed: %v", err) + } + + // Wait for job to become DOWNLOADING + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + current, _ := mgr.Get(context.Background(), j.ID) + if current != nil && current.Status == StatusDownloading { + break + } + time.Sleep(50 * time.Millisecond) + } + + current, _ := mgr.Get(context.Background(), j.ID) + if current == nil || current.Status != StatusDownloading { + t.Fatalf("expected job to reach StatusDownloading without Resume, got status=%v (error=%s)", current.Status, current.Error) + } + + // Active jobs must contain this job + active := mgr.GetActiveJobs() + if _, ok := active[j.ID]; !ok { + t.Fatalf("job %s must be present in activeJobs", j.ID) + } + + // Verify no PAUSED event was emitted + eventMu.Lock() + for _, st := range events { + if st == StatusPaused { + t.Fatalf("job must NEVER transition to StatusPaused during asynchronous start confirmation, events: %v", events) + } + } + eventMu.Unlock() +} + +// 5.B Multiple transient stopped states: stoppedDL -> stoppedDL -> stalledDL -> DOWNLOADING +func TestTorrentSync_MultipleTransientStoppedStates_SuccessfullyReachesDownloading(t *testing.T) { + jobRepo := newFakeJobRepository() + queueRepo := &fakeQueueRepo{entries: make(map[string]*QueueEntry)} + torrentRepo := newFakeTorrentRepository(jobRepo, queueRepo) + + // Sequence: 3 stoppedDL states followed by stalledDL (active) + eng := newAsyncTransitionEngine("stoppedDL", "stoppedDL", "stoppedDL", "stalledDL") + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + bus := newFakeEventBus() + + mgr := NewManager(jobRepo, reg, bus, t.TempDir(), torrentRepo) + sched := NewScheduler(jobRepo, queueRepo, func(ctx context.Context) int { return 5 }, mgr.dispatchQueuedJob) + mgr.SetScheduler(sched) + mgr.SetQueueRepository(queueRepo) + sched.Start(context.Background()) + defer sched.Stop() + + j := &Job{ + ID: "job_sync_b", + Engine: "qbittorrent", + EngineID: "hash_sync_b", + Type: TypeTorrent, + Status: StatusAwaitingSelection, + DestinationDir: t.TempDir(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + _ = jobRepo.Create(context.Background(), j) + _ = torrentRepo.CreateTorrentJob(context.Background(), &TorrentJobRecord{JobID: j.ID, InfoHash: j.EngineID, Name: "test.iso", TotalSize: 1000}) + + selections := []TorrentFileSelection{ + {Index: 0, Priority: PriorityNormal}, + {Index: 1, Priority: PrioritySkip}, + } + + _, err := mgr.StartTorrentWithPolicy(context.Background(), j.ID, selections, networkpolicy.SeedingPolicy{Mode: networkpolicy.SeedingModeNone}) + if err != nil { + t.Fatalf("StartTorrentWithPolicy failed: %v", err) + } + + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + current, _ := mgr.Get(context.Background(), j.ID) + if current != nil && current.Status == StatusDownloading { + break + } + time.Sleep(50 * time.Millisecond) + } + + current, _ := mgr.Get(context.Background(), j.ID) + if current == nil || current.Status != StatusDownloading { + t.Fatalf("expected StatusDownloading after transient stalledDL, got %v", current.Status) + } +} + +// 5.C Start never leaves stoppedDL: fail closed +func TestTorrentSync_StartNeverLeavesStoppedDL_FailsClosed(t *testing.T) { + jobRepo := newFakeJobRepository() + queueRepo := &fakeQueueRepo{entries: make(map[string]*QueueEntry)} + torrentRepo := newFakeTorrentRepository(jobRepo, queueRepo) + + // Stays stoppedDL forever + eng := newAsyncTransitionEngine("stoppedDL") + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + bus := newFakeEventBus() + + mgr := NewManager(jobRepo, reg, bus, t.TempDir(), torrentRepo) + sched := NewScheduler(jobRepo, queueRepo, func(ctx context.Context) int { return 5 }, mgr.dispatchQueuedJob) + mgr.SetScheduler(sched) + mgr.SetQueueRepository(queueRepo) + sched.Start(context.Background()) + defer sched.Stop() + + j := &Job{ + ID: "job_sync_c", + Engine: "qbittorrent", + EngineID: "hash_sync_c", + Type: TypeTorrent, + Status: StatusAwaitingSelection, + DestinationDir: t.TempDir(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + _ = jobRepo.Create(context.Background(), j) + _ = torrentRepo.CreateTorrentJob(context.Background(), &TorrentJobRecord{JobID: j.ID, InfoHash: j.EngineID, Name: "test.iso", TotalSize: 1000}) + + selections := []TorrentFileSelection{ + {Index: 0, Priority: PriorityNormal}, + {Index: 1, Priority: PrioritySkip}, + } + + _, err := mgr.StartTorrentWithPolicy(context.Background(), j.ID, selections, networkpolicy.SeedingPolicy{Mode: networkpolicy.SeedingModeNone}) + if err != nil { + t.Fatalf("StartTorrentWithPolicy failed: %v", err) + } + + // Must fail deterministically within confirmation timeout + deadline := time.Now().Add(4 * time.Second) + for time.Now().Before(deadline) { + current, _ := mgr.Get(context.Background(), j.ID) + if current != nil && current.Status == StatusFailed { + break + } + time.Sleep(50 * time.Millisecond) + } + + current, _ := mgr.Get(context.Background(), j.ID) + if current == nil || current.Status != StatusFailed { + t.Fatalf("expected job to fail closed when daemon never starts, got %v", current.Status) + } + + active := mgr.GetActiveJobs() + if _, ok := active[j.ID]; ok { + t.Fatalf("failed job must not be in activeJobs") + } +} + +// 5.D Resume race: local PAUSED -> Resume called -> first engine state = stoppedDL -> later = downloading +func TestTorrentSync_ResumeRace_AutomaticallyBecomesDownloading(t *testing.T) { + jobRepo := newFakeJobRepository() + queueRepo := &fakeQueueRepo{entries: make(map[string]*QueueEntry)} + torrentRepo := newFakeTorrentRepository(jobRepo, queueRepo) + + // Initial paused state -> when resumed, first poll returns stoppedDL, next poll returns downloading + eng := newAsyncTransitionEngine("stoppedDL", "downloading") + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + bus := newFakeEventBus() + + mgr := NewManager(jobRepo, reg, bus, t.TempDir(), torrentRepo) + sched := NewScheduler(jobRepo, queueRepo, func(ctx context.Context) int { return 5 }, mgr.dispatchQueuedJob) + mgr.SetScheduler(sched) + mgr.SetQueueRepository(queueRepo) + sched.Start(context.Background()) + defer sched.Stop() + + j := &Job{ + ID: "job_sync_d", + Engine: "qbittorrent", + EngineID: "hash_sync_d", + Type: TypeTorrent, + Status: StatusPaused, + DestinationDir: t.TempDir(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + _ = jobRepo.Create(context.Background(), j) + _ = torrentRepo.CreateTorrentJob(context.Background(), &TorrentJobRecord{JobID: j.ID, InfoHash: j.EngineID, Name: "test.iso", TotalSize: 1000}) + _ = torrentRepo.SaveTorrentFiles(context.Background(), j.ID, []TorrentFileRecord{ + {JobID: j.ID, FileIndex: 0, Size: 1000, Selected: true, Priority: "normal"}, + {JobID: j.ID, FileIndex: 1, Size: 2000, Selected: false, Priority: "skip"}, + }) + + _, err := mgr.Resume(context.Background(), j.ID) + if err != nil { + t.Fatalf("Resume failed: %v", err) + } + + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + current, _ := mgr.Get(context.Background(), j.ID) + if current != nil && current.Status == StatusDownloading { + break + } + time.Sleep(50 * time.Millisecond) + } + + current, _ := mgr.Get(context.Background(), j.ID) + if current == nil || current.Status != StatusDownloading { + t.Fatalf("expected resumed job to reach StatusDownloading without second Resume, got %v", current.Status) + } +} + +// 5.E Genuine Pause: DOWNLOADING -> user clicks Pause -> qBittorrent becomes stoppedDL -> GoDownloader PAUSED, removed from active monitoring +func TestTorrentSync_GenuinePause_TransitionsToPausedImmediately(t *testing.T) { + jobRepo := newFakeJobRepository() + queueRepo := &fakeQueueRepo{entries: make(map[string]*QueueEntry)} + torrentRepo := newFakeTorrentRepository(jobRepo, queueRepo) + + eng := newAsyncTransitionEngine("downloading") + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + bus := newFakeEventBus() + + mgr := NewManager(jobRepo, reg, bus, t.TempDir(), torrentRepo) + sched := NewScheduler(jobRepo, queueRepo, func(ctx context.Context) int { return 5 }, mgr.dispatchQueuedJob) + mgr.SetScheduler(sched) + mgr.SetQueueRepository(queueRepo) + + j := &Job{ + ID: "job_sync_e", + Engine: "qbittorrent", + EngineID: "hash_sync_e", + Type: TypeTorrent, + Status: StatusDownloading, + DestinationDir: t.TempDir(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + _ = jobRepo.Create(context.Background(), j) + _ = torrentRepo.CreateTorrentJob(context.Background(), &TorrentJobRecord{JobID: j.ID, InfoHash: j.EngineID, Name: "test.iso", TotalSize: 1000}) + mgr.addActive(j) + + pausedJob, err := mgr.Pause(context.Background(), j.ID) + if err != nil { + t.Fatalf("Pause failed: %v", err) + } + + if pausedJob.Status != StatusPaused { + t.Fatalf("expected StatusPaused, got %v", pausedJob.Status) + } + + active := mgr.GetActiveJobs() + if _, ok := active[j.ID]; ok { + t.Fatalf("paused job must be removed from activeJobs") + } + + if atomic.LoadInt32(&eng.stopDownloadCalled) < 1 { + t.Fatalf("StopDownload must be called on genuine Pause") + } +} + +// 5.F Scheduler capacity: with max concurrency exhausted, StartTorrent persists QUEUED, StartDownload must NOT be called, qBittorrent remains stopped +func TestTorrentSync_SchedulerCapacityExhausted_RemainsQueuedAndStopped(t *testing.T) { + jobRepo := newFakeJobRepository() + queueRepo := &fakeQueueRepo{entries: make(map[string]*QueueEntry)} + torrentRepo := newFakeTorrentRepository(jobRepo, queueRepo) + + eng := newAsyncTransitionEngine("stoppedDL") + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + bus := newFakeEventBus() + + mgr := NewManager(jobRepo, reg, bus, t.TempDir(), torrentRepo) + // Max concurrency = 0 (exhausted) + sched := NewScheduler(jobRepo, queueRepo, func(ctx context.Context) int { return 0 }, mgr.dispatchQueuedJob) + mgr.SetScheduler(sched) + mgr.SetQueueRepository(queueRepo) + sched.Start(context.Background()) + defer sched.Stop() + + j := &Job{ + ID: "job_sync_f", + Engine: "qbittorrent", + EngineID: "hash_sync_f", + Type: TypeTorrent, + Status: StatusAwaitingSelection, + DestinationDir: t.TempDir(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + _ = jobRepo.Create(context.Background(), j) + _ = torrentRepo.CreateTorrentJob(context.Background(), &TorrentJobRecord{JobID: j.ID, InfoHash: j.EngineID, Name: "test.iso", TotalSize: 1000}) + + selections := []TorrentFileSelection{ + {Index: 0, Priority: PriorityNormal}, + {Index: 1, Priority: PrioritySkip}, + } + + _, err := mgr.StartTorrentWithPolicy(context.Background(), j.ID, selections, networkpolicy.SeedingPolicy{Mode: networkpolicy.SeedingModeNone}) + if err != nil { + t.Fatalf("StartTorrentWithPolicy failed: %v", err) + } + + time.Sleep(300 * time.Millisecond) + + current, _ := mgr.Get(context.Background(), j.ID) + if current == nil || current.Status != StatusQueued { + t.Fatalf("expected job to remain StatusQueued when capacity exhausted, got %v", current.Status) + } + + if atomic.LoadInt32(&eng.startDownloadCalled) != 0 { + t.Fatalf("StartDownload must NOT be called when scheduler capacity is exhausted, got %d calls", eng.startDownloadCalled) + } +} + +// 5.G Regression: selected TotalBytes remains selected payload only +// 5.H Regression: file priorities remain applied before qBittorrent is allowed to start +func TestTorrentSync_Regressions_PayloadSizeAndPriorityOrdering(t *testing.T) { + jobRepo := newFakeJobRepository() + queueRepo := &fakeQueueRepo{entries: make(map[string]*QueueEntry)} + torrentRepo := newFakeTorrentRepository(jobRepo, queueRepo) + + eng := newAsyncTransitionEngine("downloading") + reg := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": eng}} + bus := newFakeEventBus() + + mgr := NewManager(jobRepo, reg, bus, t.TempDir(), torrentRepo) + sched := NewScheduler(jobRepo, queueRepo, func(ctx context.Context) int { return 5 }, mgr.dispatchQueuedJob) + mgr.SetScheduler(sched) + mgr.SetQueueRepository(queueRepo) + sched.Start(context.Background()) + defer sched.Stop() + + j := &Job{ + ID: "job_sync_gh", + Engine: "qbittorrent", + EngineID: "hash_sync_gh", + Type: TypeTorrent, + Status: StatusAwaitingSelection, + DestinationDir: t.TempDir(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + _ = jobRepo.Create(context.Background(), j) + _ = torrentRepo.CreateTorrentJob(context.Background(), &TorrentJobRecord{JobID: j.ID, InfoHash: j.EngineID, Name: "test.iso", TotalSize: 3000}) + + // Select file 0 (size 1000) only, skip file 1 (size 2000) + selections := []TorrentFileSelection{ + {Index: 0, Priority: PriorityNormal}, + {Index: 1, Priority: PrioritySkip}, + } + + _, err := mgr.StartTorrentWithPolicy(context.Background(), j.ID, selections, networkpolicy.SeedingPolicy{Mode: networkpolicy.SeedingModeNone}) + if err != nil { + t.Fatalf("StartTorrentWithPolicy failed: %v", err) + } + + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + current, _ := mgr.Get(context.Background(), j.ID) + if current != nil && current.Status == StatusDownloading { + break + } + time.Sleep(50 * time.Millisecond) + } + + current, _ := mgr.Get(context.Background(), j.ID) + if current.TotalBytes != 1000 { + t.Fatalf("selected TotalBytes must be 1000 (selected payload only), got %d", current.TotalBytes) + } + + if !eng.prioritiesAppliedBeforeStart { + t.Fatalf("file priorities must be applied to engine before StartDownload is called") + } +} From 7156d50528653ee64aebb412868eb59f0752660e Mon Sep 17 00:00:00 2001 From: Kavya Arora Date: Sat, 8 Aug 2026 19:15:49 +0530 Subject: [PATCH 12/15] fix(qbittorrent): calculate expected hash before add and confirm visibility via bounded polling --- internal/engine/qbittorrent/engine.go | 62 ++- internal/engine/qbittorrent/engine_test.go | 499 ++++++++++++++++++++- 2 files changed, 543 insertions(+), 18 deletions(-) diff --git a/internal/engine/qbittorrent/engine.go b/internal/engine/qbittorrent/engine.go index 3cb086e..4fe426c 100644 --- a/internal/engine/qbittorrent/engine.go +++ b/internal/engine/qbittorrent/engine.go @@ -383,13 +383,18 @@ func (e *Engine) AddMagnet(ctx context.Context, magnet, savePath string, jobID s if err != nil { return "", fmt.Errorf("failed to extract info hash from magnet: %w", err) } + expectedHash := strings.ToLower(hash) err = e.client.AddMagnet(ctx, magnet, savePath, CategoryName, []string{jobID}, false) if err != nil { return "", err } - return strings.ToLower(hash), nil + if err := e.waitForTorrentVisible(ctx, expectedHash, 3*time.Second); err != nil { + return "", err + } + + return expectedHash, nil } func (e *Engine) AddTorrentFile(ctx context.Context, filePath, savePath string, jobID string) (string, error) { @@ -397,27 +402,62 @@ func (e *Engine) AddTorrentFile(ctx context.Context, filePath, savePath string, // ignore } - err := e.client.AddTorrentFile(ctx, filePath, savePath, CategoryName, []string{jobID}, true) + identity, err := job.ExtractTorrentIdentityFromFile(filePath) if err != nil { - return "", err + return "", fmt.Errorf("failed to extract torrent info hash from file: %w", err) + } + expectedHash := identity.QBitTorrentID + if expectedHash == "" { + return "", errors.New("failed to derive canonical qBittorrent info hash from file") } - // List torrents to find the new one by tag (jobID) - infos, err := e.client.GetTorrents(ctx, CategoryName) + err = e.client.AddTorrentFile(ctx, filePath, savePath, CategoryName, []string{jobID}, true) if err != nil { return "", err } - for _, info := range infos { - tags := strings.Split(info.Tags, ",") - for _, tag := range tags { - if strings.TrimSpace(tag) == jobID { - return strings.ToLower(info.Hash), nil + if err := e.waitForTorrentVisible(ctx, expectedHash, 3*time.Second); err != nil { + return "", err + } + + return expectedHash, nil +} + +func (e *Engine) waitForTorrentVisible(ctx context.Context, expectedHash string, timeout time.Duration) error { + if timeout <= 0 { + timeout = 3 * time.Second + } + deadline := time.Now().Add(timeout) + pollInterval := 50 * time.Millisecond + + for { + if ctx.Err() != nil { + return ctx.Err() + } + + info, err := e.client.GetTorrentInfo(ctx, expectedHash) + if err == nil && info != nil { + if strings.EqualFold(info.Hash, expectedHash) { + return nil } } + + if err != nil && !errors.Is(err, ErrTorrentNotFound) && !strings.Contains(strings.ToLower(err.Error()), "not found") { + log.Printf("waitForTorrentVisible: non-404 status query for %s: %v", expectedHash, err) + } + + if time.Now().After(deadline) { + break + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(pollInterval): + } } - return "", errors.New("torrent added but info hash not found") + return fmt.Errorf("torrent was accepted by qBittorrent but visibility could not be confirmed within %v (hash: %s)", timeout, expectedHash) } func (e *Engine) GetFiles(ctx context.Context, infoHash string) ([]job.TorrentFile, error) { diff --git a/internal/engine/qbittorrent/engine_test.go b/internal/engine/qbittorrent/engine_test.go index 825a457..4c064c5 100644 --- a/internal/engine/qbittorrent/engine_test.go +++ b/internal/engine/qbittorrent/engine_test.go @@ -2,14 +2,144 @@ package qbittorrent import ( "context" - "downloader/internal/job" + "crypto/sha1" + "crypto/sha256" + "encoding/hex" "io" "net/http" "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" "testing" + "time" + + "downloader/internal/job" + + "github.com/anacrolix/torrent/bencode" ) -func TestEngine_AddMagnet(t *testing.T) { +func makeTestTorrentFileV1(t *testing.T, dir, name string, length int64) (string, string) { + t.Helper() + infoMap := map[string]interface{}{ + "name": name, + "piece length": int64(262144), + "pieces": string(make([]byte, 20)), + "length": length, + } + infoBytes, err := bencode.Marshal(infoMap) + if err != nil { + t.Fatalf("marshal v1 info: %v", err) + } + h1 := sha1.Sum(infoBytes) + v1Hex := strings.ToLower(hex.EncodeToString(h1[:])) + + torrentMap := map[string]interface{}{ + "announce": "http://tracker.example.com/announce", + "info": bencode.Bytes(infoBytes), + } + torrentBytes, err := bencode.Marshal(torrentMap) + if err != nil { + t.Fatalf("marshal v1 torrent: %v", err) + } + + filePath := filepath.Join(dir, name+".torrent") + if err := os.WriteFile(filePath, torrentBytes, 0644); err != nil { + t.Fatalf("write v1 torrent: %v", err) + } + return filePath, v1Hex +} + +func makeTestTorrentFileV2(t *testing.T, dir, name string, length int64) (string, string) { + t.Helper() + infoMap := map[string]interface{}{ + "name": name, + "piece length": int64(262144), + "meta version": int64(2), + "file tree": map[string]interface{}{ + name: map[string]interface{}{ + "": map[string]interface{}{ + "length": length, + "pieces root": string(make([]byte, 32)), + }, + }, + }, + } + infoBytes, err := bencode.Marshal(infoMap) + if err != nil { + t.Fatalf("marshal v2 info: %v", err) + } + h2 := sha256.Sum256(infoBytes) + v2Hex := strings.ToLower(hex.EncodeToString(h2[:])) + qbitID := v2Hex[:40] + + torrentMap := map[string]interface{}{ + "announce": "http://tracker.example.com/announce", + "info": bencode.Bytes(infoBytes), + } + torrentBytes, err := bencode.Marshal(torrentMap) + if err != nil { + t.Fatalf("marshal v2 torrent: %v", err) + } + + filePath := filepath.Join(dir, name+".torrent") + if err := os.WriteFile(filePath, torrentBytes, 0644); err != nil { + t.Fatalf("write v2 torrent: %v", err) + } + return filePath, qbitID +} + +func makeTestTorrentFileHybrid(t *testing.T, dir, name string, length int64) (string, string) { + t.Helper() + infoMap := map[string]interface{}{ + "name": name, + "piece length": int64(262144), + "pieces": string(make([]byte, 20)), + "meta version": int64(2), + "file tree": map[string]interface{}{ + name: map[string]interface{}{ + "": map[string]interface{}{ + "length": length, + "pieces root": string(make([]byte, 32)), + }, + }, + }, + } + infoBytes, err := bencode.Marshal(infoMap) + if err != nil { + t.Fatalf("marshal hybrid info: %v", err) + } + h2 := sha256.Sum256(infoBytes) + v2Hex := strings.ToLower(hex.EncodeToString(h2[:])) + qbitID := v2Hex[:40] + + torrentMap := map[string]interface{}{ + "announce": "http://tracker.example.com/announce", + "info": bencode.Bytes(infoBytes), + } + torrentBytes, err := bencode.Marshal(torrentMap) + if err != nil { + t.Fatalf("marshal hybrid torrent: %v", err) + } + + filePath := filepath.Join(dir, name+".torrent") + if err := os.WriteFile(filePath, torrentBytes, 0644); err != nil { + t.Fatalf("write hybrid torrent: %v", err) + } + return filePath, qbitID +} + +// 7.A Uploaded torrent immediate visibility: +// AddTorrentFile POST -> success, first GetTorrentInfo(expectedHash) -> torrent +// Expected: AddTorrentFile returns expectedHash, no GetTorrents(category) discovery required. +func TestEngine_AddTorrentFile_ImmediateVisibility(t *testing.T) { + tmpDir := t.TempDir() + torrentPath, expectedHash := makeTestTorrentFileV1(t, tmpDir, "immediate.iso", 1024*1024) + + var categoryListingCalled int32 + var infoHashCalled int32 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/v2/auth/login" { w.Header().Set("Set-Cookie", "SID=12345; Path=/; HttpOnly") @@ -25,19 +155,375 @@ func TestEngine_AddMagnet(t *testing.T) { w.WriteHeader(http.StatusOK) return } + if r.URL.Path == "/api/v2/torrents/info" { + if strings.Contains(r.URL.RawQuery, "category=") { + atomic.AddInt32(&categoryListingCalled, 1) + } + if strings.Contains(r.URL.RawQuery, "hashes="+expectedHash) { + atomic.AddInt32(&infoHashCalled, 1) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[{"hash":"` + expectedHash + `","name":"immediate.iso","state":"stoppedDL"}]`)) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[]`)) + return + } t.Errorf("Unexpected path: %s", r.URL.Path) })) defer ts.Close() engine := NewEngine(ts.URL, "admin", "adminadmin", 5) - magnet := "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=test" - hash, err := engine.AddMagnet(context.Background(), magnet, "/tmp", "job-123") + hash, err := engine.AddTorrentFile(context.Background(), torrentPath, "/tmp", "job-imm-1") + if err != nil { + t.Fatalf("expected AddTorrentFile to succeed, got %v", err) + } + + if hash != expectedHash { + t.Fatalf("expected canonical hash %s, got %s", expectedHash, hash) + } + + if atomic.LoadInt32(&categoryListingCalled) != 0 { + t.Fatalf("expected ZERO calls to category listing, got %d", categoryListingCalled) + } + + if atomic.LoadInt32(&infoHashCalled) < 1 { + t.Fatalf("expected GetTorrentInfo to be called with expectedHash %s", expectedHash) + } +} + +// 7.B Delayed qBittorrent visibility: +// AddTorrentFile POST -> success, GetTorrentInfo -> [] -> [] -> expected torrent +// Expected: AddTorrentFile succeeds, returns expected canonical hash, no false Failed state, no Retry required. +func TestEngine_AddTorrentFile_DelayedVisibility(t *testing.T) { + tmpDir := t.TempDir() + torrentPath, expectedHash := makeTestTorrentFileV1(t, tmpDir, "delayed.iso", 2048*1024) + + var infoPollCount int32 + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v2/auth/login" { + w.Header().Set("Set-Cookie", "SID=12345; Path=/; HttpOnly") + w.WriteHeader(http.StatusOK) + w.Write([]byte("Ok.")) + return + } + if r.URL.Path == "/api/v2/torrents/createCategory" || r.URL.Path == "/api/v2/torrents/add" { + w.WriteHeader(http.StatusOK) + return + } + if r.URL.Path == "/api/v2/torrents/info" { + if strings.Contains(r.URL.RawQuery, "hashes="+expectedHash) { + count := atomic.AddInt32(&infoPollCount, 1) + if count < 3 { + // Simulate transiently not yet queryable + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[]`)) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[{"hash":"` + expectedHash + `","name":"delayed.iso","state":"stoppedDL"}]`)) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[]`)) + return + } + t.Errorf("Unexpected path: %s", r.URL.Path) + })) + defer ts.Close() + + engine := NewEngine(ts.URL, "admin", "adminadmin", 5) + + hash, err := engine.AddTorrentFile(context.Background(), torrentPath, "/tmp", "job-delayed-1") + if err != nil { + t.Fatalf("expected AddTorrentFile to succeed after transient delay, got %v", err) + } + + if hash != expectedHash { + t.Fatalf("expected canonical hash %s, got %s", expectedHash, hash) + } + + if atomic.LoadInt32(&infoPollCount) < 3 { + t.Fatalf("expected at least 3 poll queries, got %d", infoPollCount) + } +} + +// 7.C Visibility timeout: +// Add POST -> success, GetTorrentInfo always -> ErrTorrentNotFound +// Expected: bounded timeout, explicit safe error, no infinite loop. +func TestEngine_AddTorrentFile_VisibilityTimeout(t *testing.T) { + tmpDir := t.TempDir() + torrentPath, _ := makeTestTorrentFileV1(t, tmpDir, "timeout.iso", 1024*1024) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v2/auth/login" { + w.Header().Set("Set-Cookie", "SID=12345; Path=/; HttpOnly") + w.WriteHeader(http.StatusOK) + w.Write([]byte("Ok.")) + return + } + if r.URL.Path == "/api/v2/torrents/createCategory" || r.URL.Path == "/api/v2/torrents/add" { + w.WriteHeader(http.StatusOK) + return + } + if r.URL.Path == "/api/v2/torrents/info" { + // Always empty + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[]`)) + return + } + })) + defer ts.Close() + + engine := NewEngine(ts.URL, "admin", "adminadmin", 5) + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + _, err := engine.AddTorrentFile(ctx, torrentPath, "/tmp", "job-timeout-1") + if err == nil { + t.Fatalf("expected AddTorrentFile to fail on visibility timeout") + } + + if !strings.Contains(err.Error(), "visibility could not be confirmed") && !strings.Contains(err.Error(), "context deadline exceeded") { + t.Fatalf("expected explicit visibility error containing diagnostic context, got: %v", err) + } +} + +// 7.D v1 uploaded torrent: returned ID == SHA1 info hash / 40 chars +func TestEngine_AddTorrentFile_V1Identity(t *testing.T) { + tmpDir := t.TempDir() + torrentPath, expectedHash := makeTestTorrentFileV1(t, tmpDir, "v1.iso", 5000) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v2/auth/login" { + w.Header().Set("Set-Cookie", "SID=12345; Path=/; HttpOnly") + w.WriteHeader(http.StatusOK) + w.Write([]byte("Ok.")) + return + } + if r.URL.Path == "/api/v2/torrents/createCategory" || r.URL.Path == "/api/v2/torrents/add" { + w.WriteHeader(http.StatusOK) + return + } + if r.URL.Path == "/api/v2/torrents/info" && strings.Contains(r.URL.RawQuery, "hashes="+expectedHash) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[{"hash":"` + expectedHash + `","name":"v1.iso","state":"stoppedDL"}]`)) + return + } + })) + defer ts.Close() + + engine := NewEngine(ts.URL, "admin", "adminadmin", 5) + hash, err := engine.AddTorrentFile(context.Background(), torrentPath, "/tmp", "job-v1") + if err != nil { + t.Fatalf("AddTorrentFile failed: %v", err) + } + if hash != expectedHash || len(hash) != 40 { + t.Fatalf("expected 40-char v1 info hash %s, got %s", expectedHash, hash) + } +} + +// 7.E v2 uploaded torrent: returned ID == first 20 bytes SHA256 / 40 chars +func TestEngine_AddTorrentFile_V2Identity(t *testing.T) { + tmpDir := t.TempDir() + torrentPath, expectedHash := makeTestTorrentFileV2(t, tmpDir, "v2.iso", 5000) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v2/auth/login" { + w.Header().Set("Set-Cookie", "SID=12345; Path=/; HttpOnly") + w.WriteHeader(http.StatusOK) + w.Write([]byte("Ok.")) + return + } + if r.URL.Path == "/api/v2/torrents/createCategory" || r.URL.Path == "/api/v2/torrents/add" { + w.WriteHeader(http.StatusOK) + return + } + if r.URL.Path == "/api/v2/torrents/info" && strings.Contains(r.URL.RawQuery, "hashes="+expectedHash) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[{"hash":"` + expectedHash + `","name":"v2.iso","state":"stoppedDL"}]`)) + return + } + })) + defer ts.Close() + + engine := NewEngine(ts.URL, "admin", "adminadmin", 5) + hash, err := engine.AddTorrentFile(context.Background(), torrentPath, "/tmp", "job-v2") + if err != nil { + t.Fatalf("AddTorrentFile failed: %v", err) + } + if hash != expectedHash || len(hash) != 40 { + t.Fatalf("expected 40-char v2 QBitTorrentID %s, got %s", expectedHash, hash) + } +} + +// 7.F hybrid uploaded torrent: returned ID == qBittorrent TorrentID / 40 chars +func TestEngine_AddTorrentFile_HybridIdentity(t *testing.T) { + tmpDir := t.TempDir() + torrentPath, expectedHash := makeTestTorrentFileHybrid(t, tmpDir, "hybrid.iso", 5000) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v2/auth/login" { + w.Header().Set("Set-Cookie", "SID=12345; Path=/; HttpOnly") + w.WriteHeader(http.StatusOK) + w.Write([]byte("Ok.")) + return + } + if r.URL.Path == "/api/v2/torrents/createCategory" || r.URL.Path == "/api/v2/torrents/add" { + w.WriteHeader(http.StatusOK) + return + } + if r.URL.Path == "/api/v2/torrents/info" && strings.Contains(r.URL.RawQuery, "hashes="+expectedHash) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[{"hash":"` + expectedHash + `","name":"hybrid.iso","state":"stoppedDL"}]`)) + return + } + })) + defer ts.Close() + + engine := NewEngine(ts.URL, "admin", "adminadmin", 5) + hash, err := engine.AddTorrentFile(context.Background(), torrentPath, "/tmp", "job-hybrid") + if err != nil { + t.Fatalf("AddTorrentFile failed: %v", err) + } + if hash != expectedHash || len(hash) != 40 { + t.Fatalf("expected 40-char hybrid QBitTorrentID %s, got %s", expectedHash, hash) + } +} + +// 7.G Verify AddTorrentFile no longer derives identity by scanning category/tag +func TestEngine_AddTorrentFile_NoCategoryTagScanning(t *testing.T) { + tmpDir := t.TempDir() + torrentPath, expectedHash := makeTestTorrentFileV1(t, tmpDir, "notags.iso", 1024) + + var getTorrentsCategoryCalled int32 + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v2/auth/login" { + w.Header().Set("Set-Cookie", "SID=12345; Path=/; HttpOnly") + w.WriteHeader(http.StatusOK) + w.Write([]byte("Ok.")) + return + } + if r.URL.Path == "/api/v2/torrents/createCategory" || r.URL.Path == "/api/v2/torrents/add" { + w.WriteHeader(http.StatusOK) + return + } + if r.URL.Path == "/api/v2/torrents/info" { + if strings.Contains(r.URL.RawQuery, "category=") { + atomic.AddInt32(&getTorrentsCategoryCalled, 1) + } + if strings.Contains(r.URL.RawQuery, "hashes="+expectedHash) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[{"hash":"` + expectedHash + `","name":"notags.iso","state":"stoppedDL"}]`)) + return + } + } + })) + defer ts.Close() + + engine := NewEngine(ts.URL, "admin", "adminadmin", 5) + _, err := engine.AddTorrentFile(context.Background(), torrentPath, "/tmp", "job-notags") + if err != nil { + t.Fatalf("AddTorrentFile failed: %v", err) + } + if atomic.LoadInt32(&getTorrentsCategoryCalled) != 0 { + t.Fatalf("expected NO category listing calls during AddTorrentFile, got %d", getTorrentsCategoryCalled) + } +} + +// 7.H Magnet delayed visibility: AddMagnet success, first lookup not found, later lookup succeeds +func TestEngine_AddMagnet_DelayedVisibility(t *testing.T) { + const expectedHash = "0123456789abcdef0123456789abcdef01234567" + var infoPollCount int32 + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v2/auth/login" { + w.Header().Set("Set-Cookie", "SID=12345; Path=/; HttpOnly") + w.WriteHeader(http.StatusOK) + w.Write([]byte("Ok.")) + return + } + if r.URL.Path == "/api/v2/torrents/createCategory" || r.URL.Path == "/api/v2/torrents/add" { + w.WriteHeader(http.StatusOK) + return + } + if r.URL.Path == "/api/v2/torrents/info" { + if strings.Contains(r.URL.RawQuery, "hashes="+expectedHash) { + count := atomic.AddInt32(&infoPollCount, 1) + if count < 2 { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[]`)) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[{"hash":"` + expectedHash + `","name":"magnet.iso","state":"stoppedDL"}]`)) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[]`)) + return + } + })) + defer ts.Close() + + engine := NewEngine(ts.URL, "admin", "adminadmin", 5) + magnet := "magnet:?xt=urn:btih:" + expectedHash + "&dn=magnet.iso" + hash, err := engine.AddMagnet(context.Background(), magnet, "/tmp", "job-mag-delay") if err != nil { t.Fatalf("expected AddMagnet to succeed, got %v", err) } - if hash != "0123456789abcdef0123456789abcdef01234567" { - t.Errorf("expected hash to be extracted, got %s", hash) + if hash != expectedHash { + t.Fatalf("expected canonical hash %s, got %s", expectedHash, hash) + } + if atomic.LoadInt32(&infoPollCount) < 2 { + t.Fatalf("expected at least 2 poll queries for delayed magnet visibility, got %d", infoPollCount) + } +} + +// 7.I Regression: uploaded torrent is added stopped before file selection +func TestEngine_AddTorrentFile_StoppedBeforeSelection(t *testing.T) { + tmpDir := t.TempDir() + torrentPath, expectedHash := makeTestTorrentFileV1(t, tmpDir, "stopped.iso", 1024) + + var stoppedReceived string + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v2/auth/login" { + w.Header().Set("Set-Cookie", "SID=12345; Path=/; HttpOnly") + w.WriteHeader(http.StatusOK) + w.Write([]byte("Ok.")) + return + } + if r.URL.Path == "/api/v2/torrents/createCategory" { + w.WriteHeader(http.StatusOK) + return + } + if r.URL.Path == "/api/v2/torrents/add" { + if err := r.ParseMultipartForm(10 * 1024 * 1024); err == nil { + stoppedReceived = r.FormValue("stopped") + } + w.WriteHeader(http.StatusOK) + return + } + if r.URL.Path == "/api/v2/torrents/info" && strings.Contains(r.URL.RawQuery, "hashes="+expectedHash) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[{"hash":"` + expectedHash + `","name":"stopped.iso","state":"stoppedDL"}]`)) + return + } + })) + defer ts.Close() + + engine := NewEngine(ts.URL, "admin", "adminadmin", 5) + _, err := engine.AddTorrentFile(context.Background(), torrentPath, "/tmp", "job-stopped") + if err != nil { + t.Fatalf("AddTorrentFile failed: %v", err) + } + if stoppedReceived != "true" { + t.Fatalf("expected uploaded torrent to be added with stopped=true, got %q", stoppedReceived) } } @@ -153,7 +639,6 @@ func TestEngine_SetFilePriorities(t *testing.T) { func TestStatus_UsesSelectedTorrentSize(t *testing.T) { const selectedSize = int64(5 * 1024 * 1024 * 1024) // 5 GiB - const totalSize = int64(100 * 1024 * 1024 * 1024) // 100 GiB ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/v2/auth/login" { From e85a1d5e95353503bfccfbbfd9a79935b50cd93d Mon Sep 17 00:00:00 2001 From: Kavya Arora Date: Sat, 8 Aug 2026 22:24:46 +0530 Subject: [PATCH 13/15] fix(torrent): harden v0.7 lifecycle, retry persistence, restart recovery and priority verification --- internal/job/manager.go | 188 +++- internal/job/manager_test.go | 36 +- internal/job/recovery.go | 40 + internal/job/storage_integration_test.go | 10 +- .../job/torrent_lifecycle_hardening_test.go | 910 ++++++++++++++++++ .../job/torrent_selection_regression_test.go | 10 + 6 files changed, 1147 insertions(+), 47 deletions(-) create mode 100644 internal/job/torrent_lifecycle_hardening_test.go diff --git a/internal/job/manager.go b/internal/job/manager.go index 1cb395c..7281b34 100644 --- a/internal/job/manager.go +++ b/internal/job/manager.go @@ -1252,10 +1252,7 @@ loop: for { select { case <-ctx.Done(): - log.Printf("acquireTorrentMetadata: job %s background task cancelled", jobID) - if infoHash != "" { - _ = torrentEng.RemoveTorrent(context.Background(), infoHash, false) - } + log.Printf("acquireTorrentMetadata: job %s background task stopped on shutdown", jobID) return case <-timeoutCh: break loop @@ -1327,33 +1324,8 @@ loop: if info.Name != "" && info.Name != infoHash { files, errFiles := torrentEng.GetFiles(ctx, infoHash) if errFiles == nil && len(files) > 0 { - stopErr := torrentEng.StopDownload(ctx, infoHash) - - // Poll raw qBittorrent state for up to 3s to confirm pausedDL or stoppedDL - var isStopped bool - deadline := time.Now().Add(3 * time.Second) - for { - var currentRaw string - if rawProvider, ok := torrentEng.(ITorrentRawStateProvider); ok { - currentRaw, _ = rawProvider.GetRawState(ctx, infoHash) - } - if currentRaw == "" { - if st, errSt := torrentEng.Status(ctx, j); errSt == nil && st != nil { - currentRaw = st.RawState - } - } - if currentRaw == "pausedDL" || currentRaw == "stoppedDL" { - isStopped = true - break - } - if time.Now().After(deadline) { - break - } - time.Sleep(50 * time.Millisecond) - } - - if !isStopped { - log.Printf("acquireTorrentMetadata: failed to verify torrent %s stopped after metadata acquisition (stopErr=%v)", infoHash, stopErr) + if stopErr := m.verifyTorrentStopped(ctx, j, torrentEng, infoHash, 3*time.Second); stopErr != nil { + log.Printf("acquireTorrentMetadata: failed to verify torrent %s stopped after metadata acquisition: %v", infoHash, stopErr) if m.torrentRepo != nil && infoHash != "" { rec, _ := m.torrentRepo.GetTorrentJob(ctx, jobID) rec = cloneTorrentRecord(rec) @@ -1365,14 +1337,9 @@ loop: _ = m.torrentRepo.UpdateTorrentJob(ctx, rec) } - errText := "failed to verify torrent stopped after metadata acquisition" - if stopErr != nil { - errText = fmt.Sprintf("failed to stop torrent after metadata acquisition: %v", stopErr) - } - j.EngineID = infoHash j.Status = StatusFailed - j.Error = errText + j.Error = fmt.Sprintf("failed to verify torrent stopped after metadata acquisition: %v", stopErr) j.UpdatedAt = time.Now() m.repo.Update(ctx, j) m.publish(EventJobFailed, j) @@ -1597,6 +1564,12 @@ func (m *Manager) StartTorrentWithPolicy(ctx context.Context, id string, selecti return nil, &AppError{Code: ErrEngineError, Message: fmt.Sprintf("failed to set file priorities: %v", err)} } + // 5b. Verify file priorities in qBittorrent engine before queueing or starting + if err := m.verifyTorrentFilePriorities(ctx, torrentEng, j.EngineID, selections, 3*time.Second); err != nil { + log.Printf("StartTorrentWithPolicy: file priority verification failed for job %s: %v", j.ID, err) + return nil, &AppError{Code: ErrEngineError, Message: fmt.Sprintf("failed to verify file priorities in engine: %v", err)} + } + // 6. Apply seeding policy to engine while torrent remains stopped if controller, ok := eng.(ISeedingPolicyController); ok { if err := controller.ApplySeedingPolicy(ctx, j, policy); err != nil { @@ -1986,6 +1959,13 @@ func (m *Manager) Cancel(ctx context.Context, id string) (*Job, error) { log.Printf("engine cancel failed for job %s: %v", id, err) return nil, &AppError{Code: ErrEngineError, Message: fmt.Sprintf("engine cancel failed: %v", err)} } + } else if j.Type == TypeTorrent && m.torrentRepo != nil { + rec, _ := m.torrentRepo.GetTorrentJob(ctx, id) + if rec != nil && rec.InfoHash != "" { + if eng, ok := m.engines.Get("qbittorrent"); ok { + _ = eng.Cancel(ctx, &Job{EngineID: rec.InfoHash}) + } + } } m.triggerCancel(id) @@ -2104,7 +2084,11 @@ func (m *Manager) Retry(ctx context.Context, id string) (*Job, error) { j.SpeedBytesPerSecond = 0 j.ETASeconds = 0 j.UpdatedAt = time.Now() - m.repo.Update(ctx, j) + + if err := m.repo.Update(ctx, j); err != nil { + log.Printf("Retry: failed to persist ANALYZING state for torrent job %s: %v", j.ID, err) + return nil, &AppError{Code: ErrInternalError, Message: fmt.Sprintf("failed to persist retry state: %v", err)} + } m.publish(EventJobUpdated, j) go m.acquireTorrentMetadata(j.ID, j.Source, torrentFilePath) @@ -2935,7 +2919,14 @@ func (m *Manager) dispatchQueuedJob(ctx context.Context, qj *QueuedJob) error { } if j.TotalBytes != selBytes { j.TotalBytes = selBytes - _ = m.repo.Update(ctx, j) + if err := m.repo.Update(ctx, j); err != nil { + log.Printf("dispatchQueuedJob: failed to persist repaired TotalBytes for job %s: %v", j.ID, err) + targetStatus := StatusPaused + if qj.Action == QueueActionStart { + targetStatus = StatusFailed + } + return m.persistDispatchFailure(ctx, j, qj, targetStatus, fmt.Errorf("failed to persist repaired selected total bytes: %w", err)) + } } preflightTotal = selBytes @@ -2981,7 +2972,9 @@ func (m *Manager) dispatchQueuedJob(ctx context.Context, qj *QueuedJob) error { return fmt.Errorf("engine %q does not support torrent operations", j.Engine) } if err := torrentEng.StartDownload(ctx, j.EngineID); err != nil { - return err + log.Printf("dispatchQueuedJob: torrent start failed for job %s: %v", j.ID, err) + targetStatus := StatusFailed + return m.persistDispatchFailure(ctx, j, qj, targetStatus, err) } if err := m.confirmTorrentEngineActive(ctx, j, torrentEng, 3*time.Second); err != nil { log.Printf("dispatchQueuedJob: confirm torrent start failed for job %s: %v", j.ID, err) @@ -3098,6 +3091,121 @@ func (m *Manager) confirmTorrentEngineActive(ctx context.Context, j *Job, torren return fmt.Errorf("torrent engine did not transition to active state within %v (last state=%s)", timeout, lastState) } +// verifyTorrentStopped commands the torrent engine to stop the torrent and polls +// for a bounded duration to confirm that the torrent is in stoppedDL or pausedDL state. +func (m *Manager) verifyTorrentStopped(ctx context.Context, j *Job, torrentEng ITorrentEngine, infoHash string, timeout time.Duration) error { + if timeout <= 0 { + timeout = 3 * time.Second + } + stopErr := torrentEng.StopDownload(ctx, infoHash) + deadline := time.Now().Add(timeout) + pollInterval := 50 * time.Millisecond + + for { + if ctx.Err() != nil { + return ctx.Err() + } + + var currentRaw string + if rawProvider, ok := torrentEng.(ITorrentRawStateProvider); ok { + currentRaw, _ = rawProvider.GetRawState(ctx, infoHash) + } + if currentRaw == "" { + if st, errSt := torrentEng.Status(ctx, j); errSt == nil && st != nil { + currentRaw = st.RawState + } + } + + if currentRaw == "pausedDL" || currentRaw == "stoppedDL" || currentRaw == "pausedUP" || currentRaw == "stoppedUP" { + return nil + } + + if time.Now().After(deadline) { + break + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(pollInterval): + } + } + + if stopErr != nil { + return fmt.Errorf("failed to stop torrent: %w", stopErr) + } + return fmt.Errorf("torrent %s is not in stopped or paused state", infoHash) +} + +// verifyTorrentFilePriorities reads the authoritative file list back from the engine +// using GetFiles and verifies every submitted TorrentFileSelection within a bounded timeout. +func (m *Manager) verifyTorrentFilePriorities(ctx context.Context, torrentEng ITorrentEngine, engineID string, selections []TorrentFileSelection, timeout time.Duration) error { + if timeout <= 0 { + timeout = 3 * time.Second + } + deadline := time.Now().Add(timeout) + pollInterval := 50 * time.Millisecond + + var lastErr error + for { + if ctx.Err() != nil { + return ctx.Err() + } + + files, err := torrentEng.GetFiles(ctx, engineID) + if err != nil { + lastErr = err + } else { + fileMap := make(map[int]TorrentFile, len(files)) + for _, f := range files { + fileMap[f.Index] = f + } + + allMatch := true + for _, s := range selections { + f, exists := fileMap[s.Index] + if !exists { + allMatch = false + lastErr = fmt.Errorf("file index %d not found in engine file list", s.Index) + break + } + if s.Priority == PrioritySkip { + if f.Priority != PrioritySkip || f.Selected { + allMatch = false + lastErr = fmt.Errorf("file %d expected skip/unselected, got priority=%s, selected=%v", s.Index, f.Priority, f.Selected) + break + } + } else { + if f.Priority != s.Priority || !f.Selected { + allMatch = false + lastErr = fmt.Errorf("file %d expected priority=%s, got priority=%s, selected=%v", s.Index, s.Priority, f.Priority, f.Selected) + break + } + } + } + + if allMatch { + return nil + } + } + + if time.Now().After(deadline) { + break + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(pollInterval): + } + } + + if lastErr != nil { + return fmt.Errorf("torrent file priorities verification failed: %w", lastErr) + } + return fmt.Errorf("torrent file priorities could not be verified within %v", timeout) +} + func (m *Manager) cleanupQueueOnStartup(ctx context.Context) { if m.queueRepo == nil { return diff --git a/internal/job/manager_test.go b/internal/job/manager_test.go index cd41be8..a98c424 100644 --- a/internal/job/manager_test.go +++ b/internal/job/manager_test.go @@ -76,6 +76,7 @@ func (f *fakeEngine) Detect(url string) string { type fakeTorrentEngine struct { *fakeEngine isStopped bool + files map[string][]TorrentFile addMagnetFunc func(magnet string) (string, error) addTorrentFileFunc func(path string) (string, error) getOwnershipFunc func(hash string) (*TorrentOwnership, error) @@ -150,6 +151,9 @@ func (f *fakeTorrentEngine) GetFiles(ctx context.Context, infoHash string) ([]To if f.getFilesFunc != nil { return f.getFilesFunc(infoHash) } + if f.files != nil && len(f.files[infoHash]) > 0 { + return f.files[infoHash], nil + } return []TorrentFile{ {Index: 0, Path: "file1.bin", Size: 1024, Priority: PriorityNormal, Selected: true}, }, nil @@ -158,6 +162,30 @@ func (f *fakeTorrentEngine) SetFilePriorities(ctx context.Context, infoHash stri if f.setPrioritiesFunc != nil { return f.setPrioritiesFunc(infoHash) } + if f.files == nil { + f.files = make(map[string][]TorrentFile) + } + currentList := f.files[infoHash] + fileMap := make(map[int]*TorrentFile, len(currentList)) + for i := range currentList { + fileMap[currentList[i].Index] = ¤tList[i] + } + for _, s := range selections { + if file, exists := fileMap[s.Index]; exists { + file.Priority = s.Priority + file.Selected = (s.Priority != PrioritySkip) + } else { + currentList = append(currentList, TorrentFile{ + Index: s.Index, + Path: fmt.Sprintf("file_%d.bin", s.Index), + Size: 1024, + Priority: s.Priority, + Selected: (s.Priority != PrioritySkip), + }) + fileMap[s.Index] = ¤tList[len(currentList)-1] + } + } + f.files[infoHash] = currentList return nil } func (f *fakeTorrentEngine) StartDownload(ctx context.Context, infoHash string) error { @@ -168,10 +196,14 @@ func (f *fakeTorrentEngine) StartDownload(ctx context.Context, infoHash string) return nil } func (f *fakeTorrentEngine) StopDownload(ctx context.Context, infoHash string) error { - f.isStopped = true if f.stopDownloadFunc != nil { - return f.stopDownloadFunc(infoHash) + err := f.stopDownloadFunc(infoHash) + if err == nil { + f.isStopped = true + } + return err } + f.isStopped = true return nil } func (f *fakeTorrentEngine) RemoveTorrent(ctx context.Context, infoHash string, deleteFiles bool) error { diff --git a/internal/job/recovery.go b/internal/job/recovery.go index a7ffcc4..1bdde23 100644 --- a/internal/job/recovery.go +++ b/internal/job/recovery.go @@ -2,8 +2,11 @@ package job import ( "context" + "fmt" "log" + "os" "path/filepath" + "strings" "time" ) @@ -60,6 +63,43 @@ func (m *Manager) recoverJob(ctx context.Context, j *Job) { return } + // 2b. Torrent jobs in ANALYZING: resume metadata acquisition and reconciliation across backend restarts + if (j.Type == TypeTorrent || j.Engine == "qbittorrent") && j.Status == StatusAnalyzing { + torrentFilePath := "" + if m.torrentRepo != nil { + rec, err := m.torrentRepo.GetTorrentJob(ctx, j.ID) + if err != nil { + log.Printf("recovery: failed to load torrent job record for analyzing job %s: %v", j.ID, err) + } else if rec != nil { + torrentFilePath = rec.TorrentFilePath + } + } + + if strings.HasPrefix(j.Source, "torrent://") || filepath.Ext(j.Source) == ".torrent" { + if torrentFilePath == "" { + j.Status = StatusFailed + j.Error = "Torrent metainfo record missing during restart recovery. Retry the job." + j.UpdatedAt = time.Now() + m.repo.Update(ctx, j) + m.publish(EventJobFailed, j) + return + } + if _, err := os.Stat(torrentFilePath); os.IsNotExist(err) { + j.Status = StatusFailed + j.Error = fmt.Sprintf("Torrent metainfo file missing at %s during restart recovery. Retry the job.", torrentFilePath) + j.UpdatedAt = time.Now() + m.repo.Update(ctx, j) + m.publish(EventJobFailed, j) + return + } + } + + log.Printf("recovery: resuming metadata acquisition for analyzing torrent job %s (source=%s, file=%s)", j.ID, j.Source, torrentFilePath) + m.publish(EventJobUpdated, j) + go m.acquireTorrentMetadata(j.ID, j.Source, torrentFilePath) + return + } + // 3. Media subprocesses in DOWNLOADING or PROCESSING state do not survive backend restart. if j.Type == TypeMedia || j.Engine == "ytdlp" { log.Printf("recovery: active media job %s was in status %s during restart, marking failed", j.ID, j.Status) diff --git a/internal/job/storage_integration_test.go b/internal/job/storage_integration_test.go index fdff7b6..a8feb10 100644 --- a/internal/job/storage_integration_test.go +++ b/internal/job/storage_integration_test.go @@ -285,8 +285,8 @@ func TestTorrentSelection_UpdatesSelectedTotalBytes(t *testing.T) { fakeT := &fakeTorrentEngine{ getFilesFunc: func(hash string) ([]TorrentFile, error) { return []TorrentFile{ - {Index: 0, Path: "file1.iso", Size: 5 * 1024 * 1024 * 1024}, // 5 GB - {Index: 1, Path: "file2.iso", Size: 10 * 1024 * 1024 * 1024}, // 10 GB + {Index: 0, Path: "file1.iso", Size: 5 * 1024 * 1024 * 1024, Priority: PriorityNormal, Selected: true}, // 5 GB + {Index: 1, Path: "file2.iso", Size: 10 * 1024 * 1024 * 1024, Priority: PrioritySkip, Selected: false}, // 10 GB }, nil }, } @@ -2206,9 +2206,9 @@ func TestStartTorrent_AcceptsCompleteSelectionSet(t *testing.T) { torrentEng := &fakeTorrentEngine{ getFilesFunc: func(hash string) ([]TorrentFile, error) { return []TorrentFile{ - {Index: 0, Path: "file1.mp4", Size: 100, Priority: PriorityNormal}, - {Index: 1, Path: "file2.mp4", Size: 200, Priority: PriorityNormal}, - {Index: 2, Path: "file3.mp4", Size: 300, Priority: PriorityNormal}, + {Index: 0, Path: "file1.mp4", Size: 100, Priority: PriorityNormal, Selected: true}, + {Index: 1, Path: "file2.mp4", Size: 200, Priority: PrioritySkip, Selected: false}, + {Index: 2, Path: "file3.mp4", Size: 300, Priority: PriorityHigh, Selected: true}, }, nil }, setPrioritiesFunc: func(hash string) error { diff --git a/internal/job/torrent_lifecycle_hardening_test.go b/internal/job/torrent_lifecycle_hardening_test.go new file mode 100644 index 0000000..5818eaa --- /dev/null +++ b/internal/job/torrent_lifecycle_hardening_test.go @@ -0,0 +1,910 @@ +package job + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "downloader/internal/networkpolicy" + + "github.com/anacrolix/torrent/bencode" +) + +func makeTestTorrentFileHelper(t *testing.T, dir, name string, length int64) (string, string) { + t.Helper() + infoMap := map[string]interface{}{ + "name": name, + "piece length": int64(262144), + "pieces": string(make([]byte, 20)), + "length": length, + } + infoBytes, err := bencode.Marshal(infoMap) + if err != nil { + t.Fatalf("marshal v1 info: %v", err) + } + h1 := sha1.Sum(infoBytes) + v1Hex := strings.ToLower(hex.EncodeToString(h1[:])) + + torrentMap := map[string]interface{}{ + "announce": "http://tracker.example.com/announce", + "info": bencode.Bytes(infoBytes), + } + torrentBytes, err := bencode.Marshal(torrentMap) + if err != nil { + t.Fatalf("marshal v1 torrent: %v", err) + } + + filePath := filepath.Join(dir, name+".torrent") + if err := os.WriteFile(filePath, torrentBytes, 0644); err != nil { + t.Fatalf("write v1 torrent: %v", err) + } + return filePath, v1Hex +} + +// 1. Torrent Retry DB update failure: zero metadata goroutine engine actions +func TestTorrentRetry_DBUpdateFailure_ZeroEngineActions(t *testing.T) { + jobID := "job_retry_fail" + j := &Job{ + ID: jobID, + Type: TypeTorrent, + Source: "magnet:?xt=urn:btih:1111111111111111111111111111111111111111&dn=retry.iso", + Status: StatusFailed, + Engine: "qbittorrent", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + failRepo := &failingUpdateJobRepo{jobs: map[string]*Job{jobID: j}} + + var addMagnetCalled int32 + var addTorrentFileCalled int32 + var getOwnershipCalled int32 + + torrentEng := &fakeTorrentEngine{ + fakeEngine: &fakeEngine{}, + addMagnetFunc: func(magnet string) (string, error) { + atomic.AddInt32(&addMagnetCalled, 1) + return "1111111111111111111111111111111111111111", nil + }, + addTorrentFileFunc: func(path string) (string, error) { + atomic.AddInt32(&addTorrentFileCalled, 1) + return "1111111111111111111111111111111111111111", nil + }, + getOwnershipFunc: func(hash string) (*TorrentOwnership, error) { + atomic.AddInt32(&getOwnershipCalled, 1) + return nil, nil + }, + } + + engines := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": torrentEng}} + bus := newFakeEventBus() + torrentRepo := newFakeTorrentRepository(failRepo) + manager := NewManager(failRepo, engines, bus, t.TempDir(), torrentRepo) + + _, err := manager.Retry(context.Background(), jobID) + if err == nil { + t.Fatalf("expected Retry to fail on DB update failure") + } + + time.Sleep(100 * time.Millisecond) + + if atomic.LoadInt32(&addMagnetCalled) != 0 { + t.Errorf("expected 0 AddMagnet calls on retry DB update failure, got %d", addMagnetCalled) + } + if atomic.LoadInt32(&addTorrentFileCalled) != 0 { + t.Errorf("expected 0 AddTorrentFile calls on retry DB update failure, got %d", addTorrentFileCalled) + } + if atomic.LoadInt32(&getOwnershipCalled) != 0 { + t.Errorf("expected 0 GetTorrentOwnership calls on retry DB update failure, got %d", getOwnershipCalled) + } +} + +// 2. Graceful Stop during ANALYZING: qBittorrent torrent is NOT removed +func TestGracefulStop_DuringAnalyzing_DoesNotRemoveTorrent(t *testing.T) { + jobID := "job_stop_analyzing" + infoHash := "2222222222222222222222222222222222222222" + j := &Job{ + ID: jobID, + Type: TypeTorrent, + Source: "magnet:?xt=urn:btih:" + infoHash + "&dn=test.iso", + Status: StatusAnalyzing, + Engine: "qbittorrent", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + repo := newFakeJobRepository() + repo.jobs[jobID] = j + + var removeTorrentCalled int32 + + torrentEng := &fakeTorrentEngine{ + fakeEngine: &fakeEngine{}, + getOwnershipFunc: func(hash string) (*TorrentOwnership, error) { + return &TorrentOwnership{Hash: infoHash, Category: "godownloader", Tags: []string{jobID}}, nil + }, + getTorrentInfoFunc: func(hash string) (*TorrentInfo, error) { + // Metadata not yet ready to simulate in-flight acquisition + return nil, errors.New("torrent not found") + }, + removeTorrentFunc: func(hash string, deleteFiles bool) error { + atomic.AddInt32(&removeTorrentCalled, 1) + return nil + }, + } + + engines := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": torrentEng}} + bus := newFakeEventBus() + torrentRepo := newFakeTorrentRepository(repo) + manager := NewManager(repo, engines, bus, t.TempDir(), torrentRepo) + + // Launch metadata acquisition in background + go manager.acquireTorrentMetadata(jobID, j.Source, "") + + time.Sleep(100 * time.Millisecond) + + // Perform graceful backend stop + manager.Stop() + + time.Sleep(100 * time.Millisecond) + + if atomic.LoadInt32(&removeTorrentCalled) != 0 { + t.Fatalf("expected RemoveTorrent to NOT be called on graceful stop during ANALYZING, got %d calls", removeTorrentCalled) + } + + // Persisted state must remain ANALYZING + saved, _ := repo.GetByID(context.Background(), jobID) + if saved.Status != StatusAnalyzing { + t.Errorf("expected job to remain ANALYZING on graceful stop, got %s", saved.Status) + } +} + +// 3. Restart with ANALYZING magnet + existing qBit object: +// same torrent reused, no duplicate, eventually AwaitingSelection +func TestRestartRecovery_AnalyzingMagnet_ExistingQBitObject(t *testing.T) { + jobID := "job_restart_mag" + infoHash := "3333333333333333333333333333333333333333" + j := &Job{ + ID: jobID, + Type: TypeTorrent, + Source: "magnet:?xt=urn:btih:" + infoHash + "&dn=ubuntu.iso", + Status: StatusAnalyzing, + Engine: "qbittorrent", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + repo := newFakeJobRepository() + repo.jobs[jobID] = j + + torrentRepo := newFakeTorrentRepository(repo) + torrentRepo.torrentJobs[jobID] = &TorrentJobRecord{ + JobID: jobID, + InfoHash: infoHash, + } + + var addMagnetCalled int32 + var stopDownloadCalled int32 + + torrentEng := &fakeTorrentEngine{ + fakeEngine: &fakeEngine{}, + isStopped: true, + getOwnershipFunc: func(hash string) (*TorrentOwnership, error) { + return &TorrentOwnership{Hash: infoHash, Category: "godownloader", Tags: []string{jobID}}, nil + }, + addMagnetFunc: func(magnet string) (string, error) { + atomic.AddInt32(&addMagnetCalled, 1) + return infoHash, nil + }, + getTorrentInfoFunc: func(hash string) (*TorrentInfo, error) { + return &TorrentInfo{ + Name: "ubuntu.iso", + InfoHash: infoHash, + TotalSize: 1024 * 1024 * 1024, + }, nil + }, + getFilesFunc: func(hash string) ([]TorrentFile, error) { + return []TorrentFile{ + {Index: 0, Path: "ubuntu.iso", Size: 1024 * 1024 * 1024, Priority: PriorityNormal, Selected: true}, + }, nil + }, + stopDownloadFunc: func(hash string) error { + atomic.AddInt32(&stopDownloadCalled, 1) + return nil + }, + } + + engines := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": torrentEng}} + bus := newFakeEventBus() + manager := NewManager(repo, engines, bus, t.TempDir(), torrentRepo) + + // Simulate restart recovery + manager.recover(context.Background()) + + // Wait for metadata goroutine to complete + deadline := time.Now().Add(2 * time.Second) + for { + saved, _ := repo.GetByID(context.Background(), jobID) + if saved != nil && saved.Status == StatusAwaitingSelection { + break + } + if time.Now().After(deadline) { + t.Fatalf("job did not reach AwaitingSelection, current status=%v, error=%v", saved.Status, saved.Error) + } + time.Sleep(50 * time.Millisecond) + } + + if atomic.LoadInt32(&addMagnetCalled) != 0 { + t.Fatalf("expected AddMagnet to NOT be called for existing same-job torrent, called %d times", addMagnetCalled) + } + + if atomic.LoadInt32(&stopDownloadCalled) < 1 { + t.Fatalf("expected StopDownload to be called to ensure stopped state before AwaitingSelection") + } +} + +// 4. Restart with ANALYZING uploaded .torrent + existing qBit object: +// same behavior, no duplicate +func TestRestartRecovery_AnalyzingUploadedTorrent_ExistingQBitObject(t *testing.T) { + tmpDir := t.TempDir() + torrentFilePath, infoHash := makeTestTorrentFileHelper(t, tmpDir, "linux.iso", 2048*1024) + + jobID := "job_restart_file" + j := &Job{ + ID: jobID, + Type: TypeTorrent, + Source: "torrent://linux.iso", + Status: StatusAnalyzing, + Engine: "qbittorrent", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + repo := newFakeJobRepository() + repo.jobs[jobID] = j + + torrentRepo := newFakeTorrentRepository(repo) + torrentRepo.torrentJobs[jobID] = &TorrentJobRecord{ + JobID: jobID, + TorrentFilePath: torrentFilePath, + InfoHash: infoHash, + } + + var addTorrentFileCalled int32 + + torrentEng := &fakeTorrentEngine{ + fakeEngine: &fakeEngine{}, + isStopped: true, + getOwnershipFunc: func(hash string) (*TorrentOwnership, error) { + return &TorrentOwnership{Hash: infoHash, Category: "godownloader", Tags: []string{jobID}}, nil + }, + addTorrentFileFunc: func(path string) (string, error) { + atomic.AddInt32(&addTorrentFileCalled, 1) + return infoHash, nil + }, + getTorrentInfoFunc: func(hash string) (*TorrentInfo, error) { + return &TorrentInfo{ + Name: "linux.iso", + InfoHash: infoHash, + TotalSize: 2048 * 1024, + }, nil + }, + getFilesFunc: func(hash string) ([]TorrentFile, error) { + return []TorrentFile{ + {Index: 0, Path: "linux.iso", Size: 2048 * 1024, Priority: PriorityNormal, Selected: true}, + }, nil + }, + } + + engines := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": torrentEng}} + bus := newFakeEventBus() + manager := NewManager(repo, engines, bus, t.TempDir(), torrentRepo) + + manager.recover(context.Background()) + + deadline := time.Now().Add(2 * time.Second) + for { + saved, _ := repo.GetByID(context.Background(), jobID) + if saved != nil && saved.Status == StatusAwaitingSelection { + break + } + if time.Now().After(deadline) { + t.Fatalf("job did not reach AwaitingSelection, current status=%v, error=%v", saved.Status, saved.Error) + } + time.Sleep(50 * time.Millisecond) + } + + if atomic.LoadInt32(&addTorrentFileCalled) != 0 { + t.Fatalf("expected AddTorrentFile to NOT be called for existing torrent, called %d times", addTorrentFileCalled) + } +} + +// 5. Restart with ANALYZING job and qBit object absent: +// safely re-add, eventually AwaitingSelection +func TestRestartRecovery_AnalyzingJob_QBitObjectAbsent(t *testing.T) { + jobID := "job_restart_absent" + infoHash := "5555555555555555555555555555555555555555" + j := &Job{ + ID: jobID, + Type: TypeTorrent, + Source: "magnet:?xt=urn:btih:" + infoHash + "&dn=readd.iso", + Status: StatusAnalyzing, + Engine: "qbittorrent", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + repo := newFakeJobRepository() + repo.jobs[jobID] = j + + torrentRepo := newFakeTorrentRepository(repo) + torrentRepo.torrentJobs[jobID] = &TorrentJobRecord{ + JobID: jobID, + InfoHash: infoHash, + } + + var addMagnetCalled int32 + + torrentEng := &fakeTorrentEngine{ + fakeEngine: &fakeEngine{}, + isStopped: true, + getOwnershipFunc: func(hash string) (*TorrentOwnership, error) { + if atomic.LoadInt32(&addMagnetCalled) == 0 { + return nil, nil // absent initially + } + return &TorrentOwnership{Hash: infoHash, Category: "godownloader", Tags: []string{jobID}}, nil + }, + addMagnetFunc: func(magnet string) (string, error) { + atomic.AddInt32(&addMagnetCalled, 1) + return infoHash, nil + }, + getTorrentInfoFunc: func(hash string) (*TorrentInfo, error) { + return &TorrentInfo{ + Name: "readd.iso", + InfoHash: infoHash, + TotalSize: 5000, + }, nil + }, + getFilesFunc: func(hash string) ([]TorrentFile, error) { + return []TorrentFile{ + {Index: 0, Path: "readd.iso", Size: 5000, Priority: PriorityNormal, Selected: true}, + }, nil + }, + } + + engines := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": torrentEng}} + bus := newFakeEventBus() + manager := NewManager(repo, engines, bus, t.TempDir(), torrentRepo) + + manager.recover(context.Background()) + + deadline := time.Now().Add(2 * time.Second) + for { + saved, _ := repo.GetByID(context.Background(), jobID) + if saved != nil && saved.Status == StatusAwaitingSelection { + break + } + if time.Now().After(deadline) { + t.Fatalf("job did not reach AwaitingSelection, current status=%v, error=%v", saved.Status, saved.Error) + } + time.Sleep(50 * time.Millisecond) + } + + if atomic.LoadInt32(&addMagnetCalled) != 1 { + t.Fatalf("expected AddMagnet to be called exactly once to re-add absent torrent, got %d", addMagnetCalled) + } +} + +// 6. User Cancel while ANALYZING: qBit torrent removed/cancelled as expected +func TestUserCancel_DuringAnalyzing_RemovesTorrent(t *testing.T) { + jobID := "job_cancel_analyzing" + infoHash := "6666666666666666666666666666666666666666" + j := &Job{ + ID: jobID, + Type: TypeTorrent, + Source: "magnet:?xt=urn:btih:" + infoHash + "&dn=cancel.iso", + Status: StatusAnalyzing, + Engine: "qbittorrent", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + repo := newFakeJobRepository() + repo.jobs[jobID] = j + + torrentRepo := newFakeTorrentRepository(repo) + torrentRepo.torrentJobs[jobID] = &TorrentJobRecord{ + JobID: jobID, + InfoHash: infoHash, + } + + var cancelCalled int32 + + torrentEng := &fakeTorrentEngine{ + fakeEngine: &fakeEngine{ + cancelFunc: func(ctx context.Context, j *Job) error { + atomic.AddInt32(&cancelCalled, 1) + return nil + }, + }, + } + + engines := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": torrentEng}} + bus := newFakeEventBus() + manager := NewManager(repo, engines, bus, t.TempDir(), torrentRepo) + + cancelledJob, err := manager.Cancel(context.Background(), jobID) + if err != nil { + t.Fatalf("Cancel failed: %v", err) + } + + if cancelledJob.Status != StatusCancelled { + t.Errorf("expected job to be StatusCancelled, got %s", cancelledJob.Status) + } + + if atomic.LoadInt32(&cancelCalled) < 1 { + t.Fatalf("expected engine Cancel to be called for analyzing torrent job") + } +} + +// 7. Priority verification success: exact priorities read back, StartDownload allowed +func TestPriorityVerification_Success(t *testing.T) { + jobID := "job_prio_success" + infoHash := "7777777777777777777777777777777777777777" + j := &Job{ + ID: jobID, + Type: TypeTorrent, + EngineID: infoHash, + Source: "magnet:?xt=urn:btih:" + infoHash, + Status: StatusAwaitingSelection, + Engine: "qbittorrent", + DestinationDir: t.TempDir(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + TorrentInfo: &TorrentInfo{ + Name: "prio.iso", + InfoHash: infoHash, + TotalSize: 3000, + }, + } + + repo := newFakeJobRepository() + repo.jobs[jobID] = j + + torrentRepo := newFakeTorrentRepository(repo) + torrentRepo.torrentJobs[jobID] = &TorrentJobRecord{JobID: jobID, InfoHash: infoHash, Name: "prio.iso", TotalSize: 3000} + torrentRepo.torrentFiles[jobID] = []TorrentFileRecord{ + {JobID: jobID, FileIndex: 0, Size: 1000, Selected: true, Priority: "normal"}, + {JobID: jobID, FileIndex: 1, Size: 2000, Selected: false, Priority: "skip"}, + } + + var startDownloadCalled int32 + var setFilePrioritiesCalled int32 + + torrentEng := &fakeTorrentEngine{ + fakeEngine: &fakeEngine{}, + isStopped: false, + setPrioritiesFunc: func(hash string) error { + atomic.AddInt32(&setFilePrioritiesCalled, 1) + return nil + }, + getFilesFunc: func(hash string) ([]TorrentFile, error) { + return []TorrentFile{ + {Index: 0, Path: "file1.bin", Size: 1000, Priority: PriorityNormal, Selected: true}, + {Index: 1, Path: "file2.bin", Size: 2000, Priority: PrioritySkip, Selected: false}, + }, nil + }, + startDownloadFunc: func(hash string) error { + atomic.AddInt32(&startDownloadCalled, 1) + return nil + }, + } + + engines := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": torrentEng}} + bus := newFakeEventBus() + queueRepo := &fakeQueueRepo{} + manager := NewManager(repo, engines, bus, t.TempDir(), torrentRepo) + manager.SetQueueRepository(queueRepo) + + selections := []TorrentFileSelection{ + {Index: 0, Priority: PriorityNormal}, + {Index: 1, Priority: PrioritySkip}, + } + + policy := networkpolicy.SeedingPolicy{Mode: networkpolicy.SeedingModeNone} + _, err := manager.StartTorrentWithPolicy(context.Background(), jobID, selections, policy) + if err != nil { + t.Fatalf("StartTorrentWithPolicy failed: %v", err) + } + + if atomic.LoadInt32(&setFilePrioritiesCalled) != 1 { + t.Errorf("expected SetFilePriorities to be called once, got %d", setFilePrioritiesCalled) + } + + // Without scheduler, fallback runs StartDownload after verification + if atomic.LoadInt32(&startDownloadCalled) != 1 { + t.Errorf("expected StartDownload to be called after verified priorities, got %d", startDownloadCalled) + } +} + +// 8. Priority mismatch: fail closed, StartDownload == 0 calls, torrent stopped +func TestPriorityVerification_Mismatch_FailsClosed(t *testing.T) { + jobID := "job_prio_mismatch" + infoHash := "8888888888888888888888888888888888888888" + j := &Job{ + ID: jobID, + Type: TypeTorrent, + EngineID: infoHash, + Source: "magnet:?xt=urn:btih:" + infoHash, + Status: StatusAwaitingSelection, + Engine: "qbittorrent", + DestinationDir: t.TempDir(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + TorrentInfo: &TorrentInfo{ + Name: "mismatch.iso", + InfoHash: infoHash, + TotalSize: 3000, + }, + } + + repo := newFakeJobRepository() + repo.jobs[jobID] = j + + torrentRepo := newFakeTorrentRepository(repo) + torrentRepo.torrentJobs[jobID] = &TorrentJobRecord{JobID: jobID, InfoHash: infoHash, Name: "mismatch.iso", TotalSize: 3000} + torrentRepo.torrentFiles[jobID] = []TorrentFileRecord{ + {JobID: jobID, FileIndex: 0, Size: 1000, Selected: true, Priority: "normal"}, + {JobID: jobID, FileIndex: 1, Size: 2000, Selected: false, Priority: "skip"}, + } + + var startDownloadCalled int32 + + torrentEng := &fakeTorrentEngine{ + fakeEngine: &fakeEngine{}, + isStopped: true, + getFilesFunc: func(hash string) ([]TorrentFile, error) { + // Returns mismatch: file 1 is still normal / selected instead of skip + return []TorrentFile{ + {Index: 0, Path: "file1.bin", Size: 1000, Priority: PriorityNormal, Selected: true}, + {Index: 1, Path: "file2.bin", Size: 2000, Priority: PriorityNormal, Selected: true}, + }, nil + }, + startDownloadFunc: func(hash string) error { + atomic.AddInt32(&startDownloadCalled, 1) + return nil + }, + } + + engines := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": torrentEng}} + bus := newFakeEventBus() + queueRepo := &fakeQueueRepo{} + manager := NewManager(repo, engines, bus, t.TempDir(), torrentRepo) + manager.SetQueueRepository(queueRepo) + + selections := []TorrentFileSelection{ + {Index: 0, Priority: PriorityNormal}, + {Index: 1, Priority: PrioritySkip}, + } + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + policy := networkpolicy.SeedingPolicy{Mode: networkpolicy.SeedingModeNone} + _, err := manager.StartTorrentWithPolicy(ctx, jobID, selections, policy) + if err == nil { + t.Fatalf("expected StartTorrentWithPolicy to fail on priority mismatch") + } + + if atomic.LoadInt32(&startDownloadCalled) != 0 { + t.Fatalf("expected ZERO calls to StartDownload on priority verification failure, got %d", startDownloadCalled) + } + + // Verify zero queue entries created + if len(queueRepo.entries) != 0 { + t.Fatalf("expected zero queue entries on priority mismatch, got %d", len(queueRepo.entries)) + } +} + +// 9. Priority visibility delayed: polling eventually verifies without arbitrary sleep +func TestPriorityVerification_DelayedVisibility(t *testing.T) { + jobID := "job_prio_delayed" + infoHash := "9999999999999999999999999999999999999999" + j := &Job{ + ID: jobID, + Type: TypeTorrent, + EngineID: infoHash, + Source: "magnet:?xt=urn:btih:" + infoHash, + Status: StatusAwaitingSelection, + Engine: "qbittorrent", + DestinationDir: t.TempDir(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + TorrentInfo: &TorrentInfo{ + Name: "delayed.iso", + InfoHash: infoHash, + TotalSize: 3000, + }, + } + + repo := newFakeJobRepository() + repo.jobs[jobID] = j + + torrentRepo := newFakeTorrentRepository(repo) + torrentRepo.torrentJobs[jobID] = &TorrentJobRecord{JobID: jobID, InfoHash: infoHash, Name: "delayed.iso", TotalSize: 3000} + torrentRepo.torrentFiles[jobID] = []TorrentFileRecord{ + {JobID: jobID, FileIndex: 0, Size: 1000, Selected: true, Priority: "normal"}, + {JobID: jobID, FileIndex: 1, Size: 2000, Selected: false, Priority: "skip"}, + } + + var pollCount int32 + var startDownloadCalled int32 + + torrentEng := &fakeTorrentEngine{ + fakeEngine: &fakeEngine{}, + isStopped: false, + getFilesFunc: func(hash string) ([]TorrentFile, error) { + count := atomic.AddInt32(&pollCount, 1) + if count < 3 { + // Old stale priorities initially + return []TorrentFile{ + {Index: 0, Path: "file1.bin", Size: 1000, Priority: PriorityNormal, Selected: true}, + {Index: 1, Path: "file2.bin", Size: 2000, Priority: PriorityNormal, Selected: true}, + }, nil + } + // Updated priorities on 3rd poll + return []TorrentFile{ + {Index: 0, Path: "file1.bin", Size: 1000, Priority: PriorityNormal, Selected: true}, + {Index: 1, Path: "file2.bin", Size: 2000, Priority: PrioritySkip, Selected: false}, + }, nil + }, + startDownloadFunc: func(hash string) error { + atomic.AddInt32(&startDownloadCalled, 1) + return nil + }, + } + + engines := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": torrentEng}} + bus := newFakeEventBus() + queueRepo := &fakeQueueRepo{} + manager := NewManager(repo, engines, bus, t.TempDir(), torrentRepo) + manager.SetQueueRepository(queueRepo) + + selections := []TorrentFileSelection{ + {Index: 0, Priority: PriorityNormal}, + {Index: 1, Priority: PrioritySkip}, + } + + policy := networkpolicy.SeedingPolicy{Mode: networkpolicy.SeedingModeNone} + _, err := manager.StartTorrentWithPolicy(context.Background(), jobID, selections, policy) + if err != nil { + t.Fatalf("StartTorrentWithPolicy failed: %v", err) + } + + if atomic.LoadInt32(&pollCount) < 3 { + t.Fatalf("expected at least 3 poll queries, got %d", pollCount) + } + + if atomic.LoadInt32(&startDownloadCalled) != 1 { + t.Fatalf("expected StartDownload to be called after delayed priority confirmation, got %d", startDownloadCalled) + } +} + +// 10. TotalBytes repair persistence failure: StartDownload == 0, deterministic persistence state +func TestTotalBytesRepair_PersistenceFailure_FailsDispatch(t *testing.T) { + jobID := "job_repair_fail" + infoHash := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + j := &Job{ + ID: jobID, + Type: TypeTorrent, + EngineID: infoHash, + Source: "magnet:?xt=urn:btih:" + infoHash, + Status: StatusQueued, + TotalBytes: 9999999, // Inaccurate TotalBytes + Engine: "qbittorrent", + DestinationDir: t.TempDir(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + failRepo := &failingUpdateJobRepo{jobs: map[string]*Job{jobID: j}} + + torrentRepo := newFakeTorrentRepository(failRepo) + torrentRepo.torrentFiles[jobID] = []TorrentFileRecord{ + {JobID: jobID, FileIndex: 0, Size: 1000, Selected: true, Priority: "normal"}, + } + + var startDownloadCalled int32 + + torrentEng := &fakeTorrentEngine{ + fakeEngine: &fakeEngine{}, + startDownloadFunc: func(hash string) error { + atomic.AddInt32(&startDownloadCalled, 1) + return nil + }, + } + + engines := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": torrentEng}} + bus := newFakeEventBus() + queueRepo := &fakeQueueRepo{ + entries: map[string]*QueueEntry{ + jobID: {JobID: jobID, Position: 1, Action: QueueActionStart}, + }, + } + + manager := NewManager(failRepo, engines, bus, t.TempDir(), torrentRepo) + manager.SetQueueRepository(queueRepo) + + qj := &QueuedJob{ + JobID: jobID, + Position: 1, + Action: QueueActionStart, + } + + err := manager.dispatchQueuedJob(context.Background(), qj) + if err == nil { + t.Fatalf("expected dispatchQueuedJob to fail when TotalBytes repair persistence fails") + } + + if atomic.LoadInt32(&startDownloadCalled) != 0 { + t.Fatalf("expected ZERO calls to StartDownload on repair persistence failure, got %d", startDownloadCalled) + } +} + +// 11. Existing same-job torrent with metadata: cannot reach AwaitingSelection unless stopped state is verified +func TestExistingSameJobTorrent_MustVerifyStoppedState(t *testing.T) { + jobID := "job_stop_verify_fail" + infoHash := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + j := &Job{ + ID: jobID, + Type: TypeTorrent, + Source: "magnet:?xt=urn:btih:" + infoHash + "&dn=unstopped.iso", + Status: StatusAnalyzing, + Engine: "qbittorrent", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + repo := newFakeJobRepository() + repo.jobs[jobID] = j + + torrentRepo := newFakeTorrentRepository(repo) + torrentRepo.torrentJobs[jobID] = &TorrentJobRecord{JobID: jobID, InfoHash: infoHash} + + torrentEng := &fakeTorrentEngine{ + fakeEngine: &fakeEngine{}, + isStopped: false, // Never transitions to stopped + getOwnershipFunc: func(hash string) (*TorrentOwnership, error) { + return &TorrentOwnership{Hash: infoHash, Category: "godownloader", Tags: []string{jobID}}, nil + }, + getTorrentInfoFunc: func(hash string) (*TorrentInfo, error) { + return &TorrentInfo{Name: "unstopped.iso", InfoHash: infoHash, TotalSize: 4000}, nil + }, + getFilesFunc: func(hash string) ([]TorrentFile, error) { + return []TorrentFile{ + {Index: 0, Path: "unstopped.iso", Size: 4000, Priority: PriorityNormal, Selected: true}, + }, nil + }, + stopDownloadFunc: func(hash string) error { + return errors.New("daemon busy") + }, + } + + engines := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": torrentEng}} + bus := newFakeEventBus() + manager := NewManager(repo, engines, bus, t.TempDir(), torrentRepo) + + go manager.acquireTorrentMetadata(jobID, j.Source, "") + + deadline := time.Now().Add(6 * time.Second) + for { + saved, _ := repo.GetByID(context.Background(), jobID) + if saved != nil && saved.Status == StatusFailed { + break + } + if time.Now().After(deadline) { + t.Fatalf("job did not fail closed on unstopped state, status=%v", saved.Status) + } + time.Sleep(50 * time.Millisecond) + } + + saved, _ := repo.GetByID(context.Background(), jobID) + if saved.Status == StatusAwaitingSelection { + t.Fatalf("job MUST NOT reach StatusAwaitingSelection when stopped verification fails") + } +} + +// 12. Existing magnet still fetching metadata: metadata acquisition is not accidentally disabled +func TestExistingMagnet_StillFetchingMetadata_NotPrematurelyStopped(t *testing.T) { + jobID := "job_mag_metadata_fetch" + infoHash := "cccccccccccccccccccccccccccccccccccccccc" + j := &Job{ + ID: jobID, + Type: TypeTorrent, + Source: "magnet:?xt=urn:btih:" + infoHash + "&dn=fetch.iso", + Status: StatusAnalyzing, + Engine: "qbittorrent", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + repo := newFakeJobRepository() + repo.jobs[jobID] = j + + torrentRepo := newFakeTorrentRepository(repo) + torrentRepo.torrentJobs[jobID] = &TorrentJobRecord{JobID: jobID, InfoHash: infoHash} + + var metadataFetchPolls int32 + var mu sync.Mutex + isStoppedState := false + + torrentEng := &fakeTorrentEngine{ + fakeEngine: &fakeEngine{}, + getOwnershipFunc: func(hash string) (*TorrentOwnership, error) { + return &TorrentOwnership{Hash: infoHash, Category: "godownloader", Tags: []string{jobID}}, nil + }, + getTorrentInfoFunc: func(hash string) (*TorrentInfo, error) { + count := atomic.AddInt32(&metadataFetchPolls, 1) + if count < 3 { + // Metadata still pending from DHT/peers + return &TorrentInfo{Name: "", InfoHash: infoHash, TotalSize: 0}, nil + } + return &TorrentInfo{Name: "fetch.iso", InfoHash: infoHash, TotalSize: 6000}, nil + }, + getFilesFunc: func(hash string) ([]TorrentFile, error) { + count := atomic.LoadInt32(&metadataFetchPolls) + if count < 3 { + return nil, errors.New("no files yet") + } + return []TorrentFile{ + {Index: 0, Path: "fetch.iso", Size: 6000, Priority: PriorityNormal, Selected: true}, + }, nil + }, + stopDownloadFunc: func(hash string) error { + mu.Lock() + isStoppedState = true + mu.Unlock() + return nil + }, + } + + torrentEng.statusFunc = func(ctx context.Context, j *Job) (*EngineStatus, error) { + mu.Lock() + st := "downloading" + if isStoppedState { + st = "stoppedDL" + } + mu.Unlock() + return &EngineStatus{Status: StatusDownloading, RawState: st}, nil + } + + engines := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": torrentEng}} + bus := newFakeEventBus() + manager := NewManager(repo, engines, bus, t.TempDir(), torrentRepo) + + go manager.acquireTorrentMetadata(jobID, j.Source, "") + + deadline := time.Now().Add(3 * time.Second) + for { + saved, _ := repo.GetByID(context.Background(), jobID) + if saved != nil && saved.Status == StatusAwaitingSelection { + break + } + if time.Now().After(deadline) { + t.Fatalf("job did not reach AwaitingSelection, current status=%v, error=%v", saved.Status, saved.Error) + } + time.Sleep(50 * time.Millisecond) + } + + if atomic.LoadInt32(&metadataFetchPolls) < 3 { + t.Fatalf("expected metadata polling to continue until metadata arrives, got %d polls", metadataFetchPolls) + } +} diff --git a/internal/job/torrent_selection_regression_test.go b/internal/job/torrent_selection_regression_test.go index bcb6a16..662b4e7 100644 --- a/internal/job/torrent_selection_regression_test.go +++ b/internal/job/torrent_selection_regression_test.go @@ -119,6 +119,16 @@ func (m *regressionMockEngine) SetFilePriorities(ctx context.Context, infoHash s return m.setFilePrioritiesErr } m.prioritiesSet = selections + fileMap := make(map[int]*TorrentFile, len(m.filesToReturn)) + for i := range m.filesToReturn { + fileMap[m.filesToReturn[i].Index] = &m.filesToReturn[i] + } + for _, s := range selections { + if file, ok := fileMap[s.Index]; ok { + file.Priority = s.Priority + file.Selected = (s.Priority != PrioritySkip) + } + } return nil } func (m *regressionMockEngine) StartDownload(ctx context.Context, infoHash string) error { From 74a2bb01f5708768125b6054536c3330759df8da Mon Sep 17 00:00:00 2001 From: Kavya Arora Date: Sat, 8 Aug 2026 22:31:23 +0530 Subject: [PATCH 14/15] test: align mock torrent engine file priority selections in storage integration tests --- internal/job/storage_integration_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/job/storage_integration_test.go b/internal/job/storage_integration_test.go index a8feb10..156ed40 100644 --- a/internal/job/storage_integration_test.go +++ b/internal/job/storage_integration_test.go @@ -285,7 +285,7 @@ func TestTorrentSelection_UpdatesSelectedTotalBytes(t *testing.T) { fakeT := &fakeTorrentEngine{ getFilesFunc: func(hash string) ([]TorrentFile, error) { return []TorrentFile{ - {Index: 0, Path: "file1.iso", Size: 5 * 1024 * 1024 * 1024, Priority: PriorityNormal, Selected: true}, // 5 GB + {Index: 0, Path: "file1.iso", Size: 5 * 1024 * 1024 * 1024, Priority: PriorityNormal, Selected: true}, // 5 GB {Index: 1, Path: "file2.iso", Size: 10 * 1024 * 1024 * 1024, Priority: PrioritySkip, Selected: false}, // 10 GB }, nil }, From ead31249dcf8332e945be74d8924864934b73037 Mon Sep 17 00:00:00 2001 From: Kavya Arora Date: Sat, 8 Aug 2026 23:02:51 +0530 Subject: [PATCH 15/15] fix(torrent): do not premature-stop metadata acquisition during same-job reconciliation or orphan adoption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove unconditional StopDownload from isSameJob reconciliation path in acquireTorrentMetadata — metadata may still be fetching from DHT/peers. Remove unconditional StopTorrents from AdoptTorrent engine method — orphan magnet adoption must not kill in-progress metadata acquisition. The existing verifyTorrentStopped safety gate after metadata/files become available already ensures the torrent is stopped before AwaitingSelection. Strengthen TestExistingMagnet_StillFetchingMetadata_NotPrematurelyStopped: after StopDownload, metadata can never arrive (simulating qBittorrent killing DHT). Test now fails against old code, passes after fix. Add TestOrphanMagnet_MetadataIncomplete_AdoptDoesNotStop: verifies orphan adoption does not stop in-progress metadata acquisition. --- internal/engine/qbittorrent/engine.go | 11 +- internal/job/manager.go | 3 +- .../job/torrent_lifecycle_hardening_test.go | 141 +++++++++++++++++- 3 files changed, 145 insertions(+), 10 deletions(-) diff --git a/internal/engine/qbittorrent/engine.go b/internal/engine/qbittorrent/engine.go index 4fe426c..644d2d9 100644 --- a/internal/engine/qbittorrent/engine.go +++ b/internal/engine/qbittorrent/engine.go @@ -258,15 +258,14 @@ func (e *Engine) AdoptTorrent(ctx context.Context, infoHash, jobID string) error if infoHash == "" { return errors.New("info hash is required to adopt torrent") } - // 1. Stop torrent to ensure no background downloading occurs before file selection - if err := e.client.StopTorrents(ctx, []string{infoHash}); err != nil { - return fmt.Errorf("failed to stop torrent during adoption: %w", err) - } + // Do NOT stop the torrent here — metadata acquisition may still be in progress + // for orphaned magnets. The manager-level verifyTorrentStopped safety gate + // handles stopping after metadata/files become available. - // Verify stopped state using existing raw-state/status mechanism where practical + // 1. Set category to godownloader info, err := e.client.GetTorrentInfo(ctx, infoHash) if err != nil { - return fmt.Errorf("failed to verify torrent state after adoption: %w", err) + return fmt.Errorf("failed to query torrent state during adoption: %w", err) } if info != nil && strings.TrimSpace(info.Category) != CategoryName { if catErr := e.client.SetCategory(ctx, []string{infoHash}, CategoryName); catErr != nil { diff --git a/internal/job/manager.go b/internal/job/manager.go index 7281b34..621b115 100644 --- a/internal/job/manager.go +++ b/internal/job/manager.go @@ -1040,9 +1040,10 @@ func (m *Manager) acquireTorrentMetadata(jobID, source, torrentFilePath string) } // 3. Same-job existing torrent (e.g. Retry or restart recovery) -> idempotent success + // Do NOT StopDownload here: metadata acquisition may still be in progress for magnets. + // The final verifyTorrentStopped safety gate runs after metadata/files become available. if isSameJob { log.Printf("acquireTorrentMetadata: torrent %s already owned by current job %s, reusing existing torrent", expectedHash, jobID) - _ = torrentEng.StopDownload(ctx, expectedHash) return true, nil } diff --git a/internal/job/torrent_lifecycle_hardening_test.go b/internal/job/torrent_lifecycle_hardening_test.go index 5818eaa..5939d30 100644 --- a/internal/job/torrent_lifecycle_hardening_test.go +++ b/internal/job/torrent_lifecycle_hardening_test.go @@ -822,7 +822,10 @@ func TestExistingSameJobTorrent_MustVerifyStoppedState(t *testing.T) { } } -// 12. Existing magnet still fetching metadata: metadata acquisition is not accidentally disabled +// 12. Existing same-job magnet still fetching metadata: StopDownload must NOT be called +// before metadata is ready. +// Strengthened: after StopDownload, metadata can never arrive — so premature stop causes +// test timeout / failure. func TestExistingMagnet_StillFetchingMetadata_NotPrematurelyStopped(t *testing.T) { jobID := "job_mag_metadata_fetch" infoHash := "cccccccccccccccccccccccccccccccccccccccc" @@ -845,6 +848,7 @@ func TestExistingMagnet_StillFetchingMetadata_NotPrematurelyStopped(t *testing.T var metadataFetchPolls int32 var mu sync.Mutex isStoppedState := false + metadataKilled := false // once StopDownload is called, metadata can never arrive torrentEng := &fakeTorrentEngine{ fakeEngine: &fakeEngine{}, @@ -852,6 +856,13 @@ func TestExistingMagnet_StillFetchingMetadata_NotPrematurelyStopped(t *testing.T return &TorrentOwnership{Hash: infoHash, Category: "godownloader", Tags: []string{jobID}}, nil }, getTorrentInfoFunc: func(hash string) (*TorrentInfo, error) { + mu.Lock() + killed := metadataKilled + mu.Unlock() + if killed { + // Metadata can never complete after StopDownload — qBittorrent killed DHT/peers + return &TorrentInfo{Name: "", InfoHash: infoHash, TotalSize: 0}, nil + } count := atomic.AddInt32(&metadataFetchPolls, 1) if count < 3 { // Metadata still pending from DHT/peers @@ -860,6 +871,12 @@ func TestExistingMagnet_StillFetchingMetadata_NotPrematurelyStopped(t *testing.T return &TorrentInfo{Name: "fetch.iso", InfoHash: infoHash, TotalSize: 6000}, nil }, getFilesFunc: func(hash string) ([]TorrentFile, error) { + mu.Lock() + killed := metadataKilled + mu.Unlock() + if killed { + return nil, errors.New("no files yet — metadata killed by StopDownload") + } count := atomic.LoadInt32(&metadataFetchPolls) if count < 3 { return nil, errors.New("no files yet") @@ -871,6 +888,7 @@ func TestExistingMagnet_StillFetchingMetadata_NotPrematurelyStopped(t *testing.T stopDownloadFunc: func(hash string) error { mu.Lock() isStoppedState = true + metadataKilled = true // key: premature stop prevents metadata from ever arriving mu.Unlock() return nil }, @@ -892,14 +910,17 @@ func TestExistingMagnet_StillFetchingMetadata_NotPrematurelyStopped(t *testing.T go manager.acquireTorrentMetadata(jobID, j.Source, "") - deadline := time.Now().Add(3 * time.Second) + deadline := time.Now().Add(6 * time.Second) for { saved, _ := repo.GetByID(context.Background(), jobID) if saved != nil && saved.Status == StatusAwaitingSelection { break } + if saved != nil && saved.Status == StatusFailed { + t.Fatalf("job FAILED — premature StopDownload killed metadata acquisition: %s", saved.Error) + } if time.Now().After(deadline) { - t.Fatalf("job did not reach AwaitingSelection, current status=%v, error=%v", saved.Status, saved.Error) + t.Fatalf("job did not reach AwaitingSelection (premature StopDownload killed metadata), current status=%v, error=%v", saved.Status, saved.Error) } time.Sleep(50 * time.Millisecond) } @@ -908,3 +929,117 @@ func TestExistingMagnet_StillFetchingMetadata_NotPrematurelyStopped(t *testing.T t.Fatalf("expected metadata polling to continue until metadata arrives, got %d polls", metadataFetchPolls) } } + +// 13. Orphan-magnet metadata-incomplete: AdoptTorrent must NOT stop the torrent. +// After adoption, metadata acquisition must continue to completion. +func TestOrphanMagnet_MetadataIncomplete_AdoptDoesNotStop(t *testing.T) { + jobID := "job_orphan_mag_adopt" + infoHash := "dddddddddddddddddddddddddddddddddddddddd" + j := &Job{ + ID: jobID, + Type: TypeTorrent, + Source: "magnet:?xt=urn:btih:" + infoHash + "&dn=orphan.iso", + Status: StatusAnalyzing, + Engine: "qbittorrent", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + repo := newFakeJobRepository() + repo.jobs[jobID] = j + + torrentRepo := newFakeTorrentRepository(repo) + torrentRepo.torrentJobs[jobID] = &TorrentJobRecord{JobID: jobID, InfoHash: infoHash} + + var metadataFetchPolls int32 + var mu sync.Mutex + isStoppedState := false + metadataKilled := false + var adoptCalled int32 + + torrentEng := &fakeTorrentEngine{ + fakeEngine: &fakeEngine{}, + getOwnershipFunc: func(hash string) (*TorrentOwnership, error) { + // Orphan: godownloader category but tagged with a stale/other job + return &TorrentOwnership{Hash: infoHash, Category: "godownloader", Tags: []string{"job_stale_old"}}, nil + }, + adoptTorrentFunc: func(hash, jid string) error { + atomic.AddInt32(&adoptCalled, 1) + return nil + }, + getTorrentInfoFunc: func(hash string) (*TorrentInfo, error) { + mu.Lock() + killed := metadataKilled + mu.Unlock() + if killed { + return &TorrentInfo{Name: "", InfoHash: infoHash, TotalSize: 0}, nil + } + count := atomic.AddInt32(&metadataFetchPolls, 1) + if count < 3 { + return &TorrentInfo{Name: "", InfoHash: infoHash, TotalSize: 0}, nil + } + return &TorrentInfo{Name: "orphan.iso", InfoHash: infoHash, TotalSize: 8000}, nil + }, + getFilesFunc: func(hash string) ([]TorrentFile, error) { + mu.Lock() + killed := metadataKilled + mu.Unlock() + if killed { + return nil, errors.New("no files — metadata killed by premature stop") + } + count := atomic.LoadInt32(&metadataFetchPolls) + if count < 3 { + return nil, errors.New("no files yet") + } + return []TorrentFile{ + {Index: 0, Path: "orphan.iso", Size: 8000, Priority: PriorityNormal, Selected: true}, + }, nil + }, + stopDownloadFunc: func(hash string) error { + mu.Lock() + isStoppedState = true + metadataKilled = true // premature stop prevents metadata from completing + mu.Unlock() + return nil + }, + } + + torrentEng.statusFunc = func(ctx context.Context, j *Job) (*EngineStatus, error) { + mu.Lock() + st := "downloading" + if isStoppedState { + st = "stoppedDL" + } + mu.Unlock() + return &EngineStatus{Status: StatusDownloading, RawState: st}, nil + } + + engines := &fakeEngineRegistry{engines: map[string]IEngine{"qbittorrent": torrentEng}} + bus := newFakeEventBus() + manager := NewManager(repo, engines, bus, t.TempDir(), torrentRepo) + + // The stale job must not exist in the repo, so the ownership check falls through to adopt + go manager.acquireTorrentMetadata(jobID, j.Source, "") + + deadline := time.Now().Add(6 * time.Second) + for { + saved, _ := repo.GetByID(context.Background(), jobID) + if saved != nil && saved.Status == StatusAwaitingSelection { + break + } + if saved != nil && saved.Status == StatusFailed { + t.Fatalf("job FAILED — premature stop during adopt killed metadata: %s", saved.Error) + } + if time.Now().After(deadline) { + t.Fatalf("job did not reach AwaitingSelection (premature stop killed metadata), current status=%v, error=%v", saved.Status, saved.Error) + } + time.Sleep(50 * time.Millisecond) + } + + if atomic.LoadInt32(&adoptCalled) != 1 { + t.Fatalf("expected AdoptTorrent to be called exactly once, got %d", atomic.LoadInt32(&adoptCalled)) + } + if atomic.LoadInt32(&metadataFetchPolls) < 3 { + t.Fatalf("expected metadata polling to continue until metadata arrives after adoption, got %d polls", metadataFetchPolls) + } +}