diff --git a/Dockerfile b/Dockerfile index 430222ee..df1d1869 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,16 @@ RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ corepack enable \ && pnpm install --frozen-lockfile -COPY . . +# Copy only what the JS build (vite + tsup) and the final server image consume, +# so a cmd/-only change (the Go downloader) doesn't bust this layer and force a +# full server rebuild. Keep in sync with the build inputs + the final-stage COPYs. +COPY index.html vite.config.ts tsconfig.json ./ +COPY src ./src +COPY server ./server +COPY shared ./shared +COPY public ./public +COPY migrations ./migrations +COPY scripts ./scripts # .git is excluded from the build context, so git describe cannot run here. # The release workflow passes the tag via APP_VERSION and the commit SHA via # APP_COMMIT; resolveAppVersion/resolveAppCommit read them. diff --git a/cmd/internal/engine/aria2.go b/cmd/internal/engine/aria2.go index 737690e3..e1dea984 100644 --- a/cmd/internal/engine/aria2.go +++ b/cmd/internal/engine/aria2.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "math/bits" "net/http" "net/url" "os" @@ -1026,6 +1027,34 @@ func aria2Leechers(peers []arigo.Peer) *int64 { return &count } +// aria2PeerProgress derives a peer's 0..1 completion from its piece bitfield +// (aria2 has no direct percentage like qBittorrent). A seeder has every piece. +// Spare bits past the last piece are zero, so dividing by the bitfield's bit +// length only differs from the true piece count by sub-1% — fine for a bar. +func aria2PeerProgress(peer arigo.Peer) *float64 { + if peer.Seeder { + full := 1.0 + return &full + } + if peer.BitField == "" { + return nil + } + set := 0 + for _, digit := range peer.BitField { + nibble, err := strconv.ParseUint(string(digit), 16, 8) + if err != nil { + return nil + } + set += bits.OnesCount8(uint8(nibble)) + } + total := len(peer.BitField) * 4 + if total == 0 { + return nil + } + progress := float64(set) / float64(total) + return &progress +} + func aria2Peers(peers []arigo.Peer, geoIP PeerGeoIPResolver) []client.DownloadTaskPeer { out := make([]client.DownloadTaskPeer, 0, min(len(peers), 20)) for _, peer := range peers { @@ -1036,6 +1065,7 @@ func aria2Peers(peers []arigo.Peer, geoIP PeerGeoIPResolver) []client.DownloadTa up := int64(peer.UploadSpeed) item := client.DownloadTaskPeer{ Address: fmt.Sprintf("%s:%d", peer.IP, peer.Port), + Progress: aria2PeerProgress(peer), DownloadBps: &down, UploadBps: &up, } diff --git a/cmd/internal/engine/engine_test.go b/cmd/internal/engine/engine_test.go index e0bfb12a..2e35a038 100644 --- a/cmd/internal/engine/engine_test.go +++ b/cmd/internal/engine/engine_test.go @@ -201,6 +201,21 @@ func TestAria2SeedTimeMinutes(t *testing.T) { } } +func TestAria2PeerProgress(t *testing.T) { + if p := aria2PeerProgress(arigo.Peer{Seeder: true}); p == nil || *p != 1.0 { + t.Fatalf("expected seeder progress 1.0, got %v", p) + } + if p := aria2PeerProgress(arigo.Peer{BitField: "ff"}); p == nil || *p != 1.0 { + t.Fatalf("expected full bitfield 1.0, got %v", p) + } + if p := aria2PeerProgress(arigo.Peer{BitField: "f0"}); p == nil || *p != 0.5 { + t.Fatalf("expected half bitfield 0.5, got %v", p) + } + if p := aria2PeerProgress(arigo.Peer{BitField: ""}); p != nil { + t.Fatalf("expected nil progress for empty bitfield, got %v", p) + } +} + func TestQBittorrentStartArgsWritesManagedListenPort(t *testing.T) { stateDir := t.TempDir() downloadDir := t.TempDir()