mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Merge branch 'navidrome:master' into msi-insights-detection
This commit is contained in:
commit
6f560c7bee
2
.github/workflows/pipeline.yml
vendored
2
.github/workflows/pipeline.yml
vendored
@ -157,6 +157,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
- run: ./.github/workflows/validate-translations.sh -v
|
||||
|
||||
|
||||
check-push-enabled:
|
||||
name: Check Docker configuration
|
||||
|
||||
236
.github/workflows/validate-translations.sh
vendored
Executable file
236
.github/workflows/validate-translations.sh
vendored
Executable file
@ -0,0 +1,236 @@
|
||||
#!/bin/bash
|
||||
|
||||
# validate-translations.sh
|
||||
#
|
||||
# This script validates the structure of JSON translation files by comparing them
|
||||
# against the reference English translation file (ui/src/i18n/en.json).
|
||||
#
|
||||
# The script performs the following validations:
|
||||
# 1. JSON syntax validation using jq
|
||||
# 2. Structural validation - ensures all keys from English file are present
|
||||
# 3. Reports missing keys (translation incomplete)
|
||||
# 4. Reports extra keys (keys not in English reference, possibly deprecated)
|
||||
# 5. Emits GitHub Actions annotations for CI/CD integration
|
||||
#
|
||||
# Usage:
|
||||
# ./validate-translations.sh
|
||||
#
|
||||
# Environment Variables:
|
||||
# EN_FILE - Path to reference English file (default: ui/src/i18n/en.json)
|
||||
# TRANSLATION_DIR - Directory containing translation files (default: resources/i18n)
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 - All translations are valid
|
||||
# 1 - One or more translations have structural issues
|
||||
#
|
||||
# GitHub Actions Integration:
|
||||
# The script outputs GitHub Actions annotations using ::error and ::warning
|
||||
# format that will be displayed in PR checks and workflow summaries.
|
||||
|
||||
# Script to validate JSON translation files structure against en.json
|
||||
set -e
|
||||
|
||||
# Path to the reference English translation file
|
||||
EN_FILE="${EN_FILE:-ui/src/i18n/en.json}"
|
||||
TRANSLATION_DIR="${TRANSLATION_DIR:-resources/i18n}"
|
||||
VERBOSE=false
|
||||
|
||||
# Parse command line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-v|--verbose)
|
||||
VERBOSE=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: $0 [options]"
|
||||
echo ""
|
||||
echo "Validates JSON translation files structure against English reference file."
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -h, --help Show this help message"
|
||||
echo " -v, --verbose Show detailed output (default: only show errors)"
|
||||
echo ""
|
||||
echo "Environment Variables:"
|
||||
echo " EN_FILE Path to reference English file (default: ui/src/i18n/en.json)"
|
||||
echo " TRANSLATION_DIR Directory with translation files (default: resources/i18n)"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 # Validate all translation files (quiet mode)"
|
||||
echo " $0 -v # Validate with detailed output"
|
||||
echo " EN_FILE=custom/en.json $0 # Use custom reference file"
|
||||
echo " TRANSLATION_DIR=custom/i18n $0 # Use custom translations directory"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
echo "Use --help for usage information" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Color codes for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
if [[ "$VERBOSE" == "true" ]]; then
|
||||
echo "Validating translation files structure against ${EN_FILE}..."
|
||||
fi
|
||||
|
||||
# Check if English reference file exists
|
||||
if [[ ! -f "$EN_FILE" ]]; then
|
||||
echo "::error::Reference file $EN_FILE not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Function to extract all JSON keys from a file, creating a flat list of dot-separated paths
|
||||
extract_keys() {
|
||||
local file="$1"
|
||||
jq -r 'paths(scalars) as $p | $p | join(".")' "$file" 2>/dev/null | sort
|
||||
}
|
||||
|
||||
# Function to extract all non-empty string keys (to identify structural issues)
|
||||
extract_structure_keys() {
|
||||
local file="$1"
|
||||
# Get only keys where values are not empty strings
|
||||
jq -r 'paths(scalars) as $p | select(getpath($p) != "") | $p | join(".")' "$file" 2>/dev/null | sort
|
||||
}
|
||||
|
||||
# Function to validate a single translation file
|
||||
validate_translation() {
|
||||
local translation_file="$1"
|
||||
local filename=$(basename "$translation_file")
|
||||
local has_errors=false
|
||||
local verbose=${2:-false}
|
||||
|
||||
if [[ "$verbose" == "true" ]]; then
|
||||
echo "Validating $filename..."
|
||||
fi
|
||||
|
||||
# First validate JSON syntax
|
||||
if ! jq empty "$translation_file" 2>/dev/null; then
|
||||
echo "::error file=$translation_file::Invalid JSON syntax"
|
||||
echo -e "${RED}✗ $filename has invalid JSON syntax${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Extract all keys from both files (for statistics)
|
||||
local en_keys_file=$(mktemp)
|
||||
local translation_keys_file=$(mktemp)
|
||||
|
||||
extract_keys "$EN_FILE" > "$en_keys_file"
|
||||
extract_keys "$translation_file" > "$translation_keys_file"
|
||||
|
||||
# Extract only non-empty structure keys (to validate structural issues)
|
||||
local en_structure_file=$(mktemp)
|
||||
local translation_structure_file=$(mktemp)
|
||||
|
||||
extract_structure_keys "$EN_FILE" > "$en_structure_file"
|
||||
extract_structure_keys "$translation_file" > "$translation_structure_file"
|
||||
|
||||
# Find structural issues: keys in translation not in English (misplaced)
|
||||
local extra_keys=$(comm -13 "$en_keys_file" "$translation_keys_file")
|
||||
|
||||
# Find missing keys (for statistics only)
|
||||
local missing_keys=$(comm -23 "$en_keys_file" "$translation_keys_file")
|
||||
|
||||
# Count keys for statistics
|
||||
local total_en_keys=$(wc -l < "$en_keys_file")
|
||||
local total_translation_keys=$(wc -l < "$translation_keys_file")
|
||||
local missing_count=0
|
||||
local extra_count=0
|
||||
|
||||
if [[ -n "$missing_keys" ]]; then
|
||||
missing_count=$(echo "$missing_keys" | grep -c '^' || echo 0)
|
||||
fi
|
||||
|
||||
if [[ -n "$extra_keys" ]]; then
|
||||
extra_count=$(echo "$extra_keys" | grep -c '^' || echo 0)
|
||||
has_errors=true
|
||||
fi
|
||||
|
||||
# Report extra/misplaced keys (these are structural issues)
|
||||
if [[ -n "$extra_keys" ]]; then
|
||||
if [[ "$verbose" == "true" ]]; then
|
||||
echo -e "${YELLOW}Misplaced keys in $filename ($extra_count):${NC}"
|
||||
fi
|
||||
|
||||
while IFS= read -r key; do
|
||||
# Try to find the line number
|
||||
line=$(grep -n "\"$(echo "$key" | sed 's/.*\.//')" "$translation_file" | head -1 | cut -d: -f1)
|
||||
line=${line:-1} # Default to line 1 if not found
|
||||
|
||||
echo "::error file=$translation_file,line=$line::Misplaced key: $key"
|
||||
|
||||
if [[ "$verbose" == "true" ]]; then
|
||||
echo " + $key (line ~$line)"
|
||||
fi
|
||||
done <<< "$extra_keys"
|
||||
fi
|
||||
|
||||
# Clean up temp files
|
||||
rm -f "$en_keys_file" "$translation_keys_file" "$en_structure_file" "$translation_structure_file"
|
||||
|
||||
# Print statistics
|
||||
if [[ "$verbose" == "true" ]]; then
|
||||
echo " Keys: $total_translation_keys/$total_en_keys (Missing: $missing_count, Extra/Misplaced: $extra_count)"
|
||||
|
||||
if [[ "$has_errors" == "true" ]]; then
|
||||
echo -e "${RED}✗ $filename has structural issues${NC}"
|
||||
else
|
||||
echo -e "${GREEN}✓ $filename structure is valid${NC}"
|
||||
fi
|
||||
elif [[ "$has_errors" == "true" ]]; then
|
||||
echo -e "${RED}✗ $filename has structural issues (Extra/Misplaced: $extra_count)${NC}"
|
||||
fi
|
||||
|
||||
return $([[ "$has_errors" == "true" ]] && echo 1 || echo 0)
|
||||
}
|
||||
|
||||
# Main validation loop
|
||||
validation_failed=false
|
||||
total_files=0
|
||||
failed_files=0
|
||||
valid_files=0
|
||||
|
||||
for translation_file in "$TRANSLATION_DIR"/*.json; do
|
||||
if [[ -f "$translation_file" ]]; then
|
||||
total_files=$((total_files + 1))
|
||||
if ! validate_translation "$translation_file" "$VERBOSE"; then
|
||||
validation_failed=true
|
||||
failed_files=$((failed_files + 1))
|
||||
else
|
||||
valid_files=$((valid_files + 1))
|
||||
fi
|
||||
|
||||
if [[ "$VERBOSE" == "true" ]]; then
|
||||
echo "" # Add spacing between files
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Summary
|
||||
if [[ "$VERBOSE" == "true" ]]; then
|
||||
echo "========================================="
|
||||
echo "Translation Validation Summary:"
|
||||
echo " Total files: $total_files"
|
||||
echo " Valid files: $valid_files"
|
||||
echo " Files with structural issues: $failed_files"
|
||||
echo "========================================="
|
||||
fi
|
||||
|
||||
if [[ "$validation_failed" == "true" ]]; then
|
||||
if [[ "$VERBOSE" == "true" ]]; then
|
||||
echo -e "${RED}Translation validation failed - $failed_files file(s) have structural issues${NC}"
|
||||
else
|
||||
echo -e "${RED}Translation validation failed - $failed_files/$total_files file(s) have structural issues${NC}"
|
||||
fi
|
||||
exit 1
|
||||
elif [[ "$VERBOSE" == "true" ]]; then
|
||||
echo -e "${GREEN}All translation files are structurally valid${NC}"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
19
Makefile
19
Makefile
@ -41,14 +41,21 @@ test: ##@Development Run Go tests
|
||||
go test -tags netgo $(PKG)
|
||||
.PHONY: test
|
||||
|
||||
testrace: ##@Development Run Go tests with race detector
|
||||
go test -tags netgo -race -shuffle=on ./...
|
||||
.PHONY: test
|
||||
|
||||
testall: testrace ##@Development Run Go and JS tests
|
||||
@(cd ./ui && npm run test)
|
||||
testall: test-race test-i18n test-js ##@Development Run Go and JS tests
|
||||
.PHONY: testall
|
||||
|
||||
test-race: ##@Development Run Go tests with race detector
|
||||
go test -tags netgo -race -shuffle=on ./...
|
||||
.PHONY: test-race
|
||||
|
||||
test-js: ##@Development Run JS tests
|
||||
@(cd ./ui && npm run test)
|
||||
.PHONY: test-js
|
||||
|
||||
test-i18n: ##@Development Validate all translations files
|
||||
./.github/workflows/validate-translations.sh
|
||||
.PHONY: test-i18n
|
||||
|
||||
install-golangci-lint: ##@Development Install golangci-lint if not present
|
||||
@PATH=$$PATH:./bin which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s v2.1.6)
|
||||
.PHONY: install-golangci-lint
|
||||
|
||||
@ -79,22 +79,29 @@ var _ = Describe("Extractor", func() {
|
||||
|
||||
var e *extractor
|
||||
|
||||
parseTestFile := func(path string) *model.MediaFile {
|
||||
mds, err := e.Parse(path)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
info, ok := mds[path]
|
||||
Expect(ok).To(BeTrue())
|
||||
|
||||
fileInfo, err := os.Stat(path)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
info.FileInfo = testFileInfo{FileInfo: fileInfo}
|
||||
|
||||
metadata := metadata.New(path, info)
|
||||
mf := metadata.ToMediaFile(1, "folderID")
|
||||
return &mf
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
e = &extractor{}
|
||||
})
|
||||
|
||||
Describe("ReplayGain", func() {
|
||||
DescribeTable("test replaygain end-to-end", func(file string, trackGain, trackPeak, albumGain, albumPeak *float64) {
|
||||
path := "tests/fixtures/" + file
|
||||
mds, err := e.Parse(path)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
info := mds[path]
|
||||
fileInfo, _ := os.Stat(path)
|
||||
info.FileInfo = testFileInfo{FileInfo: fileInfo}
|
||||
|
||||
metadata := metadata.New(path, info)
|
||||
mf := metadata.ToMediaFile(1, "folderID")
|
||||
mf := parseTestFile("tests/fixtures/" + file)
|
||||
|
||||
Expect(mf.RGTrackGain).To(Equal(trackGain))
|
||||
Expect(mf.RGTrackPeak).To(Equal(trackPeak))
|
||||
@ -106,18 +113,82 @@ var _ = Describe("Extractor", func() {
|
||||
)
|
||||
})
|
||||
|
||||
Describe("lyrics", func() {
|
||||
makeLyrics := func(code, secondLine string) model.Lyrics {
|
||||
return model.Lyrics{
|
||||
DisplayArtist: "",
|
||||
DisplayTitle: "",
|
||||
Lang: code,
|
||||
Line: []model.Line{
|
||||
{Start: gg.P(int64(0)), Value: "This is"},
|
||||
{Start: gg.P(int64(2500)), Value: secondLine},
|
||||
},
|
||||
Offset: nil,
|
||||
Synced: true,
|
||||
}
|
||||
}
|
||||
|
||||
It("should fetch both synced and unsynced lyrics in mixed flac", func() {
|
||||
mf := parseTestFile("tests/fixtures/mixed-lyrics.flac")
|
||||
|
||||
lyrics, err := mf.StructuredLyrics()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).To(HaveLen(2))
|
||||
|
||||
Expect(lyrics[0].Synced).To(BeTrue())
|
||||
Expect(lyrics[1].Synced).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should handle mp3 with uslt and sylt", func() {
|
||||
mf := parseTestFile("tests/fixtures/test.mp3")
|
||||
|
||||
lyrics, err := mf.StructuredLyrics()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).To(HaveLen(4))
|
||||
|
||||
engSylt := makeLyrics("eng", "English SYLT")
|
||||
engUslt := makeLyrics("eng", "English")
|
||||
unsSylt := makeLyrics("xxx", "unspecified SYLT")
|
||||
unsUslt := makeLyrics("xxx", "unspecified")
|
||||
|
||||
// Why is the order inconsistent between runs? Nobody knows
|
||||
Expect(lyrics).To(Or(
|
||||
Equal(model.LyricList{engSylt, engUslt, unsSylt, unsUslt}),
|
||||
Equal(model.LyricList{unsSylt, unsUslt, engSylt, engUslt}),
|
||||
))
|
||||
})
|
||||
|
||||
DescribeTable("format-specific lyrics", func(file string, isId3 bool) {
|
||||
mf := parseTestFile("tests/fixtures/" + file)
|
||||
|
||||
lyrics, err := mf.StructuredLyrics()
|
||||
Expect(err).To(Not(HaveOccurred()))
|
||||
Expect(lyrics).To(HaveLen(2))
|
||||
|
||||
unspec := makeLyrics("xxx", "unspecified")
|
||||
eng := makeLyrics("xxx", "English")
|
||||
|
||||
if isId3 {
|
||||
eng.Lang = "eng"
|
||||
}
|
||||
|
||||
Expect(lyrics).To(Or(
|
||||
Equal(model.LyricList{unspec, eng}),
|
||||
Equal(model.LyricList{eng, unspec})))
|
||||
},
|
||||
Entry("flac", "test.flac", false),
|
||||
Entry("m4a", "test.m4a", false),
|
||||
Entry("ogg", "test.ogg", false),
|
||||
Entry("wma", "test.wma", false),
|
||||
Entry("wv", "test.wv", false),
|
||||
Entry("wav", "test.wav", true),
|
||||
Entry("aiff", "test.aiff", true),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("Participants", func() {
|
||||
DescribeTable("test tags consistent across formats", func(format string) {
|
||||
path := "tests/fixtures/test." + format
|
||||
mds, err := e.Parse(path)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
info := mds[path]
|
||||
fileInfo, _ := os.Stat(path)
|
||||
info.FileInfo = testFileInfo{FileInfo: fileInfo}
|
||||
|
||||
metadata := metadata.New(path, info)
|
||||
mf := metadata.ToMediaFile(1, "folderID")
|
||||
mf := parseTestFile("tests/fixtures/test." + format)
|
||||
|
||||
for _, data := range roles {
|
||||
role := data.Role
|
||||
@ -168,11 +239,40 @@ var _ = Describe("Extractor", func() {
|
||||
Entry("FLAC format", "flac"),
|
||||
Entry("M4a format", "m4a"),
|
||||
Entry("OGG format", "ogg"),
|
||||
Entry("WMA format", "wv"),
|
||||
Entry("WV format", "wv"),
|
||||
|
||||
Entry("MP3 format", "mp3"),
|
||||
Entry("WAV format", "wav"),
|
||||
Entry("AIFF format", "aiff"),
|
||||
)
|
||||
|
||||
It("should parse wma", func() {
|
||||
mf := parseTestFile("tests/fixtures/test.wma")
|
||||
|
||||
for _, data := range roles {
|
||||
role := data.Role
|
||||
artists := data.ParticipantList
|
||||
actual := mf.Participants[role]
|
||||
|
||||
// WMA has no Arranger role
|
||||
if role == model.RoleArranger {
|
||||
Expect(actual).To(HaveLen(0))
|
||||
continue
|
||||
}
|
||||
|
||||
Expect(actual).To(HaveLen(len(artists)), role.String())
|
||||
|
||||
// For some bizarre reason, the order is inverted. We also don't get
|
||||
// sort names or MBIDs
|
||||
for i := range artists {
|
||||
idx := len(artists) - 1 - i
|
||||
|
||||
actualArtist := actual[i]
|
||||
expectedArtist := artists[idx]
|
||||
|
||||
Expect(actualArtist.Name).To(Equal(expectedArtist.Name))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -179,7 +179,7 @@ var _ = Describe("Extractor", func() {
|
||||
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),
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=800:duration=1" test.wv
|
||||
Entry("correctly parses wv (wavpak) tags", "test.wv", "1s", 1, 44100, 16, "3.43 dB", "0.125061", "3.43 dB", "0.125061", false, false),
|
||||
Entry("correctly parses wv (wavpak) tags", "test.wv", "1s", 1, 44100, 16, "3.43 dB", "0.125061", "3.43 dB", "0.125061", false, true),
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=1000:duration=1" test.wav
|
||||
Entry("correctly parses wav tags", "test.wav", "1s", 1, 44100, 16, "3.06 dB", "0.125056", "3.06 dB", "0.125056", true, true),
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <typeinfo>
|
||||
|
||||
#define TAGLIB_STATIC
|
||||
#include <apeproperties.h>
|
||||
@ -113,7 +112,7 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
|
||||
strncpy(language, bv.data(), 3);
|
||||
}
|
||||
|
||||
char *val = (char *)frame->text().toCString(true);
|
||||
char *val = const_cast<char*>(frame->text().toCString(true));
|
||||
|
||||
goPutLyrics(id, language, val);
|
||||
}
|
||||
@ -132,7 +131,7 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
|
||||
if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMilliseconds) {
|
||||
|
||||
for (const auto &line: frame->synchedText()) {
|
||||
char *text = (char *)line.text.toCString(true);
|
||||
char *text = const_cast<char*>(line.text.toCString(true));
|
||||
goPutLyricLine(id, language, text, line.time);
|
||||
}
|
||||
} else if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMpegFrames) {
|
||||
@ -141,7 +140,7 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
|
||||
if (sampleRate != 0) {
|
||||
for (const auto &line: frame->synchedText()) {
|
||||
const int timeInMs = (line.time * 1000) / sampleRate;
|
||||
char *text = (char *)line.text.toCString(true);
|
||||
char *text = const_cast<char*>(line.text.toCString(true));
|
||||
goPutLyricLine(id, language, text, timeInMs);
|
||||
}
|
||||
}
|
||||
@ -160,9 +159,9 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
|
||||
if (m4afile != NULL) {
|
||||
const auto itemListMap = m4afile->tag()->itemMap();
|
||||
for (const auto item: itemListMap) {
|
||||
char *key = (char *)item.first.toCString(true);
|
||||
char *key = const_cast<char*>(item.first.toCString(true));
|
||||
for (const auto value: item.second.toStringList()) {
|
||||
char *val = (char *)value.toCString(true);
|
||||
char *val = const_cast<char*>(value.toCString(true));
|
||||
goPutM4AStr(id, key, val);
|
||||
}
|
||||
}
|
||||
@ -174,17 +173,24 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
|
||||
const TagLib::ASF::Tag *asfTags{asfFile->tag()};
|
||||
const auto itemListMap = asfTags->attributeListMap();
|
||||
for (const auto item : itemListMap) {
|
||||
tags.insert(item.first, item.second.front().toString());
|
||||
char *key = const_cast<char*>(item.first.toCString(true));
|
||||
|
||||
for (auto j = item.second.begin();
|
||||
j != item.second.end(); ++j) {
|
||||
|
||||
char *val = const_cast<char*>(j->toString().toCString(true));
|
||||
goPutStr(id, key, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send all collected tags to the Go map
|
||||
for (TagLib::PropertyMap::ConstIterator i = tags.begin(); i != tags.end();
|
||||
++i) {
|
||||
char *key = (char *)i->first.toCString(true);
|
||||
char *key = const_cast<char*>(i->first.toCString(true));
|
||||
for (TagLib::StringList::ConstIterator j = i->second.begin();
|
||||
j != i->second.end(); ++j) {
|
||||
char *val = (char *)(*j).toCString(true);
|
||||
char *val = const_cast<char*>((*j).toCString(true));
|
||||
goPutStr(id, key, val);
|
||||
}
|
||||
}
|
||||
@ -242,7 +248,19 @@ char has_cover(const TagLib::FileRef f) {
|
||||
// ----- WMA
|
||||
else if (TagLib::ASF::File * asfFile{dynamic_cast<TagLib::ASF::File *>(f.file())}) {
|
||||
const TagLib::ASF::Tag *tag{ asfFile->tag() };
|
||||
hasCover = tag && asfFile->tag()->attributeListMap().contains("WM/Picture");
|
||||
hasCover = tag && tag->attributeListMap().contains("WM/Picture");
|
||||
}
|
||||
// ----- DSF
|
||||
else if (TagLib::DSF::File * dsffile{ dynamic_cast<TagLib::DSF::File *>(f.file())}) {
|
||||
const TagLib::ID3v2::Tag *tag { dsffile->tag() };
|
||||
hasCover = tag && !tag->frameListMap()["APIC"].isEmpty();
|
||||
}
|
||||
// ----- WAVPAK (APE tag)
|
||||
else if (TagLib::WavPack::File * wvFile{dynamic_cast<TagLib::WavPack::File *>(f.file())}) {
|
||||
if (wvFile->hasAPETag()) {
|
||||
// This is the particular string that Picard uses
|
||||
hasCover = !wvFile->APETag()->itemListMap()["COVER ART (FRONT)"].isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
return hasCover;
|
||||
|
||||
@ -175,7 +175,7 @@ func GetPlaybackServer() playback.PlaybackServer {
|
||||
return playbackServer
|
||||
}
|
||||
|
||||
func getPluginManager() *plugins.Manager {
|
||||
func getPluginManager() plugins.Manager {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
@ -185,9 +185,9 @@ func getPluginManager() *plugins.Manager {
|
||||
|
||||
// wire_injectors.go:
|
||||
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.NewWatcher, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)))
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.NewWatcher, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)))
|
||||
|
||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||
func GetPluginManager(ctx context.Context) plugins.Manager {
|
||||
manager := getPluginManager()
|
||||
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
|
||||
return manager
|
||||
|
||||
@ -42,8 +42,8 @@ var allProviders = wire.NewSet(
|
||||
plugins.GetManager,
|
||||
metrics.GetPrometheusInstance,
|
||||
db.Db,
|
||||
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
|
||||
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
|
||||
wire.Bind(new(agents.PluginLoader), new(plugins.Manager)),
|
||||
wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)),
|
||||
)
|
||||
|
||||
func CreateDataStore() model.DataStore {
|
||||
@ -118,13 +118,13 @@ func GetPlaybackServer() playback.PlaybackServer {
|
||||
))
|
||||
}
|
||||
|
||||
func getPluginManager() *plugins.Manager {
|
||||
func getPluginManager() plugins.Manager {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||
func GetPluginManager(ctx context.Context) plugins.Manager {
|
||||
manager := getPluginManager()
|
||||
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
|
||||
return manager
|
||||
|
||||
@ -264,13 +264,15 @@ func Load(noConfigDump bool) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if Server.Plugins.Folder == "" {
|
||||
Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins")
|
||||
}
|
||||
err = os.MkdirAll(Server.Plugins.Folder, 0700)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating plugins path:", err)
|
||||
os.Exit(1)
|
||||
if Server.Plugins.Enabled {
|
||||
if Server.Plugins.Folder == "" {
|
||||
Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins")
|
||||
}
|
||||
err = os.MkdirAll(Server.Plugins.Folder, 0700)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating plugins path:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Server.ConfigFile = viper.GetViper().ConfigFileUsed()
|
||||
|
||||
@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@ -23,54 +22,9 @@ type PluginLoader interface {
|
||||
LoadMediaAgent(name string) (Interface, bool)
|
||||
}
|
||||
|
||||
type cachedAgent struct {
|
||||
agent Interface
|
||||
expiration time.Time
|
||||
}
|
||||
|
||||
// Encapsulates agent caching logic
|
||||
// agentCache is a simple TTL cache for agents
|
||||
// Not exported, only used by Agents
|
||||
|
||||
type agentCache struct {
|
||||
mu sync.Mutex
|
||||
items map[string]cachedAgent
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// TTL for cached agents
|
||||
const agentCacheTTL = 5 * time.Minute
|
||||
|
||||
func newAgentCache(ttl time.Duration) *agentCache {
|
||||
return &agentCache{
|
||||
items: make(map[string]cachedAgent),
|
||||
ttl: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *agentCache) Get(name string) Interface {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
cached, ok := c.items[name]
|
||||
if ok && cached.expiration.After(time.Now()) {
|
||||
return cached.agent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *agentCache) Set(name string, agent Interface) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.items[name] = cachedAgent{
|
||||
agent: agent,
|
||||
expiration: time.Now().Add(c.ttl),
|
||||
}
|
||||
}
|
||||
|
||||
type Agents struct {
|
||||
ds model.DataStore
|
||||
pluginLoader PluginLoader
|
||||
cache *agentCache
|
||||
}
|
||||
|
||||
// GetAgents returns the singleton instance of Agents
|
||||
@ -85,18 +39,24 @@ func createAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents {
|
||||
return &Agents{
|
||||
ds: ds,
|
||||
pluginLoader: pluginLoader,
|
||||
cache: newAgentCache(agentCacheTTL),
|
||||
}
|
||||
}
|
||||
|
||||
// getEnabledAgentNames returns the current list of enabled agent names, including:
|
||||
// enabledAgent represents an enabled agent with its type information
|
||||
type enabledAgent struct {
|
||||
name string
|
||||
isPlugin bool
|
||||
}
|
||||
|
||||
// getEnabledAgentNames returns the current list of enabled agents, including:
|
||||
// 1. Built-in agents and plugins from config (in the specified order)
|
||||
// 2. Always include LocalAgentName
|
||||
// 3. If config is empty, include ONLY LocalAgentName
|
||||
func (a *Agents) getEnabledAgentNames() []string {
|
||||
// Each enabledAgent contains the name and whether it's a plugin (true) or built-in (false)
|
||||
func (a *Agents) getEnabledAgentNames() []enabledAgent {
|
||||
// If no agents configured, ONLY use the local agent
|
||||
if conf.Server.Agents == "" {
|
||||
return []string{LocalAgentName}
|
||||
return []enabledAgent{{name: LocalAgentName, isPlugin: false}}
|
||||
}
|
||||
|
||||
// Get all available plugin names
|
||||
@ -108,19 +68,13 @@ func (a *Agents) getEnabledAgentNames() []string {
|
||||
configuredAgents := strings.Split(conf.Server.Agents, ",")
|
||||
|
||||
// Always add LocalAgentName if not already included
|
||||
hasLocalAgent := false
|
||||
for _, name := range configuredAgents {
|
||||
if name == LocalAgentName {
|
||||
hasLocalAgent = true
|
||||
break
|
||||
}
|
||||
}
|
||||
hasLocalAgent := slices.Contains(configuredAgents, LocalAgentName)
|
||||
if !hasLocalAgent {
|
||||
configuredAgents = append(configuredAgents, LocalAgentName)
|
||||
}
|
||||
|
||||
// Filter to only include valid agents (built-in or plugins)
|
||||
var validNames []string
|
||||
var validAgents []enabledAgent
|
||||
for _, name := range configuredAgents {
|
||||
// Check if it's a built-in agent
|
||||
isBuiltIn := Map[name] != nil
|
||||
@ -128,39 +82,35 @@ func (a *Agents) getEnabledAgentNames() []string {
|
||||
// Check if it's a plugin
|
||||
isPlugin := slices.Contains(availablePlugins, name)
|
||||
|
||||
if isBuiltIn || isPlugin {
|
||||
validNames = append(validNames, name)
|
||||
if isBuiltIn {
|
||||
validAgents = append(validAgents, enabledAgent{name: name, isPlugin: false})
|
||||
} else if isPlugin {
|
||||
validAgents = append(validAgents, enabledAgent{name: name, isPlugin: true})
|
||||
} else {
|
||||
log.Warn("Unknown agent ignored", "name", name)
|
||||
}
|
||||
}
|
||||
return validNames
|
||||
return validAgents
|
||||
}
|
||||
|
||||
func (a *Agents) getAgent(name string) Interface {
|
||||
// Check cache first
|
||||
agent := a.cache.Get(name)
|
||||
if agent != nil {
|
||||
return agent
|
||||
}
|
||||
|
||||
// Try to get built-in agent
|
||||
constructor, ok := Map[name]
|
||||
if ok {
|
||||
agent := constructor(a.ds)
|
||||
if agent != nil {
|
||||
a.cache.Set(name, agent)
|
||||
return agent
|
||||
func (a *Agents) getAgent(ea enabledAgent) Interface {
|
||||
if ea.isPlugin {
|
||||
// Try to load WASM plugin agent (if plugin loader is available)
|
||||
if a.pluginLoader != nil {
|
||||
agent, ok := a.pluginLoader.LoadMediaAgent(ea.name)
|
||||
if ok && agent != nil {
|
||||
return agent
|
||||
}
|
||||
}
|
||||
log.Debug("Built-in agent not available. Missing configuration?", "name", name)
|
||||
}
|
||||
|
||||
// Try to load WASM plugin agent (if plugin loader is available)
|
||||
if a.pluginLoader != nil {
|
||||
agent, ok := a.pluginLoader.LoadMediaAgent(name)
|
||||
if ok && agent != nil {
|
||||
a.cache.Set(name, agent)
|
||||
return agent
|
||||
} else {
|
||||
// Try to get built-in agent
|
||||
constructor, ok := Map[ea.name]
|
||||
if ok {
|
||||
agent := constructor(a.ds)
|
||||
if agent != nil {
|
||||
return agent
|
||||
}
|
||||
log.Debug("Built-in agent not available. Missing configuration?", "name", ea.name)
|
||||
}
|
||||
}
|
||||
|
||||
@ -179,8 +129,8 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str
|
||||
return "", nil
|
||||
}
|
||||
start := time.Now()
|
||||
for _, agentName := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(agentName)
|
||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
@ -208,8 +158,8 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin
|
||||
return "", nil
|
||||
}
|
||||
start := time.Now()
|
||||
for _, agentName := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(agentName)
|
||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
@ -237,8 +187,8 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string)
|
||||
return "", nil
|
||||
}
|
||||
start := time.Now()
|
||||
for _, agentName := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(agentName)
|
||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
@ -271,8 +221,8 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
|
||||
overLimit := int(float64(limit) * conf.Server.DevExternalArtistFetchMultiplier)
|
||||
|
||||
start := time.Now()
|
||||
for _, agentName := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(agentName)
|
||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
@ -304,8 +254,8 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]
|
||||
return nil, nil
|
||||
}
|
||||
start := time.Now()
|
||||
for _, agentName := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(agentName)
|
||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
@ -338,8 +288,8 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str
|
||||
overLimit := int(float64(count) * conf.Server.DevExternalArtistFetchMultiplier)
|
||||
|
||||
start := time.Now()
|
||||
for _, agentName := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(agentName)
|
||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
@ -364,8 +314,8 @@ func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
start := time.Now()
|
||||
for _, agentName := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(agentName)
|
||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
@ -391,8 +341,8 @@ func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string)
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
start := time.Now()
|
||||
for _, agentName := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(agentName)
|
||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -73,8 +74,10 @@ var _ = Describe("Agents with Plugin Loading", func() {
|
||||
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent", "another_plugin")
|
||||
|
||||
// Should only include the local agent
|
||||
agentNames := agents.getEnabledAgentNames()
|
||||
Expect(agentNames).To(HaveExactElements(LocalAgentName))
|
||||
enabledAgents := agents.getEnabledAgentNames()
|
||||
Expect(enabledAgents).To(HaveLen(1))
|
||||
Expect(enabledAgents[0].name).To(Equal(LocalAgentName))
|
||||
Expect(enabledAgents[0].isPlugin).To(BeFalse()) // LocalAgent is built-in, not plugin
|
||||
})
|
||||
|
||||
It("should NOT include plugin agents when no config is specified", func() {
|
||||
@ -85,9 +88,10 @@ var _ = Describe("Agents with Plugin Loading", func() {
|
||||
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
|
||||
|
||||
// Should only include the local agent
|
||||
agentNames := agents.getEnabledAgentNames()
|
||||
Expect(agentNames).To(HaveExactElements(LocalAgentName))
|
||||
Expect(agentNames).NotTo(ContainElement("plugin_agent"))
|
||||
enabledAgents := agents.getEnabledAgentNames()
|
||||
Expect(enabledAgents).To(HaveLen(1))
|
||||
Expect(enabledAgents[0].name).To(Equal(LocalAgentName))
|
||||
Expect(enabledAgents[0].isPlugin).To(BeFalse()) // LocalAgent is built-in, not plugin
|
||||
})
|
||||
|
||||
It("should include plugin agents in the enabled agents list ONLY when explicitly configured", func() {
|
||||
@ -96,14 +100,24 @@ var _ = Describe("Agents with Plugin Loading", func() {
|
||||
|
||||
// With no config, should not include plugin
|
||||
conf.Server.Agents = ""
|
||||
agentNames := agents.getEnabledAgentNames()
|
||||
Expect(agentNames).To(HaveExactElements(LocalAgentName))
|
||||
Expect(agentNames).NotTo(ContainElement("plugin_agent"))
|
||||
enabledAgents := agents.getEnabledAgentNames()
|
||||
Expect(enabledAgents).To(HaveLen(1))
|
||||
Expect(enabledAgents[0].name).To(Equal(LocalAgentName))
|
||||
|
||||
// When explicitly configured, should include plugin
|
||||
conf.Server.Agents = "plugin_agent"
|
||||
agentNames = agents.getEnabledAgentNames()
|
||||
enabledAgents = agents.getEnabledAgentNames()
|
||||
var agentNames []string
|
||||
var pluginAgentFound bool
|
||||
for _, agent := range enabledAgents {
|
||||
agentNames = append(agentNames, agent.name)
|
||||
if agent.name == "plugin_agent" {
|
||||
pluginAgentFound = true
|
||||
Expect(agent.isPlugin).To(BeTrue()) // plugin_agent is a plugin
|
||||
}
|
||||
}
|
||||
Expect(agentNames).To(ContainElements(LocalAgentName, "plugin_agent"))
|
||||
Expect(pluginAgentFound).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should only include configured plugin agents when config is specified", func() {
|
||||
@ -114,9 +128,19 @@ var _ = Describe("Agents with Plugin Loading", func() {
|
||||
conf.Server.Agents = "plugin_one"
|
||||
|
||||
// Verify only the configured one is included
|
||||
agentNames := agents.getEnabledAgentNames()
|
||||
Expect(agentNames).To(ContainElement("plugin_one"))
|
||||
enabledAgents := agents.getEnabledAgentNames()
|
||||
var agentNames []string
|
||||
var pluginOneFound bool
|
||||
for _, agent := range enabledAgents {
|
||||
agentNames = append(agentNames, agent.name)
|
||||
if agent.name == "plugin_one" {
|
||||
pluginOneFound = true
|
||||
Expect(agent.isPlugin).To(BeTrue()) // plugin_one is a plugin
|
||||
}
|
||||
}
|
||||
Expect(agentNames).To(ContainElements(LocalAgentName, "plugin_one"))
|
||||
Expect(agentNames).NotTo(ContainElement("plugin_two"))
|
||||
Expect(pluginOneFound).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should load plugin agents on demand", func() {
|
||||
@ -140,31 +164,6 @@ var _ = Describe("Agents with Plugin Loading", func() {
|
||||
Expect(mockLoader.pluginCallCount["plugin_agent"]).To(Equal(1))
|
||||
})
|
||||
|
||||
It("should cache plugin agents", func() {
|
||||
ctx := context.Background()
|
||||
|
||||
// Configure to use our plugin
|
||||
conf.Server.Agents = "plugin_agent"
|
||||
|
||||
// Add a plugin agent
|
||||
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
|
||||
mockLoader.loadedAgents["plugin_agent"] = &MockAgent{
|
||||
name: "plugin_agent",
|
||||
mbid: "plugin-mbid",
|
||||
}
|
||||
|
||||
// Call multiple times
|
||||
_, err := agents.GetArtistMBID(ctx, "123", "Artist")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = agents.GetArtistMBID(ctx, "123", "Artist")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = agents.GetArtistMBID(ctx, "123", "Artist")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Should only load once
|
||||
Expect(mockLoader.pluginCallCount["plugin_agent"]).To(Equal(1))
|
||||
})
|
||||
|
||||
It("should try both built-in and plugin agents", func() {
|
||||
// Create a mock built-in agent
|
||||
Register("built_in", func(ds model.DataStore) Interface {
|
||||
@ -188,8 +187,23 @@ var _ = Describe("Agents with Plugin Loading", func() {
|
||||
}
|
||||
|
||||
// Verify that both are in the enabled list
|
||||
agentNames := agents.getEnabledAgentNames()
|
||||
Expect(agentNames).To(ContainElements("built_in", "plugin_agent"))
|
||||
enabledAgents := agents.getEnabledAgentNames()
|
||||
var agentNames []string
|
||||
var builtInFound, pluginFound bool
|
||||
for _, agent := range enabledAgents {
|
||||
agentNames = append(agentNames, agent.name)
|
||||
if agent.name == "built_in" {
|
||||
builtInFound = true
|
||||
Expect(agent.isPlugin).To(BeFalse()) // built-in agent
|
||||
}
|
||||
if agent.name == "plugin_agent" {
|
||||
pluginFound = true
|
||||
Expect(agent.isPlugin).To(BeTrue()) // plugin agent
|
||||
}
|
||||
}
|
||||
Expect(agentNames).To(ContainElements("built_in", "plugin_agent", LocalAgentName))
|
||||
Expect(builtInFound).To(BeTrue())
|
||||
Expect(pluginFound).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should respect the order specified in configuration", func() {
|
||||
@ -212,10 +226,56 @@ var _ = Describe("Agents with Plugin Loading", func() {
|
||||
conf.Server.Agents = "plugin_y,agent_b,plugin_x,agent_a"
|
||||
|
||||
// Get the agent names
|
||||
agentNames := agents.getEnabledAgentNames()
|
||||
enabledAgents := agents.getEnabledAgentNames()
|
||||
|
||||
// Extract just the names to verify the order
|
||||
agentNames := slice.Map(enabledAgents, func(a enabledAgent) string { return a.name })
|
||||
|
||||
// Verify the order matches configuration, with LocalAgentName at the end
|
||||
Expect(agentNames).To(HaveExactElements("plugin_y", "agent_b", "plugin_x", "agent_a", LocalAgentName))
|
||||
})
|
||||
|
||||
It("should NOT call LoadMediaAgent for built-in agents", func() {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a mock built-in agent
|
||||
Register("builtin_agent", func(ds model.DataStore) Interface {
|
||||
return &MockAgent{
|
||||
name: "builtin_agent",
|
||||
mbid: "builtin-mbid",
|
||||
}
|
||||
})
|
||||
defer func() {
|
||||
delete(Map, "builtin_agent")
|
||||
}()
|
||||
|
||||
// Configure to use only built-in agents
|
||||
conf.Server.Agents = "builtin_agent"
|
||||
|
||||
// Call GetArtistMBID which should only use the built-in agent
|
||||
mbid, err := agents.GetArtistMBID(ctx, "123", "Artist")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mbid).To(Equal("builtin-mbid"))
|
||||
|
||||
// Verify LoadMediaAgent was NEVER called (no plugin loading for built-in agents)
|
||||
Expect(mockLoader.pluginCallCount).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("should NOT call LoadMediaAgent for invalid agent names", func() {
|
||||
ctx := context.Background()
|
||||
|
||||
// Configure with an invalid agent name (not built-in, not a plugin)
|
||||
conf.Server.Agents = "invalid_agent"
|
||||
|
||||
// This should only result in using the local agent (as the invalid one is ignored)
|
||||
_, err := agents.GetArtistMBID(ctx, "123", "Artist")
|
||||
|
||||
// Should get ErrNotFound since only local agent is available and it returns not found for this operation
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
|
||||
// Verify LoadMediaAgent was NEVER called for the invalid agent
|
||||
Expect(mockLoader.pluginCallCount).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -56,8 +56,8 @@ var _ = Describe("Agents", func() {
|
||||
|
||||
It("does not register disabled agents", func() {
|
||||
var ags []string
|
||||
for _, name := range ag.getEnabledAgentNames() {
|
||||
agent := ag.getAgent(name)
|
||||
for _, enabledAgent := range ag.getEnabledAgentNames() {
|
||||
agent := ag.getAgent(enabledAgent)
|
||||
if agent != nil {
|
||||
ags = append(ags, agent.AgentName())
|
||||
}
|
||||
|
||||
@ -372,7 +372,7 @@ goto loop
|
||||
`
|
||||
} else {
|
||||
scriptExt = ".sh"
|
||||
scriptContent = `#!/bin/bash
|
||||
scriptContent = `#!/bin/sh
|
||||
echo "$0"
|
||||
for arg in "$@"; do
|
||||
echo "$arg"
|
||||
|
||||
@ -138,23 +138,18 @@ func (p *playTracker) refreshPluginScrobblers() {
|
||||
}
|
||||
}
|
||||
|
||||
type stoppableScrobbler interface {
|
||||
Scrobbler
|
||||
Stop()
|
||||
}
|
||||
|
||||
// Process removals - remove plugins that no longer exist
|
||||
for name, scrobbler := range p.pluginScrobblers {
|
||||
if _, exists := current[name]; !exists {
|
||||
// Type assertion to access the Stop method
|
||||
// We need to ensure this works even with interface objects
|
||||
if bs, ok := scrobbler.(*bufferedScrobbler); ok {
|
||||
log.Debug("Stopping buffered scrobbler goroutine", "name", name)
|
||||
bs.Stop()
|
||||
} else {
|
||||
// For tests - try to see if this is a mock with a Stop method
|
||||
type stoppable interface {
|
||||
Stop()
|
||||
}
|
||||
if s, ok := scrobbler.(stoppable); ok {
|
||||
log.Debug("Stopping mock scrobbler", "name", name)
|
||||
s.Stop()
|
||||
}
|
||||
// If the scrobbler implements stoppableScrobbler, call Stop() before removing it
|
||||
if stoppable, ok := scrobbler.(stoppableScrobbler); ok {
|
||||
log.Debug("Stopping scrobbler", "name", name)
|
||||
stoppable.Stop()
|
||||
}
|
||||
delete(p.pluginScrobblers, name)
|
||||
}
|
||||
|
||||
@ -245,10 +245,14 @@ func processPairMapping(name model.TagName, mapping model.TagConf, lowered model
|
||||
}
|
||||
}
|
||||
|
||||
// always parse id3 pairs. For lyrics, Taglib appears to always provide lyrics:xxx
|
||||
// Prefer that over format-specific tags
|
||||
id3Base := parseID3Pairs(name, lowered)
|
||||
|
||||
if len(aliasValues) > 0 {
|
||||
return parseVorbisPairs(aliasValues)
|
||||
id3Base = append(id3Base, parseVorbisPairs(aliasValues)...)
|
||||
}
|
||||
return parseID3Pairs(name, lowered)
|
||||
return id3Base
|
||||
}
|
||||
|
||||
func parseID3Pairs(name model.TagName, lowered model.Tags) []string {
|
||||
|
||||
@ -10,14 +10,14 @@ import (
|
||||
)
|
||||
|
||||
// NewWasmMediaAgent creates a new adapter for a MetadataAgent plugin
|
||||
func newWasmMediaAgent(wasmPath, pluginID string, m *Manager, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
|
||||
func newWasmMediaAgent(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
|
||||
loader, err := api.NewMetadataAgentPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
|
||||
if err != nil {
|
||||
log.Error("Error creating media metadata service plugin", "plugin", pluginID, "path", wasmPath, err)
|
||||
return nil
|
||||
}
|
||||
return &wasmMediaAgent{
|
||||
wasmBasePlugin: newWasmBasePlugin[api.MetadataAgent, *api.MetadataAgentPlugin](
|
||||
baseCapability: newBaseCapability[api.MetadataAgent, *api.MetadataAgentPlugin](
|
||||
wasmPath,
|
||||
pluginID,
|
||||
CapabilityMetadataAgent,
|
||||
@ -32,7 +32,7 @@ func newWasmMediaAgent(wasmPath, pluginID string, m *Manager, runtime api.Wazero
|
||||
|
||||
// wasmMediaAgent adapts a MetadataAgent plugin to implement the agents.Interface
|
||||
type wasmMediaAgent struct {
|
||||
*wasmBasePlugin[api.MetadataAgent, *api.MetadataAgentPlugin]
|
||||
*baseCapability[api.MetadataAgent, *api.MetadataAgentPlugin]
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) AgentName() string {
|
||||
@ -49,108 +49,108 @@ func (w *wasmMediaAgent) mapError(err error) error {
|
||||
// Album-related methods
|
||||
|
||||
func (w *wasmMediaAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
|
||||
return callMethod(ctx, w, "GetAlbumInfo", func(inst api.MetadataAgent) (*agents.AlbumInfo, error) {
|
||||
res, err := inst.GetAlbumInfo(ctx, &api.AlbumInfoRequest{Name: name, Artist: artist, Mbid: mbid})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
if res == nil || res.Info == nil {
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
info := res.Info
|
||||
return &agents.AlbumInfo{
|
||||
Name: info.Name,
|
||||
MBID: info.Mbid,
|
||||
Description: info.Description,
|
||||
URL: info.Url,
|
||||
}, nil
|
||||
res, err := callMethod(ctx, w, "GetAlbumInfo", func(inst api.MetadataAgent) (*api.AlbumInfoResponse, error) {
|
||||
return inst.GetAlbumInfo(ctx, &api.AlbumInfoRequest{Name: name, Artist: artist, Mbid: mbid})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
if res == nil || res.Info == nil {
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
info := res.Info
|
||||
return &agents.AlbumInfo{
|
||||
Name: info.Name,
|
||||
MBID: info.Mbid,
|
||||
Description: info.Description,
|
||||
URL: info.Url,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
|
||||
return callMethod(ctx, w, "GetAlbumImages", func(inst api.MetadataAgent) ([]agents.ExternalImage, error) {
|
||||
res, err := inst.GetAlbumImages(ctx, &api.AlbumImagesRequest{Name: name, Artist: artist, Mbid: mbid})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
return convertExternalImages(res.Images), nil
|
||||
res, err := callMethod(ctx, w, "GetAlbumImages", func(inst api.MetadataAgent) (*api.AlbumImagesResponse, error) {
|
||||
return inst.GetAlbumImages(ctx, &api.AlbumImagesRequest{Name: name, Artist: artist, Mbid: mbid})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
return convertExternalImages(res.Images), nil
|
||||
}
|
||||
|
||||
// Artist-related methods
|
||||
|
||||
func (w *wasmMediaAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
|
||||
return callMethod(ctx, w, "GetArtistMBID", func(inst api.MetadataAgent) (string, error) {
|
||||
res, err := inst.GetArtistMBID(ctx, &api.ArtistMBIDRequest{Id: id, Name: name})
|
||||
if err != nil {
|
||||
return "", w.mapError(err)
|
||||
}
|
||||
return res.GetMbid(), nil
|
||||
res, err := callMethod(ctx, w, "GetArtistMBID", func(inst api.MetadataAgent) (*api.ArtistMBIDResponse, error) {
|
||||
return inst.GetArtistMBID(ctx, &api.ArtistMBIDRequest{Id: id, Name: name})
|
||||
})
|
||||
if err != nil {
|
||||
return "", w.mapError(err)
|
||||
}
|
||||
return res.GetMbid(), nil
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
return callMethod(ctx, w, "GetArtistURL", func(inst api.MetadataAgent) (string, error) {
|
||||
res, err := inst.GetArtistURL(ctx, &api.ArtistURLRequest{Id: id, Name: name, Mbid: mbid})
|
||||
if err != nil {
|
||||
return "", w.mapError(err)
|
||||
}
|
||||
return res.GetUrl(), nil
|
||||
res, err := callMethod(ctx, w, "GetArtistURL", func(inst api.MetadataAgent) (*api.ArtistURLResponse, error) {
|
||||
return inst.GetArtistURL(ctx, &api.ArtistURLRequest{Id: id, Name: name, Mbid: mbid})
|
||||
})
|
||||
if err != nil {
|
||||
return "", w.mapError(err)
|
||||
}
|
||||
return res.GetUrl(), nil
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
return callMethod(ctx, w, "GetArtistBiography", func(inst api.MetadataAgent) (string, error) {
|
||||
res, err := inst.GetArtistBiography(ctx, &api.ArtistBiographyRequest{Id: id, Name: name, Mbid: mbid})
|
||||
if err != nil {
|
||||
return "", w.mapError(err)
|
||||
}
|
||||
return res.GetBiography(), nil
|
||||
res, err := callMethod(ctx, w, "GetArtistBiography", func(inst api.MetadataAgent) (*api.ArtistBiographyResponse, error) {
|
||||
return inst.GetArtistBiography(ctx, &api.ArtistBiographyRequest{Id: id, Name: name, Mbid: mbid})
|
||||
})
|
||||
if err != nil {
|
||||
return "", w.mapError(err)
|
||||
}
|
||||
return res.GetBiography(), nil
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
|
||||
return callMethod(ctx, w, "GetSimilarArtists", func(inst api.MetadataAgent) ([]agents.Artist, error) {
|
||||
resp, err := inst.GetSimilarArtists(ctx, &api.ArtistSimilarRequest{Id: id, Name: name, Mbid: mbid, Limit: int32(limit)})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
artists := make([]agents.Artist, 0, len(resp.GetArtists()))
|
||||
for _, a := range resp.GetArtists() {
|
||||
artists = append(artists, agents.Artist{
|
||||
Name: a.GetName(),
|
||||
MBID: a.GetMbid(),
|
||||
})
|
||||
}
|
||||
return artists, nil
|
||||
resp, err := callMethod(ctx, w, "GetSimilarArtists", func(inst api.MetadataAgent) (*api.ArtistSimilarResponse, error) {
|
||||
return inst.GetSimilarArtists(ctx, &api.ArtistSimilarRequest{Id: id, Name: name, Mbid: mbid, Limit: int32(limit)})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
artists := make([]agents.Artist, 0, len(resp.GetArtists()))
|
||||
for _, a := range resp.GetArtists() {
|
||||
artists = append(artists, agents.Artist{
|
||||
Name: a.GetName(),
|
||||
MBID: a.GetMbid(),
|
||||
})
|
||||
}
|
||||
return artists, nil
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) {
|
||||
return callMethod(ctx, w, "GetArtistImages", func(inst api.MetadataAgent) ([]agents.ExternalImage, error) {
|
||||
res, err := inst.GetArtistImages(ctx, &api.ArtistImageRequest{Id: id, Name: name, Mbid: mbid})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
return convertExternalImages(res.Images), nil
|
||||
resp, err := callMethod(ctx, w, "GetArtistImages", func(inst api.MetadataAgent) (*api.ArtistImageResponse, error) {
|
||||
return inst.GetArtistImages(ctx, &api.ArtistImageRequest{Id: id, Name: name, Mbid: mbid})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
return convertExternalImages(resp.Images), nil
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
|
||||
return callMethod(ctx, w, "GetArtistTopSongs", func(inst api.MetadataAgent) ([]agents.Song, error) {
|
||||
resp, err := inst.GetArtistTopSongs(ctx, &api.ArtistTopSongsRequest{Id: id, ArtistName: artistName, Mbid: mbid, Count: int32(count)})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
songs := make([]agents.Song, 0, len(resp.GetSongs()))
|
||||
for _, s := range resp.GetSongs() {
|
||||
songs = append(songs, agents.Song{
|
||||
Name: s.GetName(),
|
||||
MBID: s.GetMbid(),
|
||||
})
|
||||
}
|
||||
return songs, nil
|
||||
resp, err := callMethod(ctx, w, "GetArtistTopSongs", func(inst api.MetadataAgent) (*api.ArtistTopSongsResponse, error) {
|
||||
return inst.GetArtistTopSongs(ctx, &api.ArtistTopSongsRequest{Id: id, ArtistName: artistName, Mbid: mbid, Count: int32(count)})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
songs := make([]agents.Song, 0, len(resp.GetSongs()))
|
||||
for _, s := range resp.GetSongs() {
|
||||
songs = append(songs, agents.Song{
|
||||
Name: s.GetName(),
|
||||
MBID: s.GetMbid(),
|
||||
})
|
||||
}
|
||||
return songs, nil
|
||||
}
|
||||
|
||||
// Helper function to convert ExternalImage objects from the API to the agents package
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@ -14,7 +15,7 @@ import (
|
||||
|
||||
var _ = Describe("Adapter Media Agent", func() {
|
||||
var ctx context.Context
|
||||
var mgr *Manager
|
||||
var mgr *managerImpl
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
@ -23,7 +24,7 @@ var _ = Describe("Adapter Media Agent", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Plugins.Folder = testDataDir
|
||||
|
||||
mgr = createManager(nil, nil)
|
||||
mgr = createManager(nil, metrics.NewNoopInstance())
|
||||
mgr.ScanPlugins()
|
||||
})
|
||||
|
||||
|
||||
@ -9,14 +9,14 @@ import (
|
||||
)
|
||||
|
||||
// newWasmSchedulerCallback creates a new adapter for a SchedulerCallback plugin
|
||||
func newWasmSchedulerCallback(wasmPath, pluginID string, m *Manager, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
|
||||
func newWasmSchedulerCallback(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
|
||||
loader, err := api.NewSchedulerCallbackPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
|
||||
if err != nil {
|
||||
log.Error("Error creating scheduler callback plugin", "plugin", pluginID, "path", wasmPath, err)
|
||||
return nil
|
||||
}
|
||||
return &wasmSchedulerCallback{
|
||||
wasmBasePlugin: newWasmBasePlugin[api.SchedulerCallback, *api.SchedulerCallbackPlugin](
|
||||
baseCapability: newBaseCapability[api.SchedulerCallback, *api.SchedulerCallbackPlugin](
|
||||
wasmPath,
|
||||
pluginID,
|
||||
CapabilitySchedulerCallback,
|
||||
@ -31,5 +31,16 @@ func newWasmSchedulerCallback(wasmPath, pluginID string, m *Manager, runtime api
|
||||
|
||||
// wasmSchedulerCallback adapts a SchedulerCallback plugin
|
||||
type wasmSchedulerCallback struct {
|
||||
*wasmBasePlugin[api.SchedulerCallback, *api.SchedulerCallbackPlugin]
|
||||
*baseCapability[api.SchedulerCallback, *api.SchedulerCallbackPlugin]
|
||||
}
|
||||
|
||||
func (w *wasmSchedulerCallback) OnSchedulerCallback(ctx context.Context, scheduleID string, payload []byte, isRecurring bool) error {
|
||||
_, err := callMethod(ctx, w, "OnSchedulerCallback", func(inst api.SchedulerCallback) (*api.SchedulerCallbackResponse, error) {
|
||||
return inst.OnSchedulerCallback(ctx, &api.SchedulerCallbackRequest{
|
||||
ScheduleId: scheduleID,
|
||||
Payload: payload,
|
||||
IsRecurring: isRecurring,
|
||||
})
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
@ -12,14 +12,14 @@ import (
|
||||
"github.com/tetratelabs/wazero"
|
||||
)
|
||||
|
||||
func newWasmScrobblerPlugin(wasmPath, pluginID string, m *Manager, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
|
||||
func newWasmScrobblerPlugin(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
|
||||
loader, err := api.NewScrobblerPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
|
||||
if err != nil {
|
||||
log.Error("Error creating scrobbler service plugin", "plugin", pluginID, "path", wasmPath, err)
|
||||
return nil
|
||||
}
|
||||
return &wasmScrobblerPlugin{
|
||||
wasmBasePlugin: newWasmBasePlugin[api.Scrobbler, *api.ScrobblerPlugin](
|
||||
baseCapability: newBaseCapability[api.Scrobbler, *api.ScrobblerPlugin](
|
||||
wasmPath,
|
||||
pluginID,
|
||||
CapabilityScrobbler,
|
||||
@ -33,7 +33,7 @@ func newWasmScrobblerPlugin(wasmPath, pluginID string, m *Manager, runtime api.W
|
||||
}
|
||||
|
||||
type wasmScrobblerPlugin struct {
|
||||
*wasmBasePlugin[api.Scrobbler, *api.ScrobblerPlugin]
|
||||
*baseCapability[api.Scrobbler, *api.ScrobblerPlugin]
|
||||
}
|
||||
|
||||
func (w *wasmScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
@ -44,21 +44,16 @@ func (w *wasmScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) b
|
||||
username = u.UserName
|
||||
}
|
||||
}
|
||||
|
||||
result, err := callMethod(ctx, w, "IsAuthorized", func(inst api.Scrobbler) (bool, error) {
|
||||
resp, err := inst.IsAuthorized(ctx, &api.ScrobblerIsAuthorizedRequest{
|
||||
resp, err := callMethod(ctx, w, "IsAuthorized", func(inst api.Scrobbler) (*api.ScrobblerIsAuthorizedResponse, error) {
|
||||
return inst.IsAuthorized(ctx, &api.ScrobblerIsAuthorizedRequest{
|
||||
UserId: userId,
|
||||
Username: username,
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return false, nil
|
||||
}
|
||||
return resp.Authorized, nil
|
||||
})
|
||||
return err == nil && result
|
||||
if err != nil {
|
||||
log.Warn("Error calling IsAuthorized", "userId", userId, "pluginID", w.id, err)
|
||||
}
|
||||
return err == nil && resp.Authorized
|
||||
}
|
||||
|
||||
func (w *wasmScrobblerPlugin) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
|
||||
@ -70,25 +65,7 @@ func (w *wasmScrobblerPlugin) NowPlaying(ctx context.Context, userId string, tra
|
||||
}
|
||||
}
|
||||
|
||||
artists := make([]*api.Artist, 0, len(track.Participants[model.RoleArtist]))
|
||||
for _, a := range track.Participants[model.RoleArtist] {
|
||||
artists = append(artists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
|
||||
}
|
||||
albumArtists := make([]*api.Artist, 0, len(track.Participants[model.RoleAlbumArtist]))
|
||||
for _, a := range track.Participants[model.RoleAlbumArtist] {
|
||||
albumArtists = append(albumArtists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
|
||||
}
|
||||
trackInfo := &api.TrackInfo{
|
||||
Id: track.ID,
|
||||
Mbid: track.MbzRecordingID,
|
||||
Name: track.Title,
|
||||
Album: track.Album,
|
||||
AlbumMbid: track.MbzAlbumID,
|
||||
Artists: artists,
|
||||
AlbumArtists: albumArtists,
|
||||
Length: int32(track.Duration),
|
||||
Position: int32(position),
|
||||
}
|
||||
trackInfo := w.toTrackInfo(track, position)
|
||||
_, err := callMethod(ctx, w, "NowPlaying", func(inst api.Scrobbler) (struct{}, error) {
|
||||
resp, err := inst.NowPlaying(ctx, &api.ScrobblerNowPlayingRequest{
|
||||
UserId: userId,
|
||||
@ -115,26 +92,7 @@ func (w *wasmScrobblerPlugin) Scrobble(ctx context.Context, userId string, s scr
|
||||
username = u.UserName
|
||||
}
|
||||
}
|
||||
|
||||
track := &s.MediaFile
|
||||
artists := make([]*api.Artist, 0, len(track.Participants[model.RoleArtist]))
|
||||
for _, a := range track.Participants[model.RoleArtist] {
|
||||
artists = append(artists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
|
||||
}
|
||||
albumArtists := make([]*api.Artist, 0, len(track.Participants[model.RoleAlbumArtist]))
|
||||
for _, a := range track.Participants[model.RoleAlbumArtist] {
|
||||
albumArtists = append(albumArtists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
|
||||
}
|
||||
trackInfo := &api.TrackInfo{
|
||||
Id: track.ID,
|
||||
Mbid: track.MbzRecordingID,
|
||||
Name: track.Title,
|
||||
Album: track.Album,
|
||||
AlbumMbid: track.MbzAlbumID,
|
||||
Artists: artists,
|
||||
AlbumArtists: albumArtists,
|
||||
Length: int32(track.Duration),
|
||||
}
|
||||
trackInfo := w.toTrackInfo(&s.MediaFile, 0)
|
||||
_, err := callMethod(ctx, w, "Scrobble", func(inst api.Scrobbler) (struct{}, error) {
|
||||
resp, err := inst.Scrobble(ctx, &api.ScrobblerScrobbleRequest{
|
||||
UserId: userId,
|
||||
@ -152,3 +110,27 @@ func (w *wasmScrobblerPlugin) Scrobble(ctx context.Context, userId string, s scr
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *wasmScrobblerPlugin) toTrackInfo(track *model.MediaFile, position int) *api.TrackInfo {
|
||||
artists := make([]*api.Artist, 0, len(track.Participants[model.RoleArtist]))
|
||||
|
||||
for _, a := range track.Participants[model.RoleArtist] {
|
||||
artists = append(artists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
|
||||
}
|
||||
albumArtists := make([]*api.Artist, 0, len(track.Participants[model.RoleAlbumArtist]))
|
||||
for _, a := range track.Participants[model.RoleAlbumArtist] {
|
||||
albumArtists = append(albumArtists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
|
||||
}
|
||||
trackInfo := &api.TrackInfo{
|
||||
Id: track.ID,
|
||||
Mbid: track.MbzRecordingID,
|
||||
Name: track.Title,
|
||||
Album: track.Album,
|
||||
AlbumMbid: track.MbzAlbumID,
|
||||
Artists: artists,
|
||||
AlbumArtists: albumArtists,
|
||||
Length: int32(track.Duration),
|
||||
Position: int32(position),
|
||||
}
|
||||
return trackInfo
|
||||
}
|
||||
|
||||
@ -9,14 +9,14 @@ import (
|
||||
)
|
||||
|
||||
// newWasmWebSocketCallback creates a new adapter for a WebSocketCallback plugin
|
||||
func newWasmWebSocketCallback(wasmPath, pluginID string, m *Manager, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
|
||||
func newWasmWebSocketCallback(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
|
||||
loader, err := api.NewWebSocketCallbackPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
|
||||
if err != nil {
|
||||
log.Error("Error creating WebSocket callback plugin", "plugin", pluginID, "path", wasmPath, err)
|
||||
return nil
|
||||
}
|
||||
return &wasmWebSocketCallback{
|
||||
wasmBasePlugin: newWasmBasePlugin[api.WebSocketCallback, *api.WebSocketCallbackPlugin](
|
||||
baseCapability: newBaseCapability[api.WebSocketCallback, *api.WebSocketCallbackPlugin](
|
||||
wasmPath,
|
||||
pluginID,
|
||||
CapabilityWebSocketCallback,
|
||||
@ -31,5 +31,5 @@ func newWasmWebSocketCallback(wasmPath, pluginID string, m *Manager, runtime api
|
||||
|
||||
// wasmWebSocketCallback adapts a WebSocketCallback plugin
|
||||
type wasmWebSocketCallback struct {
|
||||
*wasmBasePlugin[api.WebSocketCallback, *api.WebSocketCallbackPlugin]
|
||||
*baseCapability[api.WebSocketCallback, *api.WebSocketCallbackPlugin]
|
||||
}
|
||||
|
||||
@ -3,6 +3,10 @@ package api
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("plugin:not_found")
|
||||
// ErrNotImplemented indicates that the plugin does not implement the requested method.
|
||||
// No logic should be executed by the plugin.
|
||||
ErrNotImplemented = errors.New("plugin:not_implemented")
|
||||
|
||||
// ErrNotFound indicates that the requested resource was not found by the plugin.
|
||||
ErrNotFound = errors.New("plugin:not_found")
|
||||
)
|
||||
|
||||
159
plugins/base_capability.go
Normal file
159
plugins/base_capability.go
Normal file
@ -0,0 +1,159 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
)
|
||||
|
||||
// newBaseCapability creates a new instance of baseCapability with the required parameters.
|
||||
func newBaseCapability[S any, P any](wasmPath, id, capability string, m metrics.Metrics, loader P, loadFunc loaderFunc[S, P]) *baseCapability[S, P] {
|
||||
return &baseCapability[S, P]{
|
||||
wasmPath: wasmPath,
|
||||
id: id,
|
||||
capability: capability,
|
||||
loader: loader,
|
||||
loadFunc: loadFunc,
|
||||
metrics: m,
|
||||
}
|
||||
}
|
||||
|
||||
// LoaderFunc is a generic function type that loads a plugin instance.
|
||||
type loaderFunc[S any, P any] func(ctx context.Context, loader P, path string) (S, error)
|
||||
|
||||
// baseCapability is a generic base implementation for WASM plugins.
|
||||
// S is the capability interface type and P is the plugin loader type.
|
||||
type baseCapability[S any, P any] struct {
|
||||
wasmPath string
|
||||
id string
|
||||
capability string
|
||||
loader P
|
||||
loadFunc loaderFunc[S, P]
|
||||
metrics metrics.Metrics
|
||||
}
|
||||
|
||||
func (w *baseCapability[S, P]) PluginID() string {
|
||||
return w.id
|
||||
}
|
||||
|
||||
func (w *baseCapability[S, P]) serviceName() string {
|
||||
return w.id + "_" + w.capability
|
||||
}
|
||||
|
||||
func (w *baseCapability[S, P]) getMetrics() metrics.Metrics {
|
||||
return w.metrics
|
||||
}
|
||||
|
||||
// getInstance loads a new plugin instance and returns a cleanup function.
|
||||
func (w *baseCapability[S, P]) getInstance(ctx context.Context, methodName string) (S, func(), error) {
|
||||
start := time.Now()
|
||||
// Add context metadata for tracing
|
||||
ctx = log.NewContext(ctx, "capability", w.serviceName(), "method", methodName)
|
||||
|
||||
inst, err := w.loadFunc(ctx, w.loader, w.wasmPath)
|
||||
if err != nil {
|
||||
var zero S
|
||||
return zero, func() {}, fmt.Errorf("baseCapability: failed to load instance for %s: %w", w.serviceName(), err)
|
||||
}
|
||||
// Add context metadata for tracing
|
||||
ctx = log.NewContext(ctx, "instanceID", getInstanceID(inst))
|
||||
log.Trace(ctx, "baseCapability: loaded instance", "elapsed", time.Since(start))
|
||||
return inst, func() {
|
||||
log.Trace(ctx, "baseCapability: finished using instance", "elapsed", time.Since(start))
|
||||
if closer, ok := any(inst).(interface{ Close(context.Context) error }); ok {
|
||||
_ = closer.Close(ctx)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
type wasmPlugin[S any] interface {
|
||||
PluginID() string
|
||||
getInstance(ctx context.Context, methodName string) (S, func(), error)
|
||||
getMetrics() metrics.Metrics
|
||||
}
|
||||
|
||||
func callMethod[S any, R any](ctx context.Context, wp WasmPlugin, methodName string, fn func(inst S) (R, error)) (R, error) {
|
||||
// Add a unique call ID to the context for tracing
|
||||
ctx = log.NewContext(ctx, "callID", id.NewRandom())
|
||||
var r R
|
||||
|
||||
p, ok := wp.(wasmPlugin[S])
|
||||
if !ok {
|
||||
log.Error(ctx, "callMethod: not a wasm plugin", "method", methodName, "pluginID", wp.PluginID())
|
||||
return r, fmt.Errorf("wasm plugin: not a wasm plugin: %s", wp.PluginID())
|
||||
}
|
||||
|
||||
inst, done, err := p.getInstance(ctx, methodName)
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
start := time.Now()
|
||||
defer done()
|
||||
r, err = checkErr(fn(inst))
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if !errors.Is(err, api.ErrNotImplemented) {
|
||||
id := p.PluginID()
|
||||
isOk := err == nil
|
||||
metrics := p.getMetrics()
|
||||
if metrics != nil {
|
||||
metrics.RecordPluginRequest(ctx, id, methodName, isOk, elapsed.Milliseconds())
|
||||
log.Trace(ctx, "callMethod: sending metrics", "plugin", id, "method", methodName, "ok", isOk, "elapsed", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
return r, err
|
||||
}
|
||||
|
||||
// errorResponse is an interface that defines a method to retrieve an error message.
|
||||
// It is automatically implemented (generated) by all plugin responses that have an Error field
|
||||
type errorResponse interface {
|
||||
GetError() string
|
||||
}
|
||||
|
||||
// checkErr returns an updated error if the response implements errorResponse and contains an error message.
|
||||
// If the response is nil, it returns the original error. Otherwise, it wraps or creates an error as needed.
|
||||
// It also maps error strings to their corresponding api.Err* constants.
|
||||
func checkErr[T any](resp T, err error) (T, error) {
|
||||
if any(resp) == nil {
|
||||
return resp, mapAPIError(err)
|
||||
}
|
||||
respErr, ok := any(resp).(errorResponse)
|
||||
if ok && respErr.GetError() != "" {
|
||||
respErrMsg := respErr.GetError()
|
||||
respErrErr := errors.New(respErrMsg)
|
||||
mappedErr := mapAPIError(respErrErr)
|
||||
// Check if the error was mapped to an API error (different from the temp error)
|
||||
if errors.Is(mappedErr, api.ErrNotImplemented) || errors.Is(mappedErr, api.ErrNotFound) {
|
||||
// Return the mapped API error instead of wrapping
|
||||
return resp, mappedErr
|
||||
}
|
||||
// For non-API errors, use wrap the original error if it is not nil
|
||||
return resp, errors.Join(respErrErr, err)
|
||||
}
|
||||
return resp, mapAPIError(err)
|
||||
}
|
||||
|
||||
// mapAPIError maps error strings to their corresponding api.Err* constants.
|
||||
// This is needed as errors from plugins may not be of type api.Error, due to serialization/deserialization.
|
||||
func mapAPIError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
switch errStr {
|
||||
case api.ErrNotImplemented.Error():
|
||||
return api.ErrNotImplemented
|
||||
case api.ErrNotFound.Error():
|
||||
return api.ErrNotFound
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
285
plugins/base_capability_test.go
Normal file
285
plugins/base_capability_test.go
Normal file
@ -0,0 +1,285 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
type nilInstance struct{}
|
||||
|
||||
var _ = Describe("baseCapability", func() {
|
||||
var ctx = context.Background()
|
||||
|
||||
It("should load instance using loadFunc", func() {
|
||||
called := false
|
||||
plugin := &baseCapability[*nilInstance, any]{
|
||||
wasmPath: "",
|
||||
id: "test",
|
||||
capability: "test",
|
||||
loadFunc: func(ctx context.Context, _ any, path string) (*nilInstance, error) {
|
||||
called = true
|
||||
return &nilInstance{}, nil
|
||||
},
|
||||
}
|
||||
inst, done, err := plugin.getInstance(ctx, "test")
|
||||
defer done()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(inst).ToNot(BeNil())
|
||||
Expect(called).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("checkErr", func() {
|
||||
Context("when resp is nil", func() {
|
||||
It("should return nil error when both resp and err are nil", func() {
|
||||
var resp *testErrorResponse
|
||||
|
||||
result, err := checkErr(resp, nil)
|
||||
|
||||
Expect(result).To(BeNil())
|
||||
Expect(err).To(BeNil())
|
||||
})
|
||||
|
||||
It("should return original error unchanged for non-API errors", func() {
|
||||
var resp *testErrorResponse
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(BeNil())
|
||||
Expect(err).To(Equal(originalErr))
|
||||
})
|
||||
|
||||
It("should return mapped API error for ErrNotImplemented", func() {
|
||||
var resp *testErrorResponse
|
||||
err := errors.New("plugin:not_implemented")
|
||||
|
||||
result, mappedErr := checkErr(resp, err)
|
||||
|
||||
Expect(result).To(BeNil())
|
||||
Expect(mappedErr).To(Equal(api.ErrNotImplemented))
|
||||
})
|
||||
|
||||
It("should return mapped API error for ErrNotFound", func() {
|
||||
var resp *testErrorResponse
|
||||
err := errors.New("plugin:not_found")
|
||||
|
||||
result, mappedErr := checkErr(resp, err)
|
||||
|
||||
Expect(result).To(BeNil())
|
||||
Expect(mappedErr).To(Equal(api.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when resp is a typed nil that implements errorResponse", func() {
|
||||
It("should not panic and return original error", func() {
|
||||
var resp *testErrorResponse // typed nil
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
// This should not panic
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(BeNil())
|
||||
Expect(err).To(Equal(originalErr))
|
||||
})
|
||||
|
||||
It("should handle typed nil with nil error gracefully", func() {
|
||||
var resp *testErrorResponse // typed nil
|
||||
|
||||
// This should not panic
|
||||
result, err := checkErr(resp, nil)
|
||||
|
||||
Expect(result).To(BeNil())
|
||||
Expect(err).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when resp implements errorResponse with non-empty error", func() {
|
||||
It("should create new error when original error is nil", func() {
|
||||
resp := &testErrorResponse{errorMsg: "plugin error"}
|
||||
|
||||
result, err := checkErr(resp, nil)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError("plugin error"))
|
||||
})
|
||||
|
||||
It("should wrap original error when both exist", func() {
|
||||
resp := &testErrorResponse{errorMsg: "plugin error"}
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(HaveOccurred())
|
||||
// Check that both error messages are present in the joined error
|
||||
errStr := err.Error()
|
||||
Expect(errStr).To(ContainSubstring("plugin error"))
|
||||
Expect(errStr).To(ContainSubstring("original error"))
|
||||
})
|
||||
|
||||
It("should return mapped API error for ErrNotImplemented when no original error", func() {
|
||||
resp := &testErrorResponse{errorMsg: "plugin:not_implemented"}
|
||||
|
||||
result, err := checkErr(resp, nil)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(api.ErrNotImplemented))
|
||||
})
|
||||
|
||||
It("should return mapped API error for ErrNotFound when no original error", func() {
|
||||
resp := &testErrorResponse{errorMsg: "plugin:not_found"}
|
||||
|
||||
result, err := checkErr(resp, nil)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(api.ErrNotFound))
|
||||
})
|
||||
|
||||
It("should return mapped API error for ErrNotImplemented even with original error", func() {
|
||||
resp := &testErrorResponse{errorMsg: "plugin:not_implemented"}
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(api.ErrNotImplemented))
|
||||
})
|
||||
|
||||
It("should return mapped API error for ErrNotFound even with original error", func() {
|
||||
resp := &testErrorResponse{errorMsg: "plugin:not_found"}
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(api.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when resp implements errorResponse with empty error", func() {
|
||||
It("should return original error unchanged", func() {
|
||||
resp := &testErrorResponse{errorMsg: ""}
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(originalErr))
|
||||
})
|
||||
|
||||
It("should return nil error when both are empty/nil", func() {
|
||||
resp := &testErrorResponse{errorMsg: ""}
|
||||
|
||||
result, err := checkErr(resp, nil)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(BeNil())
|
||||
})
|
||||
|
||||
It("should map original API error when response error is empty", func() {
|
||||
resp := &testErrorResponse{errorMsg: ""}
|
||||
originalErr := errors.New("plugin:not_implemented")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(api.ErrNotImplemented))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when resp does not implement errorResponse", func() {
|
||||
It("should return original error unchanged", func() {
|
||||
resp := &testNonErrorResponse{data: "some data"}
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(Equal(originalErr))
|
||||
})
|
||||
|
||||
It("should return nil error when original error is nil", func() {
|
||||
resp := &testNonErrorResponse{data: "some data"}
|
||||
|
||||
result, err := checkErr(resp, nil)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(BeNil())
|
||||
})
|
||||
|
||||
It("should map original API error when response doesn't implement errorResponse", func() {
|
||||
resp := &testNonErrorResponse{data: "some data"}
|
||||
originalErr := errors.New("plugin:not_found")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(api.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when resp is a value type (not pointer)", func() {
|
||||
It("should handle value types that implement errorResponse", func() {
|
||||
resp := testValueErrorResponse{errorMsg: "value error"}
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(HaveOccurred())
|
||||
// Check that both error messages are present in the joined error
|
||||
errStr := err.Error()
|
||||
Expect(errStr).To(ContainSubstring("value error"))
|
||||
Expect(errStr).To(ContainSubstring("original error"))
|
||||
})
|
||||
|
||||
It("should handle value types with empty error", func() {
|
||||
resp := testValueErrorResponse{errorMsg: ""}
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(originalErr))
|
||||
})
|
||||
|
||||
It("should handle value types with API error", func() {
|
||||
resp := testValueErrorResponse{errorMsg: "plugin:not_implemented"}
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(api.ErrNotImplemented))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Test helper types
|
||||
type testErrorResponse struct {
|
||||
errorMsg string
|
||||
}
|
||||
|
||||
func (t *testErrorResponse) GetError() string {
|
||||
if t == nil {
|
||||
return "" // This is what would typically happen with a typed nil
|
||||
}
|
||||
return t.errorMsg
|
||||
}
|
||||
|
||||
type testNonErrorResponse struct {
|
||||
data string
|
||||
}
|
||||
|
||||
type testValueErrorResponse struct {
|
||||
errorMsg string
|
||||
}
|
||||
|
||||
func (t testValueErrorResponse) GetError() string {
|
||||
return t.errorMsg
|
||||
}
|
||||
@ -4,11 +4,11 @@ This directory contains example plugins for Navidrome, intended for demonstratio
|
||||
|
||||
## Contents
|
||||
|
||||
- `wikimedia/`: Example plugin that retrieves artist information from Wikidata.
|
||||
- `coverartarchive/`: Example plugin that retrieves album cover images from the Cover Art Archive.
|
||||
- `crypto-ticker/`: Example plugin using websockets to log real-time cryptocurrency prices.
|
||||
- `discord-rich-presence/`: Example plugin that integrates with Discord Rich Presence to display currently playing tracks on Discord profiles.
|
||||
- `subsonicapi-demo/`: Example plugin that demonstrates how to interact with the Navidrome's Subsonic API from a plugin.
|
||||
- `wikimedia/`: Retrieves artist information from Wikidata.
|
||||
- `coverartarchive/`: Fetches album cover images from the Cover Art Archive.
|
||||
- `crypto-ticker/`: Uses websockets to log real-time cryptocurrency prices.
|
||||
- `discord-rich-presence/`: Integrates with Discord Rich Presence to display currently playing tracks on Discord profiles.
|
||||
- `subsonicapi-demo/`: Demonstrates interaction with Navidrome's Subsonic API from a plugin.
|
||||
|
||||
## Building
|
||||
|
||||
|
||||
@ -143,5 +143,9 @@ func (CoverArtArchiveAgent) GetArtistTopSongs(ctx context.Context, req *api.Arti
|
||||
func main() {}
|
||||
|
||||
func init() {
|
||||
// Configure logging: No timestamps, no source file/line
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("[CAA] ")
|
||||
|
||||
api.RegisterMetadataAgent(CoverArtArchiveAgent{})
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ This is a WebSocket-based WASM plugin for Navidrome that displays real-time cryp
|
||||
In your `navidrome.toml` file, add:
|
||||
|
||||
```toml
|
||||
[PluginSettings.crypto-ticker]
|
||||
[PluginConfig.crypto-ticker]
|
||||
tickers = "BTC,ETH,SOL,MATIC"
|
||||
```
|
||||
|
||||
|
||||
@ -294,6 +294,10 @@ func calculatePercentChange(open, current string) string {
|
||||
func main() {}
|
||||
|
||||
func init() {
|
||||
// Configure logging: No timestamps, no source file/line, prepend [Crypto]
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("[Crypto] ")
|
||||
|
||||
api.RegisterWebSocketCallback(CryptoTickerPlugin{})
|
||||
api.RegisterLifecycleManagement(CryptoTickerPlugin{})
|
||||
api.RegisterSchedulerCallback(CryptoTickerPlugin{})
|
||||
|
||||
@ -248,9 +248,37 @@ func (r *discordRPC) sendHeartbeat(ctx context.Context, username string) error {
|
||||
return r.sendMessage(ctx, username, heartbeatOpCode, resp.Value)
|
||||
}
|
||||
|
||||
func (r *discordRPC) cleanupFailedConnection(ctx context.Context, username string) {
|
||||
log.Printf("Cleaning up failed connection for user %s", username)
|
||||
|
||||
// Cancel the heartbeat schedule
|
||||
if resp, _ := r.sched.CancelSchedule(ctx, &scheduler.CancelRequest{ScheduleId: username}); resp.Error != "" {
|
||||
log.Printf("Failed to cancel heartbeat schedule for user %s: %s", username, resp.Error)
|
||||
}
|
||||
|
||||
// Close the WebSocket connection
|
||||
if resp, _ := r.ws.Close(ctx, &websocket.CloseRequest{
|
||||
ConnectionId: username,
|
||||
Code: 1000,
|
||||
Reason: "Connection lost",
|
||||
}); resp.Error != "" {
|
||||
log.Printf("Failed to close WebSocket connection for user %s: %s", username, resp.Error)
|
||||
}
|
||||
|
||||
// Clean up cache entries (just the sequence number, no failure tracking needed)
|
||||
_, _ = r.mem.Remove(ctx, &cache.RemoveRequest{Key: fmt.Sprintf("discord.seq.%s", username)})
|
||||
|
||||
log.Printf("Cleaned up connection for user %s", username)
|
||||
}
|
||||
|
||||
func (r *discordRPC) isConnected(ctx context.Context, username string) bool {
|
||||
// Try to send a heartbeat to test the connection
|
||||
err := r.sendHeartbeat(ctx, username)
|
||||
return err == nil
|
||||
if err != nil {
|
||||
log.Printf("Heartbeat test failed for user %s: %v", username, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *discordRPC) connect(ctx context.Context, username string, token string) error {
|
||||
@ -361,5 +389,14 @@ func (r *discordRPC) OnClose(_ context.Context, req *api.OnCloseRequest) (*api.O
|
||||
}
|
||||
|
||||
func (r *discordRPC) OnSchedulerCallback(ctx context.Context, req *api.SchedulerCallbackRequest) (*api.SchedulerCallbackResponse, error) {
|
||||
return nil, r.sendHeartbeat(ctx, req.ScheduleId)
|
||||
err := r.sendHeartbeat(ctx, req.ScheduleId)
|
||||
if err != nil {
|
||||
// On first heartbeat failure, immediately clean up the connection
|
||||
// The next NowPlaying call will reconnect if needed
|
||||
log.Printf("Heartbeat failed for user %s, cleaning up connection: %v", req.ScheduleId, err)
|
||||
r.cleanupFailedConnection(ctx, req.ScheduleId)
|
||||
return nil, fmt.Errorf("heartbeat failed, connection cleaned up: %w", err)
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@ -60,5 +60,9 @@ func (SubsonicAPIDemoPlugin) OnInit(ctx context.Context, req *api.InitRequest) (
|
||||
func main() {}
|
||||
|
||||
func init() {
|
||||
// Configure logging: No timestamps, no source file/line
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("[Subsonic Plugin] ")
|
||||
|
||||
api.RegisterLifecycleManagement(&SubsonicAPIDemoPlugin{})
|
||||
}
|
||||
|
||||
@ -383,5 +383,9 @@ func (WikimediaAgent) GetAlbumImages(context.Context, *api.AlbumImagesRequest) (
|
||||
func main() {}
|
||||
|
||||
func init() {
|
||||
// Configure logging: No timestamps, no source file/line
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("[Wikimedia] ")
|
||||
|
||||
api.RegisterMetadataAgent(WikimediaAgent{})
|
||||
}
|
||||
|
||||
@ -8,7 +8,6 @@ import (
|
||||
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/navidrome/navidrome/plugins/host/scheduler"
|
||||
navidsched "github.com/navidrome/navidrome/scheduler"
|
||||
)
|
||||
@ -49,13 +48,13 @@ func (s SchedulerHostFunctions) CancelSchedule(ctx context.Context, req *schedul
|
||||
type schedulerService struct {
|
||||
// Map of schedule IDs to their callback info
|
||||
schedules map[string]*ScheduledCallback
|
||||
manager *Manager
|
||||
manager *managerImpl
|
||||
navidSched navidsched.Scheduler // Navidrome scheduler for recurring jobs
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// newSchedulerService creates a new schedulerService instance
|
||||
func newSchedulerService(manager *Manager) *schedulerService {
|
||||
func newSchedulerService(manager *managerImpl) *schedulerService {
|
||||
return &schedulerService{
|
||||
schedules: make(map[string]*ScheduledCallback),
|
||||
manager: manager,
|
||||
@ -295,21 +294,10 @@ func (s *schedulerService) executeCallback(ctx context.Context, internalSchedule
|
||||
return
|
||||
}
|
||||
|
||||
callbackType := "one-time"
|
||||
if isRecurring {
|
||||
callbackType = "recurring"
|
||||
}
|
||||
|
||||
log.Debug("Executing schedule callback", "plugin", callback.PluginID, "scheduleID", callback.ID, "type", callbackType)
|
||||
ctx = log.NewContext(ctx, "plugin", callback.PluginID, "scheduleID", callback.ID, "type", callback.Type)
|
||||
log.Debug("Executing schedule callback")
|
||||
start := time.Now()
|
||||
|
||||
// Create a SchedulerCallbackRequest
|
||||
req := &api.SchedulerCallbackRequest{
|
||||
ScheduleId: callback.ID,
|
||||
Payload: callback.Payload,
|
||||
IsRecurring: isRecurring,
|
||||
}
|
||||
|
||||
// Get the plugin
|
||||
p := s.manager.LoadPlugin(callback.PluginID, CapabilitySchedulerCallback)
|
||||
if p == nil {
|
||||
@ -317,31 +305,19 @@ func (s *schedulerService) executeCallback(ctx context.Context, internalSchedule
|
||||
return
|
||||
}
|
||||
|
||||
// Get instance
|
||||
inst, closeFn, err := p.Instantiate(ctx)
|
||||
if err != nil {
|
||||
log.Error("Error getting plugin instance for callback", "plugin", callback.PluginID, err)
|
||||
return
|
||||
}
|
||||
defer closeFn()
|
||||
|
||||
// Type-check the plugin
|
||||
plugin, ok := inst.(api.SchedulerCallback)
|
||||
plugin, ok := p.(*wasmSchedulerCallback)
|
||||
if !ok {
|
||||
log.Error("Plugin does not implement SchedulerCallback", "plugin", callback.PluginID)
|
||||
return
|
||||
}
|
||||
|
||||
// Call the plugin's OnSchedulerCallback method
|
||||
log.Trace(ctx, "Executing schedule callback", "plugin", callback.PluginID, "scheduleID", callback.ID, "type", callbackType)
|
||||
resp, err := plugin.OnSchedulerCallback(ctx, req)
|
||||
log.Trace(ctx, "Executing schedule callback")
|
||||
err := plugin.OnSchedulerCallback(ctx, callback.ID, callback.Payload, isRecurring)
|
||||
if err != nil {
|
||||
log.Error("Error executing schedule callback", "plugin", callback.PluginID, "elapsed", time.Since(start), err)
|
||||
log.Error("Error executing schedule callback", "elapsed", time.Since(start), err)
|
||||
return
|
||||
}
|
||||
log.Debug("Schedule callback executed", "plugin", callback.PluginID, "elapsed", time.Since(start))
|
||||
|
||||
if resp.Error != "" {
|
||||
log.Error("Plugin reported error in schedule callback", "plugin", callback.PluginID, resp.Error)
|
||||
}
|
||||
log.Debug("Schedule callback executed", "elapsed", time.Since(start))
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package plugins
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/plugins/host/scheduler"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@ -11,12 +12,12 @@ import (
|
||||
var _ = Describe("SchedulerService", func() {
|
||||
var (
|
||||
ss *schedulerService
|
||||
manager *Manager
|
||||
manager *managerImpl
|
||||
pluginName = "test_plugin"
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
manager = createManager(nil, nil)
|
||||
manager = createManager(nil, metrics.NewNoopInstance())
|
||||
ss = manager.schedulerService
|
||||
})
|
||||
|
||||
|
||||
@ -50,12 +50,12 @@ func (s WebSocketHostFunctions) Close(ctx context.Context, req *websocket.CloseR
|
||||
// websocketService implements the WebSocket service functionality
|
||||
type websocketService struct {
|
||||
connections map[string]*WebSocketConnection
|
||||
manager *Manager
|
||||
manager *managerImpl
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// newWebsocketService creates a new websocketService instance
|
||||
func newWebsocketService(manager *Manager) *websocketService {
|
||||
func newWebsocketService(manager *managerImpl) *websocketService {
|
||||
return &websocketService{
|
||||
connections: make(map[string]*WebSocketConnection),
|
||||
manager: manager,
|
||||
@ -314,7 +314,7 @@ func (s *websocketService) handleMessages(internalID string, conn *WebSocketConn
|
||||
|
||||
// executeCallback is a common function that handles the plugin loading and execution
|
||||
// for all types of callbacks
|
||||
func (s *websocketService) executeCallback(ctx context.Context, pluginID string, fn func(context.Context, api.WebSocketCallback) error) {
|
||||
func (s *websocketService) executeCallback(ctx context.Context, pluginID, methodName string, fn func(context.Context, api.WebSocketCallback) error) {
|
||||
log.Debug(ctx, "WebSocket received")
|
||||
|
||||
start := time.Now()
|
||||
@ -326,30 +326,16 @@ func (s *websocketService) executeCallback(ctx context.Context, pluginID string,
|
||||
return
|
||||
}
|
||||
|
||||
// Get instance
|
||||
inst, closeFn, err := p.Instantiate(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error getting plugin instance for WebSocket callback", err)
|
||||
return
|
||||
}
|
||||
defer closeFn()
|
||||
|
||||
// Type-check the plugin
|
||||
plugin, ok := inst.(api.WebSocketCallback)
|
||||
if !ok {
|
||||
log.Error(ctx, "Plugin does not implement WebSocketCallback")
|
||||
return
|
||||
}
|
||||
|
||||
// Call the appropriate callback function
|
||||
log.Trace(ctx, "Executing WebSocket callback")
|
||||
|
||||
if err = fn(ctx, plugin); err != nil {
|
||||
log.Error(ctx, "Error executing WebSocket callback", "elapsed", time.Since(start), err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug(ctx, "WebSocket callback executed", "elapsed", time.Since(start))
|
||||
_, _ = callMethod(ctx, p, methodName, func(inst api.WebSocketCallback) (struct{}, error) {
|
||||
// Call the appropriate callback function
|
||||
log.Trace(ctx, "Executing WebSocket callback")
|
||||
if err := fn(ctx, inst); err != nil {
|
||||
log.Error(ctx, "Error executing WebSocket callback", "elapsed", time.Since(start), err)
|
||||
return struct{}{}, fmt.Errorf("error executing WebSocket callback: %w", err)
|
||||
}
|
||||
log.Debug(ctx, "WebSocket callback executed", "elapsed", time.Since(start))
|
||||
return struct{}{}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// notifyTextCallback notifies the plugin of a text message
|
||||
@ -361,8 +347,8 @@ func (s *websocketService) notifyTextCallback(ctx context.Context, connectionID
|
||||
|
||||
ctx = log.NewContext(ctx, "callback", "OnTextMessage", "size", len(message))
|
||||
|
||||
s.executeCallback(ctx, conn.PluginName, func(ctx context.Context, plugin api.WebSocketCallback) error {
|
||||
_, err := plugin.OnTextMessage(ctx, req)
|
||||
s.executeCallback(ctx, conn.PluginName, "OnTextMessage", func(ctx context.Context, plugin api.WebSocketCallback) error {
|
||||
_, err := checkErr(plugin.OnTextMessage(ctx, req))
|
||||
return err
|
||||
})
|
||||
}
|
||||
@ -376,8 +362,8 @@ func (s *websocketService) notifyBinaryCallback(ctx context.Context, connectionI
|
||||
|
||||
ctx = log.NewContext(ctx, "callback", "OnBinaryMessage", "size", len(data))
|
||||
|
||||
s.executeCallback(ctx, conn.PluginName, func(ctx context.Context, plugin api.WebSocketCallback) error {
|
||||
_, err := plugin.OnBinaryMessage(ctx, req)
|
||||
s.executeCallback(ctx, conn.PluginName, "OnBinaryMessage", func(ctx context.Context, plugin api.WebSocketCallback) error {
|
||||
_, err := checkErr(plugin.OnBinaryMessage(ctx, req))
|
||||
return err
|
||||
})
|
||||
}
|
||||
@ -391,8 +377,8 @@ func (s *websocketService) notifyErrorCallback(ctx context.Context, connectionID
|
||||
|
||||
ctx = log.NewContext(ctx, "callback", "OnError", "error", errorMsg)
|
||||
|
||||
s.executeCallback(ctx, conn.PluginName, func(ctx context.Context, plugin api.WebSocketCallback) error {
|
||||
_, err := plugin.OnError(ctx, req)
|
||||
s.executeCallback(ctx, conn.PluginName, "OnError", func(ctx context.Context, plugin api.WebSocketCallback) error {
|
||||
_, err := checkErr(plugin.OnError(ctx, req))
|
||||
return err
|
||||
})
|
||||
}
|
||||
@ -407,8 +393,8 @@ func (s *websocketService) notifyCloseCallback(ctx context.Context, connectionID
|
||||
|
||||
ctx = log.NewContext(ctx, "callback", "OnClose", "code", code, "reason", reason)
|
||||
|
||||
s.executeCallback(ctx, conn.PluginName, func(ctx context.Context, plugin api.WebSocketCallback) error {
|
||||
_, err := plugin.OnClose(ctx, req)
|
||||
s.executeCallback(ctx, conn.PluginName, "OnClose", func(ctx context.Context, plugin api.WebSocketCallback) error {
|
||||
_, err := checkErr(plugin.OnClose(ctx, req))
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
@ -6,9 +6,11 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
gorillaws "github.com/gorilla/websocket"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/plugins/host/websocket"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@ -17,7 +19,7 @@ import (
|
||||
var _ = Describe("WebSocket Host Service", func() {
|
||||
var (
|
||||
wsService *websocketService
|
||||
manager *Manager
|
||||
manager *managerImpl
|
||||
ctx context.Context
|
||||
server *httptest.Server
|
||||
upgrader gorillaws.Upgrader
|
||||
@ -84,7 +86,7 @@ var _ = Describe("WebSocket Host Service", func() {
|
||||
DeferCleanup(server.Close)
|
||||
|
||||
// Create a new manager and websocket service
|
||||
manager = createManager(nil, nil)
|
||||
manager = createManager(nil, metrics.NewNoopInstance())
|
||||
wsService = newWebsocketService(manager)
|
||||
})
|
||||
|
||||
@ -188,6 +190,10 @@ var _ = Describe("WebSocket Host Service", func() {
|
||||
})
|
||||
|
||||
It("handles connection errors gracefully", func() {
|
||||
if testing.Short() {
|
||||
GinkgoT().Skip("skipping test in short mode.")
|
||||
}
|
||||
|
||||
// Try to connect to an invalid URL
|
||||
req := &websocket.ConnectRequest{
|
||||
Url: "ws://invalid-url-that-does-not-exist",
|
||||
|
||||
@ -10,10 +10,10 @@ package plugins
|
||||
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/subsonicapi/subsonicapi.proto
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@ -40,7 +40,7 @@ const (
|
||||
)
|
||||
|
||||
// pluginCreators maps capability types to their respective creator functions
|
||||
type pluginConstructor func(wasmPath, pluginID string, m *Manager, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin
|
||||
type pluginConstructor func(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin
|
||||
|
||||
var pluginCreators = map[string]pluginConstructor{
|
||||
CapabilityMetadataAgent: newWasmMediaAgent,
|
||||
@ -53,8 +53,6 @@ var pluginCreators = map[string]pluginConstructor{
|
||||
type WasmPlugin interface {
|
||||
// PluginID returns the unique identifier of the plugin (folder name)
|
||||
PluginID() string
|
||||
// Instantiate creates a new instance of the plugin and returns it along with a cleanup function
|
||||
Instantiate(ctx context.Context) (any, func(), error)
|
||||
}
|
||||
|
||||
type plugin struct {
|
||||
@ -86,8 +84,18 @@ func (p *plugin) waitForCompilation() error {
|
||||
|
||||
type SubsonicRouter http.Handler
|
||||
|
||||
// Manager is a singleton that manages plugins
|
||||
type Manager struct {
|
||||
type Manager interface {
|
||||
SetSubsonicRouter(router SubsonicRouter)
|
||||
EnsureCompiled(name string) error
|
||||
PluginNames(serviceName string) []string
|
||||
LoadPlugin(name string, capability string) WasmPlugin
|
||||
LoadMediaAgent(name string) (agents.Interface, bool)
|
||||
LoadScrobbler(name string) (scrobbler.Scrobbler, bool)
|
||||
ScanPlugins()
|
||||
}
|
||||
|
||||
// managerImpl is a singleton that manages plugins
|
||||
type managerImpl struct {
|
||||
plugins map[string]*plugin // Map of plugin folder name to plugin info
|
||||
mu sync.RWMutex // Protects plugins map
|
||||
subsonicRouter atomic.Pointer[SubsonicRouter] // Subsonic API router
|
||||
@ -99,18 +107,21 @@ type Manager struct {
|
||||
metrics metrics.Metrics
|
||||
}
|
||||
|
||||
// GetManager returns the singleton instance of Manager
|
||||
func GetManager(ds model.DataStore, metrics metrics.Metrics) *Manager {
|
||||
return singleton.GetInstance(func() *Manager {
|
||||
// GetManager returns the singleton instance of managerImpl
|
||||
func GetManager(ds model.DataStore, metrics metrics.Metrics) Manager {
|
||||
if !conf.Server.Plugins.Enabled {
|
||||
return &noopManager{}
|
||||
}
|
||||
return singleton.GetInstance(func() *managerImpl {
|
||||
return createManager(ds, metrics)
|
||||
})
|
||||
}
|
||||
|
||||
// createManager creates a new Manager instance. Used in tests
|
||||
func createManager(ds model.DataStore, metrics metrics.Metrics) *Manager {
|
||||
m := &Manager{
|
||||
// createManager creates a new managerImpl instance. Used in tests
|
||||
func createManager(ds model.DataStore, metrics metrics.Metrics) *managerImpl {
|
||||
m := &managerImpl{
|
||||
plugins: make(map[string]*plugin),
|
||||
lifecycle: newPluginLifecycleManager(),
|
||||
lifecycle: newPluginLifecycleManager(metrics),
|
||||
ds: ds,
|
||||
metrics: metrics,
|
||||
}
|
||||
@ -122,14 +133,14 @@ func createManager(ds model.DataStore, metrics metrics.Metrics) *Manager {
|
||||
return m
|
||||
}
|
||||
|
||||
// SetSubsonicRouter sets the SubsonicRouter after Manager initialization
|
||||
func (m *Manager) SetSubsonicRouter(router SubsonicRouter) {
|
||||
// SetSubsonicRouter sets the SubsonicRouter after managerImpl initialization
|
||||
func (m *managerImpl) SetSubsonicRouter(router SubsonicRouter) {
|
||||
m.subsonicRouter.Store(&router)
|
||||
}
|
||||
|
||||
// registerPlugin adds a plugin to the registry with the given parameters
|
||||
// Used internally by ScanPlugins to register plugins
|
||||
func (m *Manager) registerPlugin(pluginID, pluginDir, wasmPath string, manifest *schema.PluginManifest) *plugin {
|
||||
func (m *managerImpl) registerPlugin(pluginID, pluginDir, wasmPath string, manifest *schema.PluginManifest) *plugin {
|
||||
// Create custom runtime function
|
||||
customRuntime := m.createRuntime(pluginID, manifest.Permissions)
|
||||
|
||||
@ -154,16 +165,8 @@ func (m *Manager) registerPlugin(pluginID, pluginDir, wasmPath string, manifest
|
||||
compilationReady: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Start pre-compilation of WASM module in background
|
||||
go func() {
|
||||
precompilePlugin(p)
|
||||
// Check if this plugin implements InitService and hasn't been initialized yet
|
||||
m.initializePluginIfNeeded(p)
|
||||
}()
|
||||
|
||||
// Register the plugin
|
||||
// Register the plugin first
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.plugins[pluginID] = p
|
||||
|
||||
// Register one plugin adapter for each capability
|
||||
@ -184,30 +187,59 @@ func (m *Manager) registerPlugin(pluginID, pluginDir, wasmPath string, manifest
|
||||
}
|
||||
m.adapters[pluginID+"_"+capabilityStr] = adapter
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// Start pre-compilation of WASM module in background AFTER registration
|
||||
go func() {
|
||||
precompilePlugin(p)
|
||||
// Check if this plugin implements InitService and hasn't been initialized yet
|
||||
m.initializePluginIfNeeded(p)
|
||||
}()
|
||||
|
||||
log.Info("Discovered plugin", "folder", pluginID, "name", manifest.Name, "capabilities", manifest.Capabilities, "wasm", wasmPath, "dev_mode", isSymlink)
|
||||
return m.plugins[pluginID]
|
||||
}
|
||||
|
||||
// initializePluginIfNeeded calls OnInit on plugins that implement LifecycleManagement
|
||||
func (m *Manager) initializePluginIfNeeded(plugin *plugin) {
|
||||
func (m *managerImpl) initializePluginIfNeeded(plugin *plugin) {
|
||||
// Skip if already initialized
|
||||
if m.lifecycle.isInitialized(plugin) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the plugin implements LifecycleManagement
|
||||
for _, capability := range plugin.Manifest.Capabilities {
|
||||
if capability == CapabilityLifecycleManagement {
|
||||
m.lifecycle.callOnInit(plugin)
|
||||
m.lifecycle.markInitialized(plugin)
|
||||
break
|
||||
if slices.Contains(plugin.Manifest.Capabilities, CapabilityLifecycleManagement) {
|
||||
if err := m.lifecycle.callOnInit(plugin); err != nil {
|
||||
m.unregisterPlugin(plugin.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// unregisterPlugin removes a plugin from the manager
|
||||
func (m *managerImpl) unregisterPlugin(pluginID string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
plugin, ok := m.plugins[pluginID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Clear initialization state from lifecycle manager
|
||||
m.lifecycle.clearInitialized(plugin)
|
||||
|
||||
// Unregister plugin adapters
|
||||
for _, capability := range plugin.Manifest.Capabilities {
|
||||
delete(m.adapters, pluginID+"_"+string(capability))
|
||||
}
|
||||
|
||||
// Unregister plugin
|
||||
delete(m.plugins, pluginID)
|
||||
log.Info("Unregistered plugin", "plugin", pluginID)
|
||||
}
|
||||
|
||||
// ScanPlugins scans the plugins directory, discovers all valid plugins, and registers them for use.
|
||||
func (m *Manager) ScanPlugins() {
|
||||
func (m *managerImpl) ScanPlugins() {
|
||||
// Clear existing plugins
|
||||
m.mu.Lock()
|
||||
m.plugins = make(map[string]*plugin)
|
||||
@ -259,7 +291,7 @@ func (m *Manager) ScanPlugins() {
|
||||
}
|
||||
|
||||
// PluginNames returns the folder names of all plugins that implement the specified capability
|
||||
func (m *Manager) PluginNames(capability string) []string {
|
||||
func (m *managerImpl) PluginNames(capability string) []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
@ -275,28 +307,26 @@ func (m *Manager) PluginNames(capability string) []string {
|
||||
return names
|
||||
}
|
||||
|
||||
func (m *Manager) getPlugin(name string, capability string) (*plugin, WasmPlugin) {
|
||||
func (m *managerImpl) getPlugin(name string, capability string) (*plugin, WasmPlugin, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
info, infoOk := m.plugins[name]
|
||||
adapter, adapterOk := m.adapters[name+"_"+capability]
|
||||
|
||||
if !infoOk {
|
||||
log.Warn("Plugin not found", "name", name)
|
||||
return nil, nil
|
||||
return nil, nil, fmt.Errorf("plugin not registered: %s", name)
|
||||
}
|
||||
if !adapterOk {
|
||||
log.Warn("Plugin adapter not found", "name", name, "capability", capability)
|
||||
return nil, nil
|
||||
return nil, nil, fmt.Errorf("plugin adapter not registered: %s, capability: %s", name, capability)
|
||||
}
|
||||
return info, adapter
|
||||
return info, adapter, nil
|
||||
}
|
||||
|
||||
// LoadPlugin instantiates and returns a plugin by folder name
|
||||
func (m *Manager) LoadPlugin(name string, capability string) WasmPlugin {
|
||||
info, adapter := m.getPlugin(name, capability)
|
||||
if info == nil {
|
||||
log.Warn("Plugin not found", "name", name, "capability", capability)
|
||||
func (m *managerImpl) LoadPlugin(name string, capability string) WasmPlugin {
|
||||
info, adapter, err := m.getPlugin(name, capability)
|
||||
if err != nil {
|
||||
log.Warn("Error loading plugin", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -318,7 +348,7 @@ func (m *Manager) LoadPlugin(name string, capability string) WasmPlugin {
|
||||
// EnsureCompiled waits for a plugin to finish compilation and returns any compilation error.
|
||||
// This is useful when you need to wait for compilation without loading a specific capability,
|
||||
// such as during plugin refresh operations or health checks.
|
||||
func (m *Manager) EnsureCompiled(name string) error {
|
||||
func (m *managerImpl) EnsureCompiled(name string) error {
|
||||
m.mu.RLock()
|
||||
plugin, ok := m.plugins[name]
|
||||
m.mu.RUnlock()
|
||||
@ -330,25 +360,8 @@ func (m *Manager) EnsureCompiled(name string) error {
|
||||
return plugin.waitForCompilation()
|
||||
}
|
||||
|
||||
// LoadAllPlugins instantiates and returns all plugins that implement the specified capability
|
||||
func (m *Manager) LoadAllPlugins(capability string) []WasmPlugin {
|
||||
names := m.PluginNames(capability)
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var plugins []WasmPlugin
|
||||
for _, name := range names {
|
||||
plugin := m.LoadPlugin(name, capability)
|
||||
if plugin != nil {
|
||||
plugins = append(plugins, plugin)
|
||||
}
|
||||
}
|
||||
return plugins
|
||||
}
|
||||
|
||||
// LoadMediaAgent instantiates and returns a media agent plugin by folder name
|
||||
func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) {
|
||||
func (m *managerImpl) LoadMediaAgent(name string) (agents.Interface, bool) {
|
||||
plugin := m.LoadPlugin(name, CapabilityMetadataAgent)
|
||||
if plugin == nil {
|
||||
return nil, false
|
||||
@ -357,17 +370,8 @@ func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) {
|
||||
return agent, ok
|
||||
}
|
||||
|
||||
// LoadAllMediaAgents instantiates and returns all media agent plugins
|
||||
func (m *Manager) LoadAllMediaAgents() []agents.Interface {
|
||||
plugins := m.LoadAllPlugins(CapabilityMetadataAgent)
|
||||
|
||||
return slice.Map(plugins, func(p WasmPlugin) agents.Interface {
|
||||
return p.(agents.Interface)
|
||||
})
|
||||
}
|
||||
|
||||
// LoadScrobbler instantiates and returns a scrobbler plugin by folder name
|
||||
func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
|
||||
func (m *managerImpl) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
|
||||
plugin := m.LoadPlugin(name, CapabilityScrobbler)
|
||||
if plugin == nil {
|
||||
return nil, false
|
||||
@ -376,11 +380,18 @@ func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// LoadAllScrobblers instantiates and returns all scrobbler plugins
|
||||
func (m *Manager) LoadAllScrobblers() []scrobbler.Scrobbler {
|
||||
plugins := m.LoadAllPlugins(CapabilityScrobbler)
|
||||
type noopManager struct{}
|
||||
|
||||
return slice.Map(plugins, func(p WasmPlugin) scrobbler.Scrobbler {
|
||||
return p.(scrobbler.Scrobbler)
|
||||
})
|
||||
}
|
||||
func (n noopManager) SetSubsonicRouter(router SubsonicRouter) {}
|
||||
|
||||
func (n noopManager) EnsureCompiled(name string) error { return nil }
|
||||
|
||||
func (n noopManager) PluginNames(serviceName string) []string { return nil }
|
||||
|
||||
func (n noopManager) LoadPlugin(name string, capability string) WasmPlugin { return nil }
|
||||
|
||||
func (n noopManager) LoadMediaAgent(name string) (agents.Interface, bool) { return nil, false }
|
||||
|
||||
func (n noopManager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) { return nil, false }
|
||||
|
||||
func (n noopManager) ScanPlugins() {}
|
||||
|
||||
@ -7,12 +7,14 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/plugins/schema"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Plugin Manager", func() {
|
||||
var mgr *Manager
|
||||
var mgr *managerImpl
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
@ -27,7 +29,7 @@ var _ = Describe("Plugin Manager", func() {
|
||||
conf.Server.Plugins.Folder = testDataDir
|
||||
|
||||
ctx = GinkgoT().Context()
|
||||
mgr = createManager(nil, nil)
|
||||
mgr = createManager(nil, metrics.NewNoopInstance())
|
||||
mgr.ScanPlugins()
|
||||
})
|
||||
|
||||
@ -36,17 +38,21 @@ var _ = Describe("Plugin Manager", func() {
|
||||
|
||||
mediaAgentNames := mgr.PluginNames("MetadataAgent")
|
||||
Expect(mediaAgentNames).To(HaveLen(4))
|
||||
Expect(mediaAgentNames).To(ContainElement("fake_artist_agent"))
|
||||
Expect(mediaAgentNames).To(ContainElement("fake_album_agent"))
|
||||
Expect(mediaAgentNames).To(ContainElement("multi_plugin"))
|
||||
Expect(mediaAgentNames).To(ContainElement("unauthorized_plugin"))
|
||||
Expect(mediaAgentNames).To(ContainElements(
|
||||
"fake_artist_agent",
|
||||
"fake_album_agent",
|
||||
"multi_plugin",
|
||||
"unauthorized_plugin",
|
||||
))
|
||||
|
||||
scrobblerNames := mgr.PluginNames("Scrobbler")
|
||||
Expect(scrobblerNames).To(ContainElement("fake_scrobbler"))
|
||||
|
||||
initServiceNames := mgr.PluginNames("LifecycleManagement")
|
||||
Expect(initServiceNames).To(ContainElement("multi_plugin"))
|
||||
Expect(initServiceNames).To(ContainElement("fake_init_service"))
|
||||
Expect(initServiceNames).To(ContainElements("multi_plugin", "fake_init_service"))
|
||||
|
||||
schedulerCallbackNames := mgr.PluginNames("SchedulerCallback")
|
||||
Expect(schedulerCallbackNames).To(ContainElement("multi_plugin"))
|
||||
})
|
||||
|
||||
It("should load a MetadataAgent plugin and invoke artist-related methods", func() {
|
||||
@ -65,18 +71,23 @@ var _ = Describe("Plugin Manager", func() {
|
||||
})
|
||||
|
||||
It("should load all MetadataAgent plugins", func() {
|
||||
agents := mgr.LoadAllMediaAgents()
|
||||
Expect(agents).To(HaveLen(4))
|
||||
var names []string
|
||||
for _, a := range agents {
|
||||
names = append(names, a.AgentName())
|
||||
mediaAgentNames := mgr.PluginNames("MetadataAgent")
|
||||
Expect(mediaAgentNames).To(HaveLen(4))
|
||||
|
||||
var agentNames []string
|
||||
for _, name := range mediaAgentNames {
|
||||
agent, ok := mgr.LoadMediaAgent(name)
|
||||
if ok {
|
||||
agentNames = append(agentNames, agent.AgentName())
|
||||
}
|
||||
}
|
||||
Expect(names).To(ContainElements("fake_artist_agent", "fake_album_agent", "multi_plugin", "unauthorized_plugin"))
|
||||
|
||||
Expect(agentNames).To(ContainElements("fake_artist_agent", "fake_album_agent", "multi_plugin", "unauthorized_plugin"))
|
||||
})
|
||||
|
||||
Describe("ScanPlugins", func() {
|
||||
var tempPluginsDir string
|
||||
var m *Manager
|
||||
var m *managerImpl
|
||||
|
||||
BeforeEach(func() {
|
||||
tempPluginsDir, _ = os.MkdirTemp("", "navidrome-plugins-test-*")
|
||||
@ -85,7 +96,7 @@ var _ = Describe("Plugin Manager", func() {
|
||||
})
|
||||
|
||||
conf.Server.Plugins.Folder = tempPluginsDir
|
||||
m = createManager(nil, nil)
|
||||
m = createManager(nil, metrics.NewNoopInstance())
|
||||
})
|
||||
|
||||
// Helper to create a complete valid plugin for manager testing
|
||||
@ -193,21 +204,8 @@ var _ = Describe("Plugin Manager", func() {
|
||||
|
||||
Describe("Invoke Methods", func() {
|
||||
It("should load all MetadataAgent plugins and invoke methods", func() {
|
||||
mediaAgentNames := mgr.PluginNames("MetadataAgent")
|
||||
Expect(mediaAgentNames).NotTo(BeEmpty())
|
||||
|
||||
plugins := mgr.LoadAllPlugins("MetadataAgent")
|
||||
Expect(plugins).To(HaveLen(len(mediaAgentNames)))
|
||||
|
||||
var fakeAlbumPlugin agents.Interface
|
||||
for _, p := range plugins {
|
||||
if agent, ok := p.(agents.Interface); ok {
|
||||
if agent.AgentName() == "fake_album_agent" {
|
||||
fakeAlbumPlugin = agent
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
fakeAlbumPlugin, isMediaAgent := mgr.LoadMediaAgent("fake_album_agent")
|
||||
Expect(isMediaAgent).To(BeTrue())
|
||||
|
||||
Expect(fakeAlbumPlugin).NotTo(BeNil(), "fake_album_agent should be loaded")
|
||||
|
||||
@ -254,4 +252,95 @@ var _ = Describe("Plugin Manager", func() {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Plugin Initialization Lifecycle", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = testDataDir
|
||||
})
|
||||
|
||||
Context("when OnInit is successful", func() {
|
||||
It("should register and initialize the plugin", func() {
|
||||
conf.Server.PluginConfig = nil
|
||||
mgr = createManager(nil, metrics.NewNoopInstance()) // Create manager after setting config
|
||||
mgr.ScanPlugins()
|
||||
|
||||
plugin := mgr.plugins["fake_init_service"]
|
||||
Expect(plugin).NotTo(BeNil())
|
||||
|
||||
Eventually(func() bool {
|
||||
return mgr.lifecycle.isInitialized(plugin)
|
||||
}).Should(BeTrue())
|
||||
|
||||
// Check that the plugin is still registered
|
||||
names := mgr.PluginNames(CapabilityLifecycleManagement)
|
||||
Expect(names).To(ContainElement("fake_init_service"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when OnInit fails", func() {
|
||||
It("should unregister the plugin if OnInit returns an error string", func() {
|
||||
conf.Server.PluginConfig = map[string]map[string]string{
|
||||
"fake_init_service": {
|
||||
"returnError": "response_error",
|
||||
},
|
||||
}
|
||||
mgr = createManager(nil, metrics.NewNoopInstance()) // Create manager after setting config
|
||||
mgr.ScanPlugins()
|
||||
|
||||
Eventually(func() []string {
|
||||
return mgr.PluginNames(CapabilityLifecycleManagement)
|
||||
}).ShouldNot(ContainElement("fake_init_service"))
|
||||
})
|
||||
|
||||
It("should unregister the plugin if OnInit returns a Go error", func() {
|
||||
conf.Server.PluginConfig = map[string]map[string]string{
|
||||
"fake_init_service": {
|
||||
"returnError": "go_error",
|
||||
},
|
||||
}
|
||||
mgr = createManager(nil, metrics.NewNoopInstance()) // Create manager after setting config
|
||||
mgr.ScanPlugins()
|
||||
|
||||
Eventually(func() []string {
|
||||
return mgr.PluginNames(CapabilityLifecycleManagement)
|
||||
}).ShouldNot(ContainElement("fake_init_service"))
|
||||
})
|
||||
})
|
||||
|
||||
It("should clear lifecycle state when unregistering a plugin", func() {
|
||||
// Create a manager and register a plugin
|
||||
mgr := createManager(nil, metrics.NewNoopInstance())
|
||||
|
||||
// Create a mock plugin with LifecycleManagement capability
|
||||
plugin := &plugin{
|
||||
ID: "test-plugin",
|
||||
Capabilities: []string{CapabilityLifecycleManagement},
|
||||
Manifest: &schema.PluginManifest{
|
||||
Version: "1.0.0",
|
||||
},
|
||||
}
|
||||
|
||||
// Register the plugin in the manager
|
||||
mgr.mu.Lock()
|
||||
mgr.plugins[plugin.ID] = plugin
|
||||
mgr.mu.Unlock()
|
||||
|
||||
// Mark the plugin as initialized in the lifecycle manager
|
||||
mgr.lifecycle.markInitialized(plugin)
|
||||
Expect(mgr.lifecycle.isInitialized(plugin)).To(BeTrue())
|
||||
|
||||
// Unregister the plugin
|
||||
mgr.unregisterPlugin(plugin.ID)
|
||||
|
||||
// Verify that the plugin is no longer in the manager
|
||||
mgr.mu.RLock()
|
||||
_, exists := mgr.plugins[plugin.ID]
|
||||
mgr.mu.RUnlock()
|
||||
Expect(exists).To(BeFalse())
|
||||
|
||||
// Verify that the lifecycle state has been cleared
|
||||
Expect(mgr.lifecycle.isInitialized(plugin)).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/plugins/schema"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@ -47,7 +48,7 @@ func createTestPlugin(tempDir, name string, permissions schema.PluginManifestPer
|
||||
|
||||
var _ = Describe("Plugin Permissions", func() {
|
||||
var (
|
||||
mgr *Manager
|
||||
mgr *managerImpl
|
||||
tempDir string
|
||||
ctx context.Context
|
||||
)
|
||||
@ -55,7 +56,7 @@ var _ = Describe("Plugin Permissions", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = context.Background()
|
||||
mgr = createManager(nil, nil)
|
||||
mgr = createManager(nil, metrics.NewNoopInstance())
|
||||
tempDir = GinkgoT().TempDir()
|
||||
})
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
)
|
||||
@ -16,13 +17,15 @@ import (
|
||||
type pluginLifecycleManager struct {
|
||||
plugins sync.Map // string -> bool
|
||||
config map[string]map[string]string
|
||||
metrics metrics.Metrics
|
||||
}
|
||||
|
||||
// newPluginLifecycleManager creates a new plugin lifecycle manager
|
||||
func newPluginLifecycleManager() *pluginLifecycleManager {
|
||||
func newPluginLifecycleManager(metrics metrics.Metrics) *pluginLifecycleManager {
|
||||
config := maps.Clone(conf.Server.PluginConfig)
|
||||
return &pluginLifecycleManager{
|
||||
config: config,
|
||||
config: config,
|
||||
metrics: metrics,
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,8 +42,14 @@ func (m *pluginLifecycleManager) markInitialized(plugin *plugin) {
|
||||
m.plugins.Store(key, true)
|
||||
}
|
||||
|
||||
// clearInitialized removes the initialization state of a plugin
|
||||
func (m *pluginLifecycleManager) clearInitialized(plugin *plugin) {
|
||||
key := plugin.ID + consts.Zwsp + plugin.Manifest.Version
|
||||
m.plugins.Delete(key)
|
||||
}
|
||||
|
||||
// callOnInit calls the OnInit method on a plugin that implements LifecycleManagement
|
||||
func (m *pluginLifecycleManager) callOnInit(plugin *plugin) {
|
||||
func (m *pluginLifecycleManager) callOnInit(plugin *plugin) error {
|
||||
ctx := context.Background()
|
||||
log.Debug("Initializing plugin", "name", plugin.ID)
|
||||
start := time.Now()
|
||||
@ -49,13 +58,13 @@ func (m *pluginLifecycleManager) callOnInit(plugin *plugin) {
|
||||
loader, err := api.NewLifecycleManagementPlugin(ctx, api.WazeroRuntime(plugin.Runtime), api.WazeroModuleConfig(plugin.ModConfig))
|
||||
if loader == nil || err != nil {
|
||||
log.Error("Error creating LifecycleManagement plugin", "plugin", plugin.ID, err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
initPlugin, err := loader.Load(ctx, plugin.WasmPath)
|
||||
if err != nil {
|
||||
log.Error("Error loading LifecycleManagement plugin", "plugin", plugin.ID, "path", plugin.WasmPath, err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
defer initPlugin.Close(ctx)
|
||||
|
||||
@ -71,16 +80,16 @@ func (m *pluginLifecycleManager) callOnInit(plugin *plugin) {
|
||||
}
|
||||
|
||||
// Call OnInit
|
||||
resp, err := initPlugin.OnInit(ctx, req)
|
||||
callStart := time.Now()
|
||||
_, err = checkErr(initPlugin.OnInit(ctx, req))
|
||||
m.metrics.RecordPluginRequest(ctx, plugin.ID, "OnInit", err == nil, time.Since(callStart).Milliseconds())
|
||||
if err != nil {
|
||||
log.Error("Error initializing plugin", "plugin", plugin.ID, "elapsed", time.Since(start), err)
|
||||
return
|
||||
}
|
||||
|
||||
if resp.Error != "" {
|
||||
log.Error("Plugin reported error during initialization", "plugin", plugin.ID, "error", resp.Error)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
// Mark the plugin as initialized
|
||||
m.markInitialized(plugin)
|
||||
log.Debug("Plugin initialized successfully", "plugin", plugin.ID, "elapsed", time.Since(start))
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package plugins
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/plugins/schema"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@ -22,7 +23,7 @@ var _ = Describe("LifecycleManagement", func() {
|
||||
var lifecycleManager *pluginLifecycleManager
|
||||
|
||||
BeforeEach(func() {
|
||||
lifecycleManager = newPluginLifecycleManager()
|
||||
lifecycleManager = newPluginLifecycleManager(metrics.NewNoopInstance())
|
||||
})
|
||||
|
||||
It("should track initialization state of plugins", func() {
|
||||
@ -140,5 +141,26 @@ var _ = Describe("LifecycleManagement", func() {
|
||||
|
||||
Expect(actualKey).To(Equal(expectedKey))
|
||||
})
|
||||
|
||||
It("should clear initialization state when requested", func() {
|
||||
plugin := &plugin{
|
||||
ID: "test-plugin",
|
||||
Capabilities: []string{CapabilityLifecycleManagement},
|
||||
Manifest: &schema.PluginManifest{
|
||||
Version: "1.0.0",
|
||||
},
|
||||
}
|
||||
|
||||
// Initially not initialized
|
||||
Expect(lifecycleManager.isInitialized(plugin)).To(BeFalse())
|
||||
|
||||
// Mark as initialized
|
||||
lifecycleManager.markInitialized(plugin)
|
||||
Expect(lifecycleManager.isInitialized(plugin)).To(BeTrue())
|
||||
|
||||
// Clear initialization state
|
||||
lifecycleManager.clearInitialized(plugin)
|
||||
Expect(lifecycleManager.isInitialized(plugin)).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -41,7 +41,7 @@ var (
|
||||
|
||||
// createRuntime returns a function that creates a new wazero runtime and instantiates the required host functions
|
||||
// based on the given plugin permissions
|
||||
func (m *Manager) createRuntime(pluginID string, permissions schema.PluginManifestPermissions) api.WazeroNewRuntime {
|
||||
func (m *managerImpl) createRuntime(pluginID string, permissions schema.PluginManifestPermissions) api.WazeroNewRuntime {
|
||||
return func(ctx context.Context) (wazero.Runtime, error) {
|
||||
// Check if runtime already exists
|
||||
if rt, ok := runtimePool.Load(pluginID); ok {
|
||||
@ -70,7 +70,7 @@ func (m *Manager) createRuntime(pluginID string, permissions schema.PluginManife
|
||||
}
|
||||
|
||||
// createCachingRuntime handles the complex logic of setting up a new cachingRuntime
|
||||
func (m *Manager) createCachingRuntime(ctx context.Context, pluginID string, permissions schema.PluginManifestPermissions) (*cachingRuntime, error) {
|
||||
func (m *managerImpl) createCachingRuntime(ctx context.Context, pluginID string, permissions schema.PluginManifestPermissions) (*cachingRuntime, error) {
|
||||
// Get compilation cache
|
||||
compCache, err := getCompilationCache()
|
||||
if err != nil {
|
||||
@ -94,7 +94,7 @@ func (m *Manager) createCachingRuntime(ctx context.Context, pluginID string, per
|
||||
}
|
||||
|
||||
// setupHostServices configures all the permitted host services for a plugin
|
||||
func (m *Manager) setupHostServices(ctx context.Context, r wazero.Runtime, pluginID string, permissions schema.PluginManifestPermissions) error {
|
||||
func (m *managerImpl) setupHostServices(ctx context.Context, r wazero.Runtime, pluginID string, permissions schema.PluginManifestPermissions) error {
|
||||
// Define all available host services
|
||||
type hostService struct {
|
||||
name string
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/plugins/schema"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@ -34,13 +35,13 @@ var _ = Describe("Runtime", func() {
|
||||
var _ = Describe("CachingRuntime", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
mgr *Manager
|
||||
mgr *managerImpl
|
||||
plugin *wasmScrobblerPlugin
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
mgr = createManager(nil, nil)
|
||||
mgr = createManager(nil, metrics.NewNoopInstance())
|
||||
// Add permissions for the test plugin using typed struct
|
||||
permissions := schema.PluginManifestPermissions{
|
||||
Http: &schema.PluginManifestPermissionsHttp{
|
||||
|
||||
17
plugins/testdata/fake_init_service/plugin.go
vendored
17
plugins/testdata/fake_init_service/plugin.go
vendored
@ -4,6 +4,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
@ -13,6 +14,22 @@ type initServicePlugin struct{}
|
||||
|
||||
func (p *initServicePlugin) OnInit(ctx context.Context, req *api.InitRequest) (*api.InitResponse, error) {
|
||||
log.Printf("OnInit called with %v", req)
|
||||
|
||||
// Check for specific error conditions in the config
|
||||
if req.Config != nil {
|
||||
if errorType, exists := req.Config["returnError"]; exists {
|
||||
switch errorType {
|
||||
case "go_error":
|
||||
return nil, errors.New("initialization failed with Go error")
|
||||
case "response_error":
|
||||
return &api.InitResponse{
|
||||
Error: "initialization failed with response error",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: successful initialization
|
||||
return &api.InitResponse{}, nil
|
||||
}
|
||||
|
||||
|
||||
@ -1,119 +0,0 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
)
|
||||
|
||||
// newWasmBasePlugin creates a new instance of wasmBasePlugin with the required parameters.
|
||||
func newWasmBasePlugin[S any, P any](wasmPath, id, capability string, m metrics.Metrics, loader P, loadFunc loaderFunc[S, P]) *wasmBasePlugin[S, P] {
|
||||
return &wasmBasePlugin[S, P]{
|
||||
wasmPath: wasmPath,
|
||||
id: id,
|
||||
capability: capability,
|
||||
loader: loader,
|
||||
loadFunc: loadFunc,
|
||||
metrics: m,
|
||||
}
|
||||
}
|
||||
|
||||
// LoaderFunc is a generic function type that loads a plugin instance.
|
||||
type loaderFunc[S any, P any] func(ctx context.Context, loader P, path string) (S, error)
|
||||
|
||||
// wasmBasePlugin is a generic base implementation for WASM plugins.
|
||||
// S is the service interface type and P is the plugin loader type.
|
||||
type wasmBasePlugin[S any, P any] struct {
|
||||
wasmPath string
|
||||
id string
|
||||
capability string
|
||||
loader P
|
||||
loadFunc loaderFunc[S, P]
|
||||
metrics metrics.Metrics
|
||||
}
|
||||
|
||||
func (w *wasmBasePlugin[S, P]) PluginID() string {
|
||||
return w.id
|
||||
}
|
||||
|
||||
func (w *wasmBasePlugin[S, P]) Instantiate(ctx context.Context) (any, func(), error) {
|
||||
return w.getInstance(ctx, "<none>")
|
||||
}
|
||||
|
||||
func (w *wasmBasePlugin[S, P]) serviceName() string {
|
||||
return w.id + "_" + w.capability
|
||||
}
|
||||
|
||||
func (w *wasmBasePlugin[S, P]) getMetrics() metrics.Metrics {
|
||||
return w.metrics
|
||||
}
|
||||
|
||||
// getInstance loads a new plugin instance and returns a cleanup function.
|
||||
func (w *wasmBasePlugin[S, P]) getInstance(ctx context.Context, methodName string) (S, func(), error) {
|
||||
start := time.Now()
|
||||
// Add context metadata for tracing
|
||||
ctx = log.NewContext(ctx, "capability", w.serviceName(), "method", methodName)
|
||||
|
||||
inst, err := w.loadFunc(ctx, w.loader, w.wasmPath)
|
||||
if err != nil {
|
||||
var zero S
|
||||
return zero, func() {}, fmt.Errorf("wasmBasePlugin: failed to load instance for %s: %w", w.serviceName(), err)
|
||||
}
|
||||
// Add context metadata for tracing
|
||||
ctx = log.NewContext(ctx, "instanceID", getInstanceID(inst))
|
||||
log.Trace(ctx, "wasmBasePlugin: loaded instance", "elapsed", time.Since(start))
|
||||
return inst, func() {
|
||||
log.Trace(ctx, "wasmBasePlugin: finished using instance", "elapsed", time.Since(start))
|
||||
if closer, ok := any(inst).(interface{ Close(context.Context) error }); ok {
|
||||
_ = closer.Close(ctx)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
type wasmPlugin[S any] interface {
|
||||
PluginID() string
|
||||
getInstance(ctx context.Context, methodName string) (S, func(), error)
|
||||
getMetrics() metrics.Metrics
|
||||
}
|
||||
|
||||
type errorMapper interface {
|
||||
mapError(err error) error
|
||||
}
|
||||
|
||||
func callMethod[S any, R any](ctx context.Context, w wasmPlugin[S], methodName string, fn func(inst S) (R, error)) (R, error) {
|
||||
// Add a unique call ID to the context for tracing
|
||||
ctx = log.NewContext(ctx, "callID", id.NewRandom())
|
||||
|
||||
inst, done, err := w.getInstance(ctx, methodName)
|
||||
var r R
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
start := time.Now()
|
||||
defer done()
|
||||
r, err = fn(inst)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if em, ok := any(w).(errorMapper); ok {
|
||||
mappedErr := em.mapError(err)
|
||||
|
||||
if !errors.Is(mappedErr, agents.ErrNotFound) {
|
||||
id := w.PluginID()
|
||||
isOk := mappedErr == nil
|
||||
metrics := w.getMetrics()
|
||||
if metrics != nil {
|
||||
metrics.RecordPluginRequest(ctx, id, methodName, isOk, elapsed.Milliseconds())
|
||||
}
|
||||
log.Trace(ctx, "callMethod", "plugin", id, "method", methodName, "ok", isOk, elapsed)
|
||||
}
|
||||
|
||||
return r, mappedErr
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
type nilInstance struct{}
|
||||
|
||||
var _ = Describe("wasmBasePlugin", func() {
|
||||
var ctx = context.Background()
|
||||
|
||||
It("should load instance using loadFunc", func() {
|
||||
called := false
|
||||
plugin := &wasmBasePlugin[*nilInstance, any]{
|
||||
wasmPath: "",
|
||||
id: "test",
|
||||
capability: "test",
|
||||
loadFunc: func(ctx context.Context, _ any, path string) (*nilInstance, error) {
|
||||
called = true
|
||||
return &nilInstance{}, nil
|
||||
},
|
||||
}
|
||||
inst, done, err := plugin.getInstance(ctx, "test")
|
||||
defer done()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(inst).ToNot(BeNil())
|
||||
Expect(called).To(BeTrue())
|
||||
})
|
||||
})
|
||||
@ -27,23 +27,25 @@
|
||||
"rating": "Balorazioa",
|
||||
"quality": "Kalitatea",
|
||||
"bpm": "BPM",
|
||||
"playDate": "Azkenekoz erreproduzitua:",
|
||||
"playDate": "Azken erreprodukzioa:",
|
||||
"createdAt": "Gehitu zen data:",
|
||||
"grouping": "Multzokatzea",
|
||||
"mood": "Aldartea",
|
||||
"participants": "Partaide gehiago",
|
||||
"tags": "Traola gehiago",
|
||||
"mappedTags": "Esleitutako traolak",
|
||||
"rawTags": "Traola gordinak"
|
||||
"rawTags": "Traola gordinak",
|
||||
"missing": "Ez da aurkitu"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Erreproduzitu ondoren",
|
||||
"playNow": "Erreproduzitu orain",
|
||||
"addToPlaylist": "Gehitu erreprodukzio-zerrendara",
|
||||
"showInPlaylist": "Erakutsi erreprodukzio-zerrendan",
|
||||
"shuffleAll": "Erreprodukzio aleatorioa",
|
||||
"download": "Deskargatu",
|
||||
"playNext": "Hurrengoa",
|
||||
"info": "Lortu informazioa"
|
||||
"info": "Erakutsi informazioa"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@ -61,7 +63,7 @@
|
||||
"year": "Urtea",
|
||||
"date": "Recording Date",
|
||||
"originalDate": "Jatorrizkoa",
|
||||
"releaseDate": "Argitaratze-data:",
|
||||
"releaseDate": "Argitaratze-data",
|
||||
"releases": "Argitaratzea |||| Argitaratzeak",
|
||||
"released": "Argitaratua",
|
||||
"updatedAt": "Aktualizatze-data:",
|
||||
@ -73,21 +75,22 @@
|
||||
"releaseType": "Mota",
|
||||
"grouping": "Multzokatzea",
|
||||
"media": "Multimedia",
|
||||
"mood": "Aldartea"
|
||||
"mood": "Aldartea",
|
||||
"missing": "Ez da aurkitu"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Erreproduzitu",
|
||||
"playNext": "Erreproduzitu segidan",
|
||||
"playNext": "Erreproduzitu orain",
|
||||
"addToQueue": "Erreproduzitu amaieran",
|
||||
"shuffle": "Aletorioa",
|
||||
"addToPlaylist": "Gehitu zerrendara",
|
||||
"download": "Deskargatu",
|
||||
"info": "Lortu informazioa",
|
||||
"info": "Erakutsi informazioa",
|
||||
"share": "Partekatu"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Guztiak",
|
||||
"random": "Aleatorioki",
|
||||
"random": "Aleatorioa",
|
||||
"recentlyAdded": "Berriki gehitutakoak",
|
||||
"recentlyPlayed": "Berriki entzundakoak",
|
||||
"mostPlayed": "Gehien entzundakoak",
|
||||
@ -105,7 +108,8 @@
|
||||
"playCount": "Erreprodukzio kopurua",
|
||||
"rating": "Balorazioa",
|
||||
"genre": "Generoa",
|
||||
"role": "Rola"
|
||||
"role": "Rola",
|
||||
"missing": "Ez da aurkitu"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Albumeko egilea |||| Albumeko artistak",
|
||||
@ -120,7 +124,13 @@
|
||||
"mixer": "Nahaslea |||| Nahasleak",
|
||||
"remixer": "Remixerra |||| Remixerrak",
|
||||
"djmixer": "DJ nahaslea |||| DJ nahasleak",
|
||||
"performer": "Interpretatzailea |||| Interpretatzaileak"
|
||||
"performer": "Interpretatzailea |||| Interpretatzaileak",
|
||||
"maincredit": "Albumeko egilea edo egilea |||| Albumeko egileak edo egileak"
|
||||
},
|
||||
"actions": {
|
||||
"topSongs": "Abesti apartak",
|
||||
"shuffle": "Aleatorioki",
|
||||
"radio": "Irratia"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@ -192,12 +202,19 @@
|
||||
"selectPlaylist": "Hautatu zerrenda:",
|
||||
"addNewPlaylist": "Sortu \"%{name}\"",
|
||||
"export": "Esportatu",
|
||||
"saveQueue": "Gorde ilaran daudek erreprodukzio-zerrendan",
|
||||
"makePublic": "Egin publikoa",
|
||||
"makePrivate": "Egin pribatua"
|
||||
"makePrivate": "Egin pribatua",
|
||||
"searchOrCreate": "Bilatu erreprodukzio-zerrenda edo idatzi berria sortzeko…",
|
||||
"pressEnterToCreate": "Sakatu Enter erreprodukzio-zerrenda berria sortzeko",
|
||||
"removeFromSelection": "Kendu hautaketatik",
|
||||
"removeSymbol": "×"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Hautatutako abesti batzuk lehendik ere daude zerrendan",
|
||||
"song_exist": "Bikoiztutakoak gehitzen ari dira erreprodukzio-zerrendara. Ziur gehitu nahi dituzula?"
|
||||
"song_exist": "Bikoiztutakoak gehitzen ari dira erreprodukzio-zerrendara. Ziur gehitu nahi dituzula?",
|
||||
"noPlaylistsFound": "Ez da erreprodukzio-zerrenda aurkitu",
|
||||
"noPlaylists": "Ez dago erreprodukzio-zerrendarik eskuragarri"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
@ -233,7 +250,7 @@
|
||||
"actions": {}
|
||||
},
|
||||
"missing": {
|
||||
"name": "Fitxategia falta da|||| Fitxategiak falta dira",
|
||||
"name": "Aurkitu ez den fitxategia |||| Aurkitu ez diren fitxategiak",
|
||||
"empty": "Ez da fitxategirik falta",
|
||||
"fields": {
|
||||
"path": "Bidea",
|
||||
@ -242,10 +259,10 @@
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Kendu",
|
||||
"remove_all": "Kendu guztia"
|
||||
"remove_all": "Kendu guztiak"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Faltan zeuden fitxategiak kendu dira"
|
||||
"removed": "Aurkitzen ez ziren fitxategiak kendu dira"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -399,6 +416,8 @@
|
||||
"transcodingDisabled": "Segurtasun arrazoiak direla-eta, transkodeketaren ezarpenak web-interfazearen bidez aldatzea ezgaituta dago. Transkodeketa-aukerak aldatu (editatu edo gehitu) nahi badituzu, berrabiarazi zerbitzaria konfigurazio-aukeraren %{config}-arekin.",
|
||||
"transcodingEnabled": "Navidrome %{config}-ekin martxan dago eta, beraz, web-interfazeko transkodeketa-ataletik sistema-komandoak exekuta daitezke. Segurtasun arrazoiak tarteko, ezgaitzea gomendatzen dugu, eta transkodeketa-aukerak konfiguratzen ari zarenean bakarrik gaitzea.",
|
||||
"songsAddedToPlaylist": "Abesti bat zerrendara gehitu da |||| %{smart_count} abesti zerrendara gehitu dira",
|
||||
"noSimilarSongsFound": "Ez da antzeko abestirik aurkitu",
|
||||
"noTopSongsFound": "Ez da aparteko abestirik aurkitu",
|
||||
"noPlaylistsAvailable": "Ez dago zerrendarik erabilgarri",
|
||||
"delete_user_title": "Ezabatu '%{name}' erabiltzailea",
|
||||
"delete_user_content": "Ziur zaide erabiltzaile hau eta bere datu guztiak (zerrendak eta hobespenak barne) ezabatu nahi dituzula?",
|
||||
@ -480,8 +499,8 @@
|
||||
"playModeText": {
|
||||
"order": "Ordenean",
|
||||
"orderLoop": "Errepikatu",
|
||||
"singleLoop": "Errepikatu bakarra",
|
||||
"shufflePlay": "Aleatorioa"
|
||||
"singleLoop": "Errepikatu abesti hau",
|
||||
"shufflePlay": "Aleatorioki"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
@ -494,6 +513,21 @@
|
||||
"disabled": "Ezgaituta",
|
||||
"waiting": "Zain"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"about": "Honi buruz",
|
||||
"config": "Konfigurazioa"
|
||||
},
|
||||
"config": {
|
||||
"configName": "Konfigurazioaren izena",
|
||||
"environmentVariable": "Ingurune-aldagaia",
|
||||
"currentValue": "Uneko balioa",
|
||||
"configurationFile": "Konfigurazio-fitxategia",
|
||||
"exportToml": "Esportatu konfigurazioa (TOML)",
|
||||
"exportSuccess": "Konfigurazioa arbelera esportatu da TOML formatuan",
|
||||
"exportFailed": "Konfigurazioa kopiatzeak huts egin du",
|
||||
"devFlagsHeader": "Garapen-adierazleak (aldatu/kendu litezke)",
|
||||
"devFlagsComment": "Ezarpen esperimentalak dira eta litekeena da etorkizunean desagertzea"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
@ -507,6 +541,11 @@
|
||||
"status": "Errorea arakatzean",
|
||||
"elapsedTime": "Igarotako denbora"
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "Une honetan erreproduzitzen",
|
||||
"empty": "Ez dago erreproduzitzeko ezer",
|
||||
"minutesAgo": "Duela minutu %{smart_count} |||| Duela %{smart_count} minutu"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidromeren laster-teklak",
|
||||
"hotkeys": {
|
||||
|
||||
@ -41,6 +41,7 @@
|
||||
"addToQueue": "Lejátszás útolsóként",
|
||||
"playNow": "Lejátszás",
|
||||
"addToPlaylist": "Lejátszási listához adás",
|
||||
"showInPlaylist": "Megjelenítés a lejátszási listában",
|
||||
"shuffleAll": "Keverés",
|
||||
"download": "Letöltés",
|
||||
"playNext": "Lejátszás következőként",
|
||||
@ -123,7 +124,13 @@
|
||||
"mixer": "Keverő |||| Keverők",
|
||||
"remixer": "Átdolgozó |||| Átdolgozók",
|
||||
"djmixer": "DJ keverő |||| DJ keverők",
|
||||
"performer": "Előadóművész |||| Előadóművészek"
|
||||
"performer": "Előadóművész |||| Előadóművészek",
|
||||
"maincredit": "Album előadó vagy előadó |||| Album előadók vagy előadók"
|
||||
},
|
||||
"actions": {
|
||||
"topSongs": "Top számok",
|
||||
"shuffle": "Keverés",
|
||||
"radio": "Rádió"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@ -197,11 +204,17 @@
|
||||
"export": "Exportálás",
|
||||
"saveQueue": "Műsorlista elmentése lejátszási listaként",
|
||||
"makePublic": "Publikussá tétel",
|
||||
"makePrivate": "Priváttá tétel"
|
||||
"makePrivate": "Priváttá tétel",
|
||||
"searchOrCreate": "Keress lejátszási listák között vagy hozz létre egyet...",
|
||||
"pressEnterToCreate": "Nyomj Entert, hogy létrehozz egy lejátszási listát",
|
||||
"removeFromSelection": "Eltávolítás a kiválasztásból",
|
||||
"removeSymbol": "×"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Duplikált számok hozzáadása",
|
||||
"song_exist": "Egyes számok már hozzá vannak adva a listához. Még egyszer hozzá akarod adni?"
|
||||
"song_exist": "Egyes számok már hozzá vannak adva a listához. Még egyszer hozzá akarod adni?",
|
||||
"noPlaylistsFound": "Nem található lejátszási lista",
|
||||
"noPlaylists": "Nincsenek lejátszási listák"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
@ -401,6 +414,8 @@
|
||||
"transcodingDisabled": "Az átkódolási konfiguráció módosítása a webes felületen keresztül biztonsági okokból nem lehetséges. Ha módosítani szeretnéd az átkódolási beállításokat, indítsd újra a kiszolgálót a %{config} konfigurációs opcióval.",
|
||||
"transcodingEnabled": "A Navidrome jelenleg a következőkkel fut %{config}, ez lehetővé teszi a rendszerparancsok futtatását az átkódolási beállításokból a webes felület segítségével. Javasoljuk, hogy biztonsági okokból tiltsd ezt le, és csak az átkódolási beállítások konfigurálásának idejére kapcsold be.",
|
||||
"songsAddedToPlaylist": "1 szám hozzáadva a lejátszási listához |||| %{smart_count} szám hozzáadva a lejátszási listához",
|
||||
"noSimilarSongsFound": "Nem találhatóak hasonló számok",
|
||||
"noTopSongsFound": "Nincsenek top számok",
|
||||
"noPlaylistsAvailable": "Nem áll rendelkezésre",
|
||||
"delete_user_title": "Felhasználó törlése '%{name}'",
|
||||
"delete_user_content": "Biztos, hogy törölni akarod ezt a felhasználót az adataival (beállítások és lejátszási listák) együtt?",
|
||||
@ -496,6 +511,21 @@
|
||||
"disabled": "Kikapcsolva",
|
||||
"waiting": "Várakozás"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"about": "Rólunk",
|
||||
"config": "Konfiguráció"
|
||||
},
|
||||
"config": {
|
||||
"configName": "Beállítás neve",
|
||||
"environmentVariable": "Környezeti változó",
|
||||
"currentValue": "Jelenlegi érték",
|
||||
"configurationFile": "Konfigurációs fájl",
|
||||
"exportToml": "Konfiguráció exportálása (TOML)",
|
||||
"exportSuccess": "Konfiguráció kiexportálva a vágólapra, TOML formában",
|
||||
"exportFailed": "Nem sikerült kimásolni a konfigurációt",
|
||||
"devFlagsHeader": "Fejlesztői beállítások (változások/eltávolítás jogát fenntartjuk)",
|
||||
"devFlagsComment": "Ezek kísérleti beállítások, és a jövőbeli verziókban eltávolíthatók"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
@ -509,6 +539,11 @@
|
||||
"status": "Szkennelési hiba",
|
||||
"elapsedTime": "Eltelt idő"
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "Most megy",
|
||||
"empty": "Nem hallgatsz semmit",
|
||||
"minutesAgo": "%{smart_count} perce |||| %{smart_count} perce"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome Gyorsbillentyűk",
|
||||
"hotkeys": {
|
||||
|
||||
@ -108,7 +108,8 @@ main:
|
||||
bpm:
|
||||
aliases: [ tbpm, bpm, tmpo, wm/beatsperminute ]
|
||||
lyrics:
|
||||
aliases: [ uslt:description, lyrics, ©lyr, wm/lyrics, unsyncedlyrics ]
|
||||
# Note, @lyr and wm/lyrics have been removed. Taglib somehow appears to always populate `lyrics:xxx`
|
||||
aliases: [ uslt:description, lyrics, unsyncedlyrics ]
|
||||
maxLength: 32768
|
||||
type: pair # ex: lyrics:eng, lyrics:xxx
|
||||
comment:
|
||||
|
||||
BIN
tests/fixtures/mixed-lyrics.flac
vendored
Normal file
BIN
tests/fixtures/mixed-lyrics.flac
vendored
Normal file
Binary file not shown.
@ -49,11 +49,21 @@ describe('ArtistActions', () => {
|
||||
// Mock console.error to suppress error logging in tests
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const songWithReplayGain = {
|
||||
id: 'rec1',
|
||||
replayGain: {
|
||||
albumGain: -5,
|
||||
albumPeak: 1,
|
||||
trackGain: -6,
|
||||
trackPeak: 0.8,
|
||||
},
|
||||
}
|
||||
|
||||
subsonic.getSimilarSongs2.mockResolvedValue({
|
||||
json: {
|
||||
'subsonic-response': {
|
||||
status: 'ok',
|
||||
similarSongs2: { song: [{ id: 'rec1' }] },
|
||||
similarSongs2: { song: [songWithReplayGain] },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -61,7 +71,7 @@ describe('ArtistActions', () => {
|
||||
json: {
|
||||
'subsonic-response': {
|
||||
status: 'ok',
|
||||
topSongs: { song: [{ id: 'rec1' }] },
|
||||
topSongs: { song: [songWithReplayGain] },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -93,6 +103,22 @@ describe('ArtistActions', () => {
|
||||
)
|
||||
expect(mockDispatch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps replaygain info', async () => {
|
||||
renderArtistActions()
|
||||
clickActionButton('radio')
|
||||
|
||||
await waitFor(() =>
|
||||
expect(subsonic.getSimilarSongs2).toHaveBeenCalledWith('ar1', 100),
|
||||
)
|
||||
const action = mockDispatch.mock.calls[0][0]
|
||||
expect(action.data.rec1).toMatchObject({
|
||||
rgAlbumGain: -5,
|
||||
rgAlbumPeak: 1,
|
||||
rgTrackGain: -6,
|
||||
rgTrackPeak: 0.8,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Play action', () => {
|
||||
@ -106,6 +132,22 @@ describe('ArtistActions', () => {
|
||||
expect(mockDispatch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps replaygain info for top songs', async () => {
|
||||
renderArtistActions()
|
||||
clickActionButton('topSongs')
|
||||
|
||||
await waitFor(() =>
|
||||
expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100),
|
||||
)
|
||||
const action = mockDispatch.mock.calls[0][0]
|
||||
expect(action.data.rec1).toMatchObject({
|
||||
rgAlbumGain: -5,
|
||||
rgAlbumPeak: 1,
|
||||
rgTrackGain: -6,
|
||||
rgTrackPeak: 0.8,
|
||||
})
|
||||
})
|
||||
|
||||
it('handles API rejection', async () => {
|
||||
subsonic.getTopSongs.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
|
||||
@ -1,6 +1,32 @@
|
||||
import subsonic from '../subsonic/index.js'
|
||||
import { playTracks } from '../actions/index.js'
|
||||
|
||||
const mapReplayGain = (song) => {
|
||||
const { replayGain: rg } = song
|
||||
if (!rg) {
|
||||
return song
|
||||
}
|
||||
|
||||
return {
|
||||
...song,
|
||||
...(rg.albumGain !== undefined && { rgAlbumGain: rg.albumGain }),
|
||||
...(rg.albumPeak !== undefined && { rgAlbumPeak: rg.albumPeak }),
|
||||
...(rg.trackGain !== undefined && { rgTrackGain: rg.trackGain }),
|
||||
...(rg.trackPeak !== undefined && { rgTrackPeak: rg.trackPeak }),
|
||||
}
|
||||
}
|
||||
|
||||
const processSongsForPlayback = (songs) => {
|
||||
const songData = {}
|
||||
const ids = []
|
||||
songs.forEach((s) => {
|
||||
const song = mapReplayGain(s)
|
||||
songData[song.id] = song
|
||||
ids.push(song.id)
|
||||
})
|
||||
return { songData, ids }
|
||||
}
|
||||
|
||||
export const playTopSongs = async (dispatch, notify, artistName) => {
|
||||
const res = await subsonic.getTopSongs(artistName, 100)
|
||||
const data = res.json['subsonic-response']
|
||||
@ -17,12 +43,7 @@ export const playTopSongs = async (dispatch, notify, artistName) => {
|
||||
return
|
||||
}
|
||||
|
||||
const songData = {}
|
||||
const ids = []
|
||||
songs.forEach((s) => {
|
||||
songData[s.id] = s
|
||||
ids.push(s.id)
|
||||
})
|
||||
const { songData, ids } = processSongsForPlayback(songs)
|
||||
dispatch(playTracks(songData, ids))
|
||||
}
|
||||
|
||||
@ -42,12 +63,7 @@ export const playSimilar = async (dispatch, notify, id) => {
|
||||
return
|
||||
}
|
||||
|
||||
const songData = {}
|
||||
const ids = []
|
||||
songs.forEach((s) => {
|
||||
songData[s.id] = s
|
||||
ids.push(s.id)
|
||||
})
|
||||
const { songData, ids } = processSongsForPlayback(songs)
|
||||
dispatch(playTracks(songData, ids))
|
||||
}
|
||||
|
||||
|
||||
@ -32,6 +32,8 @@ describe('startEventStream', () => {
|
||||
localStorage.setItem('is-authenticated', 'true')
|
||||
localStorage.setItem('token', 'abc')
|
||||
config.devNewEventStream = true
|
||||
// Mock console.log to suppress output during tests
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user