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()) }) }) })