mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-20 16:27:07 +02:00
* feat(jellyfin): compute the address advertised by auto-discovery * fix(jellyfin): use TLSEnabled and path.Join for the auto-discovery address * feat(jellyfin): answer LAN auto-discovery broadcasts * test(jellyfin): e2e check that auto-discovery matches the public server identity * feat(jellyfin): add opt-in AutoDiscovery option and start the listener * fix(jellyfin): close the auto-discovery socket on read errors and quiet reply failures * refactor(jellyfin): build the auto-discovery address with publicurl and log bind failures in place discoveryAddress now hands only the discovery-specific part (the requester-facing host) to publicurl.AbsoluteURL, so the BaseURL, scheme and BasePath rules live in one place. ServeDiscovery logs its own bind failure and returns nothing, so the caller cannot route the error into the server errgroup. Tests use a DescribeTable and no longer wait on a fixed timeout to prove a packet was ignored. * refactor(jellyfin): run auto-discovery as its own service in the main errgroup Discovery is now a small type that needs only a DataStore, started by startJellyfinDiscovery like the other background services, so the errgroup waits for it on shutdown and startServer is back to a one-line mount. The server id resolution moved to resolveServerID with a package-level lock: the Router and Discovery are separate objects, and a per-Router lock would let them persist two different ids on first boot. * refactor(jellyfin): build Discovery through wire and drop the serverName forwarder startJellyfinDiscovery now follows its siblings: negative guard with a DISABLED debug log, and the service comes from a CreateJellyfinDiscovery wire injector instead of an inline constructor. The Router.serverName method only forwarded to the package function, so its four call sites call the function directly. * fix(jellyfin): skip auto-discovery for unix socket servers without a BaseURL host With Address set to a unix socket nothing listens on Port, so the route-facing IP plus Port pointed clients at a dead URL. Discovery now logs a warning and does not start in that mode unless BaseURL names the proxy host, which AbsoluteURL already advertises as-is. * docs(jellyfin): note which address auto-discovery advertises on restricted binds Also stop using a hostname Address in the fallback spec: a hostname like localhost binds a single interface, so it is not an example of the route-facing fallback being right. An empty Address is.
175 lines
5.3 KiB
Go
175 lines
5.3 KiB
Go
package jellyfin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
type failingConn struct {
|
|
net.PacketConn
|
|
closed bool
|
|
}
|
|
|
|
func (c *failingConn) ReadFrom([]byte) (int, net.Addr, error) {
|
|
return 0, nil, errors.New("read failed")
|
|
}
|
|
|
|
func (c *failingConn) Close() error {
|
|
c.closed = true
|
|
return nil
|
|
}
|
|
|
|
var _ = Describe("Discovery", func() {
|
|
var d *Discovery
|
|
|
|
BeforeEach(func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.Jellyfin.ServerName = "Test Server"
|
|
conf.Server.Address = "0.0.0.0"
|
|
conf.Server.Port = 4533
|
|
conf.Server.BaseHost = ""
|
|
conf.Server.BaseScheme = ""
|
|
conf.Server.BasePath = ""
|
|
conf.Server.TLSCert = ""
|
|
conf.Server.TLSKey = ""
|
|
d = &Discovery{}
|
|
})
|
|
|
|
DescribeTable("discoveryAddress",
|
|
func(setup func(), expected string) {
|
|
setup()
|
|
remote := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 50000}
|
|
Expect(discoveryAddress(context.Background(), remote)).To(Equal(expected))
|
|
},
|
|
Entry("uses the BaseURL host and scheme when set", func() {
|
|
conf.Server.BaseScheme = "https"
|
|
conf.Server.BaseHost = "music.example.com"
|
|
conf.Server.BasePath = "/nd"
|
|
}, "https://music.example.com/nd/jellyfin"),
|
|
Entry("uses a specific bind Address with the Port", func() {
|
|
conf.Server.Address = "192.168.1.10"
|
|
}, "http://192.168.1.10:4533/jellyfin"),
|
|
Entry("falls back to the interface facing the requester when Address is unspecified", func() {},
|
|
"http://127.0.0.1:4533/jellyfin"),
|
|
Entry("falls back to the interface facing the requester when Address is empty", func() {
|
|
conf.Server.Address = ""
|
|
}, "http://127.0.0.1:4533/jellyfin"),
|
|
Entry("advertises https when TLS is configured", func() {
|
|
conf.Server.TLSCert = "/path/cert.pem"
|
|
conf.Server.TLSKey = "/path/key.pem"
|
|
}, "https://127.0.0.1:4533/jellyfin"),
|
|
Entry("advertises http when only the TLS cert is configured", func() {
|
|
conf.Server.TLSCert = "/path/cert.pem"
|
|
}, "http://127.0.0.1:4533/jellyfin"),
|
|
Entry("keeps a path-only BaseURL as the path prefix", func() {
|
|
conf.Server.BasePath = "/music"
|
|
}, "http://127.0.0.1:4533/music/jellyfin"),
|
|
Entry("does not double the slash when BasePath has a trailing slash", func() {
|
|
conf.Server.BasePath = "/music/"
|
|
}, "http://127.0.0.1:4533/music/jellyfin"),
|
|
)
|
|
|
|
DescribeTable("hasAdvertisableAddress",
|
|
func(address, baseHost string, expected bool) {
|
|
conf.Server.Address = address
|
|
conf.Server.BaseHost = baseHost
|
|
Expect(hasAdvertisableAddress()).To(Equal(expected))
|
|
},
|
|
Entry("TCP listener", "0.0.0.0", "", true),
|
|
Entry("unix socket without a BaseURL host", "unix:/tmp/navidrome.sock", "", false),
|
|
Entry("unix socket behind a proxy named by BaseURL", "unix:/tmp/navidrome.sock", "music.example.com", true),
|
|
)
|
|
|
|
It("closes the connection when the read loop fails", func() {
|
|
fake := &failingConn{}
|
|
d.ServeOn(context.Background(), fake)
|
|
Expect(fake.closed).To(BeTrue())
|
|
})
|
|
|
|
Describe("ServeOn", func() {
|
|
var (
|
|
server net.PacketConn
|
|
client net.PacketConn
|
|
cancel context.CancelFunc
|
|
done chan struct{}
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
var err error
|
|
server, err = net.ListenPacket("udp4", "127.0.0.1:0")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
client, err = net.ListenPacket("udp4", "127.0.0.1:0")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
DeferCleanup(client.Close)
|
|
|
|
var ctx context.Context
|
|
ctx, cancel = context.WithCancel(context.Background())
|
|
done = make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
d.ServeOn(ctx, server)
|
|
}()
|
|
DeferCleanup(func() {
|
|
cancel()
|
|
Eventually(done).Should(BeClosed())
|
|
})
|
|
})
|
|
|
|
send := func(msg string) {
|
|
_, err := client.WriteTo([]byte(msg), server.LocalAddr())
|
|
Expect(err).ToNot(HaveOccurred())
|
|
}
|
|
receive := func() ([]byte, error) {
|
|
Expect(client.SetReadDeadline(time.Now().Add(time.Second))).To(Succeed())
|
|
buf := make([]byte, 1024)
|
|
n, _, err := client.ReadFrom(buf)
|
|
return buf[:n], err
|
|
}
|
|
|
|
It("answers the discovery query with the server identity", func() {
|
|
send("who is JellyfinServer?")
|
|
res, err := receive()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
var info discoveryInfo
|
|
Expect(json.Unmarshal(res, &info)).To(Succeed())
|
|
Expect(info.Address).To(Equal("http://127.0.0.1:4533/jellyfin"))
|
|
Expect(info.Id).To(Equal(d.serverID(context.Background())))
|
|
Expect(info.Name).To(Equal("Test Server"))
|
|
Expect(string(res)).To(ContainSubstring(`"EndpointAddress":null`))
|
|
})
|
|
|
|
It("matches the query case-insensitively", func() {
|
|
send("WHO IS JELLYFINSERVER?")
|
|
_, err := receive()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
// The loop is serial, so a reply to the first packet would arrive before the second's.
|
|
It("ignores unrelated packets", func() {
|
|
send("who is PlexServer?")
|
|
send("who is JellyfinServer?")
|
|
_, err := receive()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(client.SetReadDeadline(time.Now().Add(50 * time.Millisecond))).To(Succeed())
|
|
_, _, err = client.ReadFrom(make([]byte, 1024))
|
|
Expect(errors.Is(err, os.ErrDeadlineExceeded)).To(BeTrue())
|
|
})
|
|
|
|
It("stops and closes the socket when the context is cancelled", func() {
|
|
cancel()
|
|
Eventually(done).Should(BeClosed())
|
|
_, _, err := server.ReadFrom(make([]byte, 1))
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
})
|
|
})
|