mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-16 09:51:00 -05:00
Compare commits
33 Commits
claude/cre
...
plugins-en
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c260db60c | ||
|
|
fc113d1dc6 | ||
|
|
425fe862ba | ||
|
|
b1a51f9bbe | ||
|
|
9a004fd043 | ||
|
|
5c52bbb130 | ||
|
|
b0f91715b9 | ||
|
|
9f7b6870ac | ||
|
|
f00af7f983 | ||
|
|
875ffc2b78 | ||
|
|
885334c819 | ||
|
|
ff86b9f2b9 | ||
|
|
13d3d510f5 | ||
|
|
656009e5f8 | ||
|
|
06b3a1f33e | ||
|
|
0f4e8376cb | ||
|
|
199cde4109 | ||
|
|
897de02a84 | ||
|
|
7ee56fe3bf | ||
|
|
34c6f12aee | ||
|
|
eb9ebc3fba | ||
|
|
e05a7e230f | ||
|
|
62f9c3a458 | ||
|
|
fd09ca103f | ||
|
|
ed79a8897b | ||
|
|
302d99aa8b | ||
|
|
bee0305831 | ||
|
|
c280dd67a4 | ||
|
|
8319905d2c | ||
|
|
c80ef8ae41 | ||
|
|
0a4722802a | ||
|
|
a704e86ac1 | ||
|
|
408aa78ed5 |
138
.github/workflows/push-translations.sh
vendored
Executable file
138
.github/workflows/push-translations.sh
vendored
Executable file
@@ -0,0 +1,138 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
I18N_DIR=resources/i18n
|
||||
|
||||
# Normalize JSON for deterministic comparison:
|
||||
# remove empty/null attributes, sort keys alphabetically
|
||||
process_json() {
|
||||
jq 'walk(if type == "object" then with_entries(select(.value != null and .value != "" and .value != [] and .value != {})) | to_entries | sort_by(.key) | from_entries else . end)' "$1"
|
||||
}
|
||||
|
||||
# Get list of all languages configured in the POEditor project
|
||||
get_language_list() {
|
||||
curl -s -X POST https://api.poeditor.com/v2/languages/list \
|
||||
-d api_token="${POEDITOR_APIKEY}" \
|
||||
-d id="${POEDITOR_PROJECTID}"
|
||||
}
|
||||
|
||||
# Extract language name from the language list JSON given a language code
|
||||
get_language_name() {
|
||||
lang_code="$1"
|
||||
lang_list="$2"
|
||||
echo "$lang_list" | jq -r ".result.languages[] | select(.code == \"$lang_code\") | .name"
|
||||
}
|
||||
|
||||
# Extract language code from a file path (e.g., "resources/i18n/fr.json" -> "fr")
|
||||
get_lang_code() {
|
||||
filepath="$1"
|
||||
filename=$(basename "$filepath")
|
||||
echo "${filename%.*}"
|
||||
}
|
||||
|
||||
# Export the current translation for a language from POEditor (v2 API)
|
||||
export_language() {
|
||||
lang_code="$1"
|
||||
response=$(curl -s -X POST https://api.poeditor.com/v2/projects/export \
|
||||
-d api_token="${POEDITOR_APIKEY}" \
|
||||
-d id="${POEDITOR_PROJECTID}" \
|
||||
-d language="$lang_code" \
|
||||
-d type="key_value_json")
|
||||
|
||||
url=$(echo "$response" | jq -r '.result.url')
|
||||
if [ -z "$url" ] || [ "$url" = "null" ]; then
|
||||
echo "Failed to export $lang_code: $response" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "$url"
|
||||
}
|
||||
|
||||
# Flatten nested JSON to POEditor languages/update format.
|
||||
# POEditor uses term + context pairs, where:
|
||||
# term = the leaf key name
|
||||
# context = the parent path as "key1"."key2"."key3" (empty for root keys)
|
||||
flatten_to_poeditor() {
|
||||
jq -c '[paths(scalars) as $p |
|
||||
{
|
||||
"term": ($p | last | tostring),
|
||||
"context": (if ($p | length) > 1 then ($p[:-1] | map("\"" + tostring + "\"") | join(".")) else "" end),
|
||||
"translation": {"content": getpath($p)}
|
||||
}
|
||||
]' "$1"
|
||||
}
|
||||
|
||||
# Update translations for a language in POEditor via languages/update API
|
||||
update_language() {
|
||||
lang_code="$1"
|
||||
file="$2"
|
||||
|
||||
flatten_to_poeditor "$file" > /tmp/poeditor_data.json
|
||||
response=$(curl -s -X POST https://api.poeditor.com/v2/languages/update \
|
||||
-d api_token="${POEDITOR_APIKEY}" \
|
||||
-d id="${POEDITOR_PROJECTID}" \
|
||||
-d language="$lang_code" \
|
||||
--data-urlencode data@/tmp/poeditor_data.json)
|
||||
rm -f /tmp/poeditor_data.json
|
||||
|
||||
status=$(echo "$response" | jq -r '.response.status')
|
||||
if [ "$status" != "success" ]; then
|
||||
echo "Failed to update $lang_code: $response" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
parsed=$(echo "$response" | jq -r '.result.translations.parsed')
|
||||
added=$(echo "$response" | jq -r '.result.translations.added')
|
||||
updated=$(echo "$response" | jq -r '.result.translations.updated')
|
||||
echo " Translations - parsed: $parsed, added: $added, updated: $updated"
|
||||
}
|
||||
|
||||
# --- Main ---
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: $0 <file1> [file2] ..."
|
||||
echo "No files specified. Nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
lang_list=$(get_language_list)
|
||||
upload_count=0
|
||||
|
||||
for file in "$@"; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "Warning: File not found: $file, skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
lang_code=$(get_lang_code "$file")
|
||||
lang_name=$(get_language_name "$lang_code" "$lang_list")
|
||||
|
||||
if [ -z "$lang_name" ]; then
|
||||
echo "Warning: Language code '$lang_code' not found in POEditor, skipping $file"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Processing $lang_name ($lang_code)..."
|
||||
|
||||
# Export current state from POEditor
|
||||
url=$(export_language "$lang_code")
|
||||
curl -sSL "$url" -o poeditor_export.json
|
||||
|
||||
# Normalize both files for comparison
|
||||
process_json "$file" > local_normalized.json
|
||||
process_json poeditor_export.json > remote_normalized.json
|
||||
|
||||
# Compare normalized versions
|
||||
if diff -q local_normalized.json remote_normalized.json > /dev/null 2>&1; then
|
||||
echo " No differences, skipping"
|
||||
else
|
||||
echo " Differences found, updating POEditor..."
|
||||
update_language "$lang_code" "$file"
|
||||
upload_count=$((upload_count + 1))
|
||||
fi
|
||||
|
||||
rm -f poeditor_export.json local_normalized.json remote_normalized.json
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Done. Updated $upload_count translation(s) in POEditor."
|
||||
32
.github/workflows/push-translations.yml
vendored
Normal file
32
.github/workflows/push-translations.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: POEditor export
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- 'resources/i18n/*.json'
|
||||
|
||||
jobs:
|
||||
push-translations:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'navidrome' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Detect changed translation files
|
||||
id: changed
|
||||
run: |
|
||||
CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD -- 'resources/i18n/*.json' | tr '\n' ' ')
|
||||
echo "files=$CHANGED_FILES" >> $GITHUB_OUTPUT
|
||||
echo "Changed translation files: $CHANGED_FILES"
|
||||
|
||||
- name: Push translations to POEditor
|
||||
if: ${{ steps.changed.outputs.files != '' }}
|
||||
env:
|
||||
POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }}
|
||||
POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
|
||||
run: |
|
||||
.github/workflows/push-translations.sh ${{ steps.changed.outputs.files }}
|
||||
2
Makefile
2
Makefile
@@ -20,7 +20,7 @@ DOCKER_TAG ?= deluan/navidrome:develop
|
||||
|
||||
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
|
||||
CROSS_TAGLIB_VERSION ?= 2.1.1-2
|
||||
GOLANGCI_LINT_VERSION ?= v2.8.0
|
||||
GOLANGCI_LINT_VERSION ?= v2.9.0
|
||||
|
||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||
|
||||
|
||||
@@ -252,7 +252,7 @@ var _ = Describe("JWT Authentication", func() {
|
||||
|
||||
// Writer goroutine
|
||||
wg.Go(func() {
|
||||
for i := 0; i < 100; i++ {
|
||||
for i := range 100 {
|
||||
cache.set(fmt.Sprintf("token-%d", i), 1*time.Hour)
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
}
|
||||
@@ -260,7 +260,7 @@ var _ = Describe("JWT Authentication", func() {
|
||||
|
||||
// Reader goroutine
|
||||
wg.Go(func() {
|
||||
for i := 0; i < 100; i++ {
|
||||
for range 100 {
|
||||
cache.get()
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ func (e extractor) Version() string {
|
||||
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
|
||||
f, close, err := e.openFile(filePath)
|
||||
if err != nil {
|
||||
log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err)
|
||||
return nil, err
|
||||
}
|
||||
defer close()
|
||||
@@ -118,7 +119,12 @@ func (e extractor) openFile(filePath string) (f *taglib.File, closeFunc func(),
|
||||
file.Close()
|
||||
return nil, nil, errors.New("file is not seekable")
|
||||
}
|
||||
f, err = taglib.OpenStream(rs, taglib.WithReadStyle(taglib.ReadStyleFast))
|
||||
// WithFilename provides a format detection hint via the file extension,
|
||||
// since OpenStream alone relies on content-sniffing which fails for some files.
|
||||
f, err = taglib.OpenStream(rs,
|
||||
taglib.WithReadStyle(taglib.ReadStyleFast),
|
||||
taglib.WithFilename(filePath),
|
||||
)
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return nil, nil, err
|
||||
@@ -254,7 +260,7 @@ func parseTIPL(tags map[string][]string) {
|
||||
}
|
||||
var currentRole string
|
||||
var currentValue []string
|
||||
for _, part := range strings.Split(tipl[0], " ") {
|
||||
for part := range strings.SplitSeq(tipl[0], " ") {
|
||||
if _, ok := tiplMapping[part]; ok {
|
||||
addRole(currentRole, currentValue)
|
||||
currentRole = part
|
||||
|
||||
@@ -173,6 +173,9 @@ var _ = Describe("Extractor", func() {
|
||||
Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
|
||||
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false, true),
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=1100:duration=1" -c:a libopus test.opus (tags added via mutagen)
|
||||
Entry("correctly parses opus tags (#4998)", "test.opus", "1s", 1, 48000, 0, "+5.12 dB", "0.11345678", "+5.12 dB", "0.11345678", false, true),
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma
|
||||
// Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order
|
||||
Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false, true),
|
||||
|
||||
@@ -65,7 +65,7 @@ func (s *Router) routes() http.Handler {
|
||||
}
|
||||
|
||||
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
|
||||
resp := map[string]interface{}{
|
||||
resp := map[string]any{
|
||||
"apiKey": s.apiKey,
|
||||
}
|
||||
u, _ := request.UserFrom(r.Context())
|
||||
|
||||
@@ -60,7 +60,7 @@ func (s *Router) routes() http.Handler {
|
||||
}
|
||||
|
||||
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
|
||||
resp := map[string]interface{}{}
|
||||
resp := map[string]any{}
|
||||
u, _ := request.UserFrom(r.Context())
|
||||
key, err := s.sessionKeys.Get(r.Context(), u.ID)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
@@ -107,7 +107,7 @@ func (s *Router) link(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_ = rest.RespondWithJSON(w, http.StatusOK, map[string]interface{}{"status": resp.Valid, "user": resp.UserName})
|
||||
_ = rest.RespondWithJSON(w, http.StatusOK, map[string]any{"status": resp.Valid, "user": resp.UserName})
|
||||
}
|
||||
|
||||
func (s *Router) unlink(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -37,7 +37,7 @@ var _ = Describe("ListenBrainz Auth Router", func() {
|
||||
req = httptest.NewRequest("GET", "/listenbrainz/link", nil)
|
||||
r.getLinkStatus(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||
var parsed map[string]interface{}
|
||||
var parsed map[string]any
|
||||
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
|
||||
Expect(parsed["status"]).To(Equal(false))
|
||||
})
|
||||
@@ -47,7 +47,7 @@ var _ = Describe("ListenBrainz Auth Router", func() {
|
||||
req = httptest.NewRequest("GET", "/listenbrainz/link", nil)
|
||||
r.getLinkStatus(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||
var parsed map[string]interface{}
|
||||
var parsed map[string]any
|
||||
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
|
||||
Expect(parsed["status"]).To(Equal(true))
|
||||
})
|
||||
@@ -80,7 +80,7 @@ var _ = Describe("ListenBrainz Auth Router", func() {
|
||||
req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{"token": "tok-1"}`))
|
||||
r.link(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||
var parsed map[string]interface{}
|
||||
var parsed map[string]any
|
||||
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
|
||||
Expect(parsed["status"]).To(Equal(true))
|
||||
Expect(parsed["user"]).To(Equal("ListenBrainzUser"))
|
||||
|
||||
@@ -75,14 +75,14 @@ const (
|
||||
|
||||
type listenInfo struct {
|
||||
ListenedAt int `json:"listened_at,omitempty"`
|
||||
TrackMetadata trackMetadata `json:"track_metadata,omitempty"`
|
||||
TrackMetadata trackMetadata `json:"track_metadata"`
|
||||
}
|
||||
|
||||
type trackMetadata struct {
|
||||
ArtistName string `json:"artist_name,omitempty"`
|
||||
TrackName string `json:"track_name,omitempty"`
|
||||
ReleaseName string `json:"release_name,omitempty"`
|
||||
AdditionalInfo additionalInfo `json:"additional_info,omitempty"`
|
||||
AdditionalInfo additionalInfo `json:"additional_info"`
|
||||
}
|
||||
|
||||
type additionalInfo struct {
|
||||
|
||||
@@ -73,7 +73,7 @@ func (c *client) authorize(ctx context.Context) (string, error) {
|
||||
auth := c.id + ":" + c.secret
|
||||
req.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(auth)))
|
||||
|
||||
response := map[string]interface{}{}
|
||||
response := map[string]any{}
|
||||
err := c.makeRequest(req, &response)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -86,7 +86,7 @@ func (c *client) authorize(ctx context.Context) (string, error) {
|
||||
return "", errors.New("invalid response")
|
||||
}
|
||||
|
||||
func (c *client) makeRequest(req *http.Request, response interface{}) error {
|
||||
func (c *client) makeRequest(req *http.Request, response any) error {
|
||||
log.Trace(req.Context(), fmt.Sprintf("Sending Spotify %s request", req.Method), "url", req.URL)
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
|
||||
10
cmd/root.go
10
cmd/root.go
@@ -14,10 +14,13 @@ import (
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/plugins"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/scheduler"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/backgrounds"
|
||||
"github.com/navidrome/navidrome/server/subsonic"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"golang.org/x/sync/errgroup"
|
||||
@@ -138,6 +141,13 @@ func startServer(ctx context.Context) func() error {
|
||||
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
|
||||
a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler())
|
||||
}
|
||||
if conf.Server.Plugins.Enabled {
|
||||
manager := GetPluginManager(ctx)
|
||||
ds := CreateDataStore()
|
||||
endpointRouter := plugins.NewEndpointRouter(manager, ds, subsonic.ValidateAuth, server.Authenticator)
|
||||
a.MountRouter("Plugin Endpoints", consts.URLPathPluginEndpoints, endpointRouter)
|
||||
a.MountRouter("Plugin Subsonic Endpoints", consts.URLPathPluginSubsonicEndpoints, endpointRouter)
|
||||
}
|
||||
return a.Run(ctx, conf.Server.Address, conf.Server.Port, conf.Server.TLSCert, conf.Server.TLSKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -238,11 +239,13 @@ type inspectOptions struct {
|
||||
}
|
||||
|
||||
type pluginsOptions struct {
|
||||
Enabled bool
|
||||
Folder string
|
||||
CacheSize string
|
||||
AutoReload bool
|
||||
LogLevel string
|
||||
Enabled bool
|
||||
Folder string
|
||||
CacheSize string
|
||||
AutoReload bool
|
||||
LogLevel string
|
||||
EndpointRequestLimit int
|
||||
EndpointRequestWindow time.Duration
|
||||
}
|
||||
|
||||
type extAuthOptions struct {
|
||||
@@ -433,7 +436,7 @@ func mapDeprecatedOption(legacyName, newName string) {
|
||||
func parseIniFileConfiguration() {
|
||||
cfgFile := viper.ConfigFileUsed()
|
||||
if strings.ToLower(filepath.Ext(cfgFile)) == ".ini" {
|
||||
var iniConfig map[string]interface{}
|
||||
var iniConfig map[string]any
|
||||
err := viper.Unmarshal(&iniConfig)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
|
||||
@@ -466,7 +469,7 @@ func disableExternalServices() {
|
||||
}
|
||||
|
||||
func validatePlaylistsPath() error {
|
||||
for _, path := range strings.Split(Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
||||
for path := range strings.SplitSeq(Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
||||
_, err := doublestar.Match(path, "")
|
||||
if err != nil {
|
||||
log.Error("Invalid PlaylistsPath", "path", path, err)
|
||||
@@ -480,7 +483,7 @@ func validatePlaylistsPath() error {
|
||||
// It trims whitespace from each entry and ensures at least [DefaultInfoLanguage] is returned.
|
||||
func parseLanguages(lang string) []string {
|
||||
var languages []string
|
||||
for _, l := range strings.Split(lang, ",") {
|
||||
for l := range strings.SplitSeq(lang, ",") {
|
||||
l = strings.TrimSpace(l)
|
||||
if l != "" {
|
||||
languages = append(languages, l)
|
||||
@@ -494,13 +497,7 @@ func parseLanguages(lang string) []string {
|
||||
|
||||
func validatePurgeMissingOption() error {
|
||||
allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull}
|
||||
valid := false
|
||||
for _, v := range allowedValues {
|
||||
if v == Server.Scanner.PurgeMissing {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
valid := slices.Contains(allowedValues, Server.Scanner.PurgeMissing)
|
||||
if !valid {
|
||||
err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
|
||||
log.Error(err.Error())
|
||||
@@ -676,6 +673,8 @@ func setViperDefaults() {
|
||||
viper.SetDefault("plugins.enabled", true)
|
||||
viper.SetDefault("plugins.cachesize", "200MB")
|
||||
viper.SetDefault("plugins.autoreload", false)
|
||||
viper.SetDefault("plugins.endpointrequestlimit", 60)
|
||||
viper.SetDefault("plugins.endpointrequestwindow", time.Minute)
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
viper.SetDefault("devlogsourceline", false)
|
||||
|
||||
@@ -36,11 +36,13 @@ const (
|
||||
DevInitialUserName = "admin"
|
||||
DevInitialName = "Dev Admin"
|
||||
|
||||
URLPathUI = "/app"
|
||||
URLPathNativeAPI = "/api"
|
||||
URLPathSubsonicAPI = "/rest"
|
||||
URLPathPublic = "/share"
|
||||
URLPathPublicImages = URLPathPublic + "/img"
|
||||
URLPathUI = "/app"
|
||||
URLPathNativeAPI = "/api"
|
||||
URLPathSubsonicAPI = "/rest"
|
||||
URLPathPluginEndpoints = "/ext"
|
||||
URLPathPluginSubsonicEndpoints = "/rest/ext"
|
||||
URLPathPublic = "/share"
|
||||
URLPathPublicImages = URLPathPublic + "/img"
|
||||
|
||||
// DefaultUILoginBackgroundURL uses Navidrome curated background images collection,
|
||||
// available at https://unsplash.com/collections/20072696/navidrome
|
||||
|
||||
@@ -365,7 +365,7 @@ var _ = Describe("Agents", func() {
|
||||
})
|
||||
|
||||
type mockAgent struct {
|
||||
Args []interface{}
|
||||
Args []any
|
||||
Err error
|
||||
}
|
||||
|
||||
@@ -374,7 +374,7 @@ func (a *mockAgent) AgentName() string {
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetArtistMBID(_ context.Context, id string, name string) (string, error) {
|
||||
a.Args = []interface{}{id, name}
|
||||
a.Args = []any{id, name}
|
||||
if a.Err != nil {
|
||||
return "", a.Err
|
||||
}
|
||||
@@ -382,7 +382,7 @@ func (a *mockAgent) GetArtistMBID(_ context.Context, id string, name string) (st
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (string, error) {
|
||||
a.Args = []interface{}{id, name, mbid}
|
||||
a.Args = []any{id, name, mbid}
|
||||
if a.Err != nil {
|
||||
return "", a.Err
|
||||
}
|
||||
@@ -390,7 +390,7 @@ func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (stri
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string) (string, error) {
|
||||
a.Args = []interface{}{id, name, mbid}
|
||||
a.Args = []any{id, name, mbid}
|
||||
if a.Err != nil {
|
||||
return "", a.Err
|
||||
}
|
||||
@@ -398,7 +398,7 @@ func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string)
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) {
|
||||
a.Args = []interface{}{id, name, mbid}
|
||||
a.Args = []any{id, name, mbid}
|
||||
if a.Err != nil {
|
||||
return nil, a.Err
|
||||
}
|
||||
@@ -409,7 +409,7 @@ func (a *mockAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetSimilarArtists(_ context.Context, id, name, mbid string, limit int) ([]Artist, error) {
|
||||
a.Args = []interface{}{id, name, mbid, limit}
|
||||
a.Args = []any{id, name, mbid, limit}
|
||||
if a.Err != nil {
|
||||
return nil, a.Err
|
||||
}
|
||||
@@ -420,7 +420,7 @@ func (a *mockAgent) GetSimilarArtists(_ context.Context, id, name, mbid string,
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetArtistTopSongs(_ context.Context, id, artistName, mbid string, count int) ([]Song, error) {
|
||||
a.Args = []interface{}{id, artistName, mbid, count}
|
||||
a.Args = []any{id, artistName, mbid, count}
|
||||
if a.Err != nil {
|
||||
return nil, a.Err
|
||||
}
|
||||
@@ -431,7 +431,7 @@ func (a *mockAgent) GetArtistTopSongs(_ context.Context, id, artistName, mbid st
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) {
|
||||
a.Args = []interface{}{name, artist, mbid}
|
||||
a.Args = []any{name, artist, mbid}
|
||||
if a.Err != nil {
|
||||
return nil, a.Err
|
||||
}
|
||||
@@ -444,7 +444,7 @@ func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string)
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetSimilarSongsByTrack(_ context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
|
||||
a.Args = []interface{}{id, name, artist, mbid, count}
|
||||
a.Args = []any{id, name, artist, mbid, count}
|
||||
if a.Err != nil {
|
||||
return nil, a.Err
|
||||
}
|
||||
@@ -455,7 +455,7 @@ func (a *mockAgent) GetSimilarSongsByTrack(_ context.Context, id, name, artist,
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetSimilarSongsByAlbum(_ context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
|
||||
a.Args = []interface{}{id, name, artist, mbid, count}
|
||||
a.Args = []any{id, name, artist, mbid, count}
|
||||
if a.Err != nil {
|
||||
return nil, a.Err
|
||||
}
|
||||
@@ -466,7 +466,7 @@ func (a *mockAgent) GetSimilarSongsByAlbum(_ context.Context, id, name, artist,
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetSimilarSongsByArtist(_ context.Context, id, name, mbid string, count int) ([]Song, error) {
|
||||
a.Args = []interface{}{id, name, mbid, count}
|
||||
a.Args = []any{id, name, mbid, count}
|
||||
if a.Err != nil {
|
||||
return nil, a.Err
|
||||
}
|
||||
@@ -488,12 +488,12 @@ type testImageAgent struct {
|
||||
Name string
|
||||
Images []ExternalImage
|
||||
Err error
|
||||
Args []interface{}
|
||||
Args []any
|
||||
}
|
||||
|
||||
func (t *testImageAgent) AgentName() string { return t.Name }
|
||||
|
||||
func (t *testImageAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) {
|
||||
t.Args = []interface{}{id, name, mbid}
|
||||
t.Args = []any{id, name, mbid}
|
||||
return t.Images, t.Err
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ var _ = Describe("CacheWarmer", func() {
|
||||
|
||||
It("processes items in batches", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
for i := 0; i < 5; i++ {
|
||||
for i := range 5 {
|
||||
cw.PreCache(model.MustParseArtworkID(fmt.Sprintf("al-%d", i)))
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ func (a *albumArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string,
|
||||
|
||||
func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc {
|
||||
var ff []sourceFunc
|
||||
for _, pattern := range strings.Split(strings.ToLower(priority), ",") {
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "embedded":
|
||||
|
||||
@@ -99,7 +99,7 @@ func (a *artistReader) Reader(ctx context.Context) (io.ReadCloser, string, error
|
||||
|
||||
func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority string) []sourceFunc {
|
||||
var ff []sourceFunc
|
||||
for _, pattern := range strings.Split(strings.ToLower(priority), ",") {
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "external":
|
||||
@@ -116,7 +116,7 @@ func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority strin
|
||||
func fromArtistFolder(ctx context.Context, artistFolder string, pattern string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
current := artistFolder
|
||||
for i := 0; i < maxArtistFolderTraversalDepth; i++ {
|
||||
for range maxArtistFolderTraversalDepth {
|
||||
if reader, path, err := findImageInFolder(ctx, current, pattern); err == nil {
|
||||
return reader, path, nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"maps"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -53,9 +54,7 @@ func createBaseClaims() map[string]any {
|
||||
|
||||
func CreatePublicToken(claims map[string]any) (string, error) {
|
||||
tokenClaims := createBaseClaims()
|
||||
for k, v := range claims {
|
||||
tokenClaims[k] = v
|
||||
}
|
||||
maps.Copy(tokenClaims, claims)
|
||||
_, token, err := TokenAuth.Encode(tokenClaims)
|
||||
|
||||
return token, err
|
||||
@@ -66,9 +65,7 @@ func CreateExpiringPublicToken(exp time.Time, claims map[string]any) (string, er
|
||||
if !exp.IsZero() {
|
||||
tokenClaims[jwt.ExpirationKey] = exp.UTC().Unix()
|
||||
}
|
||||
for k, v := range claims {
|
||||
tokenClaims[k] = v
|
||||
}
|
||||
maps.Copy(tokenClaims, claims)
|
||||
_, token, err := TokenAuth.Encode(tokenClaims)
|
||||
|
||||
return token, err
|
||||
@@ -100,7 +97,7 @@ func TouchToken(token jwt.Token) (string, error) {
|
||||
return newToken, err
|
||||
}
|
||||
|
||||
func Validate(tokenStr string) (map[string]interface{}, error) {
|
||||
func Validate(tokenStr string) (map[string]any, error) {
|
||||
token, err := jwtauth.VerifyToken(TokenAuth, tokenStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -45,7 +45,7 @@ var _ = Describe("Auth", func() {
|
||||
})
|
||||
|
||||
It("returns the claims from a valid JWT token", func() {
|
||||
claims := map[string]interface{}{}
|
||||
claims := map[string]any{}
|
||||
claims["iss"] = "issuer"
|
||||
claims["iat"] = time.Now().Unix()
|
||||
claims["exp"] = time.Now().Add(1 * time.Minute).Unix()
|
||||
@@ -58,7 +58,7 @@ var _ = Describe("Auth", func() {
|
||||
})
|
||||
|
||||
It("returns ErrExpired if the `exp` field is in the past", func() {
|
||||
claims := map[string]interface{}{}
|
||||
claims := map[string]any{}
|
||||
claims["iss"] = "issuer"
|
||||
claims["exp"] = time.Now().Add(-1 * time.Minute).Unix()
|
||||
_, tokenStr, err := auth.TokenAuth.Encode(claims)
|
||||
@@ -93,7 +93,7 @@ var _ = Describe("Auth", func() {
|
||||
Describe("TouchToken", func() {
|
||||
It("updates the expiration time", func() {
|
||||
yesterday := time.Now().Add(-oneDay)
|
||||
claims := map[string]interface{}{}
|
||||
claims := map[string]any{}
|
||||
claims["iss"] = "issuer"
|
||||
claims["exp"] = yesterday.Unix()
|
||||
token, _, err := auth.TokenAuth.Encode(claims)
|
||||
|
||||
6
core/external/extdata_helper_test.go
vendored
6
core/external/extdata_helper_test.go
vendored
@@ -40,7 +40,7 @@ func (m *mockArtistRepo) Get(id string) (*model.Artist, error) {
|
||||
|
||||
// GetAll implements model.ArtistRepository.
|
||||
func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) {
|
||||
argsSlice := make([]interface{}, len(options))
|
||||
argsSlice := make([]any, len(options))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
}
|
||||
@@ -99,7 +99,7 @@ func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ..
|
||||
|
||||
// GetAll implements model.MediaFileRepository.
|
||||
func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
argsSlice := make([]interface{}, len(options))
|
||||
argsSlice := make([]any, len(options))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
}
|
||||
@@ -152,7 +152,7 @@ func (m *mockAlbumRepo) Get(id string) (*model.Album, error) {
|
||||
|
||||
// GetAll implements model.AlbumRepository.
|
||||
func (m *mockAlbumRepo) GetAll(options ...model.QueryOptions) (model.Albums, error) {
|
||||
argsSlice := make([]interface{}, len(options))
|
||||
argsSlice := make([]any, len(options))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
}
|
||||
|
||||
4
core/external/provider.go
vendored
4
core/external/provider.go
vendored
@@ -93,7 +93,7 @@ func NewProvider(ds model.DataStore, agents Agents) Provider {
|
||||
}
|
||||
|
||||
func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) {
|
||||
var entity interface{}
|
||||
var entity any
|
||||
entity, err := model.GetEntityByID(ctx, e.ds, id)
|
||||
if err != nil {
|
||||
return auxAlbum{}, err
|
||||
@@ -187,7 +187,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
|
||||
}
|
||||
|
||||
func (e *provider) getArtist(ctx context.Context, id string) (auxArtist, error) {
|
||||
var entity interface{}
|
||||
var entity any
|
||||
entity, err := model.GetEntityByID(ctx, e.ds, id)
|
||||
if err != nil {
|
||||
return auxArtist{}, err
|
||||
|
||||
@@ -159,7 +159,7 @@ type libraryRepositoryWrapper struct {
|
||||
pluginManager PluginUnloader
|
||||
}
|
||||
|
||||
func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) {
|
||||
func (r *libraryRepositoryWrapper) Save(entity any) (string, error) {
|
||||
lib := entity.(*model.Library)
|
||||
if err := r.validateLibrary(lib); err != nil {
|
||||
return "", err
|
||||
@@ -191,7 +191,7 @@ func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) {
|
||||
return strconv.Itoa(lib.ID), nil
|
||||
}
|
||||
|
||||
func (r *libraryRepositoryWrapper) Update(id string, entity interface{}, _ ...string) error {
|
||||
func (r *libraryRepositoryWrapper) Update(id string, entity any, _ ...string) error {
|
||||
lib := entity.(*model.Library)
|
||||
libID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
|
||||
@@ -196,9 +196,7 @@ func (s *maintenanceService) getAffectedAlbumIDs(ctx context.Context, ids []stri
|
||||
// refreshStatsAsync refreshes artist and album statistics in background goroutines
|
||||
func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbumIDs []string) {
|
||||
// Refresh artist stats in background
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
s.wg.Go(func() {
|
||||
bgCtx := request.AddValues(context.Background(), ctx)
|
||||
if _, err := s.ds.Artist(bgCtx).RefreshStats(true); err != nil {
|
||||
log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err)
|
||||
@@ -214,7 +212,7 @@ func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbu
|
||||
log.Debug(bgCtx, "Successfully refreshed album stats after deleting missing files", "count", len(affectedAlbumIDs))
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// Wait waits for all background goroutines to complete.
|
||||
|
||||
@@ -220,7 +220,7 @@ var staticData = sync.OnceValue(func() insights.Data {
|
||||
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
|
||||
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
|
||||
data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != ""
|
||||
data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != ""
|
||||
data.Config.HasCustomPID = conf.Server.PID.Track != consts.DefaultTrackPID || conf.Server.PID.Album != consts.DefaultAlbumPID
|
||||
data.Config.HasCustomTags = len(conf.Server.Tags) > 0
|
||||
|
||||
return data
|
||||
|
||||
@@ -3,6 +3,7 @@ package playback
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -21,11 +22,11 @@ func NewQueue() *Queue {
|
||||
}
|
||||
|
||||
func (pd *Queue) String() string {
|
||||
filenames := ""
|
||||
var filenames strings.Builder
|
||||
for idx, item := range pd.Items {
|
||||
filenames += fmt.Sprint(idx) + ":" + item.Path + " "
|
||||
filenames.WriteString(fmt.Sprint(idx) + ":" + item.Path + " ")
|
||||
}
|
||||
return fmt.Sprintf("#Items: %d, idx: %d, files: %s", len(pd.Items), pd.Index, filenames)
|
||||
return fmt.Sprintf("#Items: %d, idx: %d, files: %s", len(pd.Items), pd.Index, filenames.String())
|
||||
}
|
||||
|
||||
// returns the current mediafile or nil
|
||||
|
||||
@@ -45,7 +45,7 @@ func InPlaylistsPath(folder model.Folder) bool {
|
||||
return true
|
||||
}
|
||||
rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath())
|
||||
for _, path := range strings.Split(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
||||
for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
||||
if match, _ := doublestar.Match(path, rel); match {
|
||||
return true
|
||||
}
|
||||
@@ -193,8 +193,8 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "file://") {
|
||||
line = strings.TrimPrefix(line, "file://")
|
||||
if after, ok := strings.CutPrefix(line, "file://"); ok {
|
||||
line = after
|
||||
line, _ = url.QueryUnescape(line)
|
||||
}
|
||||
if !model.IsAudioFile(line) {
|
||||
@@ -533,7 +533,7 @@ type nspFile struct {
|
||||
}
|
||||
|
||||
func (i *nspFile) UnmarshalJSON(data []byte) error {
|
||||
m := map[string]interface{}{}
|
||||
m := map[string]any{}
|
||||
err := json.Unmarshal(data, &m)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -212,10 +212,7 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam
|
||||
|
||||
// Calculate TTL based on remaining track duration. If position exceeds track duration,
|
||||
// remaining is set to 0 to avoid negative TTL.
|
||||
remaining := int(mf.Duration) - position
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
remaining := max(int(mf.Duration)-position, 0)
|
||||
// Add 5 seconds buffer to ensure the NowPlaying info is available slightly longer than the track duration.
|
||||
ttl := time.Duration(remaining+5) * time.Second
|
||||
_ = p.playMap.AddWithTTL(playerId, info, ttl)
|
||||
|
||||
@@ -87,7 +87,7 @@ func (r *shareRepositoryWrapper) newId() (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
|
||||
func (r *shareRepositoryWrapper) Save(entity any) (string, error) {
|
||||
s := entity.(*model.Share)
|
||||
id, err := r.newId()
|
||||
if err != nil {
|
||||
@@ -127,7 +127,7 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (r *shareRepositoryWrapper) Update(id string, entity interface{}, _ ...string) error {
|
||||
func (r *shareRepositoryWrapper) Update(id string, entity any, _ ...string) error {
|
||||
cols := []string{"description", "downloadable"}
|
||||
|
||||
// TODO Better handling of Share expiration
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"maps"
|
||||
"net/url"
|
||||
"path"
|
||||
"testing/fstest"
|
||||
@@ -135,9 +136,7 @@ func (ffs *FakeFS) UpdateTags(filePath string, newTags map[string]any, when ...t
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for k, v := range newTags {
|
||||
tags[k] = v
|
||||
}
|
||||
maps.Copy(tags, newTags)
|
||||
data, _ := json.Marshal(tags)
|
||||
f.Data = data
|
||||
ffs.Touch(filePath, when...)
|
||||
@@ -180,9 +179,7 @@ func Track(num int, title string, tags ...map[string]any) map[string]any {
|
||||
ts["title"] = title
|
||||
ts["track"] = num
|
||||
for _, t := range tags {
|
||||
for k, v := range t {
|
||||
ts[k] = v
|
||||
}
|
||||
maps.Copy(ts, t)
|
||||
}
|
||||
return ts
|
||||
}
|
||||
@@ -200,9 +197,7 @@ func MP3(tags ...map[string]any) *fstest.MapFile {
|
||||
func File(tags ...map[string]any) *fstest.MapFile {
|
||||
ts := map[string]any{}
|
||||
for _, t := range tags {
|
||||
for k, v := range t {
|
||||
ts[k] = v
|
||||
}
|
||||
maps.Copy(ts, t)
|
||||
}
|
||||
modTime := time.Now()
|
||||
if mt, ok := ts[fakeFileInfoModTime]; !ok {
|
||||
|
||||
@@ -50,12 +50,12 @@ type userRepositoryWrapper struct {
|
||||
}
|
||||
|
||||
// Save implements rest.Persistable by delegating to the underlying repository.
|
||||
func (r *userRepositoryWrapper) Save(entity interface{}) (string, error) {
|
||||
func (r *userRepositoryWrapper) Save(entity any) (string, error) {
|
||||
return r.UserRepository.(rest.Persistable).Save(entity)
|
||||
}
|
||||
|
||||
// Update implements rest.Persistable by delegating to the underlying repository.
|
||||
func (r *userRepositoryWrapper) Update(id string, entity interface{}, cols ...string) error {
|
||||
func (r *userRepositoryWrapper) Update(id string, entity any, cols ...string) error {
|
||||
return r.UserRepository.(rest.Persistable).Update(id, entity, cols...)
|
||||
}
|
||||
|
||||
|
||||
16
db/db.go
16
db/db.go
@@ -126,7 +126,7 @@ func Optimize(ctx context.Context) {
|
||||
}
|
||||
log.Debug(ctx, "Optimizing open connections", "numConns", numConns)
|
||||
var conns []*sql.Conn
|
||||
for i := 0; i < numConns; i++ {
|
||||
for range numConns {
|
||||
conn, err := Db().Conn(ctx)
|
||||
conns = append(conns, conn)
|
||||
if err != nil {
|
||||
@@ -147,8 +147,8 @@ func Optimize(ctx context.Context) {
|
||||
|
||||
type statusLogger struct{ numPending int }
|
||||
|
||||
func (*statusLogger) Fatalf(format string, v ...interface{}) { log.Fatal(fmt.Sprintf(format, v...)) }
|
||||
func (l *statusLogger) Printf(format string, v ...interface{}) {
|
||||
func (*statusLogger) Fatalf(format string, v ...any) { log.Fatal(fmt.Sprintf(format, v...)) }
|
||||
func (l *statusLogger) Printf(format string, v ...any) {
|
||||
if len(v) < 1 {
|
||||
return
|
||||
}
|
||||
@@ -183,27 +183,27 @@ type logAdapter struct {
|
||||
silent bool
|
||||
}
|
||||
|
||||
func (l *logAdapter) Fatal(v ...interface{}) {
|
||||
func (l *logAdapter) Fatal(v ...any) {
|
||||
log.Fatal(l.ctx, fmt.Sprint(v...))
|
||||
}
|
||||
|
||||
func (l *logAdapter) Fatalf(format string, v ...interface{}) {
|
||||
func (l *logAdapter) Fatalf(format string, v ...any) {
|
||||
log.Fatal(l.ctx, fmt.Sprintf(format, v...))
|
||||
}
|
||||
|
||||
func (l *logAdapter) Print(v ...interface{}) {
|
||||
func (l *logAdapter) Print(v ...any) {
|
||||
if !l.silent {
|
||||
log.Info(l.ctx, fmt.Sprint(v...))
|
||||
}
|
||||
}
|
||||
|
||||
func (l *logAdapter) Println(v ...interface{}) {
|
||||
func (l *logAdapter) Println(v ...any) {
|
||||
if !l.silent {
|
||||
log.Info(l.ctx, fmt.Sprintln(v...))
|
||||
}
|
||||
}
|
||||
|
||||
func (l *logAdapter) Printf(format string, v ...interface{}) {
|
||||
func (l *logAdapter) Printf(format string, v ...any) {
|
||||
if !l.silent {
|
||||
log.Info(l.ctx, fmt.Sprintf(format, v...))
|
||||
}
|
||||
|
||||
20
go.mod
20
go.mod
@@ -7,7 +7,7 @@ replace (
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
||||
|
||||
// Fork to implement raw tags support
|
||||
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260119020817-8753c7531798
|
||||
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260212150743-3f1b97cb0d1e
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -68,12 +68,12 @@ require (
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
|
||||
go.senan.xyz/taglib v0.11.1
|
||||
go.uber.org/goleak v1.3.0
|
||||
golang.org/x/image v0.35.0
|
||||
golang.org/x/net v0.49.0
|
||||
golang.org/x/image v0.36.0
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/sys v0.40.0
|
||||
golang.org/x/term v0.39.0
|
||||
golang.org/x/text v0.33.0
|
||||
golang.org/x/sys v0.41.0
|
||||
golang.org/x/term v0.40.0
|
||||
golang.org/x/text v0.34.0
|
||||
golang.org/x/time v0.14.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
@@ -139,11 +139,11 @@ require (
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/ini.v1 v1.67.1 // indirect
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
||||
|
||||
40
go.sum
40
go.sum
@@ -36,8 +36,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/deluan/go-taglib v0.0.0-20260119020817-8753c7531798 h1:q4fvcIK/LxElpyQILCejG6WPYjVb2F/4P93+k017ANk=
|
||||
github.com/deluan/go-taglib v0.0.0-20260119020817-8753c7531798/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
|
||||
github.com/deluan/go-taglib v0.0.0-20260212150743-3f1b97cb0d1e h1:pwx3kmHzl1N28coJV2C1zfm2ZF0qkQcGX+Z6BvXteB4=
|
||||
github.com/deluan/go-taglib v0.0.0-20260212150743-3f1b97cb0d1e/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
|
||||
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
|
||||
@@ -319,20 +319,20 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
|
||||
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -344,8 +344,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -370,11 +370,11 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo=
|
||||
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
|
||||
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0=
|
||||
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -383,8 +383,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -395,8 +395,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -406,8 +406,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
|
||||
24
log/log.go
24
log/log.go
@@ -19,7 +19,7 @@ import (
|
||||
|
||||
type Level uint32
|
||||
|
||||
type LevelFunc = func(ctx interface{}, msg interface{}, keyValuePairs ...interface{})
|
||||
type LevelFunc = func(ctx any, msg any, keyValuePairs ...any)
|
||||
|
||||
var redacted = &Hook{
|
||||
AcceptedLevels: logrus.AllLevels,
|
||||
@@ -152,7 +152,7 @@ func Redact(msg string) string {
|
||||
return r
|
||||
}
|
||||
|
||||
func NewContext(ctx context.Context, keyValuePairs ...interface{}) context.Context {
|
||||
func NewContext(ctx context.Context, keyValuePairs ...any) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
@@ -184,32 +184,32 @@ func IsGreaterOrEqualTo(level Level) bool {
|
||||
return shouldLog(level, 2)
|
||||
}
|
||||
|
||||
func Fatal(args ...interface{}) {
|
||||
func Fatal(args ...any) {
|
||||
Log(LevelFatal, args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func Error(args ...interface{}) {
|
||||
func Error(args ...any) {
|
||||
Log(LevelError, args...)
|
||||
}
|
||||
|
||||
func Warn(args ...interface{}) {
|
||||
func Warn(args ...any) {
|
||||
Log(LevelWarn, args...)
|
||||
}
|
||||
|
||||
func Info(args ...interface{}) {
|
||||
func Info(args ...any) {
|
||||
Log(LevelInfo, args...)
|
||||
}
|
||||
|
||||
func Debug(args ...interface{}) {
|
||||
func Debug(args ...any) {
|
||||
Log(LevelDebug, args...)
|
||||
}
|
||||
|
||||
func Trace(args ...interface{}) {
|
||||
func Trace(args ...any) {
|
||||
Log(LevelTrace, args...)
|
||||
}
|
||||
|
||||
func Log(level Level, args ...interface{}) {
|
||||
func Log(level Level, args ...any) {
|
||||
if !shouldLog(level, 3) {
|
||||
return
|
||||
}
|
||||
@@ -250,7 +250,7 @@ func shouldLog(requiredLevel Level, skip int) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func parseArgs(args []interface{}) (*logrus.Entry, string) {
|
||||
func parseArgs(args []any) (*logrus.Entry, string) {
|
||||
var l *logrus.Entry
|
||||
var err error
|
||||
if args[0] == nil {
|
||||
@@ -289,7 +289,7 @@ func parseArgs(args []interface{}) (*logrus.Entry, string) {
|
||||
return l, ""
|
||||
}
|
||||
|
||||
func addFields(logger *logrus.Entry, keyValuePairs []interface{}) *logrus.Entry {
|
||||
func addFields(logger *logrus.Entry, keyValuePairs []any) *logrus.Entry {
|
||||
for i := 0; i < len(keyValuePairs); i += 2 {
|
||||
switch name := keyValuePairs[i].(type) {
|
||||
case error:
|
||||
@@ -316,7 +316,7 @@ func addFields(logger *logrus.Entry, keyValuePairs []interface{}) *logrus.Entry
|
||||
return logger
|
||||
}
|
||||
|
||||
func extractLogger(ctx interface{}) (*logrus.Entry, error) {
|
||||
func extractLogger(ctx any) (*logrus.Entry, error) {
|
||||
switch ctx := ctx.(type) {
|
||||
case *logrus.Entry:
|
||||
return ctx, nil
|
||||
|
||||
@@ -23,6 +23,7 @@ var fieldMap = map[string]*mappedField{
|
||||
"releasedate": {field: "media_file.release_date"},
|
||||
"size": {field: "media_file.size"},
|
||||
"compilation": {field: "media_file.compilation"},
|
||||
"explicitstatus": {field: "media_file.explicit_status"},
|
||||
"dateadded": {field: "media_file.created_at"},
|
||||
"datemodified": {field: "media_file.updated_at"},
|
||||
"discsubtitle": {field: "media_file.disc_subtitle"},
|
||||
|
||||
@@ -41,7 +41,7 @@ type DataStore interface {
|
||||
Scrobble(ctx context.Context) ScrobbleRepository
|
||||
Plugin(ctx context.Context) PluginRepository
|
||||
|
||||
Resource(ctx context.Context, model interface{}) ResourceRepository
|
||||
Resource(ctx context.Context, model any) ResourceRepository
|
||||
|
||||
WithTx(block func(tx DataStore) error, scope ...string) error
|
||||
WithTxImmediate(block func(tx DataStore) error, scope ...string) error
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
)
|
||||
|
||||
// TODO: Should the type be encoded in the ID?
|
||||
func GetEntityByID(ctx context.Context, ds DataStore, id string) (interface{}, error) {
|
||||
func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) {
|
||||
ar, err := ds.Artist(ctx).Get(id)
|
||||
if err == nil {
|
||||
return ar, nil
|
||||
|
||||
@@ -140,7 +140,7 @@ func (mf MediaFile) Hash() string {
|
||||
}
|
||||
hash, _ := hashstructure.Hash(mf, opts)
|
||||
sum := md5.New()
|
||||
sum.Write([]byte(fmt.Sprintf("%d", hash)))
|
||||
sum.Write(fmt.Appendf(nil, "%d", hash))
|
||||
sum.Write(mf.Tags.Hash())
|
||||
sum.Write(mf.Participants.Hash())
|
||||
return fmt.Sprintf("%x", sum.Sum(nil))
|
||||
|
||||
@@ -268,8 +268,8 @@ func parseID3Pairs(name model.TagName, lowered model.Tags) []string {
|
||||
prefix := string(name) + ":"
|
||||
for tagKey, tagValues := range lowered {
|
||||
keyStr := string(tagKey)
|
||||
if strings.HasPrefix(keyStr, prefix) {
|
||||
keyPart := strings.TrimPrefix(keyStr, prefix)
|
||||
if after, ok := strings.CutPrefix(keyStr, prefix); ok {
|
||||
keyPart := after
|
||||
if keyPart == string(name) {
|
||||
keyPart = ""
|
||||
}
|
||||
|
||||
@@ -49,8 +49,8 @@ func createGetPID(hash hashFunc) getPIDFunc {
|
||||
}
|
||||
getPID = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
|
||||
pid := ""
|
||||
fields := strings.Split(spec, "|")
|
||||
for _, field := range fields {
|
||||
fields := strings.SplitSeq(spec, "|")
|
||||
for field := range fields {
|
||||
attributes := strings.Split(field, ",")
|
||||
hasValue := false
|
||||
values := slice.Map(attributes, func(attr string) string {
|
||||
|
||||
@@ -51,13 +51,13 @@ func ParseTargets(libFolders []string) ([]ScanTarget, error) {
|
||||
}
|
||||
|
||||
// Split by the first colon
|
||||
colonIdx := strings.Index(part, ":")
|
||||
if colonIdx == -1 {
|
||||
before, after, ok := strings.Cut(part, ":")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid target format: %q (expected libraryID:folderPath)", part)
|
||||
}
|
||||
|
||||
libIDStr := part[:colonIdx]
|
||||
folderPath := part[colonIdx+1:]
|
||||
libIDStr := before
|
||||
folderPath := after
|
||||
|
||||
libID, err := strconv.Atoi(libIDStr)
|
||||
if err != nil {
|
||||
|
||||
@@ -22,8 +22,8 @@ type Share struct {
|
||||
Format string `structs:"format" json:"format,omitempty"`
|
||||
MaxBitRate int `structs:"max_bit_rate" json:"maxBitRate,omitempty"`
|
||||
VisitCount int `structs:"visit_count" json:"visitCount,omitempty"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt,omitempty"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt,omitempty"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
Tracks MediaFiles `structs:"-" json:"tracks,omitempty"`
|
||||
Albums Albums `structs:"-" json:"albums,omitempty"`
|
||||
URL string `structs:"-" json:"-"`
|
||||
|
||||
@@ -144,10 +144,8 @@ func (t Tags) Merge(tags Tags) {
|
||||
}
|
||||
|
||||
func (t Tags) Add(name TagName, v string) {
|
||||
for _, existing := range t[name] {
|
||||
if existing == v {
|
||||
return
|
||||
}
|
||||
if slices.Contains(t[name], v) {
|
||||
return
|
||||
}
|
||||
t[name] = append(t[name], v)
|
||||
}
|
||||
|
||||
@@ -145,11 +145,11 @@ func recentlyAddedSort() string {
|
||||
return "created_at"
|
||||
}
|
||||
|
||||
func recentlyPlayedFilter(string, interface{}) Sqlizer {
|
||||
func recentlyPlayedFilter(string, any) Sqlizer {
|
||||
return Gt{"play_count": 0}
|
||||
}
|
||||
|
||||
func yearFilter(_ string, value interface{}) Sqlizer {
|
||||
func yearFilter(_ string, value any) Sqlizer {
|
||||
return Or{
|
||||
And{
|
||||
Gt{"min_year": 0},
|
||||
@@ -160,14 +160,14 @@ func yearFilter(_ string, value interface{}) Sqlizer {
|
||||
}
|
||||
}
|
||||
|
||||
func artistFilter(_ string, value interface{}) Sqlizer {
|
||||
func artistFilter(_ string, value any) Sqlizer {
|
||||
return Or{
|
||||
Exists("json_tree(participants, '$.albumartist')", Eq{"value": value}),
|
||||
Exists("json_tree(participants, '$.artist')", Eq{"value": value}),
|
||||
}
|
||||
}
|
||||
|
||||
func artistRoleFilter(name string, value interface{}) Sqlizer {
|
||||
func artistRoleFilter(name string, value any) Sqlizer {
|
||||
roleName := strings.TrimSuffix(strings.TrimPrefix(name, "role_"), "_id")
|
||||
|
||||
// Check if the role name is valid. If not, return an invalid filter
|
||||
@@ -177,7 +177,7 @@ func artistRoleFilter(name string, value interface{}) Sqlizer {
|
||||
return Exists(fmt.Sprintf("json_tree(participants, '$.%s')", roleName), Eq{"value": value})
|
||||
}
|
||||
|
||||
func allRolesFilter(_ string, value interface{}) Sqlizer {
|
||||
func allRolesFilter(_ string, value any) Sqlizer {
|
||||
return Like{"participants": fmt.Sprintf(`%%"%s"%%`, value)}
|
||||
}
|
||||
|
||||
@@ -248,7 +248,7 @@ func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting album to copy fields from: %w", err)
|
||||
}
|
||||
to := make(map[string]interface{})
|
||||
to := make(map[string]any)
|
||||
for _, col := range columns {
|
||||
to[col] = from[col]
|
||||
}
|
||||
@@ -370,11 +370,11 @@ func (r *albumRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *albumRepository) Read(id string) (interface{}, error) {
|
||||
func (r *albumRepository) Read(id string) (any, error) {
|
||||
return r.Get(id)
|
||||
}
|
||||
|
||||
func (r *albumRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
func (r *albumRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
@@ -382,7 +382,7 @@ func (r *albumRepository) EntityName() string {
|
||||
return "album"
|
||||
}
|
||||
|
||||
func (r *albumRepository) NewInstance() interface{} {
|
||||
func (r *albumRepository) NewInstance() any {
|
||||
return &model.Album{}
|
||||
}
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ var _ = Describe("AlbumRepository", func() {
|
||||
|
||||
newID := id.NewRandom()
|
||||
Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed())
|
||||
for i := 0; i < playCount; i++ {
|
||||
for range playCount {
|
||||
Expect(albumRepo.IncPlayCount(newID, time.Now())).To(Succeed())
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ var _ = Describe("AlbumRepository", func() {
|
||||
|
||||
newID := id.NewRandom()
|
||||
Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed())
|
||||
for i := 0; i < playCount; i++ {
|
||||
for range playCount {
|
||||
Expect(albumRepo.IncPlayCount(newID, time.Now())).To(Succeed())
|
||||
}
|
||||
|
||||
@@ -406,7 +406,7 @@ var _ = Describe("AlbumRepository", func() {
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(Equal(expectedSQL))
|
||||
Expect(args).To(Equal([]interface{}{artistID}))
|
||||
Expect(args).To(Equal([]any{artistID}))
|
||||
},
|
||||
Entry("artist role", "role_artist_id", "123",
|
||||
"exists (select 1 from json_tree(participants, '$.artist') where value = ?)"),
|
||||
@@ -428,7 +428,7 @@ var _ = Describe("AlbumRepository", func() {
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(Equal(fmt.Sprintf("exists (select 1 from json_tree(participants, '$.%s') where value = ?)", roleName)))
|
||||
Expect(args).To(Equal([]interface{}{"test-id"}))
|
||||
Expect(args).To(Equal([]any{"test-id"}))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ func roleFilter(_ string, role any) Sqlizer {
|
||||
}
|
||||
|
||||
// artistLibraryIdFilter filters artists based on library access through the library_artist table
|
||||
func artistLibraryIdFilter(_ string, value interface{}) Sqlizer {
|
||||
func artistLibraryIdFilter(_ string, value any) Sqlizer {
|
||||
return Eq{"library_artist.library_id": value}
|
||||
}
|
||||
|
||||
@@ -534,11 +534,11 @@ func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *artistRepository) Read(id string) (interface{}, error) {
|
||||
func (r *artistRepository) Read(id string) (any, error) {
|
||||
return r.Get(id)
|
||||
}
|
||||
|
||||
func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
role := "total"
|
||||
if len(options) > 0 {
|
||||
if v, ok := options[0].Filters["role"].(string); ok {
|
||||
@@ -555,7 +555,7 @@ func (r *artistRepository) EntityName() string {
|
||||
return "artist"
|
||||
}
|
||||
|
||||
func (r *artistRepository) NewInstance() interface{} {
|
||||
func (r *artistRepository) NewInstance() any {
|
||||
return &model.Artist{}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -117,9 +118,7 @@ func (r folderRepository) GetFolderUpdateInfo(lib model.Library, targetPaths ...
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for id, info := range batchResult {
|
||||
result[id] = info
|
||||
}
|
||||
maps.Copy(result, batchResult)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
|
||||
@@ -33,18 +33,18 @@ func (r *genreRepository) GetAll(opt ...model.QueryOptions) (model.Genres, error
|
||||
|
||||
// Override ResourceRepository methods to return Genre objects instead of Tag objects
|
||||
|
||||
func (r *genreRepository) Read(id string) (interface{}, error) {
|
||||
func (r *genreRepository) Read(id string) (any, error) {
|
||||
sel := r.selectGenre().Where(Eq{"tag.id": id})
|
||||
var res model.Genre
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *genreRepository) NewInstance() interface{} {
|
||||
func (r *genreRepository) NewInstance() any {
|
||||
return &model.Genre{}
|
||||
}
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ var _ = Describe("GenreRepository", func() {
|
||||
It("should filter by name using like match", func() {
|
||||
// Test filtering by partial name match using the "name" filter which maps to containsFilter("tag_value")
|
||||
options := rest.QueryOptions{
|
||||
Filters: map[string]interface{}{"name": "%rock%"},
|
||||
Filters: map[string]any{"name": "%rock%"},
|
||||
}
|
||||
count, err := restRepo.Count(options)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@@ -289,7 +289,7 @@ var _ = Describe("GenreRepository", func() {
|
||||
It("should allow headless processes to apply explicit library_id filters", func() {
|
||||
// Filter by specific library
|
||||
genres, err := headlessRestRepo.ReadAll(rest.QueryOptions{
|
||||
Filters: map[string]interface{}{"library_id": 2},
|
||||
Filters: map[string]any{"library_id": 2},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ type PostMapper interface {
|
||||
PostMapArgs(map[string]any) error
|
||||
}
|
||||
|
||||
func toSQLArgs(rec interface{}) (map[string]interface{}, error) {
|
||||
func toSQLArgs(rec any) (map[string]any, error) {
|
||||
m := structs.Map(rec)
|
||||
for k, v := range m {
|
||||
switch t := v.(type) {
|
||||
@@ -71,7 +71,7 @@ type existsCond struct {
|
||||
not bool
|
||||
}
|
||||
|
||||
func (e existsCond) ToSql() (string, []interface{}, error) {
|
||||
func (e existsCond) ToSql() (string, []any, error) {
|
||||
sql, args, err := e.cond.ToSql()
|
||||
sql = fmt.Sprintf("exists (select 1 from %s where %s)", e.subTable, sql)
|
||||
if e.not {
|
||||
|
||||
@@ -305,7 +305,7 @@ func (r *libraryRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *libraryRepository) Read(id string) (interface{}, error) {
|
||||
func (r *libraryRepository) Read(id string) (any, error) {
|
||||
idInt, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
log.Trace(r.ctx, "invalid library id: %s", id, err)
|
||||
@@ -314,7 +314,7 @@ func (r *libraryRepository) Read(id string) (interface{}, error) {
|
||||
return r.Get(idInt)
|
||||
}
|
||||
|
||||
func (r *libraryRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
func (r *libraryRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
@@ -322,11 +322,11 @@ func (r *libraryRepository) EntityName() string {
|
||||
return "library"
|
||||
}
|
||||
|
||||
func (r *libraryRepository) NewInstance() interface{} {
|
||||
func (r *libraryRepository) NewInstance() any {
|
||||
return &model.Library{}
|
||||
}
|
||||
|
||||
func (r *libraryRepository) Save(entity interface{}) (string, error) {
|
||||
func (r *libraryRepository) Save(entity any) (string, error) {
|
||||
lib := entity.(*model.Library)
|
||||
lib.ID = 0 // Reset ID to ensure we create a new library
|
||||
err := r.Put(lib)
|
||||
@@ -336,7 +336,7 @@ func (r *libraryRepository) Save(entity interface{}) (string, error) {
|
||||
return strconv.Itoa(lib.ID), nil
|
||||
}
|
||||
|
||||
func (r *libraryRepository) Update(id string, entity interface{}, cols ...string) error {
|
||||
func (r *libraryRepository) Update(id string, entity any, cols ...string) error {
|
||||
lib := entity.(*model.Library)
|
||||
idInt, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
|
||||
@@ -443,11 +443,11 @@ func (r *mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error)
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Read(id string) (interface{}, error) {
|
||||
func (r *mediaFileRepository) Read(id string) (any, error) {
|
||||
return r.Get(id)
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
func (r *mediaFileRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
@@ -455,7 +455,7 @@ func (r *mediaFileRepository) EntityName() string {
|
||||
return "mediafile"
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) NewInstance() interface{} {
|
||||
func (r *mediaFileRepository) NewInstance() any {
|
||||
return &model.MediaFile{}
|
||||
}
|
||||
|
||||
|
||||
@@ -310,7 +310,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
|
||||
// Update "Old Song": created long ago, updated recently
|
||||
_, err := db.Update("media_file",
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"created_at": oldTime,
|
||||
"updated_at": newTime,
|
||||
},
|
||||
@@ -319,7 +319,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
|
||||
// Update "Middle Song": created and updated at the same middle time
|
||||
_, err = db.Update("media_file",
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"created_at": middleTime,
|
||||
"updated_at": middleTime,
|
||||
},
|
||||
@@ -328,7 +328,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
|
||||
// Update "New Song": created recently, updated long ago
|
||||
_, err = db.Update("media_file",
|
||||
map[string]interface{}{
|
||||
map[string]any{
|
||||
"created_at": newTime,
|
||||
"updated_at": oldTime,
|
||||
},
|
||||
|
||||
@@ -97,7 +97,7 @@ func (s *SQLStore) Plugin(ctx context.Context) model.PluginRepository {
|
||||
return NewPluginRepository(ctx, s.getDBXBuilder())
|
||||
}
|
||||
|
||||
func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
|
||||
func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository {
|
||||
switch m.(type) {
|
||||
case model.User:
|
||||
return s.User(ctx).(model.ResourceRepository)
|
||||
|
||||
@@ -103,14 +103,14 @@ func (r *playerRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *playerRepository) Read(id string) (interface{}, error) {
|
||||
func (r *playerRepository) Read(id string) (any, error) {
|
||||
sel := r.newRestSelect().Where(Eq{"player.id": id})
|
||||
var res model.Player
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *playerRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
func (r *playerRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
sel := r.newRestSelect(r.parseRestOptions(r.ctx, options...))
|
||||
res := model.Players{}
|
||||
err := r.queryAll(sel, &res)
|
||||
@@ -121,7 +121,7 @@ func (r *playerRepository) EntityName() string {
|
||||
return "player"
|
||||
}
|
||||
|
||||
func (r *playerRepository) NewInstance() interface{} {
|
||||
func (r *playerRepository) NewInstance() any {
|
||||
return &model.Player{}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ func (r *playerRepository) isPermitted(p *model.Player) bool {
|
||||
return u.IsAdmin || p.UserId == u.ID
|
||||
}
|
||||
|
||||
func (r *playerRepository) Save(entity interface{}) (string, error) {
|
||||
func (r *playerRepository) Save(entity any) (string, error) {
|
||||
t := entity.(*model.Player)
|
||||
if !r.isPermitted(t) {
|
||||
return "", rest.ErrPermissionDenied
|
||||
@@ -142,7 +142,7 @@ func (r *playerRepository) Save(entity interface{}) (string, error) {
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (r *playerRepository) Update(id string, entity interface{}, cols ...string) error {
|
||||
func (r *playerRepository) Update(id string, entity any, cols ...string) error {
|
||||
t := entity.(*model.Player)
|
||||
t.ID = id
|
||||
if !r.isPermitted(t) {
|
||||
|
||||
@@ -61,14 +61,14 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe
|
||||
return r
|
||||
}
|
||||
|
||||
func playlistFilter(_ string, value interface{}) Sqlizer {
|
||||
func playlistFilter(_ string, value any) Sqlizer {
|
||||
return Or{
|
||||
substringFilter("playlist.name", value),
|
||||
substringFilter("playlist.comment", value),
|
||||
}
|
||||
}
|
||||
|
||||
func smartPlaylistFilter(string, interface{}) Sqlizer {
|
||||
func smartPlaylistFilter(string, any) Sqlizer {
|
||||
return Or{
|
||||
Eq{"rules": ""},
|
||||
Eq{"rules": nil},
|
||||
@@ -421,11 +421,11 @@ func (r *playlistRepository) Count(options ...rest.QueryOptions) (int64, error)
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Read(id string) (interface{}, error) {
|
||||
func (r *playlistRepository) Read(id string) (any, error) {
|
||||
return r.Get(id)
|
||||
}
|
||||
|
||||
func (r *playlistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
func (r *playlistRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
@@ -433,11 +433,11 @@ func (r *playlistRepository) EntityName() string {
|
||||
return "playlist"
|
||||
}
|
||||
|
||||
func (r *playlistRepository) NewInstance() interface{} {
|
||||
func (r *playlistRepository) NewInstance() any {
|
||||
return &model.Playlist{}
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Save(entity interface{}) (string, error) {
|
||||
func (r *playlistRepository) Save(entity any) (string, error) {
|
||||
pls := entity.(*model.Playlist)
|
||||
pls.OwnerID = loggedUser(r.ctx).ID
|
||||
pls.ID = "" // Make sure we don't override an existing playlist
|
||||
@@ -448,7 +448,7 @@ func (r *playlistRepository) Save(entity interface{}) (string, error) {
|
||||
return pls.ID, err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Update(id string, entity interface{}, cols ...string) error {
|
||||
func (r *playlistRepository) Update(id string, entity any, cols ...string) error {
|
||||
pls := dbPlaylist{Playlist: *entity.(*model.Playlist)}
|
||||
current, err := r.Get(id)
|
||||
if err != nil {
|
||||
|
||||
@@ -84,7 +84,7 @@ func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, er
|
||||
return r.count(query, r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) Read(id string) (interface{}, error) {
|
||||
func (r *playlistTrackRepository) Read(id string) (any, error) {
|
||||
userID := loggedUser(r.ctx).ID
|
||||
sel := r.newSelect().
|
||||
LeftJoin("annotation on ("+
|
||||
@@ -128,7 +128,7 @@ func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([]
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
func (r *playlistTrackRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ func (r *playlistTrackRepository) EntityName() string {
|
||||
return "playlist_tracks"
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) NewInstance() interface{} {
|
||||
func (r *playlistTrackRepository) NewInstance() any {
|
||||
return &model.PlaylistTrack{}
|
||||
}
|
||||
|
||||
|
||||
@@ -122,8 +122,8 @@ func (r *playQueueRepository) toModel(pq *playQueue) model.PlayQueue {
|
||||
UpdatedAt: pq.UpdatedAt,
|
||||
}
|
||||
if strings.TrimSpace(pq.Items) != "" {
|
||||
tracks := strings.Split(pq.Items, ",")
|
||||
for _, t := range tracks {
|
||||
tracks := strings.SplitSeq(pq.Items, ",")
|
||||
for t := range tracks {
|
||||
q.Items = append(q.Items, model.MediaFile{ID: t})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func (r *radioRepository) Put(radio *model.Radio) error {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
|
||||
var values map[string]interface{}
|
||||
var values map[string]any
|
||||
|
||||
radio.UpdatedAt = time.Now()
|
||||
|
||||
@@ -97,19 +97,19 @@ func (r *radioRepository) EntityName() string {
|
||||
return "radio"
|
||||
}
|
||||
|
||||
func (r *radioRepository) NewInstance() interface{} {
|
||||
func (r *radioRepository) NewInstance() any {
|
||||
return &model.Radio{}
|
||||
}
|
||||
|
||||
func (r *radioRepository) Read(id string) (interface{}, error) {
|
||||
func (r *radioRepository) Read(id string) (any, error) {
|
||||
return r.Get(id)
|
||||
}
|
||||
|
||||
func (r *radioRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
func (r *radioRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *radioRepository) Save(entity interface{}) (string, error) {
|
||||
func (r *radioRepository) Save(entity any) (string, error) {
|
||||
t := entity.(*model.Radio)
|
||||
if !r.isPermitted() {
|
||||
return "", rest.ErrPermissionDenied
|
||||
@@ -121,7 +121,7 @@ func (r *radioRepository) Save(entity interface{}) (string, error) {
|
||||
return t.ID, err
|
||||
}
|
||||
|
||||
func (r *radioRepository) Update(id string, entity interface{}, cols ...string) error {
|
||||
func (r *radioRepository) Update(id string, entity any, cols ...string) error {
|
||||
t := entity.(*model.Radio)
|
||||
t.ID = id
|
||||
if !r.isPermitted() {
|
||||
|
||||
@@ -51,7 +51,7 @@ func (r *scrobbleBufferRepository) UserIDs(service string) ([]string, error) {
|
||||
}
|
||||
|
||||
func (r *scrobbleBufferRepository) Enqueue(service, userId, mediaFileId string, playTime time.Time) error {
|
||||
ins := Insert(r.tableName).SetMap(map[string]interface{}{
|
||||
ins := Insert(r.tableName).SetMap(map[string]any{
|
||||
"id": id.NewRandom(),
|
||||
"user_id": userId,
|
||||
"service": service,
|
||||
|
||||
@@ -24,7 +24,7 @@ var _ = Describe("ScrobbleBufferRepository", func() {
|
||||
id := id.NewRandom()
|
||||
ids = append(ids, id)
|
||||
|
||||
ins := squirrel.Insert("scrobble_buffer").SetMap(map[string]interface{}{
|
||||
ins := squirrel.Insert("scrobble_buffer").SetMap(map[string]any{
|
||||
"id": id,
|
||||
"user_id": userId,
|
||||
"service": service,
|
||||
|
||||
@@ -23,7 +23,7 @@ func NewScrobbleRepository(ctx context.Context, db dbx.Builder) model.ScrobbleRe
|
||||
|
||||
func (r *scrobbleRepository) RecordScrobble(mediaFileID string, submissionTime time.Time) error {
|
||||
userID := loggedUser(r.ctx).ID
|
||||
values := map[string]interface{}{
|
||||
values := map[string]any{
|
||||
"media_file_id": mediaFileID,
|
||||
"user_id": userID,
|
||||
"submission_time": submissionTime.Unix(),
|
||||
|
||||
@@ -138,7 +138,7 @@ func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles {
|
||||
return sorted
|
||||
}
|
||||
|
||||
func (r *shareRepository) Update(id string, entity interface{}, cols ...string) error {
|
||||
func (r *shareRepository) Update(id string, entity any, cols ...string) error {
|
||||
s := entity.(*model.Share)
|
||||
// TODO Validate record
|
||||
s.ID = id
|
||||
@@ -151,7 +151,7 @@ func (r *shareRepository) Update(id string, entity interface{}, cols ...string)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *shareRepository) Save(entity interface{}) (string, error) {
|
||||
func (r *shareRepository) Save(entity any) (string, error) {
|
||||
s := entity.(*model.Share)
|
||||
// TODO Validate record
|
||||
u := loggedUser(r.ctx)
|
||||
@@ -179,18 +179,18 @@ func (r *shareRepository) EntityName() string {
|
||||
return "share"
|
||||
}
|
||||
|
||||
func (r *shareRepository) NewInstance() interface{} {
|
||||
func (r *shareRepository) NewInstance() any {
|
||||
return &model.Share{}
|
||||
}
|
||||
|
||||
func (r *shareRepository) Read(id string) (interface{}, error) {
|
||||
func (r *shareRepository) Read(id string) (any, error) {
|
||||
sel := r.selectShare().Where(Eq{"share.id": id})
|
||||
var res model.Share
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *shareRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
func (r *shareRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
sq := r.selectShare(r.parseRestOptions(r.ctx, options...))
|
||||
res := model.Shares{}
|
||||
err := r.queryAll(sq, &res)
|
||||
|
||||
@@ -47,7 +47,7 @@ var _ = Describe("ShareRepository", func() {
|
||||
_, err := GetDBXBuilder().NewQuery(`
|
||||
INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at)
|
||||
VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated})
|
||||
`).Bind(map[string]interface{}{
|
||||
`).Bind(map[string]any{
|
||||
"id": shareID,
|
||||
"user": adminUser.ID,
|
||||
"desc": "Headless Test Share",
|
||||
@@ -79,7 +79,7 @@ var _ = Describe("ShareRepository", func() {
|
||||
_, err := GetDBXBuilder().NewQuery(`
|
||||
INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at)
|
||||
VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated})
|
||||
`).Bind(map[string]interface{}{
|
||||
`).Bind(map[string]any{
|
||||
"id": shareID,
|
||||
"user": adminUser.ID,
|
||||
"desc": "Headless Get Share",
|
||||
@@ -110,7 +110,7 @@ var _ = Describe("ShareRepository", func() {
|
||||
_, err := GetDBXBuilder().NewQuery(`
|
||||
INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at)
|
||||
VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated})
|
||||
`).Bind(map[string]interface{}{
|
||||
`).Bind(map[string]any{
|
||||
"id": shareID,
|
||||
"user": adminUser.ID,
|
||||
"desc": "SQL Test Share",
|
||||
|
||||
@@ -66,7 +66,7 @@ func (r sqlRepository) annId(itemID ...string) And {
|
||||
}
|
||||
}
|
||||
|
||||
func (r sqlRepository) annUpsert(values map[string]interface{}, itemIDs ...string) error {
|
||||
func (r sqlRepository) annUpsert(values map[string]any, itemIDs ...string) error {
|
||||
upd := Update(annotationTable).Where(r.annId(itemIDs...))
|
||||
for f, v := range values {
|
||||
upd = upd.Set(f, v)
|
||||
@@ -90,12 +90,12 @@ func (r sqlRepository) annUpsert(values map[string]interface{}, itemIDs ...strin
|
||||
|
||||
func (r sqlRepository) SetStar(starred bool, ids ...string) error {
|
||||
starredAt := time.Now()
|
||||
return r.annUpsert(map[string]interface{}{"starred": starred, "starred_at": starredAt}, ids...)
|
||||
return r.annUpsert(map[string]any{"starred": starred, "starred_at": starredAt}, ids...)
|
||||
}
|
||||
|
||||
func (r sqlRepository) SetRating(rating int, itemID string) error {
|
||||
ratedAt := time.Now()
|
||||
err := r.annUpsert(map[string]interface{}{"rating": rating, "rated_at": ratedAt}, itemID)
|
||||
err := r.annUpsert(map[string]any{"rating": rating, "rated_at": ratedAt}, itemID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -121,7 +121,7 @@ func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
|
||||
|
||||
if c == 0 || errors.Is(err, sql.ErrNoRows) {
|
||||
userID := loggedUser(r.ctx).ID
|
||||
values := map[string]interface{}{}
|
||||
values := map[string]any{}
|
||||
values["user_id"] = userID
|
||||
values["item_type"] = r.tableName
|
||||
values["item_id"] = itemID
|
||||
|
||||
@@ -32,17 +32,17 @@ var _ = Describe("Annotation Filters", func() {
|
||||
|
||||
Describe("annotationBoolFilter", func() {
|
||||
DescribeTable("creates correct SQL expressions",
|
||||
func(field, value string, expectedSQL string, expectedArgs []interface{}) {
|
||||
func(field, value string, expectedSQL string, expectedArgs []any) {
|
||||
sqlizer := annotationBoolFilter(field)(field, value)
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(Equal(expectedSQL))
|
||||
Expect(args).To(Equal(expectedArgs))
|
||||
},
|
||||
Entry("starred=true", "starred", "true", "COALESCE(starred, 0) > 0", []interface{}(nil)),
|
||||
Entry("starred=false", "starred", "false", "COALESCE(starred, 0) = 0", []interface{}(nil)),
|
||||
Entry("starred=True (case insensitive)", "starred", "True", "COALESCE(starred, 0) > 0", []interface{}(nil)),
|
||||
Entry("rating=true", "rating", "true", "COALESCE(rating, 0) > 0", []interface{}(nil)),
|
||||
Entry("starred=true", "starred", "true", "COALESCE(starred, 0) > 0", []any(nil)),
|
||||
Entry("starred=false", "starred", "false", "COALESCE(starred, 0) = 0", []any(nil)),
|
||||
Entry("starred=True (case insensitive)", "starred", "True", "COALESCE(starred, 0) > 0", []any(nil)),
|
||||
Entry("rating=true", "rating", "true", "COALESCE(rating, 0) > 0", []any(nil)),
|
||||
)
|
||||
|
||||
It("returns nil if value is not a string", func() {
|
||||
|
||||
@@ -196,7 +196,7 @@ func (r *sqlRepository) withTableName(filter filterFunc) filterFunc {
|
||||
}
|
||||
|
||||
// libraryIdFilter is a filter function to be added to resources that have a library_id column.
|
||||
func libraryIdFilter(_ string, value interface{}) Sqlizer {
|
||||
func libraryIdFilter(_ string, value any) Sqlizer {
|
||||
return Eq{"library_id": value}
|
||||
}
|
||||
|
||||
@@ -281,7 +281,7 @@ func (r sqlRepository) toSQL(sq Sqlizer) (string, dbx.Params, error) {
|
||||
return result, params, nil
|
||||
}
|
||||
|
||||
func (r sqlRepository) queryOne(sq Sqlizer, response interface{}) error {
|
||||
func (r sqlRepository) queryOne(sq Sqlizer, response any) error {
|
||||
query, args, err := r.toSQL(sq)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -328,7 +328,7 @@ func queryWithStableResults[T any](r sqlRepository, sq SelectBuilder, options ..
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r sqlRepository) queryAll(sq SelectBuilder, response interface{}, options ...model.QueryOptions) error {
|
||||
func (r sqlRepository) queryAll(sq SelectBuilder, response any, options ...model.QueryOptions) error {
|
||||
if len(options) > 0 && options[0].Offset > 0 {
|
||||
sq = r.optimizePagination(sq, options[0])
|
||||
}
|
||||
@@ -347,7 +347,7 @@ func (r sqlRepository) queryAll(sq SelectBuilder, response interface{}, options
|
||||
}
|
||||
|
||||
// queryAllSlice is a helper function to query a single column and return the result in a slice
|
||||
func (r sqlRepository) queryAllSlice(sq SelectBuilder, response interface{}) error {
|
||||
func (r sqlRepository) queryAllSlice(sq SelectBuilder, response any) error {
|
||||
query, args, err := r.toSQL(sq)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -394,7 +394,7 @@ func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOpt
|
||||
return res.Count, err
|
||||
}
|
||||
|
||||
func (r sqlRepository) putByMatch(filter Sqlizer, id string, m interface{}, colsToUpdate ...string) (string, error) {
|
||||
func (r sqlRepository) putByMatch(filter Sqlizer, id string, m any, colsToUpdate ...string) (string, error) {
|
||||
if id != "" {
|
||||
return r.put(id, m, colsToUpdate...)
|
||||
}
|
||||
@@ -408,14 +408,14 @@ func (r sqlRepository) putByMatch(filter Sqlizer, id string, m interface{}, cols
|
||||
return r.put(res.ID, m, colsToUpdate...)
|
||||
}
|
||||
|
||||
func (r sqlRepository) put(id string, m interface{}, colsToUpdate ...string) (newId string, err error) {
|
||||
func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId string, err error) {
|
||||
values, err := toSQLArgs(m)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error preparing values to write to DB: %w", err)
|
||||
}
|
||||
// If there's an ID, try to update first
|
||||
if id != "" {
|
||||
updateValues := map[string]interface{}{}
|
||||
updateValues := map[string]any{}
|
||||
|
||||
// This is a map of the columns that need to be updated, if specified
|
||||
c2upd := slice.ToMap(colsToUpdate, func(s string) (string, struct{}) {
|
||||
|
||||
@@ -37,7 +37,7 @@ func (r sqlRepository) bmkID(itemID ...string) And {
|
||||
func (r sqlRepository) bmkUpsert(itemID, comment string, position int64) error {
|
||||
client, _ := request.ClientFrom(r.ctx)
|
||||
user, _ := request.UserFrom(r.ctx)
|
||||
values := map[string]interface{}{
|
||||
values := map[string]any{
|
||||
"comment": comment,
|
||||
"position": position,
|
||||
"updated_at": time.Now(),
|
||||
|
||||
@@ -30,7 +30,7 @@ var _ = Describe("sqlRestful", func() {
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"name": fullTextFilter("table"),
|
||||
}
|
||||
options.Filters = map[string]interface{}{"name": "'"}
|
||||
options.Filters = map[string]any{"name": "'"}
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(BeEmpty())
|
||||
})
|
||||
|
||||
@@ -40,32 +40,32 @@ var _ = Describe("sqlRestful", func() {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
options.Filters = map[string]interface{}{"name": "joe"}
|
||||
options.Filters = map[string]any{"name": "joe"}
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns a '=' condition for 'id' filter", func() {
|
||||
options.Filters = map[string]interface{}{"id": "123"}
|
||||
options.Filters = map[string]any{"id": "123"}
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": "123"}}))
|
||||
})
|
||||
|
||||
It("returns a 'in' condition for multiples 'id' filters", func() {
|
||||
options.Filters = map[string]interface{}{"id": []string{"123", "456"}}
|
||||
options.Filters = map[string]any{"id": []string{"123", "456"}}
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": []string{"123", "456"}}}))
|
||||
})
|
||||
|
||||
It("returns a 'like' condition for other filters", func() {
|
||||
options.Filters = map[string]interface{}{"name": "joe"}
|
||||
options.Filters = map[string]any{"name": "joe"}
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Like{"name": "joe%"}}))
|
||||
})
|
||||
|
||||
It("uses the custom filter", func() {
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"test": func(field string, value interface{}) squirrel.Sqlizer {
|
||||
"test": func(field string, value any) squirrel.Sqlizer {
|
||||
return squirrel.Gt{field: value}
|
||||
},
|
||||
}
|
||||
options.Filters = map[string]interface{}{"test": 100}
|
||||
options.Filters = map[string]any{"test": 100}
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Gt{"test": 100}}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -60,7 +60,7 @@ func tagIDFilter(name string, idValue any) Sqlizer {
|
||||
}
|
||||
|
||||
// tagLibraryIdFilter filters tags based on library access through the library_tag table
|
||||
func tagLibraryIdFilter(_ string, value interface{}) Sqlizer {
|
||||
func tagLibraryIdFilter(_ string, value any) Sqlizer {
|
||||
return Eq{"library_tag.library_id": value}
|
||||
}
|
||||
|
||||
@@ -142,14 +142,14 @@ func (r *baseTagRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.count(sq, r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *baseTagRepository) Read(id string) (interface{}, error) {
|
||||
func (r *baseTagRepository) Read(id string) (any, error) {
|
||||
query := r.newSelect().Where(Eq{"id": id})
|
||||
var res model.Tag
|
||||
err := r.queryOne(query, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *baseTagRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
func (r *baseTagRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
query := r.newSelect(r.parseRestOptions(r.ctx, options...))
|
||||
var res model.TagList
|
||||
err := r.queryAll(query, &res)
|
||||
@@ -160,7 +160,7 @@ func (r *baseTagRepository) EntityName() string {
|
||||
return "tag"
|
||||
}
|
||||
|
||||
func (r *baseTagRepository) NewInstance() interface{} {
|
||||
func (r *baseTagRepository) NewInstance() any {
|
||||
return model.Tag{}
|
||||
}
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ var _ = Describe("Tag Library Filtering", func() {
|
||||
|
||||
It("should respect explicit library_id filters within accessible libraries", func() {
|
||||
tags := readAllTags(®ularUser, rest.QueryOptions{
|
||||
Filters: map[string]interface{}{"library_id": libraryID2},
|
||||
Filters: map[string]any{"library_id": libraryID2},
|
||||
})
|
||||
// Should see only tags from library 2: pop and rock(lib2)
|
||||
Expect(tags).To(HaveLen(2))
|
||||
@@ -174,7 +174,7 @@ var _ = Describe("Tag Library Filtering", func() {
|
||||
|
||||
It("should not return tags when filtering by inaccessible library", func() {
|
||||
tags := readAllTags(®ularUser, rest.QueryOptions{
|
||||
Filters: map[string]interface{}{"library_id": libraryID3},
|
||||
Filters: map[string]any{"library_id": libraryID3},
|
||||
})
|
||||
// Should return no tags since user can't access library 3
|
||||
Expect(tags).To(HaveLen(0))
|
||||
@@ -182,7 +182,7 @@ var _ = Describe("Tag Library Filtering", func() {
|
||||
|
||||
It("should filter by library 1 correctly", func() {
|
||||
tags := readAllTags(®ularUser, rest.QueryOptions{
|
||||
Filters: map[string]interface{}{"library_id": libraryID1},
|
||||
Filters: map[string]any{"library_id": libraryID1},
|
||||
})
|
||||
// Should see only rock from library 1
|
||||
Expect(tags).To(HaveLen(1))
|
||||
@@ -227,7 +227,7 @@ var _ = Describe("Tag Library Filtering", func() {
|
||||
|
||||
It("should allow headless processes to apply explicit library_id filters", func() {
|
||||
tags := readAllTags(nil, rest.QueryOptions{
|
||||
Filters: map[string]interface{}{"library_id": libraryID3},
|
||||
Filters: map[string]any{"library_id": libraryID3},
|
||||
})
|
||||
// Should see only jazz from library 3
|
||||
Expect(tags).To(HaveLen(1))
|
||||
@@ -243,7 +243,7 @@ var _ = Describe("Tag Library Filtering", func() {
|
||||
|
||||
It("should respect explicit library_id filters", func() {
|
||||
tags := readAllTags(&adminUser, rest.QueryOptions{
|
||||
Filters: map[string]interface{}{"library_id": libraryID3},
|
||||
Filters: map[string]any{"library_id": libraryID3},
|
||||
})
|
||||
// Should see only jazz from library 3
|
||||
Expect(tags).To(HaveLen(1))
|
||||
@@ -252,7 +252,7 @@ var _ = Describe("Tag Library Filtering", func() {
|
||||
|
||||
It("should filter by library 2 correctly", func() {
|
||||
tags := readAllTags(&adminUser, rest.QueryOptions{
|
||||
Filters: map[string]interface{}{"library_id": libraryID2},
|
||||
Filters: map[string]any{"library_id": libraryID2},
|
||||
})
|
||||
// Should see pop and rock from library 2
|
||||
Expect(tags).To(HaveLen(2))
|
||||
|
||||
@@ -234,7 +234,7 @@ var _ = Describe("TagRepository", func() {
|
||||
|
||||
It("should filter tags by partial value correctly", func() {
|
||||
options := rest.QueryOptions{
|
||||
Filters: map[string]interface{}{"name": "%rock%"}, // Tags containing 'rock'
|
||||
Filters: map[string]any{"name": "%rock%"}, // Tags containing 'rock'
|
||||
}
|
||||
result, err := restRepo.ReadAll(options)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@@ -249,7 +249,7 @@ var _ = Describe("TagRepository", func() {
|
||||
|
||||
It("should filter tags by partial value using LIKE", func() {
|
||||
options := rest.QueryOptions{
|
||||
Filters: map[string]interface{}{"name": "%e%"}, // Tags containing 'e'
|
||||
Filters: map[string]any{"name": "%e%"}, // Tags containing 'e'
|
||||
}
|
||||
result, err := restRepo.ReadAll(options)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@@ -264,7 +264,7 @@ var _ = Describe("TagRepository", func() {
|
||||
|
||||
It("should sort tags by value ascending", func() {
|
||||
options := rest.QueryOptions{
|
||||
Filters: map[string]interface{}{"name": "%r%"}, // Tags containing 'r'
|
||||
Filters: map[string]any{"name": "%r%"}, // Tags containing 'r'
|
||||
Sort: "name",
|
||||
Order: "asc",
|
||||
}
|
||||
@@ -280,7 +280,7 @@ var _ = Describe("TagRepository", func() {
|
||||
|
||||
It("should sort tags by value descending", func() {
|
||||
options := rest.QueryOptions{
|
||||
Filters: map[string]interface{}{"name": "%r%"}, // Tags containing 'r'
|
||||
Filters: map[string]any{"name": "%r%"}, // Tags containing 'r'
|
||||
Sort: "name",
|
||||
Order: "desc",
|
||||
}
|
||||
|
||||
@@ -52,11 +52,11 @@ func (r *transcodingRepository) Count(options ...rest.QueryOptions) (int64, erro
|
||||
return r.count(Select(), r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Read(id string) (interface{}, error) {
|
||||
func (r *transcodingRepository) Read(id string) (any, error) {
|
||||
return r.Get(id)
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
sel := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*")
|
||||
res := model.Transcodings{}
|
||||
err := r.queryAll(sel, &res)
|
||||
@@ -67,11 +67,11 @@ func (r *transcodingRepository) EntityName() string {
|
||||
return "transcoding"
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) NewInstance() interface{} {
|
||||
func (r *transcodingRepository) NewInstance() any {
|
||||
return &model.Transcoding{}
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Save(entity interface{}) (string, error) {
|
||||
func (r *transcodingRepository) Save(entity any) (string, error) {
|
||||
if !loggedUser(r.ctx).IsAdmin {
|
||||
return "", rest.ErrPermissionDenied
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func (r *transcodingRepository) Save(entity interface{}) (string, error) {
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Update(id string, entity interface{}, cols ...string) error {
|
||||
func (r *transcodingRepository) Update(id string, entity any, cols ...string) error {
|
||||
if !loggedUser(r.ctx).IsAdmin {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package plugins
|
||||
|
||||
import "slices"
|
||||
|
||||
// Capability represents a plugin capability type.
|
||||
// Capabilities are detected by checking which functions a plugin exports.
|
||||
type Capability string
|
||||
@@ -25,11 +27,8 @@ func detectCapabilities(plugin functionExistsChecker) []Capability {
|
||||
var capabilities []Capability
|
||||
|
||||
for cap, functions := range capabilityFunctions {
|
||||
for _, fn := range functions {
|
||||
if plugin.FunctionExists(fn) {
|
||||
capabilities = append(capabilities, cap)
|
||||
break // Found at least one function, plugin has this capability
|
||||
}
|
||||
if slices.ContainsFunc(functions, plugin.FunctionExists) {
|
||||
capabilities = append(capabilities, cap) // Found at least one function, plugin has this capability
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,10 +37,5 @@ func detectCapabilities(plugin functionExistsChecker) []Capability {
|
||||
|
||||
// hasCapability checks if the given capabilities slice contains a specific capability.
|
||||
func hasCapability(capabilities []Capability, cap Capability) bool {
|
||||
for _, c := range capabilities {
|
||||
if c == cap {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return slices.Contains(capabilities, cap)
|
||||
}
|
||||
|
||||
52
plugins/capabilities/http_endpoint.go
Normal file
52
plugins/capabilities/http_endpoint.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package capabilities
|
||||
|
||||
// HTTPEndpoint allows plugins to handle incoming HTTP requests.
|
||||
// Plugins that declare the 'endpoints' permission must implement this capability.
|
||||
// The host dispatches incoming HTTP requests to the plugin's HandleRequest function.
|
||||
//
|
||||
//nd:capability name=httpendpoint required=true
|
||||
type HTTPEndpoint interface {
|
||||
// HandleRequest processes an incoming HTTP request and returns a response.
|
||||
//nd:export name=nd_http_handle_request raw=true
|
||||
HandleRequest(HTTPHandleRequest) (HTTPHandleResponse, error)
|
||||
}
|
||||
|
||||
// HTTPHandleRequest is the input provided when an HTTP request is dispatched to a plugin.
|
||||
type HTTPHandleRequest struct {
|
||||
// Method is the HTTP method (GET, POST, PUT, DELETE, PATCH, etc.).
|
||||
Method string `json:"method"`
|
||||
// Path is the request path relative to the plugin's base URL.
|
||||
// For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook".
|
||||
// Both /ext/my-plugin and /ext/my-plugin/ are normalized to Path = "".
|
||||
Path string `json:"path"`
|
||||
// Query is the raw query string without the leading '?'.
|
||||
Query string `json:"query,omitempty"`
|
||||
// Headers contains the HTTP request headers.
|
||||
Headers map[string][]string `json:"headers,omitempty"`
|
||||
// Body is the request body content.
|
||||
Body []byte `json:"body,omitempty"`
|
||||
// User contains the authenticated user information. Nil for auth:"none" endpoints.
|
||||
User *HTTPUser `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// HTTPUser contains authenticated user information passed to the plugin.
|
||||
type HTTPUser struct {
|
||||
// ID is the internal Navidrome user ID.
|
||||
ID string `json:"id"`
|
||||
// Username is the user's login name.
|
||||
Username string `json:"username"`
|
||||
// Name is the user's display name.
|
||||
Name string `json:"name"`
|
||||
// IsAdmin indicates whether the user has admin privileges.
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
}
|
||||
|
||||
// HTTPHandleResponse is the response returned by the plugin's HandleRequest function.
|
||||
type HTTPHandleResponse struct {
|
||||
// Status is the HTTP status code. Defaults to 200 if zero or not set.
|
||||
Status int32 `json:"status,omitempty"`
|
||||
// Headers contains the HTTP response headers to set.
|
||||
Headers map[string][]string `json:"headers,omitempty"`
|
||||
// Body is the response body content.
|
||||
Body []byte `json:"body,omitempty"`
|
||||
}
|
||||
81
plugins/capabilities/http_endpoint.yaml
Normal file
81
plugins/capabilities/http_endpoint.yaml
Normal file
@@ -0,0 +1,81 @@
|
||||
version: v1-draft
|
||||
exports:
|
||||
nd_http_handle_request:
|
||||
description: HandleRequest processes an incoming HTTP request and returns a response.
|
||||
input:
|
||||
$ref: '#/components/schemas/HTTPHandleRequest'
|
||||
contentType: application/json
|
||||
output:
|
||||
$ref: '#/components/schemas/HTTPHandleResponse'
|
||||
contentType: application/json
|
||||
components:
|
||||
schemas:
|
||||
HTTPHandleRequest:
|
||||
description: HTTPHandleRequest is the input provided when an HTTP request is dispatched to a plugin.
|
||||
properties:
|
||||
method:
|
||||
type: string
|
||||
description: Method is the HTTP method (GET, POST, PUT, DELETE, PATCH, etc.).
|
||||
path:
|
||||
type: string
|
||||
description: |-
|
||||
Path is the request path relative to the plugin's base URL.
|
||||
For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook".
|
||||
Both /ext/my-plugin and /ext/my-plugin/ are normalized to Path = "".
|
||||
query:
|
||||
type: string
|
||||
description: Query is the raw query string without the leading '?'.
|
||||
headers:
|
||||
type: object
|
||||
description: Headers contains the HTTP request headers.
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
body:
|
||||
type: buffer
|
||||
description: Body is the request body content.
|
||||
user:
|
||||
$ref: '#/components/schemas/HTTPUser'
|
||||
description: User contains the authenticated user information. Nil for auth:"none" endpoints.
|
||||
nullable: true
|
||||
required:
|
||||
- method
|
||||
- path
|
||||
HTTPHandleResponse:
|
||||
description: HTTPHandleResponse is the response returned by the plugin's HandleRequest function.
|
||||
properties:
|
||||
status:
|
||||
type: integer
|
||||
format: int32
|
||||
description: Status is the HTTP status code. Defaults to 200 if zero or not set.
|
||||
headers:
|
||||
type: object
|
||||
description: Headers contains the HTTP response headers to set.
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
body:
|
||||
type: buffer
|
||||
description: Body is the response body content.
|
||||
HTTPUser:
|
||||
description: HTTPUser contains authenticated user information passed to the plugin.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: ID is the internal Navidrome user ID.
|
||||
username:
|
||||
type: string
|
||||
description: Username is the user's login name.
|
||||
name:
|
||||
type: string
|
||||
description: Name is the user's display name.
|
||||
isAdmin:
|
||||
type: boolean
|
||||
description: IsAdmin indicates whether the user has admin privileges.
|
||||
required:
|
||||
- id
|
||||
- username
|
||||
- name
|
||||
- isAdmin
|
||||
14
plugins/capability_http_endpoint.go
Normal file
14
plugins/capability_http_endpoint.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package plugins
|
||||
|
||||
// CapabilityHTTPEndpoint indicates the plugin can handle incoming HTTP requests.
|
||||
// Detected when the plugin exports the nd_http_handle_request function.
|
||||
const CapabilityHTTPEndpoint Capability = "HTTPEndpoint"
|
||||
|
||||
const FuncHTTPHandleRequest = "nd_http_handle_request"
|
||||
|
||||
func init() {
|
||||
registerCapability(
|
||||
CapabilityHTTPEndpoint,
|
||||
FuncHTTPHandleRequest,
|
||||
)
|
||||
}
|
||||
@@ -364,6 +364,27 @@ func capabilityFuncMap(cap Capability) template.FuncMap {
|
||||
"providerInterface": func(e Export) string { return e.ProviderInterfaceName() },
|
||||
"implVar": func(e Export) string { return e.ImplVarName() },
|
||||
"exportFunc": func(e Export) string { return e.ExportFuncName() },
|
||||
"rawFieldName": rawFieldName(cap),
|
||||
}
|
||||
}
|
||||
|
||||
// rawFieldName returns a template function that finds the first []byte field name
|
||||
// in a struct by type name. This is used by raw export templates to generate
|
||||
// field-specific binary frame code.
|
||||
func rawFieldName(cap Capability) func(string) string {
|
||||
structMap := make(map[string]StructDef)
|
||||
for _, s := range cap.Structs {
|
||||
structMap[s.Name] = s
|
||||
}
|
||||
return func(typeName string) string {
|
||||
if s, ok := structMap[typeName]; ok {
|
||||
for _, f := range s.Fields {
|
||||
if f.Type == "[]byte" {
|
||||
return f.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,6 +487,7 @@ func rustCapabilityFuncMap(cap Capability) template.FuncMap {
|
||||
"providerInterface": func(e Export) string { return e.ProviderInterfaceName() },
|
||||
"registerMacroName": func(name string) string { return registerMacroName(cap.Name, name) },
|
||||
"snakeCase": ToSnakeCase,
|
||||
"rawFieldName": rawFieldName(cap),
|
||||
"indent": func(spaces int, s string) string {
|
||||
indent := strings.Repeat(" ", spaces)
|
||||
lines := strings.Split(s, "\n")
|
||||
@@ -560,9 +582,15 @@ func rustConstName(name string) string {
|
||||
|
||||
// skipSerializingFunc returns the appropriate skip_serializing_if function name.
|
||||
func skipSerializingFunc(goType string) string {
|
||||
if strings.HasPrefix(goType, "*") || strings.HasPrefix(goType, "[]") || strings.HasPrefix(goType, "map[") {
|
||||
if goType == "[]byte" {
|
||||
return "Vec::is_empty"
|
||||
}
|
||||
if strings.HasPrefix(goType, "*") || strings.HasPrefix(goType, "[]") {
|
||||
return "Option::is_none"
|
||||
}
|
||||
if strings.HasPrefix(goType, "map[") {
|
||||
return "HashMap::is_empty"
|
||||
}
|
||||
switch goType {
|
||||
case "string":
|
||||
return "String::is_empty"
|
||||
|
||||
@@ -1432,12 +1432,20 @@ type OnInitOutput struct {
|
||||
|
||||
var _ = Describe("Rust Generation", func() {
|
||||
Describe("skipSerializingFunc", func() {
|
||||
It("should return Option::is_none for pointer, slice, and map types", func() {
|
||||
It("should return Vec::is_empty for []byte type", func() {
|
||||
Expect(skipSerializingFunc("[]byte")).To(Equal("Vec::is_empty"))
|
||||
})
|
||||
|
||||
It("should return Option::is_none for pointer and slice types", func() {
|
||||
Expect(skipSerializingFunc("*string")).To(Equal("Option::is_none"))
|
||||
Expect(skipSerializingFunc("*MyStruct")).To(Equal("Option::is_none"))
|
||||
Expect(skipSerializingFunc("[]string")).To(Equal("Option::is_none"))
|
||||
Expect(skipSerializingFunc("[]int32")).To(Equal("Option::is_none"))
|
||||
Expect(skipSerializingFunc("map[string]int")).To(Equal("Option::is_none"))
|
||||
})
|
||||
|
||||
It("should return HashMap::is_empty for map types", func() {
|
||||
Expect(skipSerializingFunc("map[string]int")).To(Equal("HashMap::is_empty"))
|
||||
Expect(skipSerializingFunc("map[string]string")).To(Equal("HashMap::is_empty"))
|
||||
})
|
||||
|
||||
It("should return String::is_empty for string type", func() {
|
||||
|
||||
@@ -269,6 +269,7 @@ func parseExport(name string, funcType *ast.FuncType, annotation map[string]stri
|
||||
Name: name,
|
||||
ExportName: annotation["name"],
|
||||
Doc: doc,
|
||||
Raw: annotation["raw"] == "true",
|
||||
}
|
||||
|
||||
// Capability exports have exactly one input parameter (the struct type)
|
||||
|
||||
@@ -635,6 +635,68 @@ type Output struct {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ParseCapabilities raw=true", func() {
|
||||
It("should parse raw=true export annotation", func() {
|
||||
src := `package capabilities
|
||||
|
||||
//nd:capability name=httpendpoint required=true
|
||||
type HTTPEndpoint interface {
|
||||
//nd:export name=nd_http_handle_request raw=true
|
||||
HandleRequest(HTTPHandleRequest) (HTTPHandleResponse, error)
|
||||
}
|
||||
|
||||
type HTTPHandleRequest struct {
|
||||
Method string ` + "`json:\"method\"`" + `
|
||||
Body []byte ` + "`json:\"body,omitempty\"`" + `
|
||||
}
|
||||
|
||||
type HTTPHandleResponse struct {
|
||||
Status int32 ` + "`json:\"status,omitempty\"`" + `
|
||||
Body []byte ` + "`json:\"body,omitempty\"`" + `
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "http_endpoint.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
capabilities, err := ParseCapabilities(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(capabilities).To(HaveLen(1))
|
||||
|
||||
cap := capabilities[0]
|
||||
Expect(cap.Methods).To(HaveLen(1))
|
||||
Expect(cap.Methods[0].Raw).To(BeTrue())
|
||||
Expect(cap.HasRawMethods()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should default Raw to false for export annotations without raw", func() {
|
||||
src := `package capabilities
|
||||
|
||||
//nd:capability name=test required=true
|
||||
type TestCapability interface {
|
||||
//nd:export name=nd_test
|
||||
Test(TestInput) (TestOutput, error)
|
||||
}
|
||||
|
||||
type TestInput struct {
|
||||
Value string ` + "`json:\"value\"`" + `
|
||||
}
|
||||
|
||||
type TestOutput struct {
|
||||
Result string ` + "`json:\"result\"`" + `
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
capabilities, err := ParseCapabilities(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(capabilities).To(HaveLen(1))
|
||||
|
||||
Expect(capabilities[0].Methods[0].Raw).To(BeFalse())
|
||||
Expect(capabilities[0].HasRawMethods()).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Export helpers", func() {
|
||||
It("should generate correct provider interface name", func() {
|
||||
e := Export{Name: "GetArtistBiography"}
|
||||
|
||||
@@ -9,6 +9,10 @@ package {{.Package}}
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
{{- if .Capability.HasRawMethods}}
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
{{- end}}
|
||||
)
|
||||
|
||||
{{- /* Generate type alias definitions */ -}}
|
||||
@@ -56,6 +60,7 @@ func (e {{$typeName}}) Error() string { return string(e) }
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate struct definitions */ -}}
|
||||
{{- $capability := .Capability}}
|
||||
{{- range .Capability.Structs}}
|
||||
|
||||
{{- if .Doc}}
|
||||
@@ -68,8 +73,12 @@ type {{.Name}} struct {
|
||||
{{- if .Doc}}
|
||||
{{formatDoc .Doc | indent 1}}
|
||||
{{- end}}
|
||||
{{- if and (eq .Type "[]byte") $capability.HasRawMethods}}
|
||||
{{.Name}} {{.Type}} `json:"-"`
|
||||
{{- else}}
|
||||
{{.Name}} {{.Type}} `json:"{{.JSONTag}}{{if .OmitEmpty}},omitempty{{end}}"`
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
@@ -172,6 +181,53 @@ func {{exportFunc .}}() int32 {
|
||||
// Return standard code - host will skip this plugin gracefully
|
||||
return NotImplementedCode
|
||||
}
|
||||
{{- if .Raw}}
|
||||
{{- /* Raw binary frame input/output */ -}}
|
||||
{{- if .HasInput}}
|
||||
|
||||
// Parse input frame: [json_len:4B][JSON without []byte field][raw bytes]
|
||||
raw := pdk.Input()
|
||||
if len(raw) < 4 {
|
||||
pdk.SetErrorString("malformed input frame")
|
||||
return -1
|
||||
}
|
||||
jsonLen := binary.BigEndian.Uint32(raw[:4])
|
||||
if uint32(len(raw)-4) < jsonLen {
|
||||
pdk.SetErrorString("invalid json length in input frame")
|
||||
return -1
|
||||
}
|
||||
var input {{.Input.Type}}
|
||||
if err := json.Unmarshal(raw[4:4+jsonLen], &input); err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
input.{{rawFieldName .Input.Type}} = raw[4+jsonLen:]
|
||||
{{- end}}
|
||||
{{- if and .HasInput .HasOutput}}
|
||||
|
||||
output, err := {{implVar .}}(input)
|
||||
if err != nil {
|
||||
// Error frame: [0x01][UTF-8 error message]
|
||||
errMsg := []byte(err.Error())
|
||||
errFrame := make([]byte, 1+len(errMsg))
|
||||
errFrame[0] = 0x01
|
||||
copy(errFrame[1:], errMsg)
|
||||
pdk.Output(errFrame)
|
||||
return 0
|
||||
}
|
||||
|
||||
// Success frame: [0x00][json_len:4B][JSON without []byte field][raw bytes]
|
||||
jsonBytes, _ := json.Marshal(output)
|
||||
rawBytes := output.{{rawFieldName .Output.Type}}
|
||||
frame := make([]byte, 1+4+len(jsonBytes)+len(rawBytes))
|
||||
frame[0] = 0x00
|
||||
binary.BigEndian.PutUint32(frame[1:5], uint32(len(jsonBytes)))
|
||||
copy(frame[5:5+len(jsonBytes)], jsonBytes)
|
||||
copy(frame[5+len(jsonBytes):], rawBytes)
|
||||
pdk.Output(frame)
|
||||
{{- end}}
|
||||
{{- else}}
|
||||
{{- /* Standard JSON input/output */ -}}
|
||||
{{- if .HasInput}}
|
||||
|
||||
var input {{.Input.Type}}
|
||||
@@ -216,6 +272,7 @@ func {{exportFunc .}}() int32 {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
return 0
|
||||
|
||||
@@ -52,6 +52,7 @@ pub const {{rustConstName $v.Name}}: &'static str = {{$v.Value}};
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate struct definitions */ -}}
|
||||
{{- $capability := .Capability}}
|
||||
{{- range .Capability.Structs}}
|
||||
|
||||
{{- if .Doc}}
|
||||
@@ -66,13 +67,17 @@ pub struct {{.Name}} {
|
||||
{{- if .Doc}}
|
||||
{{rustDocComment .Doc | indent 4}}
|
||||
{{- end}}
|
||||
{{- if .OmitEmpty}}
|
||||
{{- if and (eq .Type "[]byte") $capability.HasRawMethods}}
|
||||
#[serde(skip)]
|
||||
pub {{rustFieldName .Name}}: {{fieldRustType .}},
|
||||
{{- else if .OmitEmpty}}
|
||||
#[serde(default, skip_serializing_if = "{{skipSerializingFunc .Type}}")]
|
||||
pub {{rustFieldName .Name}}: {{fieldRustType .}},
|
||||
{{- else}}
|
||||
#[serde(default)]
|
||||
{{- end}}
|
||||
pub {{rustFieldName .Name}}: {{fieldRustType .}},
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
@@ -124,6 +129,56 @@ pub trait {{agentName .Capability}} {
|
||||
macro_rules! register_{{snakeCase .Package}} {
|
||||
($plugin_type:ty) => {
|
||||
{{- range .Capability.Methods}}
|
||||
{{- if .Raw}}
|
||||
#[extism_pdk::plugin_fn]
|
||||
pub fn {{.ExportName}}(
|
||||
{{- if .HasInput}}
|
||||
_raw_input: extism_pdk::Raw<Vec<u8>>
|
||||
{{- end}}
|
||||
) -> extism_pdk::FnResult<extism_pdk::Raw<Vec<u8>>> {
|
||||
let plugin = <$plugin_type>::default();
|
||||
{{- if .HasInput}}
|
||||
// Parse input frame: [json_len:4B][JSON without []byte field][raw bytes]
|
||||
let raw_bytes = _raw_input.0;
|
||||
if raw_bytes.len() < 4 {
|
||||
let mut err_frame = vec![0x01u8];
|
||||
err_frame.extend_from_slice(b"malformed input frame");
|
||||
return Ok(extism_pdk::Raw(err_frame));
|
||||
}
|
||||
let json_len = u32::from_be_bytes([raw_bytes[0], raw_bytes[1], raw_bytes[2], raw_bytes[3]]) as usize;
|
||||
if json_len > raw_bytes.len() - 4 {
|
||||
let mut err_frame = vec![0x01u8];
|
||||
err_frame.extend_from_slice(b"invalid json length in input frame");
|
||||
return Ok(extism_pdk::Raw(err_frame));
|
||||
}
|
||||
let mut req: $crate::{{snakeCase $.Package}}::{{rustOutputType .Input.Type}} = serde_json::from_slice(&raw_bytes[4..4+json_len])
|
||||
.map_err(|e| extism_pdk::Error::msg(e.to_string()))?;
|
||||
req.{{rustFieldName (rawFieldName .Input.Type)}} = raw_bytes[4+json_len..].to_vec();
|
||||
{{- end}}
|
||||
{{- if and .HasInput .HasOutput}}
|
||||
match $crate::{{snakeCase $.Package}}::{{agentName $.Capability}}::{{rustMethodName .Name}}(&plugin, req) {
|
||||
Ok(output) => {
|
||||
// Success frame: [0x00][json_len:4B][JSON without []byte field][raw bytes]
|
||||
let json_bytes = serde_json::to_vec(&output)
|
||||
.map_err(|e| extism_pdk::Error::msg(e.to_string()))?;
|
||||
let raw_field = &output.{{rustFieldName (rawFieldName .Output.Type)}};
|
||||
let mut frame = Vec::with_capacity(1 + 4 + json_bytes.len() + raw_field.len());
|
||||
frame.push(0x00);
|
||||
frame.extend_from_slice(&(json_bytes.len() as u32).to_be_bytes());
|
||||
frame.extend_from_slice(&json_bytes);
|
||||
frame.extend_from_slice(raw_field);
|
||||
Ok(extism_pdk::Raw(frame))
|
||||
}
|
||||
Err(e) => {
|
||||
// Error frame: [0x01][UTF-8 error message]
|
||||
let mut err_frame = vec![0x01u8];
|
||||
err_frame.extend_from_slice(e.message.as_bytes());
|
||||
Ok(extism_pdk::Raw(err_frame))
|
||||
}
|
||||
}
|
||||
{{- end}}
|
||||
}
|
||||
{{- else}}
|
||||
#[extism_pdk::plugin_fn]
|
||||
pub fn {{.ExportName}}(
|
||||
{{- if .HasInput}}
|
||||
@@ -146,6 +201,7 @@ macro_rules! register_{{snakeCase .Package}} {
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
};
|
||||
}
|
||||
{{- else}}
|
||||
@@ -171,6 +227,56 @@ pub trait {{providerInterface .}} {
|
||||
#[macro_export]
|
||||
macro_rules! {{registerMacroName .Name}} {
|
||||
($plugin_type:ty) => {
|
||||
{{- if .Raw}}
|
||||
#[extism_pdk::plugin_fn]
|
||||
pub fn {{.ExportName}}(
|
||||
{{- if .HasInput}}
|
||||
_raw_input: extism_pdk::Raw<Vec<u8>>
|
||||
{{- end}}
|
||||
) -> extism_pdk::FnResult<extism_pdk::Raw<Vec<u8>>> {
|
||||
let plugin = <$plugin_type>::default();
|
||||
{{- if .HasInput}}
|
||||
// Parse input frame: [json_len:4B][JSON without []byte field][raw bytes]
|
||||
let raw_bytes = _raw_input.0;
|
||||
if raw_bytes.len() < 4 {
|
||||
let mut err_frame = vec![0x01u8];
|
||||
err_frame.extend_from_slice(b"malformed input frame");
|
||||
return Ok(extism_pdk::Raw(err_frame));
|
||||
}
|
||||
let json_len = u32::from_be_bytes([raw_bytes[0], raw_bytes[1], raw_bytes[2], raw_bytes[3]]) as usize;
|
||||
if json_len > raw_bytes.len() - 4 {
|
||||
let mut err_frame = vec![0x01u8];
|
||||
err_frame.extend_from_slice(b"invalid json length in input frame");
|
||||
return Ok(extism_pdk::Raw(err_frame));
|
||||
}
|
||||
let mut req: $crate::{{snakeCase $.Package}}::{{rustOutputType .Input.Type}} = serde_json::from_slice(&raw_bytes[4..4+json_len])
|
||||
.map_err(|e| extism_pdk::Error::msg(e.to_string()))?;
|
||||
req.{{rustFieldName (rawFieldName .Input.Type)}} = raw_bytes[4+json_len..].to_vec();
|
||||
{{- end}}
|
||||
{{- if and .HasInput .HasOutput}}
|
||||
match $crate::{{snakeCase $.Package}}::{{providerInterface .}}::{{rustMethodName .Name}}(&plugin, req) {
|
||||
Ok(output) => {
|
||||
// Success frame: [0x00][json_len:4B][JSON without []byte field][raw bytes]
|
||||
let json_bytes = serde_json::to_vec(&output)
|
||||
.map_err(|e| extism_pdk::Error::msg(e.to_string()))?;
|
||||
let raw_field = &output.{{rustFieldName (rawFieldName .Output.Type)}};
|
||||
let mut frame = Vec::with_capacity(1 + 4 + json_bytes.len() + raw_field.len());
|
||||
frame.push(0x00);
|
||||
frame.extend_from_slice(&(json_bytes.len() as u32).to_be_bytes());
|
||||
frame.extend_from_slice(&json_bytes);
|
||||
frame.extend_from_slice(raw_field);
|
||||
Ok(extism_pdk::Raw(frame))
|
||||
}
|
||||
Err(e) => {
|
||||
// Error frame: [0x01][UTF-8 error message]
|
||||
let mut err_frame = vec![0x01u8];
|
||||
err_frame.extend_from_slice(e.message.as_bytes());
|
||||
Ok(extism_pdk::Raw(err_frame))
|
||||
}
|
||||
}
|
||||
{{- end}}
|
||||
}
|
||||
{{- else}}
|
||||
#[extism_pdk::plugin_fn]
|
||||
pub fn {{.ExportName}}(
|
||||
{{- if .HasInput}}
|
||||
@@ -192,6 +298,7 @@ macro_rules! {{registerMacroName .Name}} {
|
||||
Ok(())
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
};
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
@@ -53,6 +53,7 @@ func (e {{$typeName}}) Error() string { return string(e) }
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate struct definitions */ -}}
|
||||
{{- $capability := .Capability}}
|
||||
{{- range .Capability.Structs}}
|
||||
|
||||
{{- if .Doc}}
|
||||
@@ -65,8 +66,12 @@ type {{.Name}} struct {
|
||||
{{- if .Doc}}
|
||||
{{formatDoc .Doc | indent 1}}
|
||||
{{- end}}
|
||||
{{- if and (eq .Type "[]byte") $capability.HasRawMethods}}
|
||||
{{.Name}} {{.Type}} `json:"-"`
|
||||
{{- else}}
|
||||
{{.Name}} {{.Type}} `json:"{{.JSONTag}}{{if .OmitEmpty}},omitempty{{end}}"`
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
|
||||
@@ -48,6 +48,16 @@ type ConstDef struct {
|
||||
Doc string // Documentation comment
|
||||
}
|
||||
|
||||
// HasRawMethods returns true if any export in the capability uses raw binary framing.
|
||||
func (c Capability) HasRawMethods() bool {
|
||||
for _, m := range c.Methods {
|
||||
if m.Raw {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// KnownStructs returns a map of struct names defined in this capability.
|
||||
func (c Capability) KnownStructs() map[string]bool {
|
||||
result := make(map[string]bool)
|
||||
@@ -64,6 +74,7 @@ type Export struct {
|
||||
Input Param // Single input parameter (the struct type)
|
||||
Output Param // Single output return value (the struct type)
|
||||
Doc string // Documentation comment for the method
|
||||
Raw bool // If true, uses binary framing instead of JSON for []byte fields
|
||||
}
|
||||
|
||||
// ProviderInterfaceName returns the optional provider interface name.
|
||||
|
||||
@@ -54,6 +54,14 @@ type (
|
||||
Nullable bool `yaml:"nullable,omitempty"`
|
||||
Items *xtpProperty `yaml:"items,omitempty"`
|
||||
}
|
||||
|
||||
// xtpMapProperty represents a map property in XTP (type: object with additionalProperties).
|
||||
xtpMapProperty struct {
|
||||
Type string `yaml:"type"`
|
||||
Description string `yaml:"description,omitempty"`
|
||||
Nullable bool `yaml:"nullable,omitempty"`
|
||||
AdditionalProperties *xtpProperty `yaml:"additionalProperties"`
|
||||
}
|
||||
)
|
||||
|
||||
// GenerateSchema generates an XTP YAML schema from a capability.
|
||||
@@ -206,7 +214,12 @@ func buildObjectSchema(st StructDef, knownTypes map[string]bool) xtpObjectSchema
|
||||
|
||||
for _, field := range st.Fields {
|
||||
propName := getJSONFieldName(field)
|
||||
addToMap(&schema.Properties, propName, buildProperty(field, knownTypes))
|
||||
goType := strings.TrimPrefix(field.Type, "*")
|
||||
if strings.HasPrefix(goType, "map[") {
|
||||
addToMap(&schema.Properties, propName, buildMapProperty(goType, field.Doc, strings.HasPrefix(field.Type, "*"), knownTypes))
|
||||
} else {
|
||||
addToMap(&schema.Properties, propName, buildProperty(field, knownTypes))
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(field.Type, "*") && !field.OmitEmpty {
|
||||
schema.Required = append(schema.Required, propName)
|
||||
@@ -246,6 +259,12 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty {
|
||||
return prop
|
||||
}
|
||||
|
||||
// Handle []byte as buffer type (must be checked before generic slice handling)
|
||||
if goType == "[]byte" {
|
||||
prop.Type = "buffer"
|
||||
return prop
|
||||
}
|
||||
|
||||
// Handle slice types
|
||||
if strings.HasPrefix(goType, "[]") {
|
||||
elemType := goType[2:]
|
||||
@@ -264,6 +283,55 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty {
|
||||
return prop
|
||||
}
|
||||
|
||||
// buildMapProperty builds an XTP MapProperty for a Go map type.
|
||||
// It parses map[K]V and generates additionalProperties describing V.
|
||||
func buildMapProperty(goType, doc string, isPointer bool, knownTypes map[string]bool) xtpMapProperty {
|
||||
prop := xtpMapProperty{
|
||||
Type: "object",
|
||||
Description: cleanDocForYAML(doc),
|
||||
Nullable: isPointer,
|
||||
}
|
||||
|
||||
// Parse value type from map[K]V
|
||||
valueType := parseMapValueType(goType)
|
||||
|
||||
valProp := &xtpProperty{}
|
||||
if strings.HasPrefix(valueType, "[]") {
|
||||
elemType := valueType[2:]
|
||||
valProp.Type = "array"
|
||||
valProp.Items = &xtpProperty{}
|
||||
if isKnownType(elemType, knownTypes) {
|
||||
valProp.Items.Ref = "#/components/schemas/" + elemType
|
||||
} else {
|
||||
valProp.Items.Type = goTypeToXTPType(elemType)
|
||||
}
|
||||
} else if isKnownType(valueType, knownTypes) {
|
||||
valProp.Ref = "#/components/schemas/" + valueType
|
||||
} else {
|
||||
valProp.Type, valProp.Format = goTypeToXTPTypeAndFormat(valueType)
|
||||
}
|
||||
prop.AdditionalProperties = valProp
|
||||
|
||||
return prop
|
||||
}
|
||||
|
||||
// parseMapValueType extracts the value type from a Go map type string like "map[string][]string".
|
||||
func parseMapValueType(goType string) string {
|
||||
// Find the closing bracket of the key type
|
||||
depth := 0
|
||||
for i, ch := range goType {
|
||||
if ch == '[' {
|
||||
depth++
|
||||
} else if ch == ']' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return goType[i+1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
return "object" // fallback
|
||||
}
|
||||
|
||||
// addToMap adds a key-value pair to a yaml.Node map, preserving insertion order.
|
||||
func addToMap[T any](node *yaml.Node, key string, value T) {
|
||||
var valNode yaml.Node
|
||||
|
||||
@@ -719,4 +719,139 @@ var _ = Describe("XTP Schema Generation", func() {
|
||||
Expect(schemas).NotTo(HaveKey("UnusedStatus"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GenerateSchema with []byte fields", func() {
|
||||
It("should render []byte as buffer type and validate against XTP JSONSchema", func() {
|
||||
capability := Capability{
|
||||
Name: "buffer_test",
|
||||
SourceFile: "buffer_test",
|
||||
Methods: []Export{
|
||||
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
|
||||
},
|
||||
Structs: []StructDef{
|
||||
{
|
||||
Name: "Input",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Name", Type: "string", JSONTag: "name"},
|
||||
{Name: "Data", Type: "[]byte", JSONTag: "data,omitempty", OmitEmpty: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Output",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Body", Type: "[]byte", JSONTag: "body,omitempty", OmitEmpty: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
schema, err := GenerateSchema(capability)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||
|
||||
doc := parseSchema(schema)
|
||||
components := doc["components"].(map[string]any)
|
||||
schemas := components["schemas"].(map[string]any)
|
||||
input := schemas["Input"].(map[string]any)
|
||||
props := input["properties"].(map[string]any)
|
||||
data := props["data"].(map[string]any)
|
||||
Expect(data["type"]).To(Equal("buffer"))
|
||||
Expect(data).NotTo(HaveKey("items"))
|
||||
Expect(data).NotTo(HaveKey("format"))
|
||||
|
||||
output := schemas["Output"].(map[string]any)
|
||||
outProps := output["properties"].(map[string]any)
|
||||
body := outProps["body"].(map[string]any)
|
||||
Expect(body["type"]).To(Equal("buffer"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GenerateSchema with map fields", func() {
|
||||
It("should render map[string][]string as object with additionalProperties and validate", func() {
|
||||
capability := Capability{
|
||||
Name: "map_test",
|
||||
SourceFile: "map_test",
|
||||
Methods: []Export{
|
||||
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
|
||||
},
|
||||
Structs: []StructDef{
|
||||
{
|
||||
Name: "Input",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Headers", Type: "map[string][]string", JSONTag: "headers,omitempty", OmitEmpty: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Output",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Value", Type: "string", JSONTag: "value"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
schema, err := GenerateSchema(capability)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||
|
||||
doc := parseSchema(schema)
|
||||
components := doc["components"].(map[string]any)
|
||||
schemas := components["schemas"].(map[string]any)
|
||||
input := schemas["Input"].(map[string]any)
|
||||
props := input["properties"].(map[string]any)
|
||||
headers := props["headers"].(map[string]any)
|
||||
Expect(headers).To(HaveKey("additionalProperties"))
|
||||
addlProps := headers["additionalProperties"].(map[string]any)
|
||||
Expect(addlProps["type"]).To(Equal("array"))
|
||||
items := addlProps["items"].(map[string]any)
|
||||
Expect(items["type"]).To(Equal("string"))
|
||||
})
|
||||
|
||||
It("should render map[string]string as object with string additionalProperties", func() {
|
||||
capability := Capability{
|
||||
Name: "map_string_test",
|
||||
SourceFile: "map_string_test",
|
||||
Methods: []Export{
|
||||
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
|
||||
},
|
||||
Structs: []StructDef{
|
||||
{
|
||||
Name: "Input",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Metadata", Type: "map[string]string", JSONTag: "metadata,omitempty", OmitEmpty: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Output",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Value", Type: "string", JSONTag: "value"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
schema, err := GenerateSchema(capability)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||
|
||||
doc := parseSchema(schema)
|
||||
components := doc["components"].(map[string]any)
|
||||
schemas := components["schemas"].(map[string]any)
|
||||
input := schemas["Input"].(map[string]any)
|
||||
props := input["properties"].(map[string]any)
|
||||
metadata := props["metadata"].(map[string]any)
|
||||
Expect(metadata).To(HaveKey("additionalProperties"))
|
||||
addlProps := metadata["additionalProperties"].(map[string]any)
|
||||
Expect(addlProps["type"]).To(Equal("string"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("parseMapValueType", func() {
|
||||
DescribeTable("should extract value type from Go map types",
|
||||
func(goType, wantValue string) {
|
||||
Expect(parseMapValueType(goType)).To(Equal(wantValue))
|
||||
},
|
||||
Entry("map[string]string", "map[string]string", "string"),
|
||||
Entry("map[string]int", "map[string]int", "int"),
|
||||
Entry("map[string][]string", "map[string][]string", "[]string"),
|
||||
Entry("map[string][]byte", "map[string][]byte", "[]byte"),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,27 +26,19 @@ const subsonicAPIVersion = "1.16.1"
|
||||
// URL Format: Only the path and query parameters are used - host/protocol are ignored.
|
||||
// Automatic Parameters: The service adds 'c' (client), 'v' (version), and optionally 'f' (format).
|
||||
type subsonicAPIServiceImpl struct {
|
||||
pluginID string
|
||||
router SubsonicRouter
|
||||
ds model.DataStore
|
||||
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
|
||||
allUsers bool // If true, plugin can access all users
|
||||
userIDMap map[string]struct{}
|
||||
pluginName string
|
||||
router SubsonicRouter
|
||||
ds model.DataStore
|
||||
userAccess UserAccess
|
||||
}
|
||||
|
||||
// newSubsonicAPIService creates a new SubsonicAPIService for a plugin.
|
||||
func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.DataStore, allowedUserIDs []string, allUsers bool) host.SubsonicAPIService {
|
||||
userIDMap := make(map[string]struct{})
|
||||
for _, id := range allowedUserIDs {
|
||||
userIDMap[id] = struct{}{}
|
||||
}
|
||||
func newSubsonicAPIService(pluginName string, router SubsonicRouter, ds model.DataStore, userAccess UserAccess) host.SubsonicAPIService {
|
||||
return &subsonicAPIServiceImpl{
|
||||
pluginID: pluginID,
|
||||
router: router,
|
||||
ds: ds,
|
||||
allowedUserIDs: allowedUserIDs,
|
||||
allUsers: allUsers,
|
||||
userIDMap: userIDMap,
|
||||
pluginName: pluginName,
|
||||
router: router,
|
||||
ds: ds,
|
||||
userAccess: userAccess,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,12 +66,12 @@ func (s *subsonicAPIServiceImpl) executeRequest(ctx context.Context, uri string,
|
||||
}
|
||||
|
||||
if err := s.checkPermissions(ctx, username); err != nil {
|
||||
log.Warn(ctx, "SubsonicAPI call blocked by permissions", "plugin", s.pluginID, "user", username, err)
|
||||
log.Warn(ctx, "SubsonicAPI call blocked by permissions", "plugin", s.pluginName, "user", username, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add required Subsonic API parameters
|
||||
query.Set("c", s.pluginID) // Client name (plugin ID)
|
||||
query.Set("c", s.pluginName) // Client name (plugin ID)
|
||||
query.Set("v", subsonicAPIVersion) // API version
|
||||
if setJSON {
|
||||
query.Set("f", "json") // Response format
|
||||
@@ -94,11 +86,8 @@ func (s *subsonicAPIServiceImpl) executeRequest(ctx context.Context, uri string,
|
||||
RawQuery: query.Encode(),
|
||||
}
|
||||
|
||||
// Create HTTP request with a fresh context to avoid Chi RouteContext pollution.
|
||||
// Using http.NewRequest (instead of http.NewRequestWithContext) ensures the internal
|
||||
// SubsonicAPI call doesn't inherit routing information from the parent handler,
|
||||
// which would cause Chi to invoke the wrong handler. Authentication context is
|
||||
// explicitly added in the next step via request.WithInternalAuth.
|
||||
// Use http.NewRequest (not WithContext) to avoid inheriting Chi RouteContext;
|
||||
// auth context is set explicitly below via request.WithInternalAuth.
|
||||
httpReq, err := http.NewRequest("GET", finalURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
|
||||
@@ -135,14 +124,13 @@ func (s *subsonicAPIServiceImpl) CallRaw(ctx context.Context, uri string) (strin
|
||||
}
|
||||
|
||||
func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error {
|
||||
// If allUsers is true, allow any user
|
||||
if s.allUsers {
|
||||
if s.userAccess.allUsers {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Must have at least one allowed user ID configured
|
||||
if len(s.allowedUserIDs) == 0 {
|
||||
return fmt.Errorf("no users configured for plugin %s", s.pluginID)
|
||||
// Must have at least one allowed user configured
|
||||
if !s.userAccess.HasConfiguredUsers() {
|
||||
return fmt.Errorf("no users configured for plugin %s", s.pluginName)
|
||||
}
|
||||
|
||||
// Look up the user by username to get their ID
|
||||
@@ -155,7 +143,7 @@ func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username
|
||||
}
|
||||
|
||||
// Check if the user's ID is in the allowed list
|
||||
if _, ok := s.userIDMap[usr.ID]; !ok {
|
||||
if !s.userAccess.IsAllowed(usr.ID) {
|
||||
return fmt.Errorf("user %s is not authorized for this plugin", username)
|
||||
}
|
||||
|
||||
|
||||
@@ -268,7 +268,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
Context("with specific user IDs allowed", func() {
|
||||
It("blocks users not in the allowed list", func() {
|
||||
// allowedUserIDs contains "user2", but testuser is "user1"
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user2"}))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "/ping?u=testuser")
|
||||
@@ -278,7 +278,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
|
||||
It("allows users in the allowed list", func() {
|
||||
// allowedUserIDs contains "user2" which is "alloweduser"
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user2"}))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
response, err := service.Call(ctx, "/ping?u=alloweduser")
|
||||
@@ -288,7 +288,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
|
||||
It("blocks admin users when not in allowed list", func() {
|
||||
// allowedUserIDs only contains "user1" (testuser), not "admin1"
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user1"}))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "/ping?u=adminuser")
|
||||
@@ -298,7 +298,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
|
||||
It("allows admin users when in allowed list", func() {
|
||||
// allowedUserIDs contains "admin1"
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"admin1"}, false)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"admin1"}))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
response, err := service.Call(ctx, "/ping?u=adminuser")
|
||||
@@ -309,7 +309,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
|
||||
Context("with allUsers=true", func() {
|
||||
It("allows all users regardless of allowed list", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
response, err := service.Call(ctx, "/ping?u=testuser")
|
||||
@@ -318,7 +318,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("allows admin users when allUsers is true", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
response, err := service.Call(ctx, "/ping?u=adminuser")
|
||||
@@ -329,7 +329,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
|
||||
Context("with no users configured", func() {
|
||||
It("returns error when no users are configured", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, false)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "/ping?u=testuser")
|
||||
@@ -338,7 +338,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("returns error for empty user list", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{}, false)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{}))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "/ping?u=testuser")
|
||||
@@ -350,7 +350,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
|
||||
Describe("URL Handling", func() {
|
||||
It("returns error for missing username parameter", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "/ping")
|
||||
@@ -359,7 +359,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("returns error for invalid URL", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "://invalid")
|
||||
@@ -368,7 +368,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("extracts endpoint from path correctly", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user1"}))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "/rest/ping.view?u=testuser")
|
||||
@@ -381,7 +381,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
|
||||
Describe("CallRaw", func() {
|
||||
It("returns binary data and content-type", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
contentType, data, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
|
||||
@@ -391,7 +391,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("does not set f=json parameter", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
|
||||
@@ -403,7 +403,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("enforces permission checks", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user2"}))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
|
||||
@@ -412,7 +412,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("returns error when username is missing", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, _, err := service.CallRaw(ctx, "/getCoverArt")
|
||||
@@ -421,7 +421,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("returns error when router is nil", func() {
|
||||
service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", nil, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser")
|
||||
@@ -430,7 +430,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("returns error for invalid URL", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, _, err := service.CallRaw(ctx, "://invalid")
|
||||
@@ -441,7 +441,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
|
||||
Describe("Router Availability", func() {
|
||||
It("returns error when router is nil", func() {
|
||||
service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", nil, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "/ping?u=testuser")
|
||||
|
||||
@@ -9,16 +9,14 @@ import (
|
||||
)
|
||||
|
||||
type usersServiceImpl struct {
|
||||
ds model.DataStore
|
||||
allowedUsers []string // User IDs this plugin can access
|
||||
allUsers bool // If true, plugin can access all users
|
||||
ds model.DataStore
|
||||
userAccess UserAccess
|
||||
}
|
||||
|
||||
func newUsersService(ds model.DataStore, allowedUsers []string, allUsers bool) host.UsersService {
|
||||
func newUsersService(ds model.DataStore, userAccess UserAccess) host.UsersService {
|
||||
return &usersServiceImpl{
|
||||
ds: ds,
|
||||
allowedUsers: allowedUsers,
|
||||
allUsers: allUsers,
|
||||
ds: ds,
|
||||
userAccess: userAccess,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,17 +26,9 @@ func (s *usersServiceImpl) GetUsers(ctx context.Context) ([]host.User, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build allowed users map for efficient lookup
|
||||
allowedMap := make(map[string]bool, len(s.allowedUsers))
|
||||
for _, id := range s.allowedUsers {
|
||||
allowedMap[id] = true
|
||||
}
|
||||
|
||||
var result []host.User
|
||||
for _, u := range users {
|
||||
// If allUsers is true, include all users
|
||||
// Otherwise, only include users in the allowed list
|
||||
if s.allUsers || allowedMap[u.ID] {
|
||||
if s.userAccess.IsAllowed(u.ID) {
|
||||
result = append(result, host.User{
|
||||
UserName: u.UserName,
|
||||
Name: u.Name,
|
||||
|
||||
@@ -61,7 +61,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
||||
|
||||
Context("with allUsers=true", func() {
|
||||
BeforeEach(func() {
|
||||
service = newUsersService(ds, nil, true)
|
||||
service = newUsersService(ds, NewUserAccess(true, nil))
|
||||
})
|
||||
|
||||
It("should return all users", func() {
|
||||
@@ -100,7 +100,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
||||
Context("with specific allowed users", func() {
|
||||
BeforeEach(func() {
|
||||
// Only allow access to user1 and user3
|
||||
service = newUsersService(ds, []string{"user1", "user3"}, false)
|
||||
service = newUsersService(ds, NewUserAccess(false, []string{"user1", "user3"}))
|
||||
})
|
||||
|
||||
It("should return only allowed users", func() {
|
||||
@@ -119,7 +119,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
||||
|
||||
Context("with empty allowed users and allUsers=false", func() {
|
||||
BeforeEach(func() {
|
||||
service = newUsersService(ds, []string{}, false)
|
||||
service = newUsersService(ds, NewUserAccess(false, []string{}))
|
||||
})
|
||||
|
||||
It("should return no users", func() {
|
||||
@@ -132,7 +132,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
||||
Context("when datastore returns error", func() {
|
||||
BeforeEach(func() {
|
||||
mockUserRepo.Error = model.ErrNotFound
|
||||
service = newUsersService(ds, nil, true)
|
||||
service = newUsersService(ds, NewUserAccess(true, nil))
|
||||
})
|
||||
|
||||
It("should propagate the error", func() {
|
||||
@@ -170,7 +170,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
||||
|
||||
Context("with allUsers=true", func() {
|
||||
BeforeEach(func() {
|
||||
service = newUsersService(ds, nil, true)
|
||||
service = newUsersService(ds, NewUserAccess(true, nil))
|
||||
})
|
||||
|
||||
It("should return only admin users", func() {
|
||||
@@ -185,7 +185,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
||||
Context("with specific allowed users including admin", func() {
|
||||
BeforeEach(func() {
|
||||
// Allow access to user1 (admin) and user2 (non-admin)
|
||||
service = newUsersService(ds, []string{"user1", "user2"}, false)
|
||||
service = newUsersService(ds, NewUserAccess(false, []string{"user1", "user2"}))
|
||||
})
|
||||
|
||||
It("should return only admin users from allowed list", func() {
|
||||
@@ -199,7 +199,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
||||
Context("with specific allowed users excluding admin", func() {
|
||||
BeforeEach(func() {
|
||||
// Only allow access to non-admin users
|
||||
service = newUsersService(ds, []string{"user2", "user3"}, false)
|
||||
service = newUsersService(ds, NewUserAccess(false, []string{"user2", "user3"}))
|
||||
})
|
||||
|
||||
It("should return empty when no admins in allowed list", func() {
|
||||
@@ -212,7 +212,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
||||
Context("when datastore returns error", func() {
|
||||
BeforeEach(func() {
|
||||
mockUserRepo.Error = model.ErrNotFound
|
||||
service = newUsersService(ds, nil, true)
|
||||
service = newUsersService(ds, NewUserAccess(true, nil))
|
||||
})
|
||||
|
||||
It("should propagate the error", func() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -200,9 +201,7 @@ func (s *webSocketServiceImpl) CloseConnection(ctx context.Context, connectionID
|
||||
func (s *webSocketServiceImpl) Close() error {
|
||||
s.mu.Lock()
|
||||
connections := make(map[string]*wsConnection, len(s.connections))
|
||||
for k, v := range s.connections {
|
||||
connections[k] = v
|
||||
}
|
||||
maps.Copy(connections, s.connections)
|
||||
s.connections = make(map[string]*wsConnection)
|
||||
s.mu.Unlock()
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -594,9 +595,7 @@ func (t *testableWebSocketService) getConnectionCount() int {
|
||||
func (t *testableWebSocketService) closeAllConnections() {
|
||||
t.mu.Lock()
|
||||
conns := make(map[string]*wsConnection, len(t.connections))
|
||||
for k, v := range t.connections {
|
||||
conns[k] = v
|
||||
}
|
||||
maps.Copy(conns, t.connections)
|
||||
t.connections = make(map[string]*wsConnection)
|
||||
t.mu.Unlock()
|
||||
|
||||
|
||||
189
plugins/http_endpoint.go
Normal file
189
plugins/http_endpoint.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/httprate"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/plugins/capabilities"
|
||||
)
|
||||
|
||||
const maxEndpointBodySize = 1 << 20 // 1MB
|
||||
|
||||
// SubsonicAuthValidator validates Subsonic authentication and returns the user.
|
||||
// This is set by the cmd/ package to avoid import cycles (plugins -> server/subsonic).
|
||||
type SubsonicAuthValidator func(ds model.DataStore, r *http.Request) (*model.User, error)
|
||||
|
||||
// NativeAuthMiddleware is an HTTP middleware that authenticates using JWT tokens.
|
||||
// This is set by the cmd/ package to avoid import cycles (plugins -> server).
|
||||
type NativeAuthMiddleware func(ds model.DataStore) func(next http.Handler) http.Handler
|
||||
|
||||
// NewEndpointRouter creates an HTTP handler that dispatches requests to plugin endpoints.
|
||||
// It should be mounted at both /ext and /rest/ext. The handler uses a catch-all pattern
|
||||
// because Chi does not support adding routes after startup, and plugins can be loaded/unloaded
|
||||
// at runtime. Plugin lookup happens per-request under RLock.
|
||||
func NewEndpointRouter(manager *Manager, ds model.DataStore, subsonicAuth SubsonicAuthValidator, nativeAuth NativeAuthMiddleware) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Apply rate limiting if configured
|
||||
if conf.Server.Plugins.EndpointRequestLimit > 0 {
|
||||
r.Use(httprate.LimitByIP(conf.Server.Plugins.EndpointRequestLimit, conf.Server.Plugins.EndpointRequestWindow))
|
||||
}
|
||||
|
||||
h := &endpointHandler{
|
||||
manager: manager,
|
||||
ds: ds,
|
||||
subsonicAuth: subsonicAuth,
|
||||
nativeAuth: nativeAuth,
|
||||
}
|
||||
r.HandleFunc("/{pluginID}/*", h.ServeHTTP)
|
||||
r.HandleFunc("/{pluginID}", h.ServeHTTP)
|
||||
return r
|
||||
}
|
||||
|
||||
type endpointHandler struct {
|
||||
manager *Manager
|
||||
ds model.DataStore
|
||||
subsonicAuth SubsonicAuthValidator
|
||||
nativeAuth NativeAuthMiddleware
|
||||
}
|
||||
|
||||
func (h *endpointHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
pluginID := chi.URLParam(r, "pluginID")
|
||||
|
||||
h.manager.mu.RLock()
|
||||
p, ok := h.manager.plugins[pluginID]
|
||||
h.manager.mu.RUnlock()
|
||||
|
||||
if !ok || !hasCapability(p.capabilities, CapabilityHTTPEndpoint) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if p.manifest.Permissions == nil || p.manifest.Permissions.Endpoints == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
authType := p.manifest.Permissions.Endpoints.Auth
|
||||
|
||||
switch authType {
|
||||
case EndpointsPermissionAuthSubsonic:
|
||||
h.serveWithSubsonicAuth(w, r, p)
|
||||
case EndpointsPermissionAuthNative:
|
||||
h.serveWithNativeAuth(w, r, p)
|
||||
case EndpointsPermissionAuthNone:
|
||||
h.dispatch(w, r, p)
|
||||
default:
|
||||
http.Error(w, "Unknown auth type", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *endpointHandler) serveWithSubsonicAuth(w http.ResponseWriter, r *http.Request, p *plugin) {
|
||||
usr, err := h.subsonicAuth(h.ds, r)
|
||||
if err != nil {
|
||||
log.Warn(r.Context(), "Plugin endpoint auth failed", "plugin", p.name, "auth", "subsonic", err)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
ctx := request.WithUser(r.Context(), *usr)
|
||||
h.dispatch(w, r.WithContext(ctx), p)
|
||||
}
|
||||
|
||||
func (h *endpointHandler) serveWithNativeAuth(w http.ResponseWriter, r *http.Request, p *plugin) {
|
||||
h.nativeAuth(h.ds)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h.dispatch(w, r, p)
|
||||
})).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (h *endpointHandler) dispatch(w http.ResponseWriter, r *http.Request, p *plugin) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Check user authorization and extract user info (skip for auth:"none")
|
||||
var httpUser *capabilities.HTTPUser
|
||||
if p.manifest.Permissions.Endpoints.Auth != EndpointsPermissionAuthNone {
|
||||
user, ok := request.UserFrom(ctx)
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !p.userAccess.IsAllowed(user.ID) {
|
||||
log.Warn(ctx, "Plugin endpoint access denied", "plugin", p.name, "user", user.UserName)
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
httpUser = &capabilities.HTTPUser{
|
||||
ID: user.ID,
|
||||
Username: user.UserName,
|
||||
Name: user.Name,
|
||||
IsAdmin: user.IsAdmin,
|
||||
}
|
||||
}
|
||||
|
||||
// Read request body with size limit
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxEndpointBodySize))
|
||||
if err != nil {
|
||||
log.Error(ctx, "Failed to read request body", "plugin", p.name, err)
|
||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Build the plugin request
|
||||
// Normalize path: both /ext/plugin and /ext/plugin/ map to ""
|
||||
rawPath := chi.URLParam(r, "*")
|
||||
relPath := ""
|
||||
if rawPath != "" {
|
||||
relPath = "/" + rawPath
|
||||
}
|
||||
|
||||
pluginReq := capabilities.HTTPHandleRequest{
|
||||
Method: r.Method,
|
||||
Path: relPath,
|
||||
Query: r.URL.RawQuery,
|
||||
Headers: r.Header,
|
||||
Body: body,
|
||||
User: httpUser,
|
||||
}
|
||||
|
||||
// Call the plugin using binary framing for []byte Body fields
|
||||
resp, err := callPluginFunctionRaw(
|
||||
ctx, p, FuncHTTPHandleRequest,
|
||||
pluginReq, pluginReq.Body,
|
||||
func(r *capabilities.HTTPHandleResponse, raw []byte) { r.Body = raw },
|
||||
)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Plugin endpoint call failed", "plugin", p.name, "path", relPath, err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Write response headers from plugin
|
||||
for key, values := range resp.Headers {
|
||||
for _, v := range values {
|
||||
w.Header().Add(key, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Security hardening: override any plugin-set security headers
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox")
|
||||
|
||||
// Write status code (default to 200)
|
||||
status := int(resp.Status)
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
|
||||
// Write response body
|
||||
if len(resp.Body) > 0 {
|
||||
if _, err := w.Write(resp.Body); err != nil {
|
||||
log.Error(ctx, "Failed to write plugin endpoint response", "plugin", p.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
480
plugins/http_endpoint_test.go
Normal file
480
plugins/http_endpoint_test.go
Normal file
@@ -0,0 +1,480 @@
|
||||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// fakeNativeAuth is a mock native auth middleware that authenticates by looking up
|
||||
// the "X-Test-User" header and setting the user in the context.
|
||||
func fakeNativeAuth(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
username := r.Header.Get("X-Test-User")
|
||||
if username == "" {
|
||||
http.Error(w, "Not authenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
user, err := ds.User(r.Context()).FindByUsername(username)
|
||||
if err != nil {
|
||||
http.Error(w, "Not authenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
ctx := request.WithUser(r.Context(), *user)
|
||||
ctx = request.WithUsername(ctx, user.UserName)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fakeSubsonicAuth is a mock subsonic auth that validates by looking up
|
||||
// the "u" query parameter.
|
||||
func fakeSubsonicAuth(ds model.DataStore, r *http.Request) (*model.User, error) {
|
||||
username := r.URL.Query().Get("u")
|
||||
if username == "" {
|
||||
return nil, model.ErrInvalidAuth
|
||||
}
|
||||
user, err := ds.User(r.Context()).FindByUsername(username)
|
||||
if err != nil {
|
||||
return nil, model.ErrInvalidAuth
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
var _ = Describe("HTTP Endpoint Handler", Ordered, func() {
|
||||
var (
|
||||
manager *Manager
|
||||
tmpDir string
|
||||
userRepo *tests.MockedUserRepo
|
||||
dataStore *tests.MockDataStore
|
||||
router http.Handler
|
||||
)
|
||||
|
||||
BeforeAll(func() {
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "http-endpoint-test-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Copy all test plugins
|
||||
for _, pluginName := range []string{"test-http-endpoint", "test-http-endpoint-public", "test-http-endpoint-native"} {
|
||||
srcPath := filepath.Join(testdataDir, pluginName+PackageExtension)
|
||||
destPath := filepath.Join(tmpDir, pluginName+PackageExtension)
|
||||
data, err := os.ReadFile(srcPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = os.WriteFile(destPath, data, 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
// Setup config
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = tmpDir
|
||||
conf.Server.Plugins.AutoReload = false
|
||||
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
|
||||
|
||||
// Setup mock data store
|
||||
userRepo = tests.CreateMockUserRepo()
|
||||
dataStore = &tests.MockDataStore{MockedUser: userRepo}
|
||||
|
||||
// Add test users
|
||||
_ = userRepo.Put(&model.User{
|
||||
ID: "user1",
|
||||
UserName: "testuser",
|
||||
Name: "Test User",
|
||||
IsAdmin: false,
|
||||
})
|
||||
_ = userRepo.Put(&model.User{
|
||||
ID: "admin1",
|
||||
UserName: "adminuser",
|
||||
Name: "Admin User",
|
||||
IsAdmin: true,
|
||||
})
|
||||
|
||||
// Build enabled plugins list
|
||||
var enabledPlugins model.Plugins
|
||||
for _, pluginName := range []string{"test-http-endpoint", "test-http-endpoint-public", "test-http-endpoint-native"} {
|
||||
pluginPath := filepath.Join(tmpDir, pluginName+PackageExtension)
|
||||
data, err := os.ReadFile(pluginPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
hash := sha256.Sum256(data)
|
||||
hashHex := hex.EncodeToString(hash[:])
|
||||
|
||||
enabledPlugins = append(enabledPlugins, model.Plugin{
|
||||
ID: pluginName,
|
||||
Path: pluginPath,
|
||||
SHA256: hashHex,
|
||||
Enabled: true,
|
||||
AllUsers: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Setup mock plugin repo
|
||||
mockPluginRepo := dataStore.Plugin(GinkgoT().Context()).(*tests.MockPluginRepo)
|
||||
mockPluginRepo.Permitted = true
|
||||
mockPluginRepo.SetData(enabledPlugins)
|
||||
|
||||
// Create and start manager
|
||||
manager = &Manager{
|
||||
plugins: make(map[string]*plugin),
|
||||
ds: dataStore,
|
||||
metrics: noopMetricsRecorder{},
|
||||
subsonicRouter: http.NotFoundHandler(),
|
||||
}
|
||||
err = manager.Start(GinkgoT().Context())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Create the endpoint router with fake auth functions
|
||||
router = NewEndpointRouter(manager, dataStore, fakeSubsonicAuth, fakeNativeAuth)
|
||||
|
||||
DeferCleanup(func() {
|
||||
_ = manager.Stop()
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Plugin Loading", func() {
|
||||
It("loads the authenticated endpoint plugin", func() {
|
||||
manager.mu.RLock()
|
||||
p := manager.plugins["test-http-endpoint"]
|
||||
manager.mu.RUnlock()
|
||||
|
||||
Expect(p).ToNot(BeNil())
|
||||
Expect(p.manifest.Name).To(Equal("Test HTTP Endpoint Plugin"))
|
||||
Expect(p.manifest.Permissions.Endpoints).ToNot(BeNil())
|
||||
Expect(string(p.manifest.Permissions.Endpoints.Auth)).To(Equal("subsonic"))
|
||||
Expect(hasCapability(p.capabilities, CapabilityHTTPEndpoint)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("loads the native auth endpoint plugin", func() {
|
||||
manager.mu.RLock()
|
||||
p := manager.plugins["test-http-endpoint-native"]
|
||||
manager.mu.RUnlock()
|
||||
|
||||
Expect(p).ToNot(BeNil())
|
||||
Expect(p.manifest.Name).To(Equal("Test HTTP Endpoint Native Plugin"))
|
||||
Expect(p.manifest.Permissions.Endpoints).ToNot(BeNil())
|
||||
Expect(string(p.manifest.Permissions.Endpoints.Auth)).To(Equal("native"))
|
||||
Expect(hasCapability(p.capabilities, CapabilityHTTPEndpoint)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("loads the public endpoint plugin", func() {
|
||||
manager.mu.RLock()
|
||||
p := manager.plugins["test-http-endpoint-public"]
|
||||
manager.mu.RUnlock()
|
||||
|
||||
Expect(p).ToNot(BeNil())
|
||||
Expect(p.manifest.Name).To(Equal("Test HTTP Endpoint Public Plugin"))
|
||||
Expect(p.manifest.Permissions.Endpoints).ToNot(BeNil())
|
||||
Expect(string(p.manifest.Permissions.Endpoints.Auth)).To(Equal("none"))
|
||||
Expect(hasCapability(p.capabilities, CapabilityHTTPEndpoint)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Subsonic Auth Endpoints", func() {
|
||||
It("returns hello response with valid auth", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint/hello?u=testuser", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(Equal("Hello from plugin!"))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("text/plain"))
|
||||
})
|
||||
|
||||
It("returns echo response with request details", func() {
|
||||
req := httptest.NewRequest("POST", "/test-http-endpoint/echo?u=testuser&foo=bar", strings.NewReader("test body"))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
|
||||
|
||||
var resp map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp["method"]).To(Equal("POST"))
|
||||
Expect(resp["path"]).To(Equal("/echo"))
|
||||
Expect(resp["body"]).To(Equal("test body"))
|
||||
Expect(resp["hasUser"]).To(BeTrue())
|
||||
Expect(resp["username"]).To(Equal("testuser"))
|
||||
})
|
||||
|
||||
It("returns plugin-defined error status", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint/error?u=testuser", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
Expect(w.Body.String()).To(Equal("Something went wrong"))
|
||||
})
|
||||
|
||||
It("returns plugin 404 for unknown paths", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint/unknown?u=testuser", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
Expect(w.Body.String()).To(Equal("Not found: /unknown"))
|
||||
})
|
||||
|
||||
It("returns 401 without auth credentials", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint/hello", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("returns 401 with invalid auth credentials", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint/hello?u=nonexistent", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Native Auth Endpoints", func() {
|
||||
It("returns hello response with valid native auth", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint-native/hello", nil)
|
||||
req.Header.Set("X-Test-User", "testuser")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(Equal("Hello from native auth plugin!"))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("text/plain"))
|
||||
})
|
||||
|
||||
It("returns echo response with user details", func() {
|
||||
req := httptest.NewRequest("POST", "/test-http-endpoint-native/echo?foo=bar", strings.NewReader("native body"))
|
||||
req.Header.Set("X-Test-User", "adminuser")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
|
||||
|
||||
var resp map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp["method"]).To(Equal("POST"))
|
||||
Expect(resp["path"]).To(Equal("/echo"))
|
||||
Expect(resp["body"]).To(Equal("native body"))
|
||||
Expect(resp["hasUser"]).To(BeTrue())
|
||||
Expect(resp["username"]).To(Equal("adminuser"))
|
||||
})
|
||||
|
||||
It("returns 401 without auth header", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint-native/hello", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("returns 401 with invalid auth header", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint-native/hello", nil)
|
||||
req.Header.Set("X-Test-User", "nonexistent")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Public Endpoints (auth: none)", func() {
|
||||
It("returns webhook response without auth", func() {
|
||||
req := httptest.NewRequest("POST", "/test-http-endpoint-public/webhook", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(Equal("webhook received"))
|
||||
})
|
||||
|
||||
It("does not pass user info to public endpoints", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint-public/check-no-user", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(Equal("hasUser=false"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Security Headers", func() {
|
||||
It("includes security headers in authenticated endpoint responses", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint/hello?u=testuser", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("X-Content-Type-Options")).To(Equal("nosniff"))
|
||||
Expect(w.Header().Get("Content-Security-Policy")).To(Equal("default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"))
|
||||
})
|
||||
|
||||
It("includes security headers in public endpoint responses", func() {
|
||||
req := httptest.NewRequest("POST", "/test-http-endpoint-public/webhook", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("X-Content-Type-Options")).To(Equal("nosniff"))
|
||||
Expect(w.Header().Get("Content-Security-Policy")).To(Equal("default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"))
|
||||
})
|
||||
|
||||
It("overrides plugin-set security headers", func() {
|
||||
req := httptest.NewRequest("POST", "/test-http-endpoint/echo?u=testuser", strings.NewReader("body"))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("X-Content-Type-Options")).To(Equal("nosniff"))
|
||||
Expect(w.Header().Get("Content-Security-Policy")).To(Equal("default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Unknown Plugin", func() {
|
||||
It("returns 404 for nonexistent plugin", func() {
|
||||
req := httptest.NewRequest("GET", "/nonexistent-plugin/hello", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("User Authorization", func() {
|
||||
var restrictedRouter http.Handler
|
||||
|
||||
BeforeAll(func() {
|
||||
// Create a manager with a plugin restricted to specific users
|
||||
restrictedTmpDir, err := os.MkdirTemp("", "http-endpoint-restricted-test-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
srcPath := filepath.Join(testdataDir, "test-http-endpoint"+PackageExtension)
|
||||
destPath := filepath.Join(restrictedTmpDir, "test-http-endpoint"+PackageExtension)
|
||||
data, err := os.ReadFile(srcPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = os.WriteFile(destPath, data, 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
hash := sha256.Sum256(data)
|
||||
hashHex := hex.EncodeToString(hash[:])
|
||||
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = restrictedTmpDir
|
||||
conf.Server.Plugins.AutoReload = false
|
||||
conf.Server.CacheFolder = filepath.Join(restrictedTmpDir, "cache")
|
||||
|
||||
restrictedPluginRepo := tests.CreateMockPluginRepo()
|
||||
restrictedPluginRepo.Permitted = true
|
||||
restrictedPluginRepo.SetData(model.Plugins{{
|
||||
ID: "test-http-endpoint",
|
||||
Path: destPath,
|
||||
SHA256: hashHex,
|
||||
Enabled: true,
|
||||
AllUsers: false,
|
||||
Users: `["admin1"]`, // Only admin1 is allowed
|
||||
}})
|
||||
restrictedDS := &tests.MockDataStore{
|
||||
MockedPlugin: restrictedPluginRepo,
|
||||
MockedUser: userRepo,
|
||||
}
|
||||
|
||||
restrictedManager := &Manager{
|
||||
plugins: make(map[string]*plugin),
|
||||
ds: restrictedDS,
|
||||
metrics: noopMetricsRecorder{},
|
||||
subsonicRouter: http.NotFoundHandler(),
|
||||
}
|
||||
err = restrictedManager.Start(GinkgoT().Context())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
restrictedRouter = NewEndpointRouter(restrictedManager, restrictedDS, fakeSubsonicAuth, fakeNativeAuth)
|
||||
|
||||
DeferCleanup(func() {
|
||||
_ = restrictedManager.Stop()
|
||||
_ = os.RemoveAll(restrictedTmpDir)
|
||||
})
|
||||
})
|
||||
|
||||
It("allows authorized users", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint/hello?u=adminuser", nil)
|
||||
w := httptest.NewRecorder()
|
||||
restrictedRouter.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(Equal("Hello from plugin!"))
|
||||
})
|
||||
|
||||
It("denies unauthorized users", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint/hello?u=testuser", nil)
|
||||
w := httptest.NewRecorder()
|
||||
restrictedRouter.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Request without trailing path", func() {
|
||||
It("handles requests to plugin root", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint-public/webhook", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Binary Response", func() {
|
||||
It("returns raw binary data intact", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint/binary?u=testuser", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("image/png"))
|
||||
// PNG header bytes
|
||||
Expect(w.Body.Bytes()).To(Equal([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Request body handling", func() {
|
||||
It("passes request body to the plugin", func() {
|
||||
body := `{"event":"push","ref":"refs/heads/main"}`
|
||||
req := httptest.NewRequest("POST", "/test-http-endpoint/echo?u=testuser", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
respBody, err := io.ReadAll(w.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
var resp map[string]any
|
||||
err = json.Unmarshal(respBody, &resp)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp["body"]).To(Equal(body))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -260,19 +260,10 @@ func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Build user ID map for fast lookups
|
||||
userIDMap := make(map[string]struct{})
|
||||
for _, id := range plugin.allowedUserIDs {
|
||||
userIDMap[id] = struct{}{}
|
||||
}
|
||||
|
||||
// Create a new scrobbler adapter for this plugin with user authorization config
|
||||
// Create a new scrobbler adapter for this plugin
|
||||
return &ScrobblerPlugin{
|
||||
name: plugin.name,
|
||||
plugin: plugin,
|
||||
allowedUserIDs: plugin.allowedUserIDs,
|
||||
allUsers: plugin.allUsers,
|
||||
userIDMap: userIDMap,
|
||||
name: plugin.name,
|
||||
plugin: plugin,
|
||||
}, true
|
||||
}
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ var _ = Describe("purgeCacheBySize", func() {
|
||||
now := time.Now()
|
||||
|
||||
// Create 5 files, 1MiB each (total 5MiB)
|
||||
for i := 0; i < 5; i++ {
|
||||
for i := range 5 {
|
||||
path := filepath.Join(cacheDir, filepath.Join("dir", "file"+string(rune('0'+i))+".bin"))
|
||||
createFileWithSize(path, 1*1024*1024, now.Add(-time.Duration(5-i)*time.Hour))
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -60,39 +61,147 @@ func callPluginFunction[I any, O any](ctx context.Context, plugin *plugin, funcN
|
||||
startCall := time.Now()
|
||||
exit, output, err := p.CallWithContext(ctx, funcName, inputBytes)
|
||||
elapsed := time.Since(startCall)
|
||||
|
||||
success := false
|
||||
skipMetrics := false
|
||||
defer func() {
|
||||
if !skipMetrics {
|
||||
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, success, elapsed.Milliseconds())
|
||||
}
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
// If context was cancelled, return that error instead of the plugin error
|
||||
if ctx.Err() != nil {
|
||||
skipMetrics = true
|
||||
log.Debug(ctx, "Plugin call cancelled", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed)
|
||||
return result, ctx.Err()
|
||||
}
|
||||
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds())
|
||||
log.Trace(ctx, "Plugin call failed", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start), err)
|
||||
return result, fmt.Errorf("plugin call failed: %w", err)
|
||||
}
|
||||
if exit != 0 {
|
||||
if exit == notImplementedCode {
|
||||
skipMetrics = true
|
||||
log.Trace(ctx, "Plugin function not implemented", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start))
|
||||
// TODO Should we record metrics for not implemented calls?
|
||||
//plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, true, elapsed.Milliseconds())
|
||||
return result, fmt.Errorf("%w: %s", errNotImplemented, funcName)
|
||||
}
|
||||
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds())
|
||||
return result, fmt.Errorf("plugin call exited with code %d", exit)
|
||||
}
|
||||
|
||||
if len(output) > 0 {
|
||||
err = json.Unmarshal(output, &result)
|
||||
if err != nil {
|
||||
if err = json.Unmarshal(output, &result); err != nil {
|
||||
log.Trace(ctx, "Plugin call failed", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start), err)
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
|
||||
// Record metrics for successful calls (or JSON unmarshal failures)
|
||||
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, err == nil, elapsed.Milliseconds())
|
||||
|
||||
success = true
|
||||
log.Trace(ctx, "Plugin call succeeded", "plugin", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall), "navidromeDuration", startCall.Sub(start))
|
||||
return result, err
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// callPluginFunctionRaw calls a plugin function using binary framing for []byte fields.
|
||||
// The input is JSON-encoded (with []byte field excluded via json:"-"), followed by raw bytes.
|
||||
// The output frame is: [status:1B][json_len:4B][JSON][raw bytes] for success (0x00),
|
||||
// or [0x01][UTF-8 error message] for errors.
|
||||
func callPluginFunctionRaw[I any, O any](
|
||||
ctx context.Context, plugin *plugin, funcName string,
|
||||
input I, rawInputBytes []byte,
|
||||
setRawOutput func(*O, []byte),
|
||||
) (O, error) {
|
||||
start := time.Now()
|
||||
|
||||
var result O
|
||||
|
||||
p, err := plugin.instance(ctx)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("failed to create plugin: %w", err)
|
||||
}
|
||||
defer p.Close(ctx)
|
||||
|
||||
if !p.FunctionExists(funcName) {
|
||||
log.Trace(ctx, "Plugin function not found", "plugin", plugin.name, "function", funcName)
|
||||
return result, fmt.Errorf("%w: %s", errFunctionNotFound, funcName)
|
||||
}
|
||||
|
||||
// Build input frame: [json_len:4B][JSON][raw bytes]
|
||||
jsonBytes, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("failed to marshal input: %w", err)
|
||||
}
|
||||
const maxFrameSize = 2 << 20 // 2 MiB
|
||||
if len(jsonBytes) > maxFrameSize || len(rawInputBytes) > maxFrameSize {
|
||||
return result, fmt.Errorf("input frame too large")
|
||||
}
|
||||
frame := make([]byte, 4+len(jsonBytes)+len(rawInputBytes))
|
||||
binary.BigEndian.PutUint32(frame[:4], uint32(len(jsonBytes)))
|
||||
copy(frame[4:4+len(jsonBytes)], jsonBytes)
|
||||
copy(frame[4+len(jsonBytes):], rawInputBytes)
|
||||
|
||||
startCall := time.Now()
|
||||
exit, output, err := p.CallWithContext(ctx, funcName, frame)
|
||||
elapsed := time.Since(startCall)
|
||||
|
||||
success := false
|
||||
skipMetrics := false
|
||||
defer func() {
|
||||
if !skipMetrics {
|
||||
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, success, elapsed.Milliseconds())
|
||||
}
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
skipMetrics = true
|
||||
log.Debug(ctx, "Plugin call cancelled", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed)
|
||||
return result, ctx.Err()
|
||||
}
|
||||
log.Trace(ctx, "Plugin call failed", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start), err)
|
||||
return result, fmt.Errorf("plugin call failed: %w", err)
|
||||
}
|
||||
if exit != 0 {
|
||||
if exit == notImplementedCode {
|
||||
skipMetrics = true
|
||||
log.Trace(ctx, "Plugin function not implemented", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start))
|
||||
return result, fmt.Errorf("%w: %s", errNotImplemented, funcName)
|
||||
}
|
||||
return result, fmt.Errorf("plugin call exited with code %d", exit)
|
||||
}
|
||||
|
||||
// Parse output frame
|
||||
if len(output) < 1 {
|
||||
return result, fmt.Errorf("empty response from plugin")
|
||||
}
|
||||
|
||||
statusByte := output[0]
|
||||
if statusByte == 0x01 {
|
||||
return result, fmt.Errorf("plugin error: %s", string(output[1:]))
|
||||
}
|
||||
if statusByte != 0x00 {
|
||||
return result, fmt.Errorf("unknown response status byte: 0x%02x", statusByte)
|
||||
}
|
||||
|
||||
// Success frame: [0x00][json_len:4B][JSON][raw bytes]
|
||||
if len(output) < 5 {
|
||||
return result, fmt.Errorf("malformed success response from plugin")
|
||||
}
|
||||
|
||||
jsonLen := binary.BigEndian.Uint32(output[1:5])
|
||||
if uint32(len(output)-5) < jsonLen {
|
||||
return result, fmt.Errorf("invalid json length in response frame: %d exceeds available %d bytes", jsonLen, len(output)-5)
|
||||
}
|
||||
jsonData := output[5 : 5+jsonLen]
|
||||
rawData := output[5+jsonLen:]
|
||||
|
||||
if err := json.Unmarshal(jsonData, &result); err != nil {
|
||||
return result, fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
setRawOutput(&result, rawData)
|
||||
|
||||
success = true
|
||||
log.Trace(ctx, "Plugin call succeeded", "plugin", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall), "navidromeDuration", startCall.Sub(start))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// extismLogger is a helper to log messages from Extism plugins
|
||||
|
||||
@@ -24,10 +24,9 @@ type serviceContext struct {
|
||||
manager *Manager
|
||||
permissions *Permissions
|
||||
config map[string]string
|
||||
allowedUsers []string // User IDs this plugin can access
|
||||
allUsers bool // If true, plugin can access all users
|
||||
allowedLibraries []int // Library IDs this plugin can access
|
||||
allLibraries bool // If true, plugin can access all libraries
|
||||
userAccess UserAccess // User authorization for this plugin
|
||||
allowedLibraries []int // Library IDs this plugin can access
|
||||
allLibraries bool // If true, plugin can access all libraries
|
||||
}
|
||||
|
||||
// hostServiceEntry defines a host service for table-driven registration.
|
||||
@@ -52,7 +51,7 @@ var hostServices = []hostServiceEntry{
|
||||
name: "SubsonicAPI",
|
||||
hasPermission: func(p *Permissions) bool { return p != nil && p.Subsonicapi != nil },
|
||||
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
|
||||
service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, ctx.allowedUsers, ctx.allUsers)
|
||||
service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, ctx.userAccess)
|
||||
return host.RegisterSubsonicAPIHostFunctions(service), nil
|
||||
},
|
||||
},
|
||||
@@ -115,7 +114,7 @@ var hostServices = []hostServiceEntry{
|
||||
name: "Users",
|
||||
hasPermission: func(p *Permissions) bool { return p != nil && p.Users != nil },
|
||||
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
|
||||
service := newUsersService(ctx.manager.ds, ctx.allowedUsers, ctx.allUsers)
|
||||
service := newUsersService(ctx.manager.ds, ctx.userAccess)
|
||||
return host.RegisterUsersHostFunctions(service), nil
|
||||
},
|
||||
},
|
||||
@@ -302,13 +301,14 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
var hostFunctions []extism.HostFunction
|
||||
var closers []io.Closer
|
||||
|
||||
userAccess := NewUserAccess(p.AllUsers, allowedUsers)
|
||||
|
||||
svcCtx := &serviceContext{
|
||||
pluginName: p.ID,
|
||||
manager: m,
|
||||
permissions: pkg.Manifest.Permissions,
|
||||
config: pluginConfig,
|
||||
allowedUsers: allowedUsers,
|
||||
allUsers: p.AllUsers,
|
||||
userAccess: userAccess,
|
||||
allowedLibraries: allowedLibraries,
|
||||
allLibraries: p.AllLibraries,
|
||||
}
|
||||
@@ -361,15 +361,14 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
|
||||
m.mu.Lock()
|
||||
m.plugins[p.ID] = &plugin{
|
||||
name: p.ID,
|
||||
path: p.Path,
|
||||
manifest: pkg.Manifest,
|
||||
compiled: compiled,
|
||||
capabilities: capabilities,
|
||||
closers: closers,
|
||||
metrics: m.metrics,
|
||||
allowedUserIDs: allowedUsers,
|
||||
allUsers: p.AllUsers,
|
||||
name: p.ID,
|
||||
path: p.Path,
|
||||
manifest: pkg.Manifest,
|
||||
compiled: compiled,
|
||||
capabilities: capabilities,
|
||||
closers: closers,
|
||||
metrics: m.metrics,
|
||||
userAccess: userAccess,
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user