Skip to content

Commit 41a9026

Browse files
committed
fix(startup): bind HTTP listeners before sd_notify and improve library match logging
Split net.Listen (synchronous) from server.Serve (goroutine) so ports are guaranteed bound before READY=1 is sent to systemd. Replace per-target Warn "Libraries Not Found" with processor-level aggregation: targets return ErrLibraryNotMatched sentinel, processor warns only when NO target matched a scan (real config problem). Individual target misses logged at Debug for traceability. Eliminates false warnings in non-overlapping multi-target setups. Signed-off-by: Anagh Kumar Baranwal <6824881+darthShadow@users.noreply.github.com>
1 parent c5b029a commit 41a9026

File tree

7 files changed

+181
-65
lines changed

7 files changed

+181
-65
lines changed

autoscan.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ func LimitReadCloser(rc io.ReadCloser) io.ReadCloser {
5757
}
5858

5959
var (
60+
// ErrLibraryNotMatched is returned by a target's Scan method when the
61+
// scan folder does not match any of the target's known libraries.
62+
// The processor uses this to distinguish "no match" (expected in
63+
// non-overlapping setups) from real errors.
64+
ErrLibraryNotMatched = errors.New("no matching library")
65+
6066
// ErrTargetUnavailable may occur when a Target goes offline
6167
// or suffers from fatal errors. In this case, the processor
6268
// will halt operations until the target is back online.

cmd/autoscan/main.go

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"io"
8+
"net"
89
"net/http"
910
"os"
1011
"os/signal"
@@ -246,30 +247,40 @@ func initProcessor(cfg config, db *sqlite.DB, procStats *stats.Stats) *processor
246247
return proc
247248
}
248249

249-
// startHTTPServers starts one goroutine per host address that serves the router.
250-
// Calls log.Fatal if any server fails to start.
250+
// startHTTPServers binds a listener per host address, then serves in background
251+
// goroutines. The function returns only after every listener has successfully
252+
// bound, so callers can rely on the ports being open. Calls log.Fatal on bind
253+
// failure.
251254
func startHTTPServers(cfg config, router http.Handler) {
252255
for _, hostAddr := range cfg.Host {
253-
go func(host string) {
254-
addr := host
255-
if !strings.Contains(addr, ":") {
256-
addr = fmt.Sprintf("%s:%d", host, cfg.Port)
257-
}
256+
if !strings.Contains(hostAddr, ":") {
257+
hostAddr = fmt.Sprintf("%s:%d", hostAddr, cfg.Port)
258+
}
258259

259-
log.Info().Str("addr", addr).Msg("Server Starting")
260-
server := &http.Server{
261-
Addr: addr,
262-
Handler: router,
263-
ReadTimeout: serverTimeout,
264-
WriteTimeout: serverTimeout,
265-
}
266-
if listenErr := server.ListenAndServe(); listenErr != nil {
260+
var lc net.ListenConfig
261+
listener, err := lc.Listen(context.Background(), "tcp", hostAddr)
262+
if err != nil {
263+
log.Fatal().
264+
Str("addr", hostAddr).
265+
Err(err).
266+
Msg("Server Bind Failed")
267+
}
268+
269+
log.Info().Str("addr", hostAddr).Msg("Server Listening")
270+
server := &http.Server{
271+
Handler: router,
272+
ReadTimeout: serverTimeout,
273+
WriteTimeout: serverTimeout,
274+
}
275+
276+
go func() {
277+
if serveErr := server.Serve(listener); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
267278
log.Fatal().
268-
Str("addr", addr).
269-
Err(listenErr).
270-
Msg("Server Start Failed")
279+
Str("addr", hostAddr).
280+
Err(serveErr).
281+
Msg("Server Failed")
271282
}
272-
}(hostAddr)
283+
}()
273284
}
274285
}
275286

processor/processor.go

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package processor
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"os"
78
"sync"
@@ -121,20 +122,47 @@ func (*Processor) CheckAvailability(targets []autoscan.Target) error {
121122
}
122123

123124
func (*Processor) callTargets(targets []autoscan.Target, scan autoscan.Scan) error {
124-
ctx, cancel := context.WithTimeout(context.Background(), processorTimeout)
125-
defer cancel()
126-
127-
g, _ := errgroup.WithContext(ctx)
125+
errs := make([]error, len(targets))
126+
var wg sync.WaitGroup
128127

129-
for _, target := range targets {
130-
g.Go(func() error {
131-
return target.Scan(scan)
128+
for i, t := range targets {
129+
wg.Go(func() {
130+
errs[i] = t.Scan(scan)
132131
})
133132
}
134133

135-
if err := g.Wait(); err != nil {
136-
return fmt.Errorf("call targets: %w", err)
134+
wg.Wait()
135+
136+
var (
137+
matched int
138+
skipped int
139+
firstErr error
140+
)
141+
142+
for _, err := range errs {
143+
switch {
144+
case err == nil:
145+
matched++
146+
case errors.Is(err, autoscan.ErrLibraryNotMatched):
147+
skipped++
148+
default:
149+
if firstErr == nil {
150+
firstErr = err
151+
}
152+
}
153+
}
154+
155+
if firstErr != nil {
156+
return fmt.Errorf("call targets: %w", firstErr)
137157
}
158+
159+
if matched == 0 && skipped > 0 {
160+
log.Warn().
161+
Str("folder", scan.Folder).
162+
Int("targets_skipped", skipped).
163+
Msg("No Targets Matched Scan")
164+
}
165+
138166
return nil
139167
}
140168

processor/processor_test.go

Lines changed: 102 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
package processor
22

33
import (
4+
"errors"
5+
"fmt"
46
"os"
57
"path/filepath"
8+
"strings"
69
"testing"
710

11+
"github.com/cloudbox/autoscan"
812
"github.com/cloudbox/autoscan/stats"
913
)
1014

@@ -42,6 +46,104 @@ func newTestProcessor(anchors []string) *Processor {
4246
}
4347
}
4448

49+
// mockTarget is a minimal autoscan.Target for testing callTargets.
50+
type mockTarget struct {
51+
scanFn func(autoscan.Scan) error
52+
}
53+
54+
func (m *mockTarget) Scan(scan autoscan.Scan) error {
55+
return m.scanFn(scan)
56+
}
57+
58+
func (*mockTarget) Available() error {
59+
return nil
60+
}
61+
62+
func TestCallTargets(t *testing.T) {
63+
p := &Processor{}
64+
scan := autoscan.Scan{Folder: "/media/movies"}
65+
66+
t.Run("AllMatch", func(t *testing.T) {
67+
targets := []autoscan.Target{
68+
&mockTarget{scanFn: func(_ autoscan.Scan) error { return nil }},
69+
&mockTarget{scanFn: func(_ autoscan.Scan) error { return nil }},
70+
&mockTarget{scanFn: func(_ autoscan.Scan) error { return nil }},
71+
}
72+
if err := p.callTargets(targets, scan); err != nil {
73+
t.Errorf("expected nil error, got: %v", err)
74+
}
75+
})
76+
77+
t.Run("AllSkipped", func(t *testing.T) {
78+
targets := []autoscan.Target{
79+
&mockTarget{scanFn: func(_ autoscan.Scan) error {
80+
return fmt.Errorf("%w: /tv", autoscan.ErrLibraryNotMatched)
81+
}},
82+
&mockTarget{scanFn: func(_ autoscan.Scan) error {
83+
return fmt.Errorf("%w: /tv", autoscan.ErrLibraryNotMatched)
84+
}},
85+
&mockTarget{scanFn: func(_ autoscan.Scan) error {
86+
return fmt.Errorf("%w: /tv", autoscan.ErrLibraryNotMatched)
87+
}},
88+
}
89+
// All skipped — scan is consumed, not retried. No error returned.
90+
if err := p.callTargets(targets, scan); err != nil {
91+
t.Errorf("expected nil error when all targets skipped, got: %v", err)
92+
}
93+
})
94+
95+
t.Run("MixMatchAndSkip", func(t *testing.T) {
96+
targets := []autoscan.Target{
97+
&mockTarget{scanFn: func(_ autoscan.Scan) error { return nil }},
98+
&mockTarget{scanFn: func(_ autoscan.Scan) error {
99+
return fmt.Errorf("%w: /tv", autoscan.ErrLibraryNotMatched)
100+
}},
101+
&mockTarget{scanFn: func(_ autoscan.Scan) error { return nil }},
102+
}
103+
if err := p.callTargets(targets, scan); err != nil {
104+
t.Errorf("expected nil error for mixed match/skip, got: %v", err)
105+
}
106+
})
107+
108+
t.Run("RealError", func(t *testing.T) {
109+
targets := []autoscan.Target{
110+
&mockTarget{scanFn: func(_ autoscan.Scan) error { return nil }},
111+
&mockTarget{scanFn: func(_ autoscan.Scan) error {
112+
return errors.New("connection refused")
113+
}},
114+
}
115+
err := p.callTargets(targets, scan)
116+
if err == nil {
117+
t.Fatal("expected non-nil error, got nil")
118+
}
119+
if !strings.Contains(err.Error(), "connection refused") {
120+
t.Errorf("expected error to contain 'connection refused', got: %v", err)
121+
}
122+
})
123+
124+
t.Run("RealErrorPlusSkip", func(t *testing.T) {
125+
targets := []autoscan.Target{
126+
&mockTarget{scanFn: func(_ autoscan.Scan) error {
127+
return fmt.Errorf("%w: /movies", autoscan.ErrLibraryNotMatched)
128+
}},
129+
&mockTarget{scanFn: func(_ autoscan.Scan) error { return nil }},
130+
&mockTarget{scanFn: func(_ autoscan.Scan) error {
131+
return errors.New("timeout")
132+
}},
133+
}
134+
err := p.callTargets(targets, scan)
135+
if err == nil {
136+
t.Fatal("expected non-nil error, got nil")
137+
}
138+
if !strings.Contains(err.Error(), "timeout") {
139+
t.Errorf("expected error to contain 'timeout', got: %v", err)
140+
}
141+
if errors.Is(err, autoscan.ErrLibraryNotMatched) {
142+
t.Error("returned error must not wrap ErrLibraryNotMatched")
143+
}
144+
})
145+
}
146+
45147
func TestCheckAnchorsNoAnchors(t *testing.T) {
46148
p := newTestProcessor(nil)
47149

@@ -135,25 +237,3 @@ func TestCheckAnchorsStateTransitions(t *testing.T) {
135237
t.Fatal("expected anchorState to be true after restore")
136238
}
137239
}
138-
139-
func TestCheckAnchorsDirectorySupport(t *testing.T) {
140-
dir := t.TempDir()
141-
anchorDir := filepath.Join(dir, "mount-check")
142-
if err := os.Mkdir(anchorDir, 0o755); err != nil {
143-
t.Fatal(err)
144-
}
145-
146-
p := newTestProcessor([]string{anchorDir})
147-
148-
if !p.CheckAnchors() {
149-
t.Error("expected CheckAnchors to return true for directory anchor")
150-
}
151-
152-
// Remove directory
153-
if err := os.Remove(anchorDir); err != nil {
154-
t.Fatal(err)
155-
}
156-
if p.CheckAnchors() {
157-
t.Error("expected CheckAnchors to return false after directory removed")
158-
}
159-
}

targets/emby/emby.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,8 @@ func (t target) Scan(scan autoscan.Scan) error {
7272

7373
lib, err := t.getScanLibrary(scanFolder)
7474
if err != nil {
75-
t.log.Warn().
76-
Err(err).
77-
Msg("Libraries Not Found")
78-
79-
return nil
75+
t.log.Debug().Str("folder", scanFolder).Msg("Library Not Matched")
76+
return fmt.Errorf("%w: %s", autoscan.ErrLibraryNotMatched, scanFolder)
8077
}
8178

8279
scanPath := scanFolder

targets/jellyfin/jellyfin.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,8 @@ func (t target) Scan(scan autoscan.Scan) error {
7272

7373
lib, err := t.getScanLibrary(scanFolder)
7474
if err != nil {
75-
t.log.Warn().
76-
Err(err).
77-
Msg("Libraries Not Found")
78-
79-
return nil
75+
t.log.Debug().Str("folder", scanFolder).Msg("Library Not Matched")
76+
return fmt.Errorf("%w: %s", autoscan.ErrLibraryNotMatched, scanFolder)
8077
}
8178

8279
scanPath := scanFolder

targets/plex/plex.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,8 @@ func (t target) Scan(scan autoscan.Scan) error {
8282

8383
libs, err := t.getScanLibrary(scanFolder)
8484
if err != nil {
85-
t.log.Warn().
86-
Err(err).
87-
Msg("Libraries Not Found")
88-
89-
return nil
85+
t.log.Debug().Str("folder", scanFolder).Msg("Library Not Matched")
86+
return fmt.Errorf("%w: %s", autoscan.ErrLibraryNotMatched, scanFolder)
9087
}
9188

9289
// send scan request

0 commit comments

Comments
 (0)