Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
5aa2c92
fix(v0.7): harden release, bulk actions, torrent upload, test isolati…
professionalman Aug 5, 2026
f43785a
fix(v0.7): fix Linux CI path test, enforce monotonic progress, remove…
professionalman Aug 5, 2026
cc99ffe
fix(torrent): transactional selection persistence, disk preflight ord…
professionalman Aug 5, 2026
7c83832
fix(torrent): enforce priority verification ordering, hard transactio…
professionalman Aug 5, 2026
3f0c374
fix(storage): map storage preflight errors to AppError and update UI/…
professionalman Aug 5, 2026
66538eb
fix(torrent): propagate live torrent runtime stats and deep copy Torr…
professionalman Aug 5, 2026
31c64aa
fix(torrent): implement atomic torrent creation transaction and sanit…
professionalman Aug 8, 2026
d5470da
fix(torrent): harden atomic torrent creation contract and require tor…
professionalman Aug 8, 2026
ef5ceac
fix(qbittorrent): implement idempotent torrent ownership reconciliati…
professionalman Aug 8, 2026
c3c9fcf
fix(qbittorrent): enforce 40-char TorrentID semantics, fail-closed ad…
professionalman Aug 8, 2026
6230b14
fix(torrent): eliminate qbittorrent start/resume race with bounded co…
professionalman Aug 8, 2026
7156d50
fix(qbittorrent): calculate expected hash before add and confirm visi…
professionalman Aug 8, 2026
e85a1d5
fix(torrent): harden v0.7 lifecycle, retry persistence, restart recov…
professionalman Aug 8, 2026
74a2bb0
test: align mock torrent engine file priority selections in storage i…
professionalman Aug 8, 2026
ead3124
fix(torrent): do not premature-stop metadata acquisition during same-…
professionalman Aug 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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 |
Expand Down
11 changes: 10 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +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/huandu/xstrings v1.3.2 // indirect
)
281 changes: 281 additions & 0 deletions go.sum

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion internal/api/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
Expand Down Expand Up @@ -427,16 +428,23 @@ 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
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")
}

Expand Down
66 changes: 66 additions & 0 deletions internal/api/handler_error_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading