2023-01-22 14:38:55 -05:00
|
|
|
package subsonic
|
|
|
|
|
|
|
|
|
|
import (
|
fix(subsonic): honor DefaultDownloadableShare in createShare (#6121)
* fix(subsonic): honor DefaultDownloadableShare in createShare
The DefaultDownloadableShare option was only sent to the web UI, which used
it to pre-tick the "Allow Downloads?" checkbox. The Subsonic createShare
handler built the model.Share without touching Downloadable, so it fell back
to the Go zero value and every share created through the API was stored as
non-downloadable, regardless of the configured default.
createShare now reads an optional downloadable parameter and falls back to
conf.Server.DefaultDownloadableShare when the client omits it, matching the
web UI. Fixes #6119.
updateShare had a related problem: core's share repository wrapper always
writes the downloadable column, but the handler never set the field, so any
updateShare call silently reset the share to non-downloadable. It now loads
the current share and uses its value as the fallback.
* refactor(subsonic): trim the share downloadable lookup and align with the UI
updateShare fetched the share with Get to recover the stored downloadable
flag, which also runs loadMedia and materializes every album and track the
share points at, just to read one boolean. It now uses Read, which skips
loadMedia, and only queries at all when the client omitted the parameter.
createShare now ANDs the default with EnableDownloads, matching what the web
UI already computes, so both paths apply the same rule.
The specs collapse the create-path matrix into a DescribeTable, reuse the
existing albumIDByName helper, and set the request-time config after
setupTestDB so it does not leak into the config snapshot.
* fix(subsonic): keep the share description on a downloadable-only update
updateShare read the description straight from the request, so a client that
sent only id and downloadable got an empty string written over the stored
description. shareRepositoryWrapper.Update always writes that column, so the
description was silently erased.
This predates the downloadable parameter added earlier in this branch: any
updateShare that omitted description already cleared it. Adding the parameter
just made it easy to hit, since toggling downloads is a natural reason to call
updateShare without touching the description.
Both fields now use the presence-aware accessors and fall back to the stored
share, which still costs at most one read and none when the client sends both.
An explicitly empty description still clears the field.
2026-09-09 20:29:00 -04:00
|
|
|
"cmp"
|
2023-01-22 14:38:55 -05:00
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
fix(subsonic): honor DefaultDownloadableShare in createShare (#6121)
* fix(subsonic): honor DefaultDownloadableShare in createShare
The DefaultDownloadableShare option was only sent to the web UI, which used
it to pre-tick the "Allow Downloads?" checkbox. The Subsonic createShare
handler built the model.Share without touching Downloadable, so it fell back
to the Go zero value and every share created through the API was stored as
non-downloadable, regardless of the configured default.
createShare now reads an optional downloadable parameter and falls back to
conf.Server.DefaultDownloadableShare when the client omits it, matching the
web UI. Fixes #6119.
updateShare had a related problem: core's share repository wrapper always
writes the downloadable column, but the handler never set the field, so any
updateShare call silently reset the share to non-downloadable. It now loads
the current share and uses its value as the fallback.
* refactor(subsonic): trim the share downloadable lookup and align with the UI
updateShare fetched the share with Get to recover the stored downloadable
flag, which also runs loadMedia and materializes every album and track the
share points at, just to read one boolean. It now uses Read, which skips
loadMedia, and only queries at all when the client omitted the parameter.
createShare now ANDs the default with EnableDownloads, matching what the web
UI already computes, so both paths apply the same rule.
The specs collapse the create-path matrix into a DescribeTable, reuse the
existing albumIDByName helper, and set the request-time config after
setupTestDB so it does not leak into the config snapshot.
* fix(subsonic): keep the share description on a downloadable-only update
updateShare read the description straight from the request, so a client that
sent only id and downloadable got an empty string written over the stored
description. shareRepositoryWrapper.Update always writes that column, so the
description was silently erased.
This predates the downloadable parameter added earlier in this branch: any
updateShare that omitted description already cleared it. Adding the parameter
just made it easy to hit, since toggling downloads is a natural reason to call
updateShare without touching the description.
Both fields now use the presence-aware accessors and fall back to the stored
share, which still costs at most one read and none when the client sends both.
An explicitly empty description still clears the field.
2026-09-09 20:29:00 -04:00
|
|
|
"github.com/navidrome/navidrome/conf"
|
2023-01-22 14:38:55 -05:00
|
|
|
"github.com/navidrome/navidrome/model"
|
|
|
|
|
"github.com/navidrome/navidrome/server/public"
|
|
|
|
|
"github.com/navidrome/navidrome/server/subsonic/responses"
|
2023-12-21 17:41:09 -05:00
|
|
|
"github.com/navidrome/navidrome/utils/req"
|
2024-10-22 22:54:31 -04:00
|
|
|
"github.com/navidrome/navidrome/utils/slice"
|
2023-01-22 14:38:55 -05:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func (api *Router) GetShares(r *http.Request) (*responses.Subsonic, error) {
|
2026-09-25 18:06:10 -04:00
|
|
|
repo := api.share.Repository()
|
|
|
|
|
shares, err := repo.GetAll(r.Context(), model.QueryOptions{Sort: "created_at desc"})
|
2023-01-22 14:38:55 -05:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
response := newResponse()
|
|
|
|
|
response.Shares = &responses.Shares{}
|
|
|
|
|
for _, share := range shares {
|
|
|
|
|
response.Shares.Share = append(response.Shares.Share, api.buildShare(r, share))
|
|
|
|
|
}
|
|
|
|
|
return response, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (api *Router) buildShare(r *http.Request, share model.Share) responses.Share {
|
2023-01-23 18:44:58 -05:00
|
|
|
resp := responses.Share{
|
2023-01-22 14:38:55 -05:00
|
|
|
ID: share.ID,
|
fix(plugins): build public URLs on the caller's address instead of localhost (#6059)
* fix(plugins): build public URLs on the caller's address instead of localhost
The artwork host service had no `*http.Request`, so it passed `nil` to
`publicurl.ImageURL`. With neither `ShareURL` nor `BaseURL` configured, that
produced `http://localhost/share/img/...`, which is useless to anything outside
the server. The Discord Rich Presence plugin explicitly drops localhost URLs, so
it fell back to the Navidrome logo instead of the real cover art.
`serverAddressMiddleware` already works out the client-facing scheme and host
from the `X-Forwarded-*` headers. It now also records them in the request
context, and `publicurl` takes a `context.Context` instead of an `*http.Request`
so any caller can reach them. Extism passes the caller's context through to host
functions, so plugins invoked during a request now get a reachable URL with no
configuration.
Switching the parameter also removes the need for a second, parallel entry
point: the package previously wanted only a scheme, a host, and a context, and
took a whole request to get them. `AbsoluteURL` no longer dereferences a
possibly-nil request on its parse-error path.
Plugin calls that start from `context.Background()` (scheduler and websocket
callbacks, the buffered scrobble drain) still fall back to localhost, since they
have no request to learn from. A debug log now points at `ShareURL` when that
happens.
* fix(publicurl): include the configured port in the localhost fallback
The last-resort fallback built `http://localhost/...`, which points at port 80
and so is unreachable for a server listening anywhere else — the default 4533
included. Use `conf.Server.Port` so a consumer on the same machine can actually
fetch the URL.
* fix(publicurl): use https in the localhost fallback when TLS is configured
The fallback hardcoded the http scheme, so a TLS-only server with no BaseURL
advertised a URL it does not answer on. Mirror the server's own switch, which
requires both a certificate and a key.
* refactor(publicurl): tidy the localhost fallback and its tests
Use gg.If for the fallback scheme so it reads as an expression, like the
BaseScheme branch above it, instead of assigning http and overwriting it.
Drop two tests the ctx refactor left redundant: one asserted PublicURL "works
without a request" but became a byte-identical copy of the ShareURL spec once
the *http.Request parameter went away, and the two port specs differed only in
the integer, where the non-default port is the stronger assertion.
* refactor(conf): add TLSEnabled and use it instead of repeating the predicate
Whether the server speaks HTTPS was decided inline in three unconnected
places. This PR added the third, in a URL-building package that has no
business inferring the transport config.
Move the rule to conf, next to the fields it derives from, and call it from
publicurl and the insights collector. server.Run keeps its own expression: it
takes the certificate and key as parameters, and its test passes values that
do not come from the config.
2026-08-31 21:27:43 -04:00
|
|
|
Url: public.ShareURL(r.Context(), share.ID),
|
2023-01-22 14:38:55 -05:00
|
|
|
Description: share.Description,
|
|
|
|
|
Username: share.Username,
|
|
|
|
|
Created: share.CreatedAt,
|
2024-02-07 17:45:08 -08:00
|
|
|
Expires: share.ExpiresAt,
|
2025-01-09 14:55:39 -05:00
|
|
|
LastVisited: share.LastVisitedAt,
|
2023-03-14 09:48:52 -04:00
|
|
|
VisitCount: int32(share.VisitCount),
|
2023-01-22 14:38:55 -05:00
|
|
|
}
|
2023-01-23 20:44:21 -05:00
|
|
|
if resp.Description == "" {
|
|
|
|
|
resp.Description = share.Contents
|
|
|
|
|
}
|
2023-01-23 18:44:58 -05:00
|
|
|
if len(share.Albums) > 0 {
|
2024-10-22 22:54:31 -04:00
|
|
|
resp.Entry = slice.MapWithArg(share.Albums, r.Context(), childFromAlbum)
|
2023-01-23 18:44:58 -05:00
|
|
|
} else {
|
2024-10-22 22:54:31 -04:00
|
|
|
resp.Entry = slice.MapWithArg(share.Tracks, r.Context(), childFromMediaFile)
|
2023-01-23 18:44:58 -05:00
|
|
|
}
|
|
|
|
|
return resp
|
2023-01-22 14:38:55 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) {
|
2023-12-21 17:41:09 -05:00
|
|
|
p := req.Params(r)
|
2026-07-18 19:30:04 -04:00
|
|
|
ids := p.Strings("id")
|
|
|
|
|
if len(ids) == 0 {
|
|
|
|
|
return nil, newError(responses.ErrorMissingParameter, "missing parameter: 'id'")
|
2023-01-22 14:38:55 -05:00
|
|
|
}
|
|
|
|
|
|
2023-12-21 17:41:09 -05:00
|
|
|
description, _ := p.String("description")
|
2026-09-25 18:06:10 -04:00
|
|
|
repo := api.share.Repository()
|
2023-01-22 14:38:55 -05:00
|
|
|
share := &model.Share{
|
fix(subsonic): honor DefaultDownloadableShare in createShare (#6121)
* fix(subsonic): honor DefaultDownloadableShare in createShare
The DefaultDownloadableShare option was only sent to the web UI, which used
it to pre-tick the "Allow Downloads?" checkbox. The Subsonic createShare
handler built the model.Share without touching Downloadable, so it fell back
to the Go zero value and every share created through the API was stored as
non-downloadable, regardless of the configured default.
createShare now reads an optional downloadable parameter and falls back to
conf.Server.DefaultDownloadableShare when the client omits it, matching the
web UI. Fixes #6119.
updateShare had a related problem: core's share repository wrapper always
writes the downloadable column, but the handler never set the field, so any
updateShare call silently reset the share to non-downloadable. It now loads
the current share and uses its value as the fallback.
* refactor(subsonic): trim the share downloadable lookup and align with the UI
updateShare fetched the share with Get to recover the stored downloadable
flag, which also runs loadMedia and materializes every album and track the
share points at, just to read one boolean. It now uses Read, which skips
loadMedia, and only queries at all when the client omitted the parameter.
createShare now ANDs the default with EnableDownloads, matching what the web
UI already computes, so both paths apply the same rule.
The specs collapse the create-path matrix into a DescribeTable, reuse the
existing albumIDByName helper, and set the request-time config after
setupTestDB so it does not leak into the config snapshot.
* fix(subsonic): keep the share description on a downloadable-only update
updateShare read the description straight from the request, so a client that
sent only id and downloadable got an empty string written over the stored
description. shareRepositoryWrapper.Update always writes that column, so the
description was silently erased.
This predates the downloadable parameter added earlier in this branch: any
updateShare that omitted description already cleared it. Adding the parameter
just made it easy to hit, since toggling downloads is a natural reason to call
updateShare without touching the description.
Both fields now use the presence-aware accessors and fall back to the stored
share, which still costs at most one read and none when the client sends both.
An explicitly empty description still clears the field.
2026-09-09 20:29:00 -04:00
|
|
|
Description: description,
|
|
|
|
|
Downloadable: p.BoolOr("downloadable", conf.Server.DefaultDownloadableShare && conf.Server.EnableDownloads),
|
|
|
|
|
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
|
|
|
|
|
ResourceIDs: strings.Join(ids, ","),
|
2023-01-22 14:38:55 -05:00
|
|
|
}
|
|
|
|
|
|
2026-09-25 18:06:10 -04:00
|
|
|
id, err := repo.Save(r.Context(), share)
|
2023-01-22 14:38:55 -05:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-25 18:06:10 -04:00
|
|
|
share, err = repo.Get(r.Context(), id)
|
2023-01-22 14:38:55 -05:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
response := newResponse()
|
|
|
|
|
response.Shares = &responses.Shares{Share: []responses.Share{api.buildShare(r, *share)}}
|
|
|
|
|
return response, nil
|
|
|
|
|
}
|
2023-01-22 20:21:06 -05:00
|
|
|
|
|
|
|
|
func (api *Router) UpdateShare(r *http.Request) (*responses.Subsonic, error) {
|
2023-12-21 17:41:09 -05:00
|
|
|
p := req.Params(r)
|
|
|
|
|
id, err := p.String("id")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
2023-01-22 20:21:06 -05:00
|
|
|
}
|
|
|
|
|
|
2026-09-25 18:06:10 -04:00
|
|
|
repo := api.share.Repository()
|
fix(subsonic): honor DefaultDownloadableShare in createShare (#6121)
* fix(subsonic): honor DefaultDownloadableShare in createShare
The DefaultDownloadableShare option was only sent to the web UI, which used
it to pre-tick the "Allow Downloads?" checkbox. The Subsonic createShare
handler built the model.Share without touching Downloadable, so it fell back
to the Go zero value and every share created through the API was stored as
non-downloadable, regardless of the configured default.
createShare now reads an optional downloadable parameter and falls back to
conf.Server.DefaultDownloadableShare when the client omits it, matching the
web UI. Fixes #6119.
updateShare had a related problem: core's share repository wrapper always
writes the downloadable column, but the handler never set the field, so any
updateShare call silently reset the share to non-downloadable. It now loads
the current share and uses its value as the fallback.
* refactor(subsonic): trim the share downloadable lookup and align with the UI
updateShare fetched the share with Get to recover the stored downloadable
flag, which also runs loadMedia and materializes every album and track the
share points at, just to read one boolean. It now uses Read, which skips
loadMedia, and only queries at all when the client omitted the parameter.
createShare now ANDs the default with EnableDownloads, matching what the web
UI already computes, so both paths apply the same rule.
The specs collapse the create-path matrix into a DescribeTable, reuse the
existing albumIDByName helper, and set the request-time config after
setupTestDB so it does not leak into the config snapshot.
* fix(subsonic): keep the share description on a downloadable-only update
updateShare read the description straight from the request, so a client that
sent only id and downloadable got an empty string written over the stored
description. shareRepositoryWrapper.Update always writes that column, so the
description was silently erased.
This predates the downloadable parameter added earlier in this branch: any
updateShare that omitted description already cleared it. Adding the parameter
just made it easy to hit, since toggling downloads is a natural reason to call
updateShare without touching the description.
Both fields now use the presence-aware accessors and fall back to the stored
share, which still costs at most one read and none when the client sends both.
An explicitly empty description still clears the field.
2026-09-09 20:29:00 -04:00
|
|
|
|
|
|
|
|
// The update always writes description and downloadable, so read back the
|
|
|
|
|
// stored value for whichever one the client omitted.
|
|
|
|
|
description := p.StringPtr("description")
|
|
|
|
|
downloadable := p.BoolPtr("downloadable")
|
|
|
|
|
if description == nil || downloadable == nil {
|
2026-09-25 18:06:10 -04:00
|
|
|
cur, err := repo.Read(r.Context(), id)
|
fix(subsonic): honor DefaultDownloadableShare in createShare (#6121)
* fix(subsonic): honor DefaultDownloadableShare in createShare
The DefaultDownloadableShare option was only sent to the web UI, which used
it to pre-tick the "Allow Downloads?" checkbox. The Subsonic createShare
handler built the model.Share without touching Downloadable, so it fell back
to the Go zero value and every share created through the API was stored as
non-downloadable, regardless of the configured default.
createShare now reads an optional downloadable parameter and falls back to
conf.Server.DefaultDownloadableShare when the client omits it, matching the
web UI. Fixes #6119.
updateShare had a related problem: core's share repository wrapper always
writes the downloadable column, but the handler never set the field, so any
updateShare call silently reset the share to non-downloadable. It now loads
the current share and uses its value as the fallback.
* refactor(subsonic): trim the share downloadable lookup and align with the UI
updateShare fetched the share with Get to recover the stored downloadable
flag, which also runs loadMedia and materializes every album and track the
share points at, just to read one boolean. It now uses Read, which skips
loadMedia, and only queries at all when the client omitted the parameter.
createShare now ANDs the default with EnableDownloads, matching what the web
UI already computes, so both paths apply the same rule.
The specs collapse the create-path matrix into a DescribeTable, reuse the
existing albumIDByName helper, and set the request-time config after
setupTestDB so it does not leak into the config snapshot.
* fix(subsonic): keep the share description on a downloadable-only update
updateShare read the description straight from the request, so a client that
sent only id and downloadable got an empty string written over the stored
description. shareRepositoryWrapper.Update always writes that column, so the
description was silently erased.
This predates the downloadable parameter added earlier in this branch: any
updateShare that omitted description already cleared it. Adding the parameter
just made it easy to hit, since toggling downloads is a natural reason to call
updateShare without touching the description.
Both fields now use the presence-aware accessors and fall back to the stored
share, which still costs at most one read and none when the client sends both.
An explicitly empty description still clears the field.
2026-09-09 20:29:00 -04:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
description = cmp.Or(description, &cur.Description)
|
|
|
|
|
downloadable = cmp.Or(downloadable, &cur.Downloadable)
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-22 20:21:06 -05:00
|
|
|
share := &model.Share{
|
fix(subsonic): honor DefaultDownloadableShare in createShare (#6121)
* fix(subsonic): honor DefaultDownloadableShare in createShare
The DefaultDownloadableShare option was only sent to the web UI, which used
it to pre-tick the "Allow Downloads?" checkbox. The Subsonic createShare
handler built the model.Share without touching Downloadable, so it fell back
to the Go zero value and every share created through the API was stored as
non-downloadable, regardless of the configured default.
createShare now reads an optional downloadable parameter and falls back to
conf.Server.DefaultDownloadableShare when the client omits it, matching the
web UI. Fixes #6119.
updateShare had a related problem: core's share repository wrapper always
writes the downloadable column, but the handler never set the field, so any
updateShare call silently reset the share to non-downloadable. It now loads
the current share and uses its value as the fallback.
* refactor(subsonic): trim the share downloadable lookup and align with the UI
updateShare fetched the share with Get to recover the stored downloadable
flag, which also runs loadMedia and materializes every album and track the
share points at, just to read one boolean. It now uses Read, which skips
loadMedia, and only queries at all when the client omitted the parameter.
createShare now ANDs the default with EnableDownloads, matching what the web
UI already computes, so both paths apply the same rule.
The specs collapse the create-path matrix into a DescribeTable, reuse the
existing albumIDByName helper, and set the request-time config after
setupTestDB so it does not leak into the config snapshot.
* fix(subsonic): keep the share description on a downloadable-only update
updateShare read the description straight from the request, so a client that
sent only id and downloadable got an empty string written over the stored
description. shareRepositoryWrapper.Update always writes that column, so the
description was silently erased.
This predates the downloadable parameter added earlier in this branch: any
updateShare that omitted description already cleared it. Adding the parameter
just made it easy to hit, since toggling downloads is a natural reason to call
updateShare without touching the description.
Both fields now use the presence-aware accessors and fall back to the stored
share, which still costs at most one read and none when the client sends both.
An explicitly empty description still clears the field.
2026-09-09 20:29:00 -04:00
|
|
|
ID: id,
|
|
|
|
|
Description: *description,
|
|
|
|
|
Downloadable: *downloadable,
|
|
|
|
|
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
|
2023-01-22 20:21:06 -05:00
|
|
|
}
|
|
|
|
|
|
2026-09-25 18:06:10 -04:00
|
|
|
err = repo.Update(r.Context(), id, *share)
|
2023-01-22 20:21:06 -05:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return newResponse(), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (api *Router) DeleteShare(r *http.Request) (*responses.Subsonic, error) {
|
2023-12-21 17:41:09 -05:00
|
|
|
p := req.Params(r)
|
|
|
|
|
id, err := p.String("id")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
2023-01-22 20:21:06 -05:00
|
|
|
}
|
|
|
|
|
|
2026-09-25 18:06:10 -04:00
|
|
|
repo := api.share.Repository()
|
|
|
|
|
err = repo.Delete(r.Context(), id)
|
2023-01-22 20:21:06 -05:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return newResponse(), nil
|
|
|
|
|
}
|