Skip to content

Commit 543b2ed

Browse files
committed
propagete nntp errors properly accross board
1 parent 3fecea6 commit 543b2ed

8 files changed

Lines changed: 183 additions & 58 deletions

File tree

.github/workflows/beta-docker.yml

Lines changed: 122 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -9,83 +9,171 @@ permissions:
99
contents: read
1010
packages: write
1111

12+
env:
13+
DOCKERHUB_IMAGE: cy01/blackhole
14+
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/decypharr
15+
1216
jobs:
13-
docker:
17+
prepare:
1418
runs-on: ubuntu-latest
19+
outputs:
20+
beta_version: ${{ steps.calculate_version.outputs.beta_version }}
1521
steps:
1622
- name: Checkout repository
1723
uses: actions/checkout@v4
1824
with:
1925
fetch-depth: 0
2026

21-
- name: Set up Go
22-
uses: actions/setup-go@v5
23-
with:
24-
go-version: '1.25'
25-
cache: true
26-
2727
- name: Calculate beta version
2828
id: calculate_version
2929
run: |
3030
LATEST_TAG=$(git tag | grep -v 'beta' | sort -V | tail -n1)
3131
echo "Found latest tag: ${LATEST_TAG}"
32-
32+
3333
IFS='.' read -r -a VERSION_PARTS <<< "$LATEST_TAG"
3434
MAJOR="${VERSION_PARTS[0]}"
3535
MINOR="${VERSION_PARTS[1]}"
3636
PATCH="${VERSION_PARTS[2]}"
37-
37+
3838
NEW_PATCH=$((PATCH + 1))
3939
BETA_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
40-
40+
4141
echo "Calculated beta version: ${BETA_VERSION}"
42-
echo "beta_version=${BETA_VERSION}" >> $GITHUB_ENV
42+
echo "beta_version=${BETA_VERSION}" >> $GITHUB_OUTPUT
43+
44+
build:
45+
needs: prepare
46+
runs-on: ubuntu-latest
47+
strategy:
48+
matrix:
49+
platform:
50+
- linux/amd64
51+
- linux/arm64
52+
steps:
53+
- name: Prepare platform pair
54+
run: |
55+
platform=${{ matrix.platform }}
56+
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
57+
58+
- name: Checkout repository
59+
uses: actions/checkout@v4
4360

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

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

50-
- name: Cache Docker layers
51-
uses: actions/cache@v3
52-
with:
53-
path: /tmp/.buildx-cache
54-
key: ${{ runner.os }}-buildx-${{ github.sha }}
55-
restore-keys: |
56-
${{ runner.os }}-buildx-
57-
58-
# Login to Docker Hub
5967
- name: Login to Docker Hub
6068
uses: docker/login-action@v3
6169
with:
6270
username: ${{ secrets.DOCKERHUB_USERNAME }}
6371
password: ${{ secrets.DOCKERHUB_TOKEN }}
6472

65-
# Login to GitHub Container Registry
6673
- name: Login to GitHub Container Registry
6774
uses: docker/login-action@v3
6875
with:
6976
registry: ghcr.io
7077
username: ${{ github.repository_owner }}
7178
password: ${{ secrets.GITHUB_TOKEN }}
7279

73-
- name: Build and push beta Docker image
74-
uses: docker/build-push-action@v5
80+
- name: Build and push by digest (Docker Hub)
81+
id: build_dockerhub
82+
uses: docker/build-push-action@v6
83+
with:
84+
context: .
85+
platforms: ${{ matrix.platform }}
86+
build-args: |
87+
VERSION=${{ needs.prepare.outputs.beta_version }}
88+
CHANNEL=beta
89+
cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
90+
cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }},mode=max
91+
outputs: type=image,name=${{ env.DOCKERHUB_IMAGE }},push-by-digest=true,name-canonical=true,push=true
92+
93+
- name: Build and push by digest (GHCR)
94+
id: build_ghcr
95+
uses: docker/build-push-action@v6
7596
with:
7697
context: .
77-
platforms: linux/amd64,linux/arm64
78-
push: true
79-
tags: |
80-
cy01/blackhole:beta
81-
ghcr.io/${{ github.repository_owner }}/decypharr:beta
82-
cache-from: type=local,src=/tmp/.buildx-cache
83-
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
98+
platforms: ${{ matrix.platform }}
8499
build-args: |
85-
VERSION=${{ env.beta_version }}
100+
VERSION=${{ needs.prepare.outputs.beta_version }}
86101
CHANNEL=beta
102+
cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
103+
outputs: type=image,name=${{ env.GHCR_IMAGE }},push-by-digest=true,name-canonical=true,push=true
104+
105+
- name: Export digests
106+
run: |
107+
mkdir -p /tmp/digests/dockerhub /tmp/digests/ghcr
108+
109+
dockerhub_digest="${{ steps.build_dockerhub.outputs.digest }}"
110+
echo "Docker Hub digest: ${dockerhub_digest}"
111+
touch "/tmp/digests/dockerhub/${dockerhub_digest#sha256:}"
112+
113+
ghcr_digest="${{ steps.build_ghcr.outputs.digest }}"
114+
echo "GHCR digest: ${ghcr_digest}"
115+
touch "/tmp/digests/ghcr/${ghcr_digest#sha256:}"
116+
117+
- name: Upload Docker Hub digest
118+
uses: actions/upload-artifact@v4
119+
with:
120+
name: digests-dockerhub-${{ env.PLATFORM_PAIR }}
121+
path: /tmp/digests/dockerhub/*
122+
if-no-files-found: error
123+
retention-days: 1
124+
125+
- name: Upload GHCR digest
126+
uses: actions/upload-artifact@v4
127+
with:
128+
name: digests-ghcr-${{ env.PLATFORM_PAIR }}
129+
path: /tmp/digests/ghcr/*
130+
if-no-files-found: error
131+
retention-days: 1
132+
133+
merge:
134+
needs: build
135+
runs-on: ubuntu-latest
136+
steps:
137+
- name: Download Docker Hub digests
138+
uses: actions/download-artifact@v4
139+
with:
140+
path: /tmp/digests/dockerhub
141+
pattern: digests-dockerhub-*
142+
merge-multiple: true
143+
144+
- name: Download GHCR digests
145+
uses: actions/download-artifact@v4
146+
with:
147+
path: /tmp/digests/ghcr
148+
pattern: digests-ghcr-*
149+
merge-multiple: true
150+
151+
- name: Set up Docker Buildx
152+
uses: docker/setup-buildx-action@v3
153+
154+
- name: Login to Docker Hub
155+
uses: docker/login-action@v3
156+
with:
157+
username: ${{ secrets.DOCKERHUB_USERNAME }}
158+
password: ${{ secrets.DOCKERHUB_TOKEN }}
159+
160+
- name: Login to GitHub Container Registry
161+
uses: docker/login-action@v3
162+
with:
163+
registry: ghcr.io
164+
username: ${{ github.repository_owner }}
165+
password: ${{ secrets.GITHUB_TOKEN }}
166+
167+
- name: Create Docker Hub manifest and push
168+
working-directory: /tmp/digests/dockerhub
169+
run: |
170+
docker buildx imagetools create \
171+
--tag ${{ env.DOCKERHUB_IMAGE }}:beta \
172+
$(printf '${{ env.DOCKERHUB_IMAGE }}@sha256:%s ' *)
87173
88-
- name: Move cache
174+
- name: Create GHCR manifest and push
175+
working-directory: /tmp/digests/ghcr
89176
run: |
90-
rm -rf /tmp/.buildx-cache
91-
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
177+
docker buildx imagetools create \
178+
--tag ${{ env.GHCR_IMAGE }}:beta \
179+
$(printf '${{ env.GHCR_IMAGE }}@sha256:%s ' *)

internal/logger/ratelimit.go

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -96,50 +96,51 @@ func (r *RateLimitedLogger) shouldLog(key string) bool {
9696
}
9797

9898
// RateLimitedEvent provides a fluent API for rate-limited logging.
99-
// If suppressed, returns a no-op logger that discards all output.
99+
// It holds a reference to the parent logger and key, evaluating the rate limit
100+
// on each log call rather than at creation time.
100101
type RateLimitedEvent struct {
101-
logger zerolog.Logger
102-
shouldLog bool
102+
parent *RateLimitedLogger
103+
key string
103104
}
104105

105106
// Rate returns a RateLimitedEvent for fluent logging with deduplication.
106-
// Usage: logger.Rate(key).Error().err(err).Msg("message")
107-
// If rate-limited, the log is silently discarded (no nil check needed).
107+
// Usage: logger.Rate(key).Error().Err(err).Msg("message")
108+
// The rate limit is checked on each Error/Warn/Info/Debug call, not at creation.
108109
func (r *RateLimitedLogger) Rate(key string) *RateLimitedEvent {
109110
return &RateLimitedEvent{
110-
logger: r.logger,
111-
shouldLog: r.shouldLog(key),
111+
parent: r,
112+
key: key,
112113
}
113114
}
114115

115116
// Error returns an error event, or a no-op event if rate-limited.
116117
func (e *RateLimitedEvent) Error() *zerolog.Event {
117-
if e.shouldLog {
118-
return e.logger.Error()
118+
if e.parent.shouldLog(e.key) {
119+
return e.parent.logger.Error()
119120
}
120121
return nopLogger.Error()
121122
}
122123

123124
// Warn returns a warning event, or a no-op event if rate-limited.
124125
func (e *RateLimitedEvent) Warn() *zerolog.Event {
125-
if e.shouldLog {
126-
return e.logger.Warn()
126+
if e.parent.shouldLog(e.key) {
127+
return e.parent.logger.Warn()
127128
}
128129
return nopLogger.Warn()
129130
}
130131

131132
// Info returns an info event, or a no-op event if rate-limited.
132133
func (e *RateLimitedEvent) Info() *zerolog.Event {
133-
if e.shouldLog {
134-
return e.logger.Info()
134+
if e.parent.shouldLog(e.key) {
135+
return e.parent.logger.Info()
135136
}
136137
return nopLogger.Info()
137138
}
138139

139140
// Debug returns a debug event, or a no-op event if rate-limited.
140141
func (e *RateLimitedEvent) Debug() *zerolog.Event {
141-
if e.shouldLog {
142-
return e.logger.Debug()
142+
if e.parent.shouldLog(e.key) {
143+
return e.parent.logger.Debug()
143144
}
144145
return nopLogger.Debug()
145146
}

pkg/manager/fixer.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ func (f *Fixer) FixTorrent(ctx context.Context, entry *storage.Entry, skipCurren
139139
if success {
140140
f.manager.logger.Info().
141141
Str("debrid", debridName).
142+
Str("name", entry.Name).
142143
Str("infohash", entry.InfoHash).
143144
Msg("Successfully re-inserted entry")
144145

pkg/manager/manager.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -444,7 +444,7 @@ func (m *Manager) Stop() error {
444444
m.repair.Stop()
445445
}
446446

447-
// Close storage (releases bbolt file lock)
447+
// Close storage
448448
if m.storage != nil {
449449
m.logger.Info().Msg("Closing storage database")
450450
if err := m.storage.Close(); err != nil {

pkg/mount/dfs/backend/cgofuse/fs.go

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ func (f *FS) Readdir(path string, fill func(name string, stat *fuse.Stat_t, ofst
139139
// Root directory - list all torrents/entries
140140
entries := f.manager.GetEntries()
141141
for _, entry := range entries {
142-
fill(entry.Name(), nil, 0)
142+
fill(entry.Name(), f.entryStat(&entry), 0)
143143
}
144144
return 0
145145
}
@@ -160,7 +160,7 @@ func (f *FS) Readdir(path string, fill func(name string, stat *fuse.Stat_t, ofst
160160
_, children = f.manager.GetTorrentChildren(groupOrTorrent)
161161
}
162162
for _, child := range children {
163-
fill(child.Name(), nil, 0)
163+
fill(child.Name(), f.entryStat(&child), 0)
164164
}
165165
return 0
166166
}
@@ -172,12 +172,39 @@ func (f *FS) Readdir(path string, fill func(name string, stat *fuse.Stat_t, ofst
172172
// get the torrent's children (files)
173173
_, children := f.manager.GetTorrentChildren(torrentName)
174174
for _, child := range children {
175-
fill(child.Name(), nil, 0)
175+
fill(child.Name(), f.entryStat(&child), 0)
176176
}
177177

178178
return 0
179179
}
180180

181+
// entryStat creates a fuse.Stat_t for a FileInfo entry
182+
// This is used by Readdir to provide file type information to clients like Samba
183+
func (f *FS) entryStat(info *manager.FileInfo) *fuse.Stat_t {
184+
stat := &fuse.Stat_t{
185+
Uid: f.config.UID,
186+
Gid: f.config.GID,
187+
Blksize: 4096,
188+
}
189+
190+
modTime := info.ModTime()
191+
stat.Atim = fuse.NewTimespec(modTime)
192+
stat.Mtim = fuse.NewTimespec(modTime)
193+
stat.Ctim = fuse.NewTimespec(modTime)
194+
195+
if info.IsDir() {
196+
stat.Mode = fuse.S_IFDIR | 0755
197+
stat.Nlink = 2
198+
} else {
199+
stat.Mode = fuse.S_IFREG | 0644
200+
stat.Nlink = 1
201+
stat.Size = info.Size()
202+
stat.Blocks = (stat.Size + 511) / 512
203+
}
204+
205+
return stat
206+
}
207+
181208
// Open opens a file
182209
func (f *FS) Open(path string, flags int) (int, uint64) {
183210
// Check if read-only access

pkg/storage/hybrid/store.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,6 @@ func New(config Config) (*Store, error) {
130130
logPath := config.DataPath
131131
var err error
132132

133-
fmt.Println("Opening log at", logPath)
134-
135133
s.log, err = openAppendLog(logPath)
136134
if err != nil {
137135
cancel()

pkg/usenet/fs/reader/fetcher.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,11 +184,20 @@ func (sf *SegmentFetcher) doFetch(ctx context.Context, segIdx int) error {
184184
}
185185

186186
// Stream body directly to disk
187-
_, err := conn.StreamBody(messageID, writer)
187+
n, err := conn.StreamBody(messageID, writer)
188188
if err != nil {
189189
return err
190190
}
191191

192+
// Treat zero-byte articles as missing — the article exists on the
193+
// server but its body is empty/corrupted after yEnc decoding.
194+
if n == 0 {
195+
return &nntp.Error{
196+
Type: nntp.ErrorTypeArticleNotFound,
197+
Message: "article produced no data after decoding",
198+
}
199+
}
200+
192201
// Finalize the write (updates cache state)
193202
if dw, ok := writer.(*diskStreamWriter); ok {
194203
dw.Finalize()

pkg/usenet/fs/reader/reader.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,9 +257,10 @@ func (sr *StreamingReader) readFromCache(p []byte, off int64, startSeg, endSeg i
257257
segDataOffset := readStart - segStart
258258
copyLen := readEnd - readStart
259259

260-
// Defensive bounds check
260+
// Bounds check — empty segment data means the article was corrupted
261261
if segDataOffset < 0 || segDataOffset >= int64(len(data)) {
262-
continue
262+
return totalRead, fmt.Errorf("segment %d has no data (expected %d bytes at offset %d)",
263+
segIdx, segEnd-segStart, segStart)
263264
}
264265
availableInSeg := int64(len(data)) - segDataOffset
265266
if copyLen > availableInSeg {

0 commit comments

Comments
 (0)