Compare commits
No commits in common. "795b77b7093dda009105581f2e3f1642139e7ff2" and "6e04e957b8e3ce208e5cb9aca7b46ecc572ef2e2" have entirely different histories.
795b77b709
...
6e04e957b8
11 changed files with 17 additions and 544 deletions
|
|
@ -69,7 +69,6 @@ binary runs with no configuration at all.
|
||||||
| `VELLUM_ADMIN_PASSWORD` | *(none)* | Password for that first admin (min 8 chars) |
|
| `VELLUM_ADMIN_PASSWORD` | *(none)* | Password for that first admin (min 8 chars) |
|
||||||
| `VELLUM_COOKIE_SECURE` | `false` | Set `true` when serving over TLS |
|
| `VELLUM_COOKIE_SECURE` | `false` | Set `true` when serving over TLS |
|
||||||
| `VELLUM_COMICVINE_API_KEY` | *(none)* | Enables comic metadata via ComicVine |
|
| `VELLUM_COMICVINE_API_KEY` | *(none)* | Enables comic metadata via ComicVine |
|
||||||
| `VELLUM_DISCOVERY` | `on` | Set `off` to stop advertising over mDNS on the LAN |
|
|
||||||
| `VELLUM_IGDB_CLIENT_ID` | *(none)* | Enables game metadata via IGDB |
|
| `VELLUM_IGDB_CLIENT_ID` | *(none)* | Enables game metadata via IGDB |
|
||||||
| `VELLUM_IGDB_CLIENT_SECRET` | *(none)* | IGDB client secret (both required) |
|
| `VELLUM_IGDB_CLIENT_SECRET` | *(none)* | IGDB client secret (both required) |
|
||||||
| `VELLUM_LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
|
| `VELLUM_LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
|
||||||
|
|
@ -108,7 +107,6 @@ internal/
|
||||||
natsort/ natural ordering for pages and tracks
|
natsort/ natural ordering for pages and tracks
|
||||||
jellyfin/ Jellyfin protocol adapter (+ dto/)
|
jellyfin/ Jellyfin protocol adapter (+ dto/)
|
||||||
subsonic/ Subsonic protocol adapter (+ dto/)
|
subsonic/ Subsonic protocol adapter (+ dto/)
|
||||||
discovery/ mDNS advertisement on the LAN
|
|
||||||
arch/ import-rule tests; holds no code
|
arch/ import-rule tests; holds no code
|
||||||
modules/
|
modules/
|
||||||
ebooks/ EPUB parsing and cover extraction
|
ebooks/ EPUB parsing and cover extraction
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import (
|
||||||
"github.com/mithral/vellum/internal/catalog"
|
"github.com/mithral/vellum/internal/catalog"
|
||||||
"github.com/mithral/vellum/internal/cli"
|
"github.com/mithral/vellum/internal/cli"
|
||||||
"github.com/mithral/vellum/internal/core"
|
"github.com/mithral/vellum/internal/core"
|
||||||
"github.com/mithral/vellum/internal/discovery"
|
|
||||||
"github.com/mithral/vellum/internal/home"
|
"github.com/mithral/vellum/internal/home"
|
||||||
"github.com/mithral/vellum/internal/httpapi"
|
"github.com/mithral/vellum/internal/httpapi"
|
||||||
"github.com/mithral/vellum/internal/jellyfin"
|
"github.com/mithral/vellum/internal/jellyfin"
|
||||||
|
|
@ -184,15 +183,6 @@ func run(cfg core.Config, log *slog.Logger) error {
|
||||||
defer cancelJobs()
|
defer cancelJobs()
|
||||||
go queue.Run(jobCtx)
|
go queue.Run(jobCtx)
|
||||||
|
|
||||||
// mDNS: advertise on the LAN so clients browse to this server instead of
|
|
||||||
// being handed a URL. Cancelled with the job queue so the records are
|
|
||||||
// withdrawn before the process exits.
|
|
||||||
kinds := make([]string, 0)
|
|
||||||
for _, mt := range library.SupportedTypes() {
|
|
||||||
kinds = append(kinds, mt.Kind)
|
|
||||||
}
|
|
||||||
go discovery.Start(jobCtx, cfg, version, kinds, log)
|
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.Listen,
|
Addr: cfg.Listen,
|
||||||
Handler: api.Handler(),
|
Handler: api.Handler(),
|
||||||
|
|
|
||||||
4
go.mod
4
go.mod
|
|
@ -3,21 +3,17 @@ module github.com/mithral/vellum
|
||||||
go 1.26.8
|
go 1.26.8
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/grandcat/zeroconf v1.0.0
|
|
||||||
golang.org/x/crypto v0.58.0
|
golang.org/x/crypto v0.58.0
|
||||||
golang.org/x/image v0.47.0
|
golang.org/x/image v0.47.0
|
||||||
modernc.org/sqlite v1.60.1
|
modernc.org/sqlite v1.60.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
|
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/miekg/dns v1.1.27 // indirect
|
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
golang.org/x/net v0.60.0 // indirect
|
|
||||||
golang.org/x/sys v0.49.0 // indirect
|
golang.org/x/sys v0.49.0 // indirect
|
||||||
modernc.org/libc v1.77.1 // indirect
|
modernc.org/libc v1.77.1 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|
|
||||||
24
go.sum
24
go.sum
|
|
@ -1,53 +1,29 @@
|
||||||
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
|
|
||||||
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
|
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE=
|
|
||||||
github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs=
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
github.com/miekg/dns v1.1.27 h1:aEH/kqUzUxGJ/UHcEKdJY+ugH6WEzsEBBSPa8zuy1aM=
|
|
||||||
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
|
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
|
||||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||||
golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo=
|
golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo=
|
||||||
golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE=
|
golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE=
|
||||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
|
||||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
|
|
||||||
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
|
||||||
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
|
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
|
||||||
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
|
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
|
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
|
||||||
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
|
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
|
||||||
|
|
|
||||||
|
|
@ -67,10 +67,6 @@ var layers = map[string]int{
|
||||||
"internal/subsonic": 4,
|
"internal/subsonic": 4,
|
||||||
"internal/subsonic/dto": 3,
|
"internal/subsonic/dto": 3,
|
||||||
"internal/cli": 4,
|
"internal/cli": 4,
|
||||||
// discovery is an adapter in the same sense the protocol handlers are: it
|
|
||||||
// speaks a foreign wire format (mDNS) on behalf of the process and knows
|
|
||||||
// nothing about the media model.
|
|
||||||
"internal/discovery": 4,
|
|
||||||
|
|
||||||
// 5 — the only place everything is wired together.
|
// 5 — the only place everything is wired together.
|
||||||
"cmd/vellum": 5,
|
"cmd/vellum": 5,
|
||||||
|
|
|
||||||
|
|
@ -54,10 +54,6 @@ type Config struct {
|
||||||
// X-Forwarded-For. Empty means trust nobody: only RemoteAddr is used.
|
// X-Forwarded-For. Empty means trust nobody: only RemoteAddr is used.
|
||||||
TrustedProxies []*net.IPNet
|
TrustedProxies []*net.IPNet
|
||||||
|
|
||||||
// Discovery advertises the server on the LAN over mDNS so clients find it
|
|
||||||
// without a typed URL.
|
|
||||||
Discovery bool
|
|
||||||
|
|
||||||
SessionTTL time.Duration
|
SessionTTL time.Duration
|
||||||
ReadTimeout time.Duration
|
ReadTimeout time.Duration
|
||||||
WriteTimeout time.Duration
|
WriteTimeout time.Duration
|
||||||
|
|
@ -94,8 +90,6 @@ func LoadConfig() (Config, error) {
|
||||||
WriteTimeout: envDuration("VELLUM_WRITE_TIMEOUT", 0), // 0 = no limit; streaming needs this
|
WriteTimeout: envDuration("VELLUM_WRITE_TIMEOUT", 0), // 0 = no limit; streaming needs this
|
||||||
IdleTimeout: envDuration("VELLUM_IDLE_TIMEOUT", 120*time.Second),
|
IdleTimeout: envDuration("VELLUM_IDLE_TIMEOUT", 120*time.Second),
|
||||||
ShutdownGrace: envDuration("VELLUM_SHUTDOWN_GRACE", 15*time.Second),
|
ShutdownGrace: envDuration("VELLUM_SHUTDOWN_GRACE", 15*time.Second),
|
||||||
|
|
||||||
Discovery: !envOff("VELLUM_DISCOVERY"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
proxies, err := envCIDRs("VELLUM_TRUSTED_PROXIES")
|
proxies, err := envCIDRs("VELLUM_TRUSTED_PROXIES")
|
||||||
|
|
@ -215,14 +209,3 @@ func envBool(key string, def bool) bool {
|
||||||
}
|
}
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
// envOff reads a switch that is ON unless it is explicitly turned off. Unlike
|
|
||||||
// envBool it accepts the on/off spellings operators actually type, because
|
|
||||||
// strconv.ParseBool rejects "off" and would silently leave the feature on.
|
|
||||||
func envOff(key string) bool {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
|
|
||||||
case "off", "no", "false", "0":
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -142,34 +142,3 @@ func TestValidateCreatesDirs(t *testing.T) {
|
||||||
t.Fatalf("Validate: %v", err)
|
t.Fatalf("Validate: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDiscoveryDefaultAndOff(t *testing.T) {
|
|
||||||
// Advertising is on by default: a server nobody can find on the LAN is the
|
|
||||||
// common case this feature exists to remove.
|
|
||||||
tests := []struct {
|
|
||||||
value string // empty means unset
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{value: "", want: true},
|
|
||||||
{value: "off", want: false},
|
|
||||||
{value: "OFF", want: false},
|
|
||||||
{value: "false", want: false},
|
|
||||||
{value: "0", want: false},
|
|
||||||
{value: "no", want: false},
|
|
||||||
{value: "on", want: true},
|
|
||||||
{value: "garbage", want: true}, // unrecognised values leave it on
|
|
||||||
}
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run("VELLUM_DISCOVERY="+tc.value, func(t *testing.T) {
|
|
||||||
t.Setenv("VELLUM_DISCOVERY", tc.value)
|
|
||||||
|
|
||||||
cfg, err := LoadConfig()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadConfig: %v", err)
|
|
||||||
}
|
|
||||||
if cfg.Discovery != tc.want {
|
|
||||||
t.Errorf("Discovery = %v, want %v", cfg.Discovery, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,197 +0,0 @@
|
||||||
// Package discovery advertises Vellum on the local network over mDNS (DNS-SD)
|
|
||||||
// so a client finds the server instead of the operator typing its URL.
|
|
||||||
//
|
|
||||||
// Three service types are registered, all on the HTTP listen port:
|
|
||||||
//
|
|
||||||
// _vellum._tcp Vellum's own native clients
|
|
||||||
// _jellyfin._tcp so Jellyfin apps (Infuse, Jellyfin Media Player) browse to it
|
|
||||||
// _subsonic._tcp so Navidrome-class music clients (Symfonium, Ultrasonic) browse to it
|
|
||||||
//
|
|
||||||
// The last two are the point of the package. Neither costs anything to answer —
|
|
||||||
// internal/jellyfin and internal/subsonic already mount at the root, which is
|
|
||||||
// where those clients look — but a client that only offers a "browse my network"
|
|
||||||
// button will never find a server that stays quiet. All three carry the same TXT
|
|
||||||
// records, so a client that resolves one of them can tell what it found:
|
|
||||||
//
|
|
||||||
// api=v1 the shape of Vellum's own API
|
|
||||||
// ver=<version> the build, same string the web UI reports
|
|
||||||
// proto=vellum,jellyfin,subsonic every protocol this server answers
|
|
||||||
// types=<kinds> the media kinds registered on this instance
|
|
||||||
// tls=true|false whether the published origin is https
|
|
||||||
// pub=<url> the public origin, present only when VELLUM_PUBLIC_URL is set
|
|
||||||
//
|
|
||||||
// Definitions and Start are split so the part worth reasoning about — what is
|
|
||||||
// advertised and with what TXT — is a pure function of config and needs no
|
|
||||||
// network to test. Start only registers what Definitions returns.
|
|
||||||
//
|
|
||||||
// Advertisement is best-effort by nature. A host with no multicast-capable
|
|
||||||
// interface, or a container network that drops 224.0.0.251, still serves HTTP
|
|
||||||
// perfectly well, so a failed registration is logged and the server carries on.
|
|
||||||
package discovery
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"maps"
|
|
||||||
"net"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/grandcat/zeroconf"
|
|
||||||
|
|
||||||
"github.com/mithral/vellum/internal/core"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Service types advertised on the LAN. The order is the order they are
|
|
||||||
// registered in, which is also the order they are logged in.
|
|
||||||
var serviceTypes = []string{
|
|
||||||
"_vellum._tcp",
|
|
||||||
"_jellyfin._tcp",
|
|
||||||
"_subsonic._tcp",
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
// instanceName is what every client shows in its server list.
|
|
||||||
instanceName = "Vellum"
|
|
||||||
// domain is the mDNS domain; DNS-SD services live in .local.
|
|
||||||
domain = "local."
|
|
||||||
// apiVersion is the version of Vellum's own API surface.
|
|
||||||
apiVersion = "v1"
|
|
||||||
// protocols is the full set of protocols this one process answers. It is the
|
|
||||||
// same on all three records because the client resolves one type and then
|
|
||||||
// wants to know what else the server can do.
|
|
||||||
protocols = "vellum,jellyfin,subsonic"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Definition is one mDNS service to register: the instance name, the service
|
|
||||||
// type, the port, and the TXT records published with it.
|
|
||||||
//
|
|
||||||
// It is deliberately plain data with no reference to zeroconf, so what Vellum
|
|
||||||
// advertises can be asserted on without a socket, a network, or a timer.
|
|
||||||
type Definition struct {
|
|
||||||
Instance string
|
|
||||||
Service string
|
|
||||||
Port int
|
|
||||||
TXT map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Definitions builds the services to advertise from the running config.
|
|
||||||
//
|
|
||||||
// It returns nil when discovery is disabled, which is the whole of the
|
|
||||||
// VELLUM_DISCOVERY=off contract: nothing is registered and nothing is logged as
|
|
||||||
// advertised. It also returns nil for a loopback-only listen address, because
|
|
||||||
// nothing on the LAN can reach one. An unparseable listen address is an error
|
|
||||||
// rather than a silent skip — advertising port 0 would be a service no client
|
|
||||||
// can reach.
|
|
||||||
func Definitions(cfg core.Config, version string, kinds []string) ([]Definition, error) {
|
|
||||||
if !cfg.Discovery {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
host, portStr, err := net.SplitHostPort(cfg.Listen)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("discovery: listen address %q: %w", cfg.Listen, err)
|
|
||||||
}
|
|
||||||
port, err := strconv.Atoi(portStr)
|
|
||||||
if err != nil || port <= 0 || port > 65535 {
|
|
||||||
return nil, fmt.Errorf("discovery: listen address %q has no usable port", cfg.Listen)
|
|
||||||
}
|
|
||||||
// A loopback bind is the normal shape behind a reverse proxy
|
|
||||||
// (VELLUM_LISTEN=127.0.0.1:7788). Advertising it would put a port in the
|
|
||||||
// LAN browse list that refuses every client that dials it, which is worse
|
|
||||||
// than staying quiet: the published origin is what clients should use, and
|
|
||||||
// that is not what mDNS is for.
|
|
||||||
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
txt := map[string]string{
|
|
||||||
"api": apiVersion,
|
|
||||||
"ver": version,
|
|
||||||
"proto": protocols,
|
|
||||||
"types": strings.Join(kinds, ","),
|
|
||||||
// The published origin, not the cookie setting: a client reading this
|
|
||||||
// wants to know whether to dial back with https.
|
|
||||||
"tls": strconv.FormatBool(strings.HasPrefix(cfg.PublicURL, "https://")),
|
|
||||||
}
|
|
||||||
if cfg.PublicURL != "" {
|
|
||||||
txt["pub"] = cfg.PublicURL
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]Definition, 0, len(serviceTypes))
|
|
||||||
for _, st := range serviceTypes {
|
|
||||||
out = append(out, Definition{
|
|
||||||
Instance: instanceName,
|
|
||||||
Service: st,
|
|
||||||
Port: port,
|
|
||||||
TXT: maps.Clone(txt),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start registers the services Definitions builds and holds them until ctx is
|
|
||||||
// cancelled, at which point each is unregistered with a goodbye packet so peers
|
|
||||||
// drop the record immediately rather than waiting out the TTL.
|
|
||||||
//
|
|
||||||
// It blocks until then. A registration that fails is logged and skipped: mDNS
|
|
||||||
// is a convenience, and a host that cannot multicast still serves HTTP.
|
|
||||||
func Start(ctx context.Context, cfg core.Config, version string, kinds []string, log *slog.Logger) {
|
|
||||||
if !cfg.Discovery {
|
|
||||||
log.Info("mdns discovery off; not advertising on the network")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defs, err := Definitions(cfg, version, kinds)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn("mdns discovery unavailable", "err", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(defs) == 0 {
|
|
||||||
// Discovery is on, so the only way to reach here with nothing to
|
|
||||||
// register is a loopback-only listen address. Say so, rather than
|
|
||||||
// staying quiet and leaving the operator to wonder.
|
|
||||||
log.Info("mdns not advertised", "listen", cfg.Listen,
|
|
||||||
"reason", "loopback-only listen address is unreachable from the LAN")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
servers := make([]*zeroconf.Server, 0, len(defs))
|
|
||||||
var advertised []string
|
|
||||||
for _, d := range defs {
|
|
||||||
srv, err := zeroconf.Register(d.Instance, d.Service, domain, d.Port, txtRecords(d.TXT), nil)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn("mdns registration failed", "service", d.Service, "err", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
servers = append(servers, srv)
|
|
||||||
advertised = append(advertised, d.Service)
|
|
||||||
}
|
|
||||||
if len(advertised) == 0 {
|
|
||||||
log.Warn("mdns discovery advertised nothing: no multicast-capable interface")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Info("mdns advertised", "services", advertised, "port", defs[0].Port, "instance", instanceName)
|
|
||||||
|
|
||||||
<-ctx.Done()
|
|
||||||
for _, srv := range servers {
|
|
||||||
srv.Shutdown()
|
|
||||||
}
|
|
||||||
log.Info("mdns unregistered", "services", advertised)
|
|
||||||
}
|
|
||||||
|
|
||||||
// txtRecords renders a TXT map as the key=value list zeroconf takes. Sorted so
|
|
||||||
// the record on the wire is deterministic and comparable between runs.
|
|
||||||
func txtRecords(txt map[string]string) []string {
|
|
||||||
keys := make([]string, 0, len(txt))
|
|
||||||
for k := range txt {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
slices.Sort(keys)
|
|
||||||
|
|
||||||
out := make([]string, 0, len(keys))
|
|
||||||
for _, k := range keys {
|
|
||||||
out = append(out, k+"="+txt[k])
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
package discovery
|
|
||||||
|
|
||||||
import (
|
|
||||||
"slices"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/mithral/vellum/internal/core"
|
|
||||||
)
|
|
||||||
|
|
||||||
// baseConfig is a config with discovery on and a port that parses.
|
|
||||||
func baseConfig() core.Config {
|
|
||||||
return core.Config{Discovery: true, Listen: ":7788"}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDefinitionsAdvertisesThreeServiceTypes(t *testing.T) {
|
|
||||||
defs, err := Definitions(baseConfig(), "1.2.3", []string{"music", "ebooks"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Definitions: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
want := []string{"_vellum._tcp", "_jellyfin._tcp", "_subsonic._tcp"}
|
|
||||||
got := make([]string, 0, len(defs))
|
|
||||||
for _, d := range defs {
|
|
||||||
got = append(got, d.Service)
|
|
||||||
if d.Instance != "Vellum" {
|
|
||||||
t.Errorf("%s Instance = %q, want Vellum", d.Service, d.Instance)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !slices.Equal(got, want) {
|
|
||||||
t.Errorf("service types = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDefinitionsTXTRecords(t *testing.T) {
|
|
||||||
cfg := baseConfig()
|
|
||||||
cfg.PublicURL = "https://media.example.com"
|
|
||||||
|
|
||||||
defs, err := Definitions(cfg, "1.2.3", []string{"music", "ebooks"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Definitions: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
want := map[string]string{
|
|
||||||
"api": "v1",
|
|
||||||
"ver": "1.2.3",
|
|
||||||
"proto": "vellum,jellyfin,subsonic",
|
|
||||||
"types": "music,ebooks",
|
|
||||||
"tls": "true",
|
|
||||||
"pub": "https://media.example.com",
|
|
||||||
}
|
|
||||||
// Every record carries the same TXT: a client resolves one service type and
|
|
||||||
// then needs to know what else this server answers.
|
|
||||||
for _, d := range defs {
|
|
||||||
if len(d.TXT) != len(want) {
|
|
||||||
t.Fatalf("%s TXT = %v, want %d keys", d.Service, d.TXT, len(want))
|
|
||||||
}
|
|
||||||
for k, v := range want {
|
|
||||||
if d.TXT[k] != v {
|
|
||||||
t.Errorf("%s TXT[%q] = %q, want %q", d.Service, k, d.TXT[k], v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDefinitionsOmitsPubWithoutPublicURL(t *testing.T) {
|
|
||||||
defs, err := Definitions(baseConfig(), "dev", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Definitions: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
d := defs[0]
|
|
||||||
if _, ok := d.TXT["pub"]; ok {
|
|
||||||
t.Errorf("TXT has pub=%q, want it absent when VELLUM_PUBLIC_URL is unset", d.TXT["pub"])
|
|
||||||
}
|
|
||||||
if d.TXT["tls"] != "false" {
|
|
||||||
t.Errorf("tls = %q, want false with no https origin", d.TXT["tls"])
|
|
||||||
}
|
|
||||||
if d.TXT["types"] != "" {
|
|
||||||
t.Errorf("types = %q, want empty with no media kinds", d.TXT["types"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDefinitionsUsesListenPort(t *testing.T) {
|
|
||||||
cfg := baseConfig()
|
|
||||||
cfg.Listen = "127.0.0.1:9999"
|
|
||||||
|
|
||||||
defs, err := Definitions(cfg, "dev", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Definitions: %v", err)
|
|
||||||
}
|
|
||||||
for _, d := range defs {
|
|
||||||
if d.Port != 9999 {
|
|
||||||
t.Errorf("%s Port = %d, want 9999", d.Service, d.Port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDefinitionsDisabled(t *testing.T) {
|
|
||||||
cfg := baseConfig()
|
|
||||||
cfg.Discovery = false
|
|
||||||
|
|
||||||
defs, err := Definitions(cfg, "dev", []string{"music"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Definitions: %v", err)
|
|
||||||
}
|
|
||||||
if defs != nil {
|
|
||||||
t.Errorf("Definitions = %v, want nil when VELLUM_DISCOVERY is off", defs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDefinitionsSkipsLoopbackListen(t *testing.T) {
|
|
||||||
// Nothing on the LAN can reach a loopback bind, which is the normal shape
|
|
||||||
// behind a reverse proxy. Advertising it would put a port in the browse
|
|
||||||
// list that refuses every client that dials it.
|
|
||||||
for _, listen := range []string{"127.0.0.1:7788", "[::1]:7788"} {
|
|
||||||
cfg := baseConfig()
|
|
||||||
cfg.Listen = listen
|
|
||||||
|
|
||||||
defs, err := Definitions(cfg, "dev", []string{"music"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Definitions(%s): %v", listen, err)
|
|
||||||
}
|
|
||||||
if defs != nil {
|
|
||||||
t.Errorf("Definitions(%s) = %v, want nil: loopback is unreachable from the LAN", listen, defs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The other side of the boundary: a wildcard bind is reachable, so it must
|
|
||||||
// still advertise.
|
|
||||||
for _, listen := range []string{":7788", "0.0.0.0:7788"} {
|
|
||||||
cfg := baseConfig()
|
|
||||||
cfg.Listen = listen
|
|
||||||
|
|
||||||
defs, err := Definitions(cfg, "dev", []string{"music"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Definitions(%s): %v", listen, err)
|
|
||||||
}
|
|
||||||
if len(defs) == 0 {
|
|
||||||
t.Errorf("Definitions(%s) advertised nothing; a wildcard bind is reachable", listen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDefinitionsRejectsListenWithoutPort(t *testing.T) {
|
|
||||||
cfg := baseConfig()
|
|
||||||
cfg.Listen = "7788" // no colon: not a host:port pair
|
|
||||||
|
|
||||||
// Advertising port 0 produces a record no client can connect to, so this is
|
|
||||||
// an error rather than a silently empty advertisement.
|
|
||||||
if _, err := Definitions(cfg, "dev", nil); err == nil {
|
|
||||||
t.Fatal("expected an error for a listen address with no port")
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.Listen = ":0" // kernel-assigned port, unknowable at registration time
|
|
||||||
if _, err := Definitions(cfg, "dev", nil); err == nil {
|
|
||||||
t.Fatal("expected an error for a kernel-assigned listen port")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTXTRecordsAreSorted(t *testing.T) {
|
|
||||||
got := txtRecords(map[string]string{"ver": "1", "api": "v1", "tls": "false"})
|
|
||||||
want := []string{"api=v1", "tls=false", "ver=1"}
|
|
||||||
if !slices.Equal(got, want) {
|
|
||||||
t.Errorf("txtRecords = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -27,12 +27,6 @@
|
||||||
// requests route here first. isAPIPath in static.go does not know these
|
// requests route here first. isAPIPath in static.go does not know these
|
||||||
// prefixes, so each Jellyfin subtree installs its own JSON 404 handler; an
|
// prefixes, so each Jellyfin subtree installs its own JSON 404 handler; an
|
||||||
// unknown /Items/... path returns JSON rather than the SPA shell.
|
// unknown /Items/... path returns JSON rather than the SPA shell.
|
||||||
//
|
|
||||||
// Every pattern is installed a second time under /emby, the legacy base path.
|
|
||||||
// Many Jellyfin apps probe GET /emby/System/Info/Public during handshake and
|
|
||||||
// give up on discovery when it 404s, showing their manual "enter your server
|
|
||||||
// URL" screen. It is the same handler behind both paths: an alias, never a
|
|
||||||
// second surface, so it cannot drift from the root.
|
|
||||||
package jellyfin
|
package jellyfin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -42,7 +36,6 @@ import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/mithral/vellum/internal/auth"
|
"github.com/mithral/vellum/internal/auth"
|
||||||
|
|
@ -69,60 +62,41 @@ func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator) *Handlers
|
||||||
return &Handlers{db: db, log: log, a: a}
|
return &Handlers{db: db, log: log, a: a}
|
||||||
}
|
}
|
||||||
|
|
||||||
// embyPrefix is the legacy Jellyfin base path. It is an alias for the root
|
|
||||||
// mount, not a second surface: every pattern below is installed under both.
|
|
||||||
const embyPrefix = "/emby"
|
|
||||||
|
|
||||||
// Register mounts the routes. See the package comment for why they live at the
|
// Register mounts the routes. See the package comment for why they live at the
|
||||||
// root and why each subtree has a JSON 404 fallback.
|
// root and why each subtree has a JSON 404 fallback.
|
||||||
func (h *Handlers) Register(mux *http.ServeMux) {
|
func (h *Handlers) Register(mux *http.ServeMux) {
|
||||||
// mount installs each pattern twice, bare and under /emby. Jellyfin's own
|
|
||||||
// clients use the bare root paths, but many real apps probe
|
|
||||||
// /emby/System/Info/Public during handshake and abandon discovery when it
|
|
||||||
// 404s. Same handler either way, so the alias cannot drift from the root.
|
|
||||||
mount := func(pattern string, handler http.Handler) {
|
|
||||||
mux.Handle(pattern, handler)
|
|
||||||
// The prefix goes on the path, not the whole pattern: "GET /Items" is
|
|
||||||
// one string and "embyGET /Items" does not parse.
|
|
||||||
method, path, hasMethod := strings.Cut(pattern, " ")
|
|
||||||
if !hasMethod {
|
|
||||||
method, path = "", pattern
|
|
||||||
}
|
|
||||||
mux.Handle(method+" "+embyPrefix+path, handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public discovery.
|
// Public discovery.
|
||||||
mount("GET /System/Info/Public", http.HandlerFunc(h.handleSystemInfoPublic))
|
mux.HandleFunc("GET /System/Info/Public", h.handleSystemInfoPublic)
|
||||||
mount("POST /Users/AuthenticateByName", http.HandlerFunc(h.handleAuthenticateByName))
|
mux.HandleFunc("POST /Users/AuthenticateByName", h.handleAuthenticateByName)
|
||||||
|
|
||||||
// Authenticated: users.
|
// Authenticated: users.
|
||||||
mount("GET /Users/Me", h.require(h.handleUserMe))
|
mux.Handle("GET /Users/Me", h.require(h.handleUserMe))
|
||||||
mount("GET /Users/{userId}", h.require(h.handleUserByID))
|
mux.Handle("GET /Users/{userId}", h.require(h.handleUserByID))
|
||||||
|
|
||||||
// Authenticated: browsing.
|
// Authenticated: browsing.
|
||||||
mount("GET /Items", h.require(h.handleItems))
|
mux.Handle("GET /Items", h.require(h.handleItems))
|
||||||
mount("GET /Users/{userId}/Items", h.require(h.handleUserItems))
|
mux.Handle("GET /Users/{userId}/Items", h.require(h.handleUserItems))
|
||||||
mount("GET /Items/{itemId}", h.require(h.handleItem))
|
mux.Handle("GET /Items/{itemId}", h.require(h.handleItem))
|
||||||
mount("GET /Items/{itemId}/Images/Primary", h.require(h.handleImagePrimary))
|
mux.Handle("GET /Items/{itemId}/Images/Primary", h.require(h.handleImagePrimary))
|
||||||
mount("GET /Items/{itemId}/PlaybackInfo", h.require(h.handlePlaybackInfo))
|
mux.Handle("GET /Items/{itemId}/PlaybackInfo", h.require(h.handlePlaybackInfo))
|
||||||
|
|
||||||
// Media. These MUST accept api_key in the query string: <video>/<audio>
|
// Media. These MUST accept api_key in the query string: <video>/<audio>
|
||||||
// elements cannot set request headers, so a Jellyfin client appends
|
// elements cannot set request headers, so a Jellyfin client appends
|
||||||
// ?api_key=... to the URL it plays.
|
// ?api_key=... to the URL it plays.
|
||||||
mount("GET /Videos/{itemId}/stream", h.require(h.handleVideoStream))
|
mux.Handle("GET /Videos/{itemId}/stream", h.require(h.handleVideoStream))
|
||||||
mount("GET /Audio/{itemId}/stream", h.require(h.handleAudioStream))
|
mux.Handle("GET /Audio/{itemId}/stream", h.require(h.handleAudioStream))
|
||||||
// Real clients ask for stream.<container> (stream.mp4, stream.mkv,
|
// Real clients ask for stream.<container> (stream.mp4, stream.mkv,
|
||||||
// stream.mp3). ServeMux wildcards are whole segments only, so a suffix
|
// stream.mp3). ServeMux wildcards are whole segments only, so a suffix
|
||||||
// pattern is impossible; a subtree handler dispatches those instead. The
|
// pattern is impossible; a subtree handler dispatches those instead. The
|
||||||
// exact /stream pattern above is more specific and still wins.
|
// exact /stream pattern above is more specific and still wins.
|
||||||
mount("GET /Videos/{itemId}/", h.require(h.handleVideoStream))
|
mux.Handle("GET /Videos/{itemId}/", h.require(h.handleVideoStream))
|
||||||
mount("GET /Audio/{itemId}/", h.require(h.handleAudioStream))
|
mux.Handle("GET /Audio/{itemId}/", h.require(h.handleAudioStream))
|
||||||
|
|
||||||
// Playback reporting. This is how a Jellyfin client tells us its resume
|
// Playback reporting. This is how a Jellyfin client tells us its resume
|
||||||
// position; it is written through the existing progress store.
|
// position; it is written through the existing progress store.
|
||||||
mount("POST /Sessions/Playing", h.require(h.handlePlaying))
|
mux.Handle("POST /Sessions/Playing", h.require(h.handlePlaying))
|
||||||
mount("POST /Sessions/Playing/Progress", h.require(h.handlePlayingProgress))
|
mux.Handle("POST /Sessions/Playing/Progress", h.require(h.handlePlayingProgress))
|
||||||
mount("POST /Sessions/Playing/Stopped", h.require(h.handlePlayingStopped))
|
mux.Handle("POST /Sessions/Playing/Stopped", h.require(h.handlePlayingStopped))
|
||||||
|
|
||||||
// JSON 404s for the rest of each Jellyfin subtree. Without these an
|
// JSON 404s for the rest of each Jellyfin subtree. Without these an
|
||||||
// unknown /Items/... falls through to the SPA shell, because static.go's
|
// unknown /Items/... falls through to the SPA shell, because static.go's
|
||||||
|
|
@ -130,11 +104,8 @@ func (h *Handlers) Register(mux *http.ServeMux) {
|
||||||
for _, prefix := range []string{
|
for _, prefix := range []string{
|
||||||
"/System/", "/Users/", "/Items/", "/Videos/", "/Audio/", "/Sessions/",
|
"/System/", "/Users/", "/Items/", "/Videos/", "/Audio/", "/Sessions/",
|
||||||
} {
|
} {
|
||||||
mount(prefix, http.HandlerFunc(h.handleNotFound))
|
mux.HandleFunc(prefix, h.handleNotFound)
|
||||||
}
|
}
|
||||||
// One subtree covers the whole alias: nothing outside the Jellyfin API
|
|
||||||
// lives under /emby, so anything unmounted there is a 404, not the shell.
|
|
||||||
mux.HandleFunc(embyPrefix+"/", h.handleNotFound)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// serverID returns a stable, per-installation server id, created on first use
|
// serverID returns a stable, per-installation server id, created on first use
|
||||||
|
|
|
||||||
|
|
@ -433,46 +433,3 @@ func TestSystemInfoPublicNeedsNoAuth(t *testing.T) {
|
||||||
t.Errorf("incomplete discovery response: %s", body)
|
t.Errorf("incomplete discovery response: %s", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── the /emby alias ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// Many Jellyfin apps probe GET /emby/System/Info/Public during handshake and
|
|
||||||
// abandon discovery when it 404s, dropping the user on a manual "enter your
|
|
||||||
// server URL" screen. The alias is the same handler behind a second path, so
|
|
||||||
// the body must be identical - not merely similar.
|
|
||||||
func TestEmbyPrefixMirrorsRoot(t *testing.T) {
|
|
||||||
f := newFixture(t)
|
|
||||||
|
|
||||||
rootResp, rootBody := f.do(t, "GET", "/System/Info/Public", "", "")
|
|
||||||
aliasResp, aliasBody := f.do(t, "GET", "/emby/System/Info/Public", "", "")
|
|
||||||
if aliasResp.StatusCode != rootResp.StatusCode {
|
|
||||||
t.Fatalf("alias status = %d, root = %d", aliasResp.StatusCode, rootResp.StatusCode)
|
|
||||||
}
|
|
||||||
if aliasBody != rootBody {
|
|
||||||
t.Errorf("alias body:\n%s\nroot body:\n%s", aliasBody, rootBody)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The authenticated routes must be aliased too, not just the public one:
|
|
||||||
// discovery that succeeds and then a 401 on the first real call is the same
|
|
||||||
// broken handshake.
|
|
||||||
if resp, _ := f.do(t, "GET", "/emby/Users/Me", "X-Emby-Token", f.admin); resp.StatusCode != http.StatusOK {
|
|
||||||
t.Errorf("/emby/Users/Me status = %d, want 200", resp.StatusCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// An unknown /emby/... path must be JSON. static.go's isAPIPath knows nothing
|
|
||||||
// about /emby, so without a 404 subtree here it would fall through to the SPA
|
|
||||||
// shell and the client would try to parse index.html as a discovery response.
|
|
||||||
func TestEmbyUnknownPathIsJSONNotShell(t *testing.T) {
|
|
||||||
f := newFixture(t)
|
|
||||||
for _, path := range []string{"/emby/Items/1/Nope", "/emby/Nope"} {
|
|
||||||
resp, body := f.do(t, "GET", path, "X-Emby-Token", f.admin)
|
|
||||||
if resp.StatusCode != http.StatusNotFound {
|
|
||||||
t.Errorf("%s: status = %d, want 404", path, resp.StatusCode)
|
|
||||||
}
|
|
||||||
var out map[string]any
|
|
||||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
|
||||||
t.Errorf("%s: body is not JSON (%s): %s", path, resp.Header.Get("Content-Type"), body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue