mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-26 20:07:47 -05:00
Compare commits
16 Commits
update-tra
...
feat/plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c40bf3540a | ||
|
|
c492ae19f3 | ||
|
|
f9beb3c2d7 | ||
|
|
2ea20f2511 | ||
|
|
6e8b826022 | ||
|
|
57b39685bc | ||
|
|
525aa0e861 | ||
|
|
11461d5f2c | ||
|
|
1cfc2d9741 | ||
|
|
f5dca3a2db | ||
|
|
7180952103 | ||
|
|
8238ed6a2c | ||
|
|
516e229b27 | ||
|
|
582d1b3cd9 | ||
|
|
cdd3432788 | ||
|
|
5bc2bbb70e |
@@ -155,6 +155,7 @@ type scannerOptions struct {
|
||||
|
||||
type subsonicOptions struct {
|
||||
AppendSubtitle bool
|
||||
AppendAlbumVersion bool
|
||||
ArtistParticipations bool
|
||||
DefaultReportRealPath bool
|
||||
EnableAverageRating bool
|
||||
@@ -689,6 +690,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("scanner.followsymlinks", true)
|
||||
viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever)
|
||||
viper.SetDefault("subsonic.appendsubtitle", true)
|
||||
viper.SetDefault("subsonic.appendalbumversion", true)
|
||||
viper.SetDefault("subsonic.artistparticipations", false)
|
||||
viper.SetDefault("subsonic.defaultreportrealpath", false)
|
||||
viper.SetDefault("subsonic.enableaveragerating", true)
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"iter"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
|
||||
"github.com/gohugoio/hashstructure"
|
||||
)
|
||||
|
||||
@@ -70,6 +73,13 @@ func (a Album) CoverArtID() ArtworkID {
|
||||
return artworkIDFromAlbum(a)
|
||||
}
|
||||
|
||||
func (a Album) FullName() string {
|
||||
if conf.Server.Subsonic.AppendAlbumVersion && len(a.Tags[TagAlbumVersion]) > 0 {
|
||||
return fmt.Sprintf("%s (%s)", a.Name, a.Tags[TagAlbumVersion][0])
|
||||
}
|
||||
return a.Name
|
||||
}
|
||||
|
||||
// Equals compares two Album structs, ignoring calculated fields
|
||||
func (a Album) Equals(other Album) bool {
|
||||
// Normalize float32 values to avoid false negatives
|
||||
|
||||
@@ -3,11 +3,30 @@ package model_test
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
. "github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Album", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
})
|
||||
DescribeTable("FullName",
|
||||
func(enabled bool, tags Tags, expected string) {
|
||||
conf.Server.Subsonic.AppendAlbumVersion = enabled
|
||||
a := Album{Name: "Album", Tags: tags}
|
||||
Expect(a.FullName()).To(Equal(expected))
|
||||
},
|
||||
Entry("appends version when enabled and tag is present", true, Tags{TagAlbumVersion: []string{"Remastered"}}, "Album (Remastered)"),
|
||||
Entry("returns just name when disabled", false, Tags{TagAlbumVersion: []string{"Remastered"}}, "Album"),
|
||||
Entry("returns just name when tag is absent", true, Tags{}, "Album"),
|
||||
Entry("returns just name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"),
|
||||
)
|
||||
})
|
||||
|
||||
var _ = Describe("Albums", func() {
|
||||
var albums Albums
|
||||
|
||||
|
||||
@@ -95,12 +95,19 @@ type MediaFile struct {
|
||||
}
|
||||
|
||||
func (mf MediaFile) FullTitle() string {
|
||||
if conf.Server.Subsonic.AppendSubtitle && mf.Tags[TagSubtitle] != nil {
|
||||
if conf.Server.Subsonic.AppendSubtitle && len(mf.Tags[TagSubtitle]) > 0 {
|
||||
return fmt.Sprintf("%s (%s)", mf.Title, mf.Tags[TagSubtitle][0])
|
||||
}
|
||||
return mf.Title
|
||||
}
|
||||
|
||||
func (mf MediaFile) FullAlbumName() string {
|
||||
if conf.Server.Subsonic.AppendAlbumVersion && len(mf.Tags[TagAlbumVersion]) > 0 {
|
||||
return fmt.Sprintf("%s (%s)", mf.Album, mf.Tags[TagAlbumVersion][0])
|
||||
}
|
||||
return mf.Album
|
||||
}
|
||||
|
||||
func (mf MediaFile) ContentType() string {
|
||||
return mime.TypeByExtension("." + mf.Suffix)
|
||||
}
|
||||
|
||||
@@ -475,7 +475,29 @@ var _ = Describe("MediaFile", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.EnableMediaFileCoverArt = true
|
||||
})
|
||||
Describe(".CoverArtId()", func() {
|
||||
DescribeTable("FullTitle",
|
||||
func(enabled bool, tags Tags, expected string) {
|
||||
conf.Server.Subsonic.AppendSubtitle = enabled
|
||||
mf := MediaFile{Title: "Song", Tags: tags}
|
||||
Expect(mf.FullTitle()).To(Equal(expected))
|
||||
},
|
||||
Entry("appends subtitle when enabled and tag is present", true, Tags{TagSubtitle: []string{"Live"}}, "Song (Live)"),
|
||||
Entry("returns just title when disabled", false, Tags{TagSubtitle: []string{"Live"}}, "Song"),
|
||||
Entry("returns just title when tag is absent", true, Tags{}, "Song"),
|
||||
Entry("returns just title when tag is an empty slice", true, Tags{TagSubtitle: []string{}}, "Song"),
|
||||
)
|
||||
DescribeTable("FullAlbumName",
|
||||
func(enabled bool, tags Tags, expected string) {
|
||||
conf.Server.Subsonic.AppendAlbumVersion = enabled
|
||||
mf := MediaFile{Album: "Album", Tags: tags}
|
||||
Expect(mf.FullAlbumName()).To(Equal(expected))
|
||||
},
|
||||
Entry("appends version when enabled and tag is present", true, Tags{TagAlbumVersion: []string{"Deluxe Edition"}}, "Album (Deluxe Edition)"),
|
||||
Entry("returns just album name when disabled", false, Tags{TagAlbumVersion: []string{"Deluxe Edition"}}, "Album"),
|
||||
Entry("returns just album name when tag is absent", true, Tags{}, "Album"),
|
||||
Entry("returns just album name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"),
|
||||
)
|
||||
Describe("CoverArtId()", func() {
|
||||
It("returns its own id if it HasCoverArt", func() {
|
||||
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
|
||||
id := mf.CoverArtID()
|
||||
|
||||
33
plugins/capabilities/taskworker.go
Normal file
33
plugins/capabilities/taskworker.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package capabilities
|
||||
|
||||
// TaskWorker provides task execution handling.
|
||||
// This capability allows plugins to receive callbacks when their queued tasks
|
||||
// are ready to execute. Plugins that use the taskqueue host service must
|
||||
// implement this capability.
|
||||
//
|
||||
//nd:capability name=taskworker
|
||||
type TaskWorker interface {
|
||||
// OnTaskExecute is called when a queued task is ready to run.
|
||||
// Return an error to trigger retry (if retries are configured).
|
||||
//nd:export name=nd_task_execute
|
||||
OnTaskExecute(TaskExecuteRequest) (TaskExecuteResponse, error)
|
||||
}
|
||||
|
||||
// TaskExecuteRequest is the request provided when a task is ready to execute.
|
||||
type TaskExecuteRequest struct {
|
||||
// QueueName is the name of the queue this task belongs to.
|
||||
QueueName string `json:"queueName"`
|
||||
// TaskID is the unique identifier for this task.
|
||||
TaskID string `json:"taskId"`
|
||||
// Payload is the opaque data provided when the task was enqueued.
|
||||
Payload []byte `json:"payload"`
|
||||
// Attempt is the current attempt number (1-based: first attempt = 1).
|
||||
Attempt int32 `json:"attempt"`
|
||||
}
|
||||
|
||||
// TaskExecuteResponse is the response from task execution.
|
||||
type TaskExecuteResponse struct {
|
||||
// Error, if non-empty, indicates the task failed. The task will be retried
|
||||
// if retries are configured and attempts remain.
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
45
plugins/capabilities/taskworker.yaml
Normal file
45
plugins/capabilities/taskworker.yaml
Normal file
@@ -0,0 +1,45 @@
|
||||
version: v1-draft
|
||||
exports:
|
||||
nd_task_execute:
|
||||
description: |-
|
||||
OnTaskExecute is called when a queued task is ready to run.
|
||||
Return an error to trigger retry (if retries are configured).
|
||||
input:
|
||||
$ref: '#/components/schemas/TaskExecuteRequest'
|
||||
contentType: application/json
|
||||
output:
|
||||
$ref: '#/components/schemas/TaskExecuteResponse'
|
||||
contentType: application/json
|
||||
components:
|
||||
schemas:
|
||||
TaskExecuteRequest:
|
||||
description: TaskExecuteRequest is the request provided when a task is ready to execute.
|
||||
properties:
|
||||
queueName:
|
||||
type: string
|
||||
description: QueueName is the name of the queue this task belongs to.
|
||||
taskId:
|
||||
type: string
|
||||
description: TaskID is the unique identifier for this task.
|
||||
payload:
|
||||
type: array
|
||||
description: Payload is the opaque data provided when the task was enqueued.
|
||||
items:
|
||||
type: object
|
||||
attempt:
|
||||
type: integer
|
||||
format: int32
|
||||
description: 'Attempt is the current attempt number (1-based: first attempt = 1).'
|
||||
required:
|
||||
- queueName
|
||||
- taskId
|
||||
- payload
|
||||
- attempt
|
||||
TaskExecuteResponse:
|
||||
description: TaskExecuteResponse is the response from task execution.
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
description: |-
|
||||
Error, if non-empty, indicates the task failed. The task will be retried
|
||||
if retries are configured and attempts remain.
|
||||
57
plugins/host/taskqueue.go
Normal file
57
plugins/host/taskqueue.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package host
|
||||
|
||||
import "context"
|
||||
|
||||
// QueueConfig holds configuration for a task queue.
|
||||
type QueueConfig struct {
|
||||
// Concurrency is the max number of parallel workers. Default: 1.
|
||||
// Capped by the plugin's manifest maxConcurrency.
|
||||
Concurrency int32 `json:"concurrency"`
|
||||
|
||||
// MaxRetries is the number of times to retry a failed task. Default: 0.
|
||||
MaxRetries int32 `json:"maxRetries"`
|
||||
|
||||
// BackoffMs is the initial backoff between retries in milliseconds.
|
||||
// Doubles each retry (exponential: backoffMs * 2^(attempt-1)). Default: 1000.
|
||||
BackoffMs int64 `json:"backoffMs"`
|
||||
|
||||
// DelayMs is the minimum delay between starting consecutive tasks
|
||||
// in milliseconds. Useful for rate limiting. Default: 0.
|
||||
DelayMs int64 `json:"delayMs"`
|
||||
|
||||
// RetentionMs is how long completed/failed/cancelled tasks are kept
|
||||
// in milliseconds. Default: 3600000 (1h). Min: 60000 (1m). Max: 604800000 (1w).
|
||||
RetentionMs int64 `json:"retentionMs"`
|
||||
}
|
||||
|
||||
// TaskQueueService provides persistent task queues for plugins.
|
||||
//
|
||||
// This service allows plugins to create named queues with configurable concurrency,
|
||||
// retry policies, and rate limiting. Tasks are persisted to SQLite and survive
|
||||
// server restarts. When a task is ready to execute, the host calls the plugin's
|
||||
// nd_task_execute callback function.
|
||||
//
|
||||
//nd:hostservice name=TaskQueue permission=taskqueue
|
||||
type TaskQueueService interface {
|
||||
// CreateQueue creates a named task queue with the given configuration.
|
||||
// Zero-value fields in config use sensible defaults.
|
||||
// If a queue with the same name already exists, returns an error.
|
||||
// On startup, this also recovers any stale "running" tasks from a previous crash.
|
||||
//nd:hostfunc
|
||||
CreateQueue(ctx context.Context, name string, config QueueConfig) error
|
||||
|
||||
// Enqueue adds a task to the named queue. Returns the task ID.
|
||||
// payload is opaque bytes passed back to the plugin on execution.
|
||||
//nd:hostfunc
|
||||
Enqueue(ctx context.Context, queueName string, payload []byte) (string, error)
|
||||
|
||||
// GetTaskStatus returns the status of a task: "pending", "running",
|
||||
// "completed", "failed", or "cancelled".
|
||||
//nd:hostfunc
|
||||
GetTaskStatus(ctx context.Context, taskID string) (string, error)
|
||||
|
||||
// CancelTask cancels a pending task. Returns error if already
|
||||
// running, completed, or failed.
|
||||
//nd:hostfunc
|
||||
CancelTask(ctx context.Context, taskID string) error
|
||||
}
|
||||
220
plugins/host/taskqueue_gen.go
Normal file
220
plugins/host/taskqueue_gen.go
Normal file
@@ -0,0 +1,220 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
|
||||
package host
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
)
|
||||
|
||||
// TaskQueueCreateQueueRequest is the request type for TaskQueue.CreateQueue.
|
||||
type TaskQueueCreateQueueRequest struct {
|
||||
Name string `json:"name"`
|
||||
Config QueueConfig `json:"config"`
|
||||
}
|
||||
|
||||
// TaskQueueCreateQueueResponse is the response type for TaskQueue.CreateQueue.
|
||||
type TaskQueueCreateQueueResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// TaskQueueEnqueueRequest is the request type for TaskQueue.Enqueue.
|
||||
type TaskQueueEnqueueRequest struct {
|
||||
QueueName string `json:"queueName"`
|
||||
Payload []byte `json:"payload"`
|
||||
}
|
||||
|
||||
// TaskQueueEnqueueResponse is the response type for TaskQueue.Enqueue.
|
||||
type TaskQueueEnqueueResponse struct {
|
||||
Result string `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// TaskQueueGetTaskStatusRequest is the request type for TaskQueue.GetTaskStatus.
|
||||
type TaskQueueGetTaskStatusRequest struct {
|
||||
TaskID string `json:"taskId"`
|
||||
}
|
||||
|
||||
// TaskQueueGetTaskStatusResponse is the response type for TaskQueue.GetTaskStatus.
|
||||
type TaskQueueGetTaskStatusResponse struct {
|
||||
Result string `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// TaskQueueCancelTaskRequest is the request type for TaskQueue.CancelTask.
|
||||
type TaskQueueCancelTaskRequest struct {
|
||||
TaskID string `json:"taskId"`
|
||||
}
|
||||
|
||||
// TaskQueueCancelTaskResponse is the response type for TaskQueue.CancelTask.
|
||||
type TaskQueueCancelTaskResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterTaskQueueHostFunctions registers TaskQueue service host functions.
|
||||
// The returned host functions should be added to the plugin's configuration.
|
||||
func RegisterTaskQueueHostFunctions(service TaskQueueService) []extism.HostFunction {
|
||||
return []extism.HostFunction{
|
||||
newTaskQueueCreateQueueHostFunction(service),
|
||||
newTaskQueueEnqueueHostFunction(service),
|
||||
newTaskQueueGetTaskStatusHostFunction(service),
|
||||
newTaskQueueCancelTaskHostFunction(service),
|
||||
}
|
||||
}
|
||||
|
||||
func newTaskQueueCreateQueueHostFunction(service TaskQueueService) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"taskqueue_createqueue",
|
||||
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||
// Read JSON request from plugin memory
|
||||
reqBytes, err := p.ReadBytes(stack[0])
|
||||
if err != nil {
|
||||
taskqueueWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
var req TaskQueueCreateQueueRequest
|
||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
||||
taskqueueWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Call the service method
|
||||
if svcErr := service.CreateQueue(ctx, req.Name, req.Config); svcErr != nil {
|
||||
taskqueueWriteError(p, stack, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Write JSON response to plugin memory
|
||||
resp := TaskQueueCreateQueueResponse{}
|
||||
taskqueueWriteResponse(p, stack, resp)
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
)
|
||||
}
|
||||
|
||||
func newTaskQueueEnqueueHostFunction(service TaskQueueService) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"taskqueue_enqueue",
|
||||
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||
// Read JSON request from plugin memory
|
||||
reqBytes, err := p.ReadBytes(stack[0])
|
||||
if err != nil {
|
||||
taskqueueWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
var req TaskQueueEnqueueRequest
|
||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
||||
taskqueueWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Call the service method
|
||||
result, svcErr := service.Enqueue(ctx, req.QueueName, req.Payload)
|
||||
if svcErr != nil {
|
||||
taskqueueWriteError(p, stack, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Write JSON response to plugin memory
|
||||
resp := TaskQueueEnqueueResponse{
|
||||
Result: result,
|
||||
}
|
||||
taskqueueWriteResponse(p, stack, resp)
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
)
|
||||
}
|
||||
|
||||
func newTaskQueueGetTaskStatusHostFunction(service TaskQueueService) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"taskqueue_gettaskstatus",
|
||||
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||
// Read JSON request from plugin memory
|
||||
reqBytes, err := p.ReadBytes(stack[0])
|
||||
if err != nil {
|
||||
taskqueueWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
var req TaskQueueGetTaskStatusRequest
|
||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
||||
taskqueueWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Call the service method
|
||||
result, svcErr := service.GetTaskStatus(ctx, req.TaskID)
|
||||
if svcErr != nil {
|
||||
taskqueueWriteError(p, stack, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Write JSON response to plugin memory
|
||||
resp := TaskQueueGetTaskStatusResponse{
|
||||
Result: result,
|
||||
}
|
||||
taskqueueWriteResponse(p, stack, resp)
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
)
|
||||
}
|
||||
|
||||
func newTaskQueueCancelTaskHostFunction(service TaskQueueService) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"taskqueue_canceltask",
|
||||
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||
// Read JSON request from plugin memory
|
||||
reqBytes, err := p.ReadBytes(stack[0])
|
||||
if err != nil {
|
||||
taskqueueWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
var req TaskQueueCancelTaskRequest
|
||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
||||
taskqueueWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Call the service method
|
||||
if svcErr := service.CancelTask(ctx, req.TaskID); svcErr != nil {
|
||||
taskqueueWriteError(p, stack, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Write JSON response to plugin memory
|
||||
resp := TaskQueueCancelTaskResponse{}
|
||||
taskqueueWriteResponse(p, stack, resp)
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
)
|
||||
}
|
||||
|
||||
// taskqueueWriteResponse writes a JSON response to plugin memory.
|
||||
func taskqueueWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
|
||||
respBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
taskqueueWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
respPtr, err := p.WriteBytes(respBytes)
|
||||
if err != nil {
|
||||
stack[0] = 0
|
||||
return
|
||||
}
|
||||
stack[0] = respPtr
|
||||
}
|
||||
|
||||
// taskqueueWriteError writes an error response to plugin memory.
|
||||
func taskqueueWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
|
||||
errResp := struct {
|
||||
Error string `json:"error"`
|
||||
}{Error: err.Error()}
|
||||
respBytes, _ := json.Marshal(errResp)
|
||||
respPtr, _ := p.WriteBytes(respBytes)
|
||||
stack[0] = respPtr
|
||||
}
|
||||
@@ -188,12 +188,6 @@ func (s *schedulerServiceImpl) invokeCallback(ctx context.Context, scheduleID st
|
||||
return
|
||||
}
|
||||
|
||||
// Check if plugin has the scheduler capability
|
||||
if !hasCapability(instance.capabilities, CapabilityScheduler) {
|
||||
log.Warn(ctx, "Plugin does not have scheduler capability", "plugin", s.pluginName, "scheduleID", scheduleID)
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare callback input
|
||||
input := capabilities.SchedulerCallbackRequest{
|
||||
ScheduleID: scheduleID,
|
||||
|
||||
558
plugins/host_taskqueue.go
Normal file
558
plugins/host_taskqueue.go
Normal file
@@ -0,0 +1,558 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/plugins/capabilities"
|
||||
"github.com/navidrome/navidrome/plugins/host"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultConcurrency int32 = 1
|
||||
defaultBackoffMs int64 = 1000
|
||||
defaultRetentionMs int64 = 3_600_000 // 1 hour
|
||||
minRetentionMs int64 = 60_000 // 1 minute
|
||||
maxRetentionMs int64 = 604_800_000 // 1 week
|
||||
maxQueueNameLength = 128
|
||||
maxPayloadSize = 1 * 1024 * 1024 // 1MB
|
||||
maxBackoffMs int64 = 3_600_000 // 1 hour
|
||||
cleanupInterval = 5 * time.Minute
|
||||
pollInterval = 5 * time.Second
|
||||
shutdownTimeout = 10 * time.Second
|
||||
|
||||
taskStatusPending = "pending"
|
||||
taskStatusRunning = "running"
|
||||
taskStatusCompleted = "completed"
|
||||
taskStatusFailed = "failed"
|
||||
taskStatusCancelled = "cancelled"
|
||||
)
|
||||
|
||||
// CapabilityTaskWorker indicates the plugin can receive task execution callbacks.
|
||||
const CapabilityTaskWorker Capability = "TaskWorker"
|
||||
|
||||
const FuncTaskWorkerCallback = "nd_task_execute"
|
||||
|
||||
func init() {
|
||||
registerCapability(CapabilityTaskWorker, FuncTaskWorkerCallback)
|
||||
}
|
||||
|
||||
type queueState struct {
|
||||
config host.QueueConfig
|
||||
signal chan struct{}
|
||||
limiter *rate.Limiter
|
||||
}
|
||||
|
||||
// notifyWorkers sends a non-blocking signal to wake up queue workers.
|
||||
func (qs *queueState) notifyWorkers() {
|
||||
select {
|
||||
case qs.signal <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// taskQueueServiceImpl implements host.TaskQueueService with SQLite persistence
|
||||
// and background worker goroutines for task execution.
|
||||
type taskQueueServiceImpl struct {
|
||||
pluginName string
|
||||
manager *Manager
|
||||
maxConcurrency int32
|
||||
db *sql.DB
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
queues map[string]*queueState
|
||||
|
||||
// For testing: override how callbacks are invoked
|
||||
invokeCallbackFn func(ctx context.Context, queueName, taskID string, payload []byte, attempt int32) error
|
||||
}
|
||||
|
||||
// newTaskQueueService creates a new taskQueueServiceImpl with its own SQLite database.
|
||||
func newTaskQueueService(pluginName string, manager *Manager, maxConcurrency int32) (*taskQueueServiceImpl, error) {
|
||||
dataDir := filepath.Join(conf.Server.DataFolder, "plugins", pluginName)
|
||||
if err := os.MkdirAll(dataDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("creating plugin data directory: %w", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(dataDir, "taskqueue.db")
|
||||
db, err := sql.Open("sqlite3", dbPath+"?_busy_timeout=5000&_journal_mode=WAL&_foreign_keys=off")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening taskqueue database: %w", err)
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(3)
|
||||
db.SetMaxIdleConns(1)
|
||||
|
||||
if err := createTaskQueueSchema(db); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("creating taskqueue schema: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(manager.ctx)
|
||||
|
||||
s := &taskQueueServiceImpl{
|
||||
pluginName: pluginName,
|
||||
manager: manager,
|
||||
maxConcurrency: maxConcurrency,
|
||||
db: db,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
queues: make(map[string]*queueState),
|
||||
}
|
||||
s.invokeCallbackFn = s.defaultInvokeCallback
|
||||
|
||||
s.wg.Add(1)
|
||||
go s.cleanupLoop()
|
||||
|
||||
log.Debug("Initialized plugin taskqueue", "plugin", pluginName, "path", dbPath, "maxConcurrency", maxConcurrency)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func createTaskQueueSchema(db *sql.DB) error {
|
||||
_, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS queues (
|
||||
name TEXT PRIMARY KEY,
|
||||
concurrency INTEGER NOT NULL DEFAULT 1,
|
||||
max_retries INTEGER NOT NULL DEFAULT 0,
|
||||
backoff_ms INTEGER NOT NULL DEFAULT 1000,
|
||||
delay_ms INTEGER NOT NULL DEFAULT 0,
|
||||
retention_ms INTEGER NOT NULL DEFAULT 3600000
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
queue_name TEXT NOT NULL REFERENCES queues(name),
|
||||
payload BLOB NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
attempt INTEGER NOT NULL DEFAULT 0,
|
||||
max_retries INTEGER NOT NULL,
|
||||
next_run_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_dequeue ON tasks(queue_name, status, next_run_at);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// applyConfigDefaults fills zero-value config fields with sensible defaults
|
||||
// and clamps values to valid ranges, logging warnings for clamped values.
|
||||
func (s *taskQueueServiceImpl) applyConfigDefaults(ctx context.Context, name string, config *host.QueueConfig) {
|
||||
if config.Concurrency <= 0 {
|
||||
config.Concurrency = defaultConcurrency
|
||||
}
|
||||
if config.BackoffMs <= 0 {
|
||||
config.BackoffMs = defaultBackoffMs
|
||||
}
|
||||
if config.RetentionMs <= 0 {
|
||||
config.RetentionMs = defaultRetentionMs
|
||||
}
|
||||
|
||||
if config.RetentionMs < minRetentionMs {
|
||||
log.Warn(ctx, "TaskQueue retention clamped to minimum", "plugin", s.pluginName, "queue", name,
|
||||
"requested", config.RetentionMs, "min", minRetentionMs)
|
||||
config.RetentionMs = minRetentionMs
|
||||
}
|
||||
if config.RetentionMs > maxRetentionMs {
|
||||
log.Warn(ctx, "TaskQueue retention clamped to maximum", "plugin", s.pluginName, "queue", name,
|
||||
"requested", config.RetentionMs, "max", maxRetentionMs)
|
||||
config.RetentionMs = maxRetentionMs
|
||||
}
|
||||
}
|
||||
|
||||
// clampConcurrency reduces config.Concurrency if it exceeds the remaining budget.
|
||||
// Must be called with s.mu held.
|
||||
func (s *taskQueueServiceImpl) clampConcurrency(ctx context.Context, name string, config *host.QueueConfig) {
|
||||
var allocated int32
|
||||
for _, qs := range s.queues {
|
||||
allocated += qs.config.Concurrency
|
||||
}
|
||||
available := max(s.maxConcurrency-allocated, 1)
|
||||
if config.Concurrency > available {
|
||||
log.Warn(ctx, "TaskQueue concurrency clamped", "plugin", s.pluginName, "queue", name,
|
||||
"requested", config.Concurrency, "available", available, "maxConcurrency", s.maxConcurrency)
|
||||
config.Concurrency = available
|
||||
}
|
||||
}
|
||||
|
||||
func (s *taskQueueServiceImpl) CreateQueue(ctx context.Context, name string, config host.QueueConfig) error {
|
||||
if len(name) == 0 {
|
||||
return fmt.Errorf("queue name cannot be empty")
|
||||
}
|
||||
if len(name) > maxQueueNameLength {
|
||||
return fmt.Errorf("queue name exceeds maximum length of %d bytes", maxQueueNameLength)
|
||||
}
|
||||
|
||||
s.applyConfigDefaults(ctx, name, &config)
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.clampConcurrency(ctx, name, &config)
|
||||
|
||||
if _, exists := s.queues[name]; exists {
|
||||
return fmt.Errorf("queue %q already exists", name)
|
||||
}
|
||||
|
||||
// Upsert into queues table (idempotent across restarts)
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO queues (name, concurrency, max_retries, backoff_ms, delay_ms, retention_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
concurrency = excluded.concurrency,
|
||||
max_retries = excluded.max_retries,
|
||||
backoff_ms = excluded.backoff_ms,
|
||||
delay_ms = excluded.delay_ms,
|
||||
retention_ms = excluded.retention_ms
|
||||
`, name, config.Concurrency, config.MaxRetries, config.BackoffMs, config.DelayMs, config.RetentionMs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating queue: %w", err)
|
||||
}
|
||||
|
||||
// Reset stale running tasks from previous crash
|
||||
now := time.Now().UnixMilli()
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
UPDATE tasks SET status = ?, updated_at = ? WHERE queue_name = ? AND status = ?
|
||||
`, taskStatusPending, now, name, taskStatusRunning)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resetting stale tasks: %w", err)
|
||||
}
|
||||
|
||||
qs := &queueState{
|
||||
config: config,
|
||||
signal: make(chan struct{}, 1),
|
||||
}
|
||||
if config.DelayMs > 0 {
|
||||
// Rate limit dispatches to enforce delay between tasks.
|
||||
// Burst of 1 allows one immediate dispatch, then enforces the delay interval.
|
||||
qs.limiter = rate.NewLimiter(rate.Every(time.Duration(config.DelayMs)*time.Millisecond), 1)
|
||||
}
|
||||
s.queues[name] = qs
|
||||
|
||||
for i := int32(0); i < config.Concurrency; i++ {
|
||||
s.wg.Add(1)
|
||||
go s.worker(name, qs)
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Created task queue", "plugin", s.pluginName, "queue", name,
|
||||
"concurrency", config.Concurrency, "maxRetries", config.MaxRetries,
|
||||
"backoffMs", config.BackoffMs, "delayMs", config.DelayMs, "retentionMs", config.RetentionMs)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *taskQueueServiceImpl) Enqueue(ctx context.Context, queueName string, payload []byte) (string, error) {
|
||||
s.mu.Lock()
|
||||
qs, exists := s.queues[queueName]
|
||||
s.mu.Unlock()
|
||||
|
||||
if !exists {
|
||||
return "", fmt.Errorf("queue %q does not exist", queueName)
|
||||
}
|
||||
if len(payload) > maxPayloadSize {
|
||||
return "", fmt.Errorf("payload size %d exceeds maximum of %d bytes", len(payload), maxPayloadSize)
|
||||
}
|
||||
|
||||
taskID := id.NewRandom()
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO tasks (id, queue_name, payload, status, attempt, max_retries, next_run_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?)
|
||||
`, taskID, queueName, payload, taskStatusPending, qs.config.MaxRetries, now, now, now)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("enqueuing task: %w", err)
|
||||
}
|
||||
|
||||
qs.notifyWorkers()
|
||||
log.Trace(ctx, "Enqueued task", "plugin", s.pluginName, "queue", queueName, "taskID", taskID)
|
||||
return taskID, nil
|
||||
}
|
||||
|
||||
// GetTaskStatus returns the status of a task.
|
||||
func (s *taskQueueServiceImpl) GetTaskStatus(ctx context.Context, taskID string) (string, error) {
|
||||
var status string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT status FROM tasks WHERE id = ?`, taskID).Scan(&status)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", fmt.Errorf("task %q not found", taskID)
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("getting task status: %w", err)
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// CancelTask cancels a pending task.
|
||||
func (s *taskQueueServiceImpl) CancelTask(ctx context.Context, taskID string) error {
|
||||
now := time.Now().UnixMilli()
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
UPDATE tasks SET status = ?, updated_at = ? WHERE id = ? AND status = ?
|
||||
`, taskStatusCancelled, now, taskID, taskStatusPending)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cancelling task: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking cancel result: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
// Check if task exists at all
|
||||
var status string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT status FROM tasks WHERE id = ?`, taskID).Scan(&status)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("task %q not found", taskID)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking task existence: %w", err)
|
||||
}
|
||||
return fmt.Errorf("task %q cannot be cancelled (status: %s)", taskID, status)
|
||||
}
|
||||
|
||||
log.Trace(ctx, "Cancelled task", "plugin", s.pluginName, "taskID", taskID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// worker is the main loop for a single worker goroutine.
|
||||
func (s *taskQueueServiceImpl) worker(queueName string, qs *queueState) {
|
||||
defer s.wg.Done()
|
||||
|
||||
// Process any existing pending tasks immediately on startup
|
||||
s.drainQueue(queueName, qs)
|
||||
|
||||
ticker := time.NewTicker(pollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case <-qs.signal:
|
||||
s.drainQueue(queueName, qs)
|
||||
case <-ticker.C:
|
||||
s.drainQueue(queueName, qs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *taskQueueServiceImpl) drainQueue(queueName string, qs *queueState) {
|
||||
for s.ctx.Err() == nil && s.processTask(queueName, qs) {
|
||||
}
|
||||
}
|
||||
|
||||
// processTask dequeues and processes a single task. Returns true if a task was processed.
|
||||
func (s *taskQueueServiceImpl) processTask(queueName string, qs *queueState) bool {
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
// Atomically dequeue a task
|
||||
var taskID string
|
||||
var payload []byte
|
||||
var attempt, maxRetries int32
|
||||
err := s.db.QueryRowContext(s.ctx, `
|
||||
UPDATE tasks SET status = ?, attempt = attempt + 1, updated_at = ?
|
||||
WHERE id = (
|
||||
SELECT id FROM tasks
|
||||
WHERE queue_name = ? AND status = ? AND next_run_at <= ?
|
||||
ORDER BY next_run_at, created_at LIMIT 1
|
||||
)
|
||||
RETURNING id, payload, attempt, max_retries
|
||||
`, taskStatusRunning, now, queueName, taskStatusPending, now).Scan(&taskID, &payload, &attempt, &maxRetries)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(s.ctx, "Failed to dequeue task", "plugin", s.pluginName, "queue", queueName, err)
|
||||
return false
|
||||
}
|
||||
|
||||
// Enforce delay between task dispatches using a rate limiter.
|
||||
// This is done after dequeue so that empty polls don't consume rate tokens.
|
||||
if qs.limiter != nil {
|
||||
if err := qs.limiter.Wait(s.ctx); err != nil {
|
||||
// Context cancelled during wait — revert task to pending for recovery
|
||||
s.revertTaskToPending(taskID)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Invoke callback
|
||||
log.Debug(s.ctx, "Executing task", "plugin", s.pluginName, "queue", queueName, "taskID", taskID, "attempt", attempt)
|
||||
callbackErr := s.invokeCallbackFn(s.ctx, queueName, taskID, payload, attempt)
|
||||
|
||||
// If context was cancelled (shutdown), revert task to pending for recovery
|
||||
if s.ctx.Err() != nil {
|
||||
s.revertTaskToPending(taskID)
|
||||
return false
|
||||
}
|
||||
|
||||
if callbackErr == nil {
|
||||
s.completeTask(queueName, taskID)
|
||||
} else {
|
||||
s.handleTaskFailure(queueName, taskID, attempt, maxRetries, qs, callbackErr)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *taskQueueServiceImpl) completeTask(queueName, taskID string) {
|
||||
now := time.Now().UnixMilli()
|
||||
if _, err := s.db.ExecContext(s.ctx, `UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?`, taskStatusCompleted, now, taskID); err != nil {
|
||||
log.Error(s.ctx, "Failed to mark task as completed", "plugin", s.pluginName, "taskID", taskID, err)
|
||||
}
|
||||
log.Debug(s.ctx, "Task completed", "plugin", s.pluginName, "queue", queueName, "taskID", taskID)
|
||||
}
|
||||
|
||||
func (s *taskQueueServiceImpl) handleTaskFailure(queueName, taskID string, attempt, maxRetries int32, qs *queueState, callbackErr error) {
|
||||
log.Warn(s.ctx, "Task execution failed", "plugin", s.pluginName, "queue", queueName,
|
||||
"taskID", taskID, "attempt", attempt, "maxRetries", maxRetries, "err", callbackErr)
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if attempt > maxRetries {
|
||||
if _, err := s.db.ExecContext(s.ctx, `UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?`, taskStatusFailed, now, taskID); err != nil {
|
||||
log.Error(s.ctx, "Failed to mark task as failed", "plugin", s.pluginName, "taskID", taskID, err)
|
||||
}
|
||||
log.Warn(s.ctx, "Task failed after all retries", "plugin", s.pluginName, "queue", queueName, "taskID", taskID)
|
||||
return
|
||||
}
|
||||
|
||||
// Exponential backoff: backoffMs * 2^(attempt-1)
|
||||
backoff := qs.config.BackoffMs << (attempt - 1)
|
||||
if backoff <= 0 || backoff > maxBackoffMs {
|
||||
backoff = maxBackoffMs
|
||||
}
|
||||
nextRunAt := now + backoff
|
||||
if _, err := s.db.ExecContext(s.ctx, `
|
||||
UPDATE tasks SET status = ?, next_run_at = ?, updated_at = ? WHERE id = ?
|
||||
`, taskStatusPending, nextRunAt, now, taskID); err != nil {
|
||||
log.Error(s.ctx, "Failed to reschedule task for retry", "plugin", s.pluginName, "taskID", taskID, err)
|
||||
}
|
||||
|
||||
// Wake worker after backoff expires
|
||||
time.AfterFunc(time.Duration(backoff)*time.Millisecond, func() {
|
||||
qs.notifyWorkers()
|
||||
})
|
||||
}
|
||||
|
||||
// revertTaskToPending puts a running task back to pending status and decrements the attempt
|
||||
// counter (used during shutdown to ensure the interrupted attempt doesn't count).
|
||||
func (s *taskQueueServiceImpl) revertTaskToPending(taskID string) {
|
||||
now := time.Now().UnixMilli()
|
||||
_, err := s.db.Exec(`UPDATE tasks SET status = ?, attempt = MAX(attempt - 1, 0), updated_at = ? WHERE id = ? AND status = ?`, taskStatusPending, now, taskID, taskStatusRunning)
|
||||
if err != nil {
|
||||
log.Error("Failed to revert task to pending", "plugin", s.pluginName, "taskID", taskID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// defaultInvokeCallback calls the plugin's nd_task_execute function.
|
||||
func (s *taskQueueServiceImpl) defaultInvokeCallback(ctx context.Context, queueName, taskID string, payload []byte, attempt int32) error {
|
||||
s.manager.mu.RLock()
|
||||
p, ok := s.manager.plugins[s.pluginName]
|
||||
s.manager.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("plugin %s not loaded", s.pluginName)
|
||||
}
|
||||
|
||||
input := capabilities.TaskExecuteRequest{
|
||||
QueueName: queueName,
|
||||
TaskID: taskID,
|
||||
Payload: payload,
|
||||
Attempt: attempt,
|
||||
}
|
||||
|
||||
result, err := callPluginFunction[capabilities.TaskExecuteRequest, capabilities.TaskExecuteResponse](ctx, p, FuncTaskWorkerCallback, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Error != "" {
|
||||
return fmt.Errorf("%s", result.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupLoop periodically removes terminal tasks past their retention period.
|
||||
func (s *taskQueueServiceImpl) cleanupLoop() {
|
||||
defer s.wg.Done()
|
||||
ticker := time.NewTicker(cleanupInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.runCleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runCleanup deletes terminal tasks past their retention period.
|
||||
func (s *taskQueueServiceImpl) runCleanup() {
|
||||
s.mu.Lock()
|
||||
queues := make(map[string]*queueState, len(s.queues))
|
||||
for k, v := range s.queues {
|
||||
queues[k] = v
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
for name, qs := range queues {
|
||||
result, err := s.db.ExecContext(s.ctx, `
|
||||
DELETE FROM tasks WHERE queue_name = ? AND status IN (?, ?, ?) AND updated_at + ? < ?
|
||||
`, name, taskStatusCompleted, taskStatusFailed, taskStatusCancelled, qs.config.RetentionMs, now)
|
||||
if err != nil {
|
||||
log.Error(s.ctx, "Failed to cleanup tasks", "plugin", s.pluginName, "queue", name, err)
|
||||
continue
|
||||
}
|
||||
if deleted, _ := result.RowsAffected(); deleted > 0 {
|
||||
log.Debug(s.ctx, "Cleaned up terminal tasks", "plugin", s.pluginName, "queue", name, "deleted", deleted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close shuts down the task queue service, stopping all workers and closing the database.
|
||||
func (s *taskQueueServiceImpl) Close() error {
|
||||
// Cancel context to signal all goroutines
|
||||
s.cancel()
|
||||
|
||||
// Wait for goroutines with timeout
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
s.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(shutdownTimeout):
|
||||
log.Warn("TaskQueue shutdown timed out", "plugin", s.pluginName)
|
||||
}
|
||||
|
||||
// Mark running tasks as pending for recovery on next startup
|
||||
if s.db != nil {
|
||||
now := time.Now().UnixMilli()
|
||||
if _, err := s.db.Exec(`UPDATE tasks SET status = ?, updated_at = ? WHERE status = ?`, taskStatusPending, now, taskStatusRunning); err != nil {
|
||||
log.Error("Failed to reset running tasks on shutdown", "plugin", s.pluginName, err)
|
||||
}
|
||||
log.Debug("Closing plugin taskqueue", "plugin", s.pluginName)
|
||||
return s.db.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compile-time verification
|
||||
var _ host.TaskQueueService = (*taskQueueServiceImpl)(nil)
|
||||
var _ io.Closer = (*taskQueueServiceImpl)(nil)
|
||||
953
plugins/host_taskqueue_test.go
Normal file
953
plugins/host_taskqueue_test.go
Normal file
@@ -0,0 +1,953 @@
|
||||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/plugins/host"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("TaskQueueService", func() {
|
||||
var tmpDir string
|
||||
var service *taskQueueServiceImpl
|
||||
var ctx context.Context
|
||||
var manager *Manager
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "taskqueue-test-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DataFolder = tmpDir
|
||||
|
||||
// Create a mock manager with context
|
||||
managerCtx, cancel := context.WithCancel(ctx)
|
||||
manager = &Manager{
|
||||
plugins: make(map[string]*plugin),
|
||||
ctx: managerCtx,
|
||||
}
|
||||
DeferCleanup(cancel)
|
||||
|
||||
service, err = newTaskQueueService("test_plugin", manager, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
if service != nil {
|
||||
service.Close()
|
||||
}
|
||||
os.RemoveAll(tmpDir)
|
||||
})
|
||||
|
||||
Describe("CreateQueue", func() {
|
||||
It("creates a queue successfully", func() {
|
||||
err := service.CreateQueue(ctx, "my-queue", host.QueueConfig{
|
||||
Concurrency: 2,
|
||||
MaxRetries: 3,
|
||||
BackoffMs: 2000,
|
||||
RetentionMs: 7200000,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
service.mu.Lock()
|
||||
qs, exists := service.queues["my-queue"]
|
||||
service.mu.Unlock()
|
||||
Expect(exists).To(BeTrue())
|
||||
Expect(qs.config.Concurrency).To(Equal(int32(2)))
|
||||
Expect(qs.config.MaxRetries).To(Equal(int32(3)))
|
||||
Expect(qs.config.BackoffMs).To(Equal(int64(2000)))
|
||||
Expect(qs.config.RetentionMs).To(Equal(int64(7200000)))
|
||||
})
|
||||
|
||||
It("returns error for duplicate queue name", func() {
|
||||
err := service.CreateQueue(ctx, "dup-queue", host.QueueConfig{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = service.CreateQueue(ctx, "dup-queue", host.QueueConfig{})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("already exists"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("CreateQueue name validation", func() {
|
||||
It("rejects empty queue name", func() {
|
||||
err := service.CreateQueue(ctx, "", host.QueueConfig{})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("queue name cannot be empty"))
|
||||
})
|
||||
|
||||
It("rejects over-length queue name", func() {
|
||||
longName := strings.Repeat("a", maxQueueNameLength+1)
|
||||
err := service.CreateQueue(ctx, longName, host.QueueConfig{})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("exceeds maximum length"))
|
||||
})
|
||||
|
||||
It("accepts queue name at maximum length", func() {
|
||||
service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) error {
|
||||
return nil
|
||||
}
|
||||
exactName := strings.Repeat("a", maxQueueNameLength)
|
||||
err := service.CreateQueue(ctx, exactName, host.QueueConfig{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("CreateQueue defaults", func() {
|
||||
It("applies defaults for zero-value config", func() {
|
||||
err := service.CreateQueue(ctx, "defaults-queue", host.QueueConfig{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
service.mu.Lock()
|
||||
qs := service.queues["defaults-queue"]
|
||||
service.mu.Unlock()
|
||||
Expect(qs.config.Concurrency).To(Equal(defaultConcurrency))
|
||||
Expect(qs.config.BackoffMs).To(Equal(defaultBackoffMs))
|
||||
Expect(qs.config.RetentionMs).To(Equal(defaultRetentionMs))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("CreateQueue defaults with negative values", func() {
|
||||
It("applies default RetentionMs for negative value", func() {
|
||||
service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) error {
|
||||
return nil
|
||||
}
|
||||
err := service.CreateQueue(ctx, "neg-retention", host.QueueConfig{
|
||||
RetentionMs: -500,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
service.mu.Lock()
|
||||
qs := service.queues["neg-retention"]
|
||||
service.mu.Unlock()
|
||||
Expect(qs.config.RetentionMs).To(Equal(defaultRetentionMs))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("CreateQueue clamping", func() {
|
||||
It("clamps concurrency exceeding maxConcurrency", func() {
|
||||
// maxConcurrency is 5; request 10
|
||||
err := service.CreateQueue(ctx, "clamped-queue", host.QueueConfig{
|
||||
Concurrency: 10,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
service.mu.Lock()
|
||||
qs := service.queues["clamped-queue"]
|
||||
service.mu.Unlock()
|
||||
Expect(qs.config.Concurrency).To(BeNumerically("<=", int32(5)))
|
||||
})
|
||||
|
||||
It("clamps retention below minimum", func() {
|
||||
err := service.CreateQueue(ctx, "low-retention", host.QueueConfig{
|
||||
RetentionMs: 100, // below minRetentionMs
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
service.mu.Lock()
|
||||
qs := service.queues["low-retention"]
|
||||
service.mu.Unlock()
|
||||
Expect(qs.config.RetentionMs).To(Equal(minRetentionMs))
|
||||
})
|
||||
|
||||
It("clamps retention above maximum", func() {
|
||||
err := service.CreateQueue(ctx, "high-retention", host.QueueConfig{
|
||||
RetentionMs: 999_999_999_999, // above maxRetentionMs
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
service.mu.Lock()
|
||||
qs := service.queues["high-retention"]
|
||||
service.mu.Unlock()
|
||||
Expect(qs.config.RetentionMs).To(Equal(maxRetentionMs))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Enqueue", func() {
|
||||
BeforeEach(func() {
|
||||
// Use a no-op callback to prevent actual execution attempts
|
||||
service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) error {
|
||||
return nil
|
||||
}
|
||||
err := service.CreateQueue(ctx, "enqueue-test", host.QueueConfig{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("enqueues a task and returns task ID", func() {
|
||||
taskID, err := service.Enqueue(ctx, "enqueue-test", []byte("payload"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(taskID).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns error for non-existent queue", func() {
|
||||
_, err := service.Enqueue(ctx, "no-such-queue", []byte("payload"))
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("does not exist"))
|
||||
})
|
||||
|
||||
It("rejects payload exceeding maximum size", func() {
|
||||
bigPayload := make([]byte, maxPayloadSize+1)
|
||||
_, err := service.Enqueue(ctx, "enqueue-test", bigPayload)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("exceeds maximum"))
|
||||
})
|
||||
|
||||
It("accepts payload at maximum size", func() {
|
||||
exactPayload := make([]byte, maxPayloadSize)
|
||||
taskID, err := service.Enqueue(ctx, "enqueue-test", exactPayload)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(taskID).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetTaskStatus", func() {
|
||||
BeforeEach(func() {
|
||||
// Use a callback that blocks until context is cancelled so tasks stay pending
|
||||
service.invokeCallbackFn = func(ctx context.Context, _, _ string, _ []byte, _ int32) error {
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
})
|
||||
|
||||
It("returns pending for a new task", func() {
|
||||
err := service.CreateQueue(ctx, "status-test", host.QueueConfig{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
taskID, err := service.Enqueue(ctx, "status-test", []byte("data"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// The task may get picked up quickly; check initial status
|
||||
// Since the callback blocks, it should be either pending or running
|
||||
status, err := service.GetTaskStatus(ctx, taskID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(status).To(BeElementOf("pending", "running"))
|
||||
})
|
||||
|
||||
It("returns error for unknown task ID", func() {
|
||||
_, err := service.GetTaskStatus(ctx, "nonexistent-id")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("not found"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("CancelTask", func() {
|
||||
BeforeEach(func() {
|
||||
// Block callback so tasks stay in pending/running
|
||||
service.invokeCallbackFn = func(ctx context.Context, _, _ string, _ []byte, _ int32) error {
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
})
|
||||
|
||||
It("cancels a pending task", func() {
|
||||
// Block the callback so the first task occupies the worker
|
||||
started := make(chan struct{})
|
||||
service.invokeCallbackFn = func(ctx context.Context, _, _ string, _ []byte, _ int32) error {
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
err := service.CreateQueue(ctx, "cancel-test", host.QueueConfig{
|
||||
Concurrency: 1,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Enqueue a blocker task to occupy the single worker
|
||||
_, err = service.Enqueue(ctx, "cancel-test", []byte("blocker"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Wait for the blocker task to start running
|
||||
Eventually(started).WithTimeout(5 * time.Second).Should(BeClosed())
|
||||
|
||||
// Enqueue a second task — it stays pending since the worker is busy
|
||||
taskID, err := service.Enqueue(ctx, "cancel-test", []byte("cancel-me"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = service.CancelTask(ctx, taskID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
status, err := service.GetTaskStatus(ctx, taskID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(status).To(Equal("cancelled"))
|
||||
})
|
||||
|
||||
It("returns error for unknown task ID", func() {
|
||||
err := service.CancelTask(ctx, "nonexistent-id")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("not found"))
|
||||
})
|
||||
|
||||
It("returns error for non-pending task", func() {
|
||||
// Create a queue where tasks complete immediately
|
||||
service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) error {
|
||||
return nil
|
||||
}
|
||||
err := service.CreateQueue(ctx, "completed-test", host.QueueConfig{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
taskID, err := service.Enqueue(ctx, "completed-test", []byte("data"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Wait for task to complete
|
||||
Eventually(func() string {
|
||||
status, _ := service.GetTaskStatus(ctx, taskID)
|
||||
return status
|
||||
}).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("completed"))
|
||||
|
||||
// Try to cancel completed task
|
||||
err = service.CancelTask(ctx, taskID)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("cannot be cancelled"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Worker execution", func() {
|
||||
It("invokes callback and completes task", func() {
|
||||
var callCount atomic.Int32
|
||||
var receivedQueueName, receivedTaskID string
|
||||
var receivedPayload []byte
|
||||
var receivedAttempt int32
|
||||
|
||||
service.invokeCallbackFn = func(_ context.Context, queueName, taskID string, payload []byte, attempt int32) error {
|
||||
callCount.Add(1)
|
||||
receivedQueueName = queueName
|
||||
receivedTaskID = taskID
|
||||
receivedPayload = payload
|
||||
receivedAttempt = attempt
|
||||
return nil
|
||||
}
|
||||
|
||||
err := service.CreateQueue(ctx, "worker-test", host.QueueConfig{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
taskID, err := service.Enqueue(ctx, "worker-test", []byte("test-payload"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Eventually(func() string {
|
||||
status, _ := service.GetTaskStatus(ctx, taskID)
|
||||
return status
|
||||
}).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("completed"))
|
||||
|
||||
Expect(callCount.Load()).To(Equal(int32(1)))
|
||||
Expect(receivedQueueName).To(Equal("worker-test"))
|
||||
Expect(receivedTaskID).To(Equal(taskID))
|
||||
Expect(receivedPayload).To(Equal([]byte("test-payload")))
|
||||
Expect(receivedAttempt).To(Equal(int32(1)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Retry on failure", func() {
|
||||
It("retries and eventually fails after exhausting retries", func() {
|
||||
var callCount atomic.Int32
|
||||
|
||||
service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) error {
|
||||
callCount.Add(1)
|
||||
return fmt.Errorf("task failed")
|
||||
}
|
||||
|
||||
err := service.CreateQueue(ctx, "retry-test", host.QueueConfig{
|
||||
MaxRetries: 2,
|
||||
BackoffMs: 10, // Very short for testing
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
taskID, err := service.Enqueue(ctx, "retry-test", []byte("retry-payload"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Eventually(func() string {
|
||||
status, _ := service.GetTaskStatus(ctx, taskID)
|
||||
return status
|
||||
}).WithTimeout(10 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("failed"))
|
||||
|
||||
// 1 initial attempt + 2 retries = 3 total calls
|
||||
Expect(callCount.Load()).To(Equal(int32(3)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Retry then succeed", func() {
|
||||
It("retries and succeeds on second attempt", func() {
|
||||
var callCount atomic.Int32
|
||||
|
||||
service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, attempt int32) error {
|
||||
callCount.Add(1)
|
||||
if attempt == 1 {
|
||||
return fmt.Errorf("temporary error")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
err := service.CreateQueue(ctx, "retry-succeed", host.QueueConfig{
|
||||
MaxRetries: 1,
|
||||
BackoffMs: 10, // Very short for testing
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
taskID, err := service.Enqueue(ctx, "retry-succeed", []byte("data"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Eventually(func() string {
|
||||
status, _ := service.GetTaskStatus(ctx, taskID)
|
||||
return status
|
||||
}).WithTimeout(10 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("completed"))
|
||||
|
||||
Expect(callCount.Load()).To(Equal(int32(2)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Backoff overflow cap", func() {
|
||||
It("caps backoff at maxRetentionMs to prevent overflow", func() {
|
||||
var callCount atomic.Int32
|
||||
|
||||
service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) error {
|
||||
callCount.Add(1)
|
||||
return fmt.Errorf("always fail")
|
||||
}
|
||||
|
||||
err := service.CreateQueue(ctx, "backoff-overflow", host.QueueConfig{
|
||||
MaxRetries: 3,
|
||||
BackoffMs: 1_000_000_000, // Very large backoff to trigger overflow on exponentiation
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
taskID, err := service.Enqueue(ctx, "backoff-overflow", []byte("overflow-test"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Wait for first attempt to fail
|
||||
Eventually(func() int32 {
|
||||
return callCount.Load()
|
||||
}).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(BeNumerically(">=", int32(1)))
|
||||
|
||||
// Check next_run_at is positive and reasonable (capped at maxRetentionMs from now)
|
||||
var nextRunAt int64
|
||||
err = service.db.QueryRow(`SELECT next_run_at FROM tasks WHERE id = ?`, taskID).Scan(&nextRunAt)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
Expect(nextRunAt).To(BeNumerically(">", int64(0)), "next_run_at should be positive")
|
||||
Expect(nextRunAt).To(BeNumerically("<=", now+maxBackoffMs+1000), "next_run_at should be at most maxBackoffMs from now")
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Delay enforcement with concurrent workers", func() {
|
||||
It("enforces delay between dispatches even with multiple workers", func() {
|
||||
var mu sync.Mutex
|
||||
var dispatchTimes []time.Time
|
||||
|
||||
service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) error {
|
||||
mu.Lock()
|
||||
dispatchTimes = append(dispatchTimes, time.Now())
|
||||
mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
err := service.CreateQueue(ctx, "delay-concurrent", host.QueueConfig{
|
||||
Concurrency: 3,
|
||||
DelayMs: 200,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Enqueue 5 tasks
|
||||
for i := 0; i < 5; i++ {
|
||||
_, err := service.Enqueue(ctx, "delay-concurrent", []byte(fmt.Sprintf("task-%d", i)))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
// Wait for all tasks to complete
|
||||
Eventually(func() int {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return len(dispatchTimes)
|
||||
}).WithTimeout(10 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal(5))
|
||||
|
||||
// Sort dispatch times and verify gaps
|
||||
mu.Lock()
|
||||
sort.Slice(dispatchTimes, func(i, j int) bool {
|
||||
return dispatchTimes[i].Before(dispatchTimes[j])
|
||||
})
|
||||
times := make([]time.Time, len(dispatchTimes))
|
||||
copy(times, dispatchTimes)
|
||||
mu.Unlock()
|
||||
|
||||
// Consecutive dispatches should have at least ~160ms gap (80% of 200ms)
|
||||
for i := 1; i < len(times); i++ {
|
||||
gap := times[i].Sub(times[i-1])
|
||||
Expect(gap).To(BeNumerically(">=", 160*time.Millisecond),
|
||||
fmt.Sprintf("gap between dispatch %d and %d was %v, expected >= 160ms", i-1, i, gap))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Shutdown recovery", func() {
|
||||
It("resets stale running tasks on CreateQueue", func() {
|
||||
// Create a first service and queue, enqueue a task
|
||||
service.invokeCallbackFn = func(ctx context.Context, _, _ string, _ []byte, _ int32) error {
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
err := service.CreateQueue(ctx, "recovery-queue", host.QueueConfig{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
taskID, err := service.Enqueue(ctx, "recovery-queue", []byte("stale-task"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Wait for the task to start running
|
||||
Eventually(func() string {
|
||||
status, _ := service.GetTaskStatus(ctx, taskID)
|
||||
return status
|
||||
}).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("running"))
|
||||
|
||||
// Close the service (simulates crash - tasks left in running state)
|
||||
service.Close()
|
||||
|
||||
// Create a new service pointing to the same DB
|
||||
managerCtx2, cancel2 := context.WithCancel(ctx)
|
||||
DeferCleanup(cancel2)
|
||||
manager2 := &Manager{
|
||||
plugins: make(map[string]*plugin),
|
||||
ctx: managerCtx2,
|
||||
}
|
||||
|
||||
service, err = newTaskQueueService("test_plugin", manager2, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Override callback to succeed
|
||||
service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Re-create the queue - the upsert handles the existing row from the old service
|
||||
err = service.CreateQueue(ctx, "recovery-queue", host.QueueConfig{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// The stale running task should now be reset to pending and eventually completed
|
||||
Eventually(func() string {
|
||||
status, _ := service.GetTaskStatus(ctx, taskID)
|
||||
return status
|
||||
}).WithTimeout(10 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("completed"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Close", func() {
|
||||
It("prevents subsequent operations after close", func() {
|
||||
err := service.CreateQueue(ctx, "close-test", host.QueueConfig{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
service.Close()
|
||||
|
||||
// After close, operations should fail
|
||||
_, err = service.Enqueue(ctx, "close-test", []byte("data"))
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Plugin isolation", func() {
|
||||
It("uses separate databases for different plugins", func() {
|
||||
managerCtx2, cancel2 := context.WithCancel(ctx)
|
||||
DeferCleanup(cancel2)
|
||||
manager2 := &Manager{
|
||||
plugins: make(map[string]*plugin),
|
||||
ctx: managerCtx2,
|
||||
}
|
||||
|
||||
service2, err := newTaskQueueService("other_plugin", manager2, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer service2.Close()
|
||||
|
||||
// Check that separate database files exist
|
||||
_, err = os.Stat(filepath.Join(tmpDir, "plugins", "test_plugin", "taskqueue.db"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = os.Stat(filepath.Join(tmpDir, "plugins", "other_plugin", "taskqueue.db"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Both services should be able to create queues with the same name independently
|
||||
service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) error { return nil }
|
||||
service2.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) error { return nil }
|
||||
|
||||
err = service.CreateQueue(ctx, "shared-name", host.QueueConfig{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = service2.CreateQueue(ctx, "shared-name", host.QueueConfig{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Enqueue to each and verify they work independently
|
||||
taskID1, err := service.Enqueue(ctx, "shared-name", []byte("plugin1"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
taskID2, err := service2.Enqueue(ctx, "shared-name", []byte("plugin2"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(taskID1).ToNot(Equal(taskID2))
|
||||
|
||||
// Both should complete
|
||||
Eventually(func() string {
|
||||
status, _ := service.GetTaskStatus(ctx, taskID1)
|
||||
return status
|
||||
}).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("completed"))
|
||||
|
||||
Eventually(func() string {
|
||||
status, _ := service2.GetTaskStatus(ctx, taskID2)
|
||||
return status
|
||||
}).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("completed"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("TaskQueueService Integration", Ordered, func() {
|
||||
var manager *Manager
|
||||
var tmpDir string
|
||||
|
||||
BeforeAll(func() {
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "taskqueue-integration-test-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Copy the test-taskqueue plugin
|
||||
srcPath := filepath.Join(testdataDir, "test-taskqueue"+PackageExtension)
|
||||
destPath := filepath.Join(tmpDir, "test-taskqueue"+PackageExtension)
|
||||
data, err := os.ReadFile(srcPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = os.WriteFile(destPath, data, 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Compute SHA256 for the plugin
|
||||
hash := sha256.Sum256(data)
|
||||
hashHex := hex.EncodeToString(hash[:])
|
||||
|
||||
// Setup config
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = tmpDir
|
||||
conf.Server.Plugins.AutoReload = false
|
||||
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
|
||||
conf.Server.DataFolder = tmpDir
|
||||
|
||||
// Setup mock DataStore with pre-enabled plugin
|
||||
mockPluginRepo := tests.CreateMockPluginRepo()
|
||||
mockPluginRepo.Permitted = true
|
||||
mockPluginRepo.SetData(model.Plugins{{
|
||||
ID: "test-taskqueue",
|
||||
Path: destPath,
|
||||
SHA256: hashHex,
|
||||
Enabled: true,
|
||||
}})
|
||||
dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo}
|
||||
|
||||
// Create and start manager
|
||||
manager = &Manager{
|
||||
plugins: make(map[string]*plugin),
|
||||
ds: dataStore,
|
||||
metrics: noopMetricsRecorder{},
|
||||
subsonicRouter: http.NotFoundHandler(),
|
||||
}
|
||||
err = manager.Start(GinkgoT().Context())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
DeferCleanup(func() {
|
||||
_ = manager.Stop()
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
})
|
||||
})
|
||||
|
||||
// Helper types for calling the test plugin
|
||||
type testQueueConfig struct {
|
||||
Concurrency int32 `json:"concurrency,omitempty"`
|
||||
MaxRetries int32 `json:"maxRetries,omitempty"`
|
||||
BackoffMs int64 `json:"backoffMs,omitempty"`
|
||||
DelayMs int64 `json:"delayMs,omitempty"`
|
||||
RetentionMs int64 `json:"retentionMs,omitempty"`
|
||||
}
|
||||
|
||||
type testTaskQueueInput struct {
|
||||
Operation string `json:"operation"`
|
||||
QueueName string `json:"queueName,omitempty"`
|
||||
Config *testQueueConfig `json:"config,omitempty"`
|
||||
Payload []byte `json:"payload,omitempty"`
|
||||
TaskID string `json:"taskId,omitempty"`
|
||||
}
|
||||
|
||||
type testTaskQueueOutput struct {
|
||||
TaskID string `json:"taskId,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Error *string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
callTestTaskQueue := func(ctx context.Context, input testTaskQueueInput) (*testTaskQueueOutput, error) {
|
||||
manager.mu.RLock()
|
||||
p := manager.plugins["test-taskqueue"]
|
||||
manager.mu.RUnlock()
|
||||
|
||||
instance, err := p.instance(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer instance.Close(ctx)
|
||||
|
||||
inputBytes, _ := json.Marshal(input)
|
||||
_, outputBytes, err := instance.Call("nd_test_taskqueue", inputBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var output testTaskQueueOutput
|
||||
if err := json.Unmarshal(outputBytes, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if output.Error != nil {
|
||||
return nil, errors.New(*output.Error)
|
||||
}
|
||||
return &output, nil
|
||||
}
|
||||
|
||||
Describe("Plugin Loading", func() {
|
||||
It("should load plugin with taskqueue permission and TaskWorker capability", func() {
|
||||
manager.mu.RLock()
|
||||
p, ok := manager.plugins["test-taskqueue"]
|
||||
manager.mu.RUnlock()
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(p.manifest.Permissions).ToNot(BeNil())
|
||||
Expect(p.manifest.Permissions.Taskqueue).ToNot(BeNil())
|
||||
Expect(p.manifest.Permissions.Taskqueue.MaxConcurrency).To(Equal(3))
|
||||
Expect(p.capabilities).To(ContainElement(CapabilityTaskWorker))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Create Queue", func() {
|
||||
It("should create a queue without error", func() {
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := callTestTaskQueue(ctx, testTaskQueueInput{
|
||||
Operation: "create_queue",
|
||||
QueueName: "test-create",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should return error for duplicate queue name", func() {
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := callTestTaskQueue(ctx, testTaskQueueInput{
|
||||
Operation: "create_queue",
|
||||
QueueName: "test-dup",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = callTestTaskQueue(ctx, testTaskQueueInput{
|
||||
Operation: "create_queue",
|
||||
QueueName: "test-dup",
|
||||
})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("already exists"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Enqueue and Task Completion", func() {
|
||||
It("should enqueue a task and complete successfully", func() {
|
||||
ctx := GinkgoT().Context()
|
||||
|
||||
// Create queue
|
||||
_, err := callTestTaskQueue(ctx, testTaskQueueInput{
|
||||
Operation: "create_queue",
|
||||
QueueName: "test-complete",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Enqueue task with payload "hello"
|
||||
output, err := callTestTaskQueue(ctx, testTaskQueueInput{
|
||||
Operation: "enqueue",
|
||||
QueueName: "test-complete",
|
||||
Payload: []byte("hello"),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output.TaskID).ToNot(BeEmpty())
|
||||
|
||||
taskID := output.TaskID
|
||||
|
||||
// Poll until completed
|
||||
Eventually(func() string {
|
||||
out, err := callTestTaskQueue(ctx, testTaskQueueInput{
|
||||
Operation: "get_task_status",
|
||||
TaskID: taskID,
|
||||
})
|
||||
if err != nil {
|
||||
return "error"
|
||||
}
|
||||
return out.Status
|
||||
}).WithTimeout(5 * time.Second).WithPolling(100 * time.Millisecond).Should(Equal("completed"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Enqueue with Failure, No Retries", func() {
|
||||
It("should fail when payload is 'fail' and maxRetries is 0", func() {
|
||||
ctx := GinkgoT().Context()
|
||||
|
||||
// Create queue with no retries
|
||||
_, err := callTestTaskQueue(ctx, testTaskQueueInput{
|
||||
Operation: "create_queue",
|
||||
QueueName: "test-fail-no-retry",
|
||||
Config: &testQueueConfig{
|
||||
MaxRetries: 0,
|
||||
},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Enqueue task that will fail
|
||||
output, err := callTestTaskQueue(ctx, testTaskQueueInput{
|
||||
Operation: "enqueue",
|
||||
QueueName: "test-fail-no-retry",
|
||||
Payload: []byte("fail"),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
taskID := output.TaskID
|
||||
|
||||
// Poll until failed
|
||||
Eventually(func() string {
|
||||
out, err := callTestTaskQueue(ctx, testTaskQueueInput{
|
||||
Operation: "get_task_status",
|
||||
TaskID: taskID,
|
||||
})
|
||||
if err != nil {
|
||||
return "error"
|
||||
}
|
||||
return out.Status
|
||||
}).WithTimeout(5 * time.Second).WithPolling(100 * time.Millisecond).Should(Equal("failed"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Enqueue with Retry Then Success", func() {
|
||||
It("should retry and eventually succeed with 'fail-then-succeed' payload", func() {
|
||||
ctx := GinkgoT().Context()
|
||||
|
||||
// Create queue with retries and short backoff
|
||||
_, err := callTestTaskQueue(ctx, testTaskQueueInput{
|
||||
Operation: "create_queue",
|
||||
QueueName: "test-retry-succeed",
|
||||
Config: &testQueueConfig{
|
||||
MaxRetries: 2,
|
||||
BackoffMs: 100,
|
||||
},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Enqueue task that fails on attempt < 2, then succeeds
|
||||
output, err := callTestTaskQueue(ctx, testTaskQueueInput{
|
||||
Operation: "enqueue",
|
||||
QueueName: "test-retry-succeed",
|
||||
Payload: []byte("fail-then-succeed"),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
taskID := output.TaskID
|
||||
|
||||
// Poll until completed
|
||||
Eventually(func() string {
|
||||
out, err := callTestTaskQueue(ctx, testTaskQueueInput{
|
||||
Operation: "get_task_status",
|
||||
TaskID: taskID,
|
||||
})
|
||||
if err != nil {
|
||||
return "error"
|
||||
}
|
||||
return out.Status
|
||||
}).WithTimeout(5 * time.Second).WithPolling(100 * time.Millisecond).Should(Equal("completed"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Cancel Pending Task", func() {
|
||||
It("should cancel a pending task", func() {
|
||||
ctx := GinkgoT().Context()
|
||||
|
||||
// Create queue with concurrency=1 and a large delay between dispatches.
|
||||
// The first task completes immediately (burst token), the second is dequeued
|
||||
// but blocks on the rate limiter. Tasks 3+ remain in 'pending' and can be cancelled.
|
||||
_, err := callTestTaskQueue(ctx, testTaskQueueInput{
|
||||
Operation: "create_queue",
|
||||
QueueName: "test-cancel",
|
||||
Config: &testQueueConfig{
|
||||
Concurrency: 1,
|
||||
DelayMs: 60000,
|
||||
},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Enqueue several tasks - the first will complete immediately,
|
||||
// the second will be dequeued but block on the rate limiter (status=running),
|
||||
// the rest will stay pending.
|
||||
var taskIDs []string
|
||||
for i := 0; i < 5; i++ {
|
||||
output, err := callTestTaskQueue(ctx, testTaskQueueInput{
|
||||
Operation: "enqueue",
|
||||
QueueName: "test-cancel",
|
||||
Payload: []byte("hello"),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
taskIDs = append(taskIDs, output.TaskID)
|
||||
}
|
||||
|
||||
// Wait for the first task to complete (it has no delay)
|
||||
Eventually(func() string {
|
||||
out, err := callTestTaskQueue(ctx, testTaskQueueInput{
|
||||
Operation: "get_task_status",
|
||||
TaskID: taskIDs[0],
|
||||
})
|
||||
if err != nil {
|
||||
return "error"
|
||||
}
|
||||
return out.Status
|
||||
}).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("completed"))
|
||||
|
||||
// Give the worker a moment to dequeue the second task (which will
|
||||
// block on the delay) so tasks 3+ stay in 'pending'
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Cancel the last task - it should still be pending
|
||||
lastTaskID := taskIDs[len(taskIDs)-1]
|
||||
_, err = callTestTaskQueue(ctx, testTaskQueueInput{
|
||||
Operation: "cancel_task",
|
||||
TaskID: lastTaskID,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Verify status is cancelled
|
||||
statusOut, err := callTestTaskQueue(ctx, testTaskQueueInput{
|
||||
Operation: "get_task_status",
|
||||
TaskID: lastTaskID,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(statusOut.Status).To(Equal("cancelled"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Enqueue to Non-Existent Queue", func() {
|
||||
It("should return error when enqueueing to a queue that does not exist", func() {
|
||||
ctx := GinkgoT().Context()
|
||||
|
||||
_, err := callTestTaskQueue(ctx, testTaskQueueInput{
|
||||
Operation: "enqueue",
|
||||
QueueName: "nonexistent-queue",
|
||||
Payload: []byte("payload"),
|
||||
})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("does not exist"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -128,6 +128,23 @@ var hostServices = []hostServiceEntry{
|
||||
return host.RegisterHTTPHostFunctions(service), nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TaskQueue",
|
||||
hasPermission: func(p *Permissions) bool { return p != nil && p.Taskqueue != nil },
|
||||
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
|
||||
perm := ctx.permissions.Taskqueue
|
||||
maxConcurrency := int32(1)
|
||||
if perm.MaxConcurrency > 0 {
|
||||
maxConcurrency = int32(perm.MaxConcurrency)
|
||||
}
|
||||
service, err := newTaskQueueService(ctx.pluginName, ctx.manager, maxConcurrency)
|
||||
if err != nil {
|
||||
log.Error("Failed to create TaskQueue service", "plugin", ctx.pluginName, err)
|
||||
return nil, nil
|
||||
}
|
||||
return host.RegisterTaskQueueHostFunctions(service), service
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// extractManifest reads manifest from an .ndp package and computes its SHA-256 hash.
|
||||
|
||||
@@ -110,6 +110,9 @@
|
||||
},
|
||||
"users": {
|
||||
"$ref": "#/$defs/UsersPermission"
|
||||
},
|
||||
"taskqueue": {
|
||||
"$ref": "#/$defs/TaskQueuePermission"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -224,6 +227,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TaskQueuePermission": {
|
||||
"type": "object",
|
||||
"description": "Task queue permissions for background task processing",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Explanation for why task queue access is needed"
|
||||
},
|
||||
"maxConcurrency": {
|
||||
"type": "integer",
|
||||
"description": "Maximum total concurrent workers across all queues. Default: 1",
|
||||
"minimum": 1,
|
||||
"default": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"UsersPermission": {
|
||||
"type": "object",
|
||||
"description": "Users service permissions for accessing user information",
|
||||
|
||||
@@ -64,6 +64,21 @@ func ValidateWithCapabilities(m *Manifest, capabilities []Capability) error {
|
||||
return fmt.Errorf("scrobbler capability requires 'users' permission to be declared in manifest")
|
||||
}
|
||||
}
|
||||
|
||||
// Scheduler permission requires SchedulerCallback capability
|
||||
if m.Permissions != nil && m.Permissions.Scheduler != nil {
|
||||
if !hasCapability(capabilities, CapabilityScheduler) {
|
||||
return fmt.Errorf("'scheduler' permission requires plugin to export '%s' function", FuncSchedulerCallback)
|
||||
}
|
||||
}
|
||||
|
||||
// TaskQueue permission requires TaskWorker capability
|
||||
if m.Permissions != nil && m.Permissions.Taskqueue != nil {
|
||||
if !hasCapability(capabilities, CapabilityTaskWorker) {
|
||||
return fmt.Errorf("'taskqueue' permission requires plugin to export '%s' function", FuncTaskWorkerCallback)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -181,6 +181,9 @@ type Permissions struct {
|
||||
// Subsonicapi corresponds to the JSON schema field "subsonicapi".
|
||||
Subsonicapi *SubsonicAPIPermission `json:"subsonicapi,omitempty" yaml:"subsonicapi,omitempty" mapstructure:"subsonicapi,omitempty"`
|
||||
|
||||
// Taskqueue corresponds to the JSON schema field "taskqueue".
|
||||
Taskqueue *TaskQueuePermission `json:"taskqueue,omitempty" yaml:"taskqueue,omitempty" mapstructure:"taskqueue,omitempty"`
|
||||
|
||||
// Users corresponds to the JSON schema field "users".
|
||||
Users *UsersPermission `json:"users,omitempty" yaml:"users,omitempty" mapstructure:"users,omitempty"`
|
||||
|
||||
@@ -200,6 +203,36 @@ type SubsonicAPIPermission struct {
|
||||
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Task queue permissions for background task processing
|
||||
type TaskQueuePermission struct {
|
||||
// Maximum total concurrent workers across all queues. Default: 1
|
||||
MaxConcurrency int `json:"maxConcurrency,omitempty" yaml:"maxConcurrency,omitempty" mapstructure:"maxConcurrency,omitempty"`
|
||||
|
||||
// Explanation for why task queue access is needed
|
||||
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
func (j *TaskQueuePermission) UnmarshalJSON(value []byte) error {
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(value, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
type Plain TaskQueuePermission
|
||||
var plain Plain
|
||||
if err := json.Unmarshal(value, &plain); err != nil {
|
||||
return err
|
||||
}
|
||||
if v, ok := raw["maxConcurrency"]; !ok || v == nil {
|
||||
plain.MaxConcurrency = 1.0
|
||||
}
|
||||
if 1 > plain.MaxConcurrency {
|
||||
return fmt.Errorf("field %s: must be >= %v", "maxConcurrency", 1)
|
||||
}
|
||||
*j = TaskQueuePermission(plain)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Enable experimental WebAssembly threads support
|
||||
type ThreadsFeature struct {
|
||||
// Explanation for why threads support is needed
|
||||
|
||||
@@ -6,3 +6,10 @@ require (
|
||||
github.com/extism/go-pdk v1.1.3
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -43,6 +43,7 @@ The following host services are available:
|
||||
- Library: provides access to music library metadata for plugins.
|
||||
- Scheduler: provides task scheduling capabilities for plugins.
|
||||
- SubsonicAPI: provides access to Navidrome's Subsonic API from plugins.
|
||||
- TaskQueue: provides persistent task queues for plugins.
|
||||
- Users: provides access to user information for plugins.
|
||||
- WebSocket: provides WebSocket communication capabilities for plugins.
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
)
|
||||
|
||||
// HTTPRequest represents the HTTPRequest data structure.
|
||||
// HTTPRequest represents an outbound HTTP request from a plugin.
|
||||
type HTTPRequest struct {
|
||||
Method string `json:"method"`
|
||||
@@ -23,6 +24,7 @@ type HTTPRequest struct {
|
||||
TimeoutMs int32 `json:"timeoutMs"`
|
||||
}
|
||||
|
||||
// HTTPResponse represents the HTTPResponse data structure.
|
||||
// HTTPResponse represents the response from an outbound HTTP request.
|
||||
type HTTPResponse struct {
|
||||
StatusCode int32 `json:"statusCode"`
|
||||
@@ -35,11 +37,11 @@ type HTTPResponse struct {
|
||||
//go:wasmimport extism:host/user http_send
|
||||
func http_send(uint64) uint64
|
||||
|
||||
type httpSendRequest struct {
|
||||
type hTTPSendRequest struct {
|
||||
Request HTTPRequest `json:"request"`
|
||||
}
|
||||
|
||||
type httpSendResponse struct {
|
||||
type hTTPSendResponse struct {
|
||||
Result *HTTPResponse `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
@@ -55,7 +57,7 @@ type httpSendResponse struct {
|
||||
// Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error.
|
||||
func HTTPSend(request HTTPRequest) (*HTTPResponse, error) {
|
||||
// Marshal request to JSON
|
||||
req := httpSendRequest{
|
||||
req := hTTPSendRequest{
|
||||
Request: request,
|
||||
}
|
||||
reqBytes, err := json.Marshal(req)
|
||||
@@ -73,7 +75,7 @@ func HTTPSend(request HTTPRequest) (*HTTPResponse, error) {
|
||||
responseBytes := responseMem.ReadBytes()
|
||||
|
||||
// Parse the response
|
||||
var response httpSendResponse
|
||||
var response hTTPSendResponse
|
||||
if err := json.Unmarshal(responseBytes, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -10,6 +10,7 @@ package host
|
||||
|
||||
import "github.com/stretchr/testify/mock"
|
||||
|
||||
// HTTPRequest represents the HTTPRequest data structure.
|
||||
// HTTPRequest represents an outbound HTTP request from a plugin.
|
||||
type HTTPRequest struct {
|
||||
Method string `json:"method"`
|
||||
@@ -19,6 +20,7 @@ type HTTPRequest struct {
|
||||
TimeoutMs int32 `json:"timeoutMs"`
|
||||
}
|
||||
|
||||
// HTTPResponse represents the HTTPResponse data structure.
|
||||
// HTTPResponse represents the response from an outbound HTTP request.
|
||||
type HTTPResponse struct {
|
||||
StatusCode int32 `json:"statusCode"`
|
||||
219
plugins/pdk/go/host/nd_host_taskqueue.go
Normal file
219
plugins/pdk/go/host/nd_host_taskqueue.go
Normal file
@@ -0,0 +1,219 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the TaskQueue host service.
|
||||
// It is intended for use in Navidrome plugins built with TinyGo.
|
||||
//
|
||||
//go:build wasip1
|
||||
|
||||
package host
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
)
|
||||
|
||||
// QueueConfig represents the QueueConfig data structure.
|
||||
// QueueConfig holds configuration for a task queue.
|
||||
type QueueConfig struct {
|
||||
Concurrency int32 `json:"concurrency"`
|
||||
MaxRetries int32 `json:"maxRetries"`
|
||||
BackoffMs int64 `json:"backoffMs"`
|
||||
DelayMs int64 `json:"delayMs"`
|
||||
RetentionMs int64 `json:"retentionMs"`
|
||||
}
|
||||
|
||||
// taskqueue_createqueue is the host function provided by Navidrome.
|
||||
//
|
||||
//go:wasmimport extism:host/user taskqueue_createqueue
|
||||
func taskqueue_createqueue(uint64) uint64
|
||||
|
||||
// taskqueue_enqueue is the host function provided by Navidrome.
|
||||
//
|
||||
//go:wasmimport extism:host/user taskqueue_enqueue
|
||||
func taskqueue_enqueue(uint64) uint64
|
||||
|
||||
// taskqueue_gettaskstatus is the host function provided by Navidrome.
|
||||
//
|
||||
//go:wasmimport extism:host/user taskqueue_gettaskstatus
|
||||
func taskqueue_gettaskstatus(uint64) uint64
|
||||
|
||||
// taskqueue_canceltask is the host function provided by Navidrome.
|
||||
//
|
||||
//go:wasmimport extism:host/user taskqueue_canceltask
|
||||
func taskqueue_canceltask(uint64) uint64
|
||||
|
||||
type taskQueueCreateQueueRequest struct {
|
||||
Name string `json:"name"`
|
||||
Config QueueConfig `json:"config"`
|
||||
}
|
||||
|
||||
type taskQueueEnqueueRequest struct {
|
||||
QueueName string `json:"queueName"`
|
||||
Payload []byte `json:"payload"`
|
||||
}
|
||||
|
||||
type taskQueueEnqueueResponse struct {
|
||||
Result string `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type taskQueueGetTaskStatusRequest struct {
|
||||
TaskID string `json:"taskId"`
|
||||
}
|
||||
|
||||
type taskQueueGetTaskStatusResponse struct {
|
||||
Result string `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type taskQueueCancelTaskRequest struct {
|
||||
TaskID string `json:"taskId"`
|
||||
}
|
||||
|
||||
// TaskQueueCreateQueue calls the taskqueue_createqueue host function.
|
||||
// CreateQueue creates a named task queue with the given configuration.
|
||||
// Zero-value fields in config use sensible defaults.
|
||||
// If a queue with the same name already exists, returns an error.
|
||||
// On startup, this also recovers any stale "running" tasks from a previous crash.
|
||||
func TaskQueueCreateQueue(name string, config QueueConfig) error {
|
||||
// Marshal request to JSON
|
||||
req := taskQueueCreateQueueRequest{
|
||||
Name: name,
|
||||
Config: config,
|
||||
}
|
||||
reqBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reqMem := pdk.AllocateBytes(reqBytes)
|
||||
defer reqMem.Free()
|
||||
|
||||
// Call the host function
|
||||
responsePtr := taskqueue_createqueue(reqMem.Offset())
|
||||
|
||||
// Read the response from memory
|
||||
responseMem := pdk.FindMemory(responsePtr)
|
||||
responseBytes := responseMem.ReadBytes()
|
||||
|
||||
// Parse error-only response
|
||||
var response struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(responseBytes, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
if response.Error != "" {
|
||||
return errors.New(response.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TaskQueueEnqueue calls the taskqueue_enqueue host function.
|
||||
// Enqueue adds a task to the named queue. Returns the task ID.
|
||||
// payload is opaque bytes passed back to the plugin on execution.
|
||||
func TaskQueueEnqueue(queueName string, payload []byte) (string, error) {
|
||||
// Marshal request to JSON
|
||||
req := taskQueueEnqueueRequest{
|
||||
QueueName: queueName,
|
||||
Payload: payload,
|
||||
}
|
||||
reqBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
reqMem := pdk.AllocateBytes(reqBytes)
|
||||
defer reqMem.Free()
|
||||
|
||||
// Call the host function
|
||||
responsePtr := taskqueue_enqueue(reqMem.Offset())
|
||||
|
||||
// Read the response from memory
|
||||
responseMem := pdk.FindMemory(responsePtr)
|
||||
responseBytes := responseMem.ReadBytes()
|
||||
|
||||
// Parse the response
|
||||
var response taskQueueEnqueueResponse
|
||||
if err := json.Unmarshal(responseBytes, &response); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Convert Error field to Go error
|
||||
if response.Error != "" {
|
||||
return "", errors.New(response.Error)
|
||||
}
|
||||
|
||||
return response.Result, nil
|
||||
}
|
||||
|
||||
// TaskQueueGetTaskStatus calls the taskqueue_gettaskstatus host function.
|
||||
// GetTaskStatus returns the status of a task: "pending", "running",
|
||||
// "completed", "failed", or "cancelled".
|
||||
func TaskQueueGetTaskStatus(taskID string) (string, error) {
|
||||
// Marshal request to JSON
|
||||
req := taskQueueGetTaskStatusRequest{
|
||||
TaskID: taskID,
|
||||
}
|
||||
reqBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
reqMem := pdk.AllocateBytes(reqBytes)
|
||||
defer reqMem.Free()
|
||||
|
||||
// Call the host function
|
||||
responsePtr := taskqueue_gettaskstatus(reqMem.Offset())
|
||||
|
||||
// Read the response from memory
|
||||
responseMem := pdk.FindMemory(responsePtr)
|
||||
responseBytes := responseMem.ReadBytes()
|
||||
|
||||
// Parse the response
|
||||
var response taskQueueGetTaskStatusResponse
|
||||
if err := json.Unmarshal(responseBytes, &response); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Convert Error field to Go error
|
||||
if response.Error != "" {
|
||||
return "", errors.New(response.Error)
|
||||
}
|
||||
|
||||
return response.Result, nil
|
||||
}
|
||||
|
||||
// TaskQueueCancelTask calls the taskqueue_canceltask host function.
|
||||
// CancelTask cancels a pending task. Returns error if already
|
||||
// running, completed, or failed.
|
||||
func TaskQueueCancelTask(taskID string) error {
|
||||
// Marshal request to JSON
|
||||
req := taskQueueCancelTaskRequest{
|
||||
TaskID: taskID,
|
||||
}
|
||||
reqBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reqMem := pdk.AllocateBytes(reqBytes)
|
||||
defer reqMem.Free()
|
||||
|
||||
// Call the host function
|
||||
responsePtr := taskqueue_canceltask(reqMem.Offset())
|
||||
|
||||
// Read the response from memory
|
||||
responseMem := pdk.FindMemory(responsePtr)
|
||||
responseBytes := responseMem.ReadBytes()
|
||||
|
||||
// Parse error-only response
|
||||
var response struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(responseBytes, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
if response.Error != "" {
|
||||
return errors.New(response.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
84
plugins/pdk/go/host/nd_host_taskqueue_stub.go
Normal file
84
plugins/pdk/go/host/nd_host_taskqueue_stub.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains mock implementations for non-WASM builds.
|
||||
// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms.
|
||||
// Plugin authors can use the exported mock instances to set expectations in tests.
|
||||
//
|
||||
//go:build !wasip1
|
||||
|
||||
package host
|
||||
|
||||
import "github.com/stretchr/testify/mock"
|
||||
|
||||
// QueueConfig represents the QueueConfig data structure.
|
||||
// QueueConfig holds configuration for a task queue.
|
||||
type QueueConfig struct {
|
||||
Concurrency int32 `json:"concurrency"`
|
||||
MaxRetries int32 `json:"maxRetries"`
|
||||
BackoffMs int64 `json:"backoffMs"`
|
||||
DelayMs int64 `json:"delayMs"`
|
||||
RetentionMs int64 `json:"retentionMs"`
|
||||
}
|
||||
|
||||
// mockTaskQueueService is the mock implementation for testing.
|
||||
type mockTaskQueueService struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// TaskQueueMock is the auto-instantiated mock instance for testing.
|
||||
// Use this to set expectations: host.TaskQueueMock.On("MethodName", args...).Return(values...)
|
||||
var TaskQueueMock = &mockTaskQueueService{}
|
||||
|
||||
// CreateQueue is the mock method for TaskQueueCreateQueue.
|
||||
func (m *mockTaskQueueService) CreateQueue(name string, config QueueConfig) error {
|
||||
args := m.Called(name, config)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// TaskQueueCreateQueue delegates to the mock instance.
|
||||
// CreateQueue creates a named task queue with the given configuration.
|
||||
// Zero-value fields in config use sensible defaults.
|
||||
// If a queue with the same name already exists, returns an error.
|
||||
// On startup, this also recovers any stale "running" tasks from a previous crash.
|
||||
func TaskQueueCreateQueue(name string, config QueueConfig) error {
|
||||
return TaskQueueMock.CreateQueue(name, config)
|
||||
}
|
||||
|
||||
// Enqueue is the mock method for TaskQueueEnqueue.
|
||||
func (m *mockTaskQueueService) Enqueue(queueName string, payload []byte) (string, error) {
|
||||
args := m.Called(queueName, payload)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
// TaskQueueEnqueue delegates to the mock instance.
|
||||
// Enqueue adds a task to the named queue. Returns the task ID.
|
||||
// payload is opaque bytes passed back to the plugin on execution.
|
||||
func TaskQueueEnqueue(queueName string, payload []byte) (string, error) {
|
||||
return TaskQueueMock.Enqueue(queueName, payload)
|
||||
}
|
||||
|
||||
// GetTaskStatus is the mock method for TaskQueueGetTaskStatus.
|
||||
func (m *mockTaskQueueService) GetTaskStatus(taskID string) (string, error) {
|
||||
args := m.Called(taskID)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
// TaskQueueGetTaskStatus delegates to the mock instance.
|
||||
// GetTaskStatus returns the status of a task: "pending", "running",
|
||||
// "completed", "failed", or "cancelled".
|
||||
func TaskQueueGetTaskStatus(taskID string) (string, error) {
|
||||
return TaskQueueMock.GetTaskStatus(taskID)
|
||||
}
|
||||
|
||||
// CancelTask is the mock method for TaskQueueCancelTask.
|
||||
func (m *mockTaskQueueService) CancelTask(taskID string) error {
|
||||
args := m.Called(taskID)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// TaskQueueCancelTask delegates to the mock instance.
|
||||
// CancelTask cancels a pending task. Returns error if already
|
||||
// running, completed, or failed.
|
||||
func TaskQueueCancelTask(taskID string) error {
|
||||
return TaskQueueMock.CancelTask(taskID)
|
||||
}
|
||||
86
plugins/pdk/go/taskworker/taskworker.go
Normal file
86
plugins/pdk/go/taskworker/taskworker.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains export wrappers for the TaskWorker capability.
|
||||
// It is intended for use in Navidrome plugins built with TinyGo.
|
||||
//
|
||||
//go:build wasip1
|
||||
|
||||
package taskworker
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
)
|
||||
|
||||
// TaskExecuteRequest is the request provided when a task is ready to execute.
|
||||
type TaskExecuteRequest struct {
|
||||
// QueueName is the name of the queue this task belongs to.
|
||||
QueueName string `json:"queueName"`
|
||||
// TaskID is the unique identifier for this task.
|
||||
TaskID string `json:"taskId"`
|
||||
// Payload is the opaque data provided when the task was enqueued.
|
||||
Payload []byte `json:"payload"`
|
||||
// Attempt is the current attempt number (1-based: first attempt = 1).
|
||||
Attempt int32 `json:"attempt"`
|
||||
}
|
||||
|
||||
// TaskExecuteResponse is the response from task execution.
|
||||
type TaskExecuteResponse struct {
|
||||
// Error, if non-empty, indicates the task failed. The task will be retried
|
||||
// if retries are configured and attempts remain.
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// TaskWorker is the marker interface for taskworker plugins.
|
||||
// Implement one or more of the provider interfaces below.
|
||||
// TaskWorker provides task execution handling.
|
||||
// This capability allows plugins to receive callbacks when their queued tasks
|
||||
// are ready to execute. Plugins that use the taskqueue host service must
|
||||
// implement this capability.
|
||||
type TaskWorker interface{}
|
||||
|
||||
// TaskExecuteProvider provides the OnTaskExecute function.
|
||||
type TaskExecuteProvider interface {
|
||||
OnTaskExecute(TaskExecuteRequest) (TaskExecuteResponse, error)
|
||||
} // Internal implementation holders
|
||||
var (
|
||||
taskExecuteImpl func(TaskExecuteRequest) (TaskExecuteResponse, error)
|
||||
)
|
||||
|
||||
// Register registers a taskworker implementation.
|
||||
// The implementation is checked for optional provider interfaces.
|
||||
func Register(impl TaskWorker) {
|
||||
if p, ok := impl.(TaskExecuteProvider); ok {
|
||||
taskExecuteImpl = p.OnTaskExecute
|
||||
}
|
||||
}
|
||||
|
||||
// NotImplementedCode is the standard return code for unimplemented functions.
|
||||
// The host recognizes this and skips the plugin gracefully.
|
||||
const NotImplementedCode int32 = -2
|
||||
|
||||
//go:wasmexport nd_task_execute
|
||||
func _NdTaskExecute() int32 {
|
||||
if taskExecuteImpl == nil {
|
||||
// Return standard code - host will skip this plugin gracefully
|
||||
return NotImplementedCode
|
||||
}
|
||||
|
||||
var input TaskExecuteRequest
|
||||
if err := pdk.InputJSON(&input); err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
|
||||
output, err := taskExecuteImpl(input)
|
||||
if err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
|
||||
if err := pdk.OutputJSON(output); err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
48
plugins/pdk/go/taskworker/taskworker_stub.go
Normal file
48
plugins/pdk/go/taskworker/taskworker_stub.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file provides stub implementations for non-WASM platforms.
|
||||
// It allows Go plugins to compile and run tests outside of WASM,
|
||||
// but the actual functionality is only available in WASM builds.
|
||||
//
|
||||
//go:build !wasip1
|
||||
|
||||
package taskworker
|
||||
|
||||
// TaskExecuteRequest is the request provided when a task is ready to execute.
|
||||
type TaskExecuteRequest struct {
|
||||
// QueueName is the name of the queue this task belongs to.
|
||||
QueueName string `json:"queueName"`
|
||||
// TaskID is the unique identifier for this task.
|
||||
TaskID string `json:"taskId"`
|
||||
// Payload is the opaque data provided when the task was enqueued.
|
||||
Payload []byte `json:"payload"`
|
||||
// Attempt is the current attempt number (1-based: first attempt = 1).
|
||||
Attempt int32 `json:"attempt"`
|
||||
}
|
||||
|
||||
// TaskExecuteResponse is the response from task execution.
|
||||
type TaskExecuteResponse struct {
|
||||
// Error, if non-empty, indicates the task failed. The task will be retried
|
||||
// if retries are configured and attempts remain.
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// TaskWorker is the marker interface for taskworker plugins.
|
||||
// Implement one or more of the provider interfaces below.
|
||||
// TaskWorker provides task execution handling.
|
||||
// This capability allows plugins to receive callbacks when their queued tasks
|
||||
// are ready to execute. Plugins that use the taskqueue host service must
|
||||
// implement this capability.
|
||||
type TaskWorker interface{}
|
||||
|
||||
// TaskExecuteProvider provides the OnTaskExecute function.
|
||||
type TaskExecuteProvider interface {
|
||||
OnTaskExecute(TaskExecuteRequest) (TaskExecuteResponse, error)
|
||||
}
|
||||
|
||||
// NotImplementedCode is the standard return code for unimplemented functions.
|
||||
const NotImplementedCode int32 = -2
|
||||
|
||||
// Register is a no-op on non-WASM platforms.
|
||||
// This stub allows code to compile outside of WASM.
|
||||
func Register(_ TaskWorker) {}
|
||||
59
plugins/pdk/python/host/nd_host_http.py
Normal file
59
plugins/pdk/python/host/nd_host_http.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# Code generated by ndpgen. DO NOT EDIT.
|
||||
#
|
||||
# This file contains client wrappers for the HTTP host service.
|
||||
# It is intended for use in Navidrome plugins built with extism-py.
|
||||
#
|
||||
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
|
||||
# The @extism.import_fn decorators are only detected when defined in the plugin's
|
||||
# main __init__.py file. Copy the needed functions from this file into your plugin.
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import extism
|
||||
import json
|
||||
|
||||
|
||||
class HostFunctionError(Exception):
|
||||
"""Raised when a host function returns an error."""
|
||||
pass
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "http_send")
|
||||
def _http_send(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
def http_send(request: Any) -> Any:
|
||||
"""Send executes an HTTP request and returns the response.
|
||||
|
||||
Parameters:
|
||||
- request: The HTTP request to execute, including method, URL, headers, body, and timeout
|
||||
|
||||
Returns the HTTP response with status code, headers, and body.
|
||||
Network errors, timeouts, and permission failures are returned as Go errors.
|
||||
Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error.
|
||||
|
||||
Args:
|
||||
request: Any parameter.
|
||||
|
||||
Returns:
|
||||
Any: The result value.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"request": request,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _http_send(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return response.get("result", None)
|
||||
153
plugins/pdk/python/host/nd_host_taskqueue.py
Normal file
153
plugins/pdk/python/host/nd_host_taskqueue.py
Normal file
@@ -0,0 +1,153 @@
|
||||
# Code generated by ndpgen. DO NOT EDIT.
|
||||
#
|
||||
# This file contains client wrappers for the TaskQueue host service.
|
||||
# It is intended for use in Navidrome plugins built with extism-py.
|
||||
#
|
||||
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
|
||||
# The @extism.import_fn decorators are only detected when defined in the plugin's
|
||||
# main __init__.py file. Copy the needed functions from this file into your plugin.
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import extism
|
||||
import json
|
||||
|
||||
|
||||
class HostFunctionError(Exception):
|
||||
"""Raised when a host function returns an error."""
|
||||
pass
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "taskqueue_createqueue")
|
||||
def _taskqueue_createqueue(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "taskqueue_enqueue")
|
||||
def _taskqueue_enqueue(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "taskqueue_gettaskstatus")
|
||||
def _taskqueue_gettaskstatus(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "taskqueue_canceltask")
|
||||
def _taskqueue_canceltask(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
def taskqueue_create_queue(name: str, config: Any) -> None:
|
||||
"""CreateQueue creates a named task queue with the given configuration.
|
||||
Zero-value fields in config use sensible defaults.
|
||||
If a queue with the same name already exists, returns an error.
|
||||
On startup, this also recovers any stale "running" tasks from a previous crash.
|
||||
|
||||
Args:
|
||||
name: str parameter.
|
||||
config: Any parameter.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"name": name,
|
||||
"config": config,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _taskqueue_createqueue(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
|
||||
|
||||
def taskqueue_enqueue(queue_name: str, payload: bytes) -> str:
|
||||
"""Enqueue adds a task to the named queue. Returns the task ID.
|
||||
payload is opaque bytes passed back to the plugin on execution.
|
||||
|
||||
Args:
|
||||
queue_name: str parameter.
|
||||
payload: bytes parameter.
|
||||
|
||||
Returns:
|
||||
str: The result value.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"queueName": queue_name,
|
||||
"payload": payload,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _taskqueue_enqueue(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return response.get("result", "")
|
||||
|
||||
|
||||
def taskqueue_get_task_status(task_id: str) -> str:
|
||||
"""GetTaskStatus returns the status of a task: "pending", "running",
|
||||
"completed", "failed", or "cancelled".
|
||||
|
||||
Args:
|
||||
task_id: str parameter.
|
||||
|
||||
Returns:
|
||||
str: The result value.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"taskId": task_id,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _taskqueue_gettaskstatus(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return response.get("result", "")
|
||||
|
||||
|
||||
def taskqueue_cancel_task(task_id: str) -> None:
|
||||
"""CancelTask cancels a pending task. Returns error if already
|
||||
running, completed, or failed.
|
||||
|
||||
Args:
|
||||
task_id: str parameter.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"taskId": task_id,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _taskqueue_canceltask(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
@@ -9,4 +9,5 @@ pub mod lifecycle;
|
||||
pub mod metadata;
|
||||
pub mod scheduler;
|
||||
pub mod scrobbler;
|
||||
pub mod taskworker;
|
||||
pub mod websocket;
|
||||
|
||||
87
plugins/pdk/rust/nd-pdk-capabilities/src/taskworker.rs
Normal file
87
plugins/pdk/rust/nd-pdk-capabilities/src/taskworker.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains export wrappers for the TaskWorker capability.
|
||||
// It is intended for use in Navidrome plugins built with extism-pdk.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// Helper functions for skip_serializing_if with numeric types
|
||||
#[allow(dead_code)]
|
||||
fn is_zero_i32(value: &i32) -> bool { *value == 0 }
|
||||
#[allow(dead_code)]
|
||||
fn is_zero_u32(value: &u32) -> bool { *value == 0 }
|
||||
#[allow(dead_code)]
|
||||
fn is_zero_i64(value: &i64) -> bool { *value == 0 }
|
||||
#[allow(dead_code)]
|
||||
fn is_zero_u64(value: &u64) -> bool { *value == 0 }
|
||||
#[allow(dead_code)]
|
||||
fn is_zero_f32(value: &f32) -> bool { *value == 0.0 }
|
||||
#[allow(dead_code)]
|
||||
fn is_zero_f64(value: &f64) -> bool { *value == 0.0 }
|
||||
/// TaskExecuteRequest is the request provided when a task is ready to execute.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TaskExecuteRequest {
|
||||
/// QueueName is the name of the queue this task belongs to.
|
||||
#[serde(default)]
|
||||
pub queue_name: String,
|
||||
/// TaskID is the unique identifier for this task.
|
||||
#[serde(default)]
|
||||
pub task_id: String,
|
||||
/// Payload is the opaque data provided when the task was enqueued.
|
||||
#[serde(default)]
|
||||
pub payload: Vec<u8>,
|
||||
/// Attempt is the current attempt number (1-based: first attempt = 1).
|
||||
#[serde(default)]
|
||||
pub attempt: i32,
|
||||
}
|
||||
/// TaskExecuteResponse is the response from task execution.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TaskExecuteResponse {
|
||||
/// Error, if non-empty, indicates the task failed. The task will be retried
|
||||
/// if retries are configured and attempts remain.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
/// Error represents an error from a capability method.
|
||||
#[derive(Debug)]
|
||||
pub struct Error {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
impl Error {
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self { message: message.into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// TaskExecuteProvider provides the OnTaskExecute function.
|
||||
pub trait TaskExecuteProvider {
|
||||
fn on_task_execute(&self, req: TaskExecuteRequest) -> Result<TaskExecuteResponse, Error>;
|
||||
}
|
||||
|
||||
/// Register the on_task_execute export.
|
||||
/// This macro generates the WASM export function for this method.
|
||||
#[macro_export]
|
||||
macro_rules! register_taskworker_task_execute {
|
||||
($plugin_type:ty) => {
|
||||
#[extism_pdk::plugin_fn]
|
||||
pub fn nd_task_execute(
|
||||
req: extism_pdk::Json<$crate::taskworker::TaskExecuteRequest>
|
||||
) -> extism_pdk::FnResult<extism_pdk::Json<$crate::taskworker::TaskExecuteResponse>> {
|
||||
let plugin = <$plugin_type>::default();
|
||||
let result = $crate::taskworker::TaskExecuteProvider::on_task_execute(&plugin, req.into_inner())?;
|
||||
Ok(extism_pdk::Json(result))
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -40,6 +40,7 @@
|
||||
//! - [`library`] - provides access to music library metadata for plugins.
|
||||
//! - [`scheduler`] - provides task scheduling capabilities for plugins.
|
||||
//! - [`subsonicapi`] - provides access to Navidrome's Subsonic API from plugins.
|
||||
//! - [`taskqueue`] - provides persistent task queues for plugins.
|
||||
//! - [`users`] - provides access to user information for plugins.
|
||||
//! - [`websocket`] - provides WebSocket communication capabilities for plugins.
|
||||
|
||||
@@ -99,6 +100,13 @@ pub mod subsonicapi {
|
||||
pub use super::nd_host_subsonicapi::*;
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
mod nd_host_taskqueue;
|
||||
/// provides persistent task queues for plugins.
|
||||
pub mod taskqueue {
|
||||
pub use super::nd_host_taskqueue::*;
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
mod nd_host_users;
|
||||
/// provides access to user information for plugins.
|
||||
|
||||
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
|
||||
/// HTTPRequest represents an outbound HTTP request from a plugin.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HttpRequest {
|
||||
pub struct HTTPRequest {
|
||||
pub method: String,
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
@@ -23,7 +23,7 @@ pub struct HttpRequest {
|
||||
/// HTTPResponse represents the response from an outbound HTTP request.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HttpResponse {
|
||||
pub struct HTTPResponse {
|
||||
pub status_code: i32,
|
||||
#[serde(default)]
|
||||
pub headers: std::collections::HashMap<String, String>,
|
||||
@@ -34,14 +34,14 @@ pub struct HttpResponse {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct HTTPSendRequest {
|
||||
request: HttpRequest,
|
||||
request: HTTPRequest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct HTTPSendResponse {
|
||||
#[serde(default)]
|
||||
result: Option<HttpResponse>,
|
||||
result: Option<HTTPResponse>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
@@ -52,23 +52,23 @@ extern "ExtismHost" {
|
||||
}
|
||||
|
||||
/// Send executes an HTTP request and returns the response.
|
||||
///
|
||||
///
|
||||
/// Parameters:
|
||||
/// - request: The HTTP request to execute, including method, URL, headers, body, and timeout
|
||||
///
|
||||
///
|
||||
/// Returns the HTTP response with status code, headers, and body.
|
||||
/// Network errors, timeouts, and permission failures are returned as errors.
|
||||
/// Network errors, timeouts, and permission failures are returned as Go errors.
|
||||
/// Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request` - HttpRequest parameter.
|
||||
/// * `request` - HTTPRequest parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The result value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn send(request: HttpRequest) -> Result<Option<HttpResponse>, Error> {
|
||||
pub fn send(request: HTTPRequest) -> Result<Option<HTTPResponse>, Error> {
|
||||
let response = unsafe {
|
||||
http_send(Json(HTTPSendRequest {
|
||||
request: request,
|
||||
|
||||
184
plugins/pdk/rust/nd-pdk-host/src/nd_host_taskqueue.rs
Normal file
184
plugins/pdk/rust/nd-pdk-host/src/nd_host_taskqueue.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the TaskQueue host service.
|
||||
// It is intended for use in Navidrome plugins built with extism-pdk.
|
||||
|
||||
use extism_pdk::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// QueueConfig holds configuration for a task queue.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QueueConfig {
|
||||
pub concurrency: i32,
|
||||
pub max_retries: i32,
|
||||
pub backoff_ms: i64,
|
||||
pub delay_ms: i64,
|
||||
pub retention_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TaskQueueCreateQueueRequest {
|
||||
name: String,
|
||||
config: QueueConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TaskQueueCreateQueueResponse {
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TaskQueueEnqueueRequest {
|
||||
queue_name: String,
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TaskQueueEnqueueResponse {
|
||||
#[serde(default)]
|
||||
result: String,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TaskQueueGetTaskStatusRequest {
|
||||
task_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TaskQueueGetTaskStatusResponse {
|
||||
#[serde(default)]
|
||||
result: String,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TaskQueueCancelTaskRequest {
|
||||
task_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TaskQueueCancelTaskResponse {
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
fn taskqueue_createqueue(input: Json<TaskQueueCreateQueueRequest>) -> Json<TaskQueueCreateQueueResponse>;
|
||||
fn taskqueue_enqueue(input: Json<TaskQueueEnqueueRequest>) -> Json<TaskQueueEnqueueResponse>;
|
||||
fn taskqueue_gettaskstatus(input: Json<TaskQueueGetTaskStatusRequest>) -> Json<TaskQueueGetTaskStatusResponse>;
|
||||
fn taskqueue_canceltask(input: Json<TaskQueueCancelTaskRequest>) -> Json<TaskQueueCancelTaskResponse>;
|
||||
}
|
||||
|
||||
/// CreateQueue creates a named task queue with the given configuration.
|
||||
/// Zero-value fields in config use sensible defaults.
|
||||
/// If a queue with the same name already exists, returns an error.
|
||||
/// On startup, this also recovers any stale "running" tasks from a previous crash.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `name` - String parameter.
|
||||
/// * `config` - QueueConfig parameter.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn create_queue(name: &str, config: QueueConfig) -> Result<(), Error> {
|
||||
let response = unsafe {
|
||||
taskqueue_createqueue(Json(TaskQueueCreateQueueRequest {
|
||||
name: name.to_owned(),
|
||||
config: config,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enqueue adds a task to the named queue. Returns the task ID.
|
||||
/// payload is opaque bytes passed back to the plugin on execution.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `queue_name` - String parameter.
|
||||
/// * `payload` - Vec<u8> parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The result value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn enqueue(queue_name: &str, payload: Vec<u8>) -> Result<String, Error> {
|
||||
let response = unsafe {
|
||||
taskqueue_enqueue(Json(TaskQueueEnqueueRequest {
|
||||
queue_name: queue_name.to_owned(),
|
||||
payload: payload,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.result)
|
||||
}
|
||||
|
||||
/// GetTaskStatus returns the status of a task: "pending", "running",
|
||||
/// "completed", "failed", or "cancelled".
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `task_id` - String parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The result value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn get_task_status(task_id: &str) -> Result<String, Error> {
|
||||
let response = unsafe {
|
||||
taskqueue_gettaskstatus(Json(TaskQueueGetTaskStatusRequest {
|
||||
task_id: task_id.to_owned(),
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.result)
|
||||
}
|
||||
|
||||
/// CancelTask cancels a pending task. Returns error if already
|
||||
/// running, completed, or failed.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `task_id` - String parameter.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn cancel_task(task_id: &str) -> Result<(), Error> {
|
||||
let response = unsafe {
|
||||
taskqueue_canceltask(Json(TaskQueueCancelTaskRequest {
|
||||
task_id: task_id.to_owned(),
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
16
plugins/testdata/test-taskqueue/go.mod
vendored
Normal file
16
plugins/testdata/test-taskqueue/go.mod
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
module test-taskqueue
|
||||
|
||||
go 1.25
|
||||
|
||||
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/extism/go-pdk v1.1.3 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go
|
||||
14
plugins/testdata/test-taskqueue/go.sum
vendored
Normal file
14
plugins/testdata/test-taskqueue/go.sum
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
|
||||
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
100
plugins/testdata/test-taskqueue/main.go
vendored
Normal file
100
plugins/testdata/test-taskqueue/main.go
vendored
Normal file
@@ -0,0 +1,100 @@
|
||||
// Test TaskQueue plugin for Navidrome plugin system integration tests.
|
||||
// Build with: tinygo build -o ../test-taskqueue.wasm -target wasip1 -buildmode=c-shared .
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/taskworker"
|
||||
)
|
||||
|
||||
func init() {
|
||||
taskworker.Register(&handler{})
|
||||
}
|
||||
|
||||
type handler struct{}
|
||||
|
||||
func (h *handler) OnTaskExecute(req taskworker.TaskExecuteRequest) (taskworker.TaskExecuteResponse, error) {
|
||||
payload := string(req.Payload)
|
||||
if payload == "fail" {
|
||||
return taskworker.TaskExecuteResponse{Error: "task failed as instructed"}, nil
|
||||
}
|
||||
if payload == "fail-then-succeed" && req.Attempt < 2 {
|
||||
return taskworker.TaskExecuteResponse{Error: "transient failure"}, nil
|
||||
}
|
||||
return taskworker.TaskExecuteResponse{}, nil
|
||||
}
|
||||
|
||||
// Test helper types
|
||||
type TestInput struct {
|
||||
Operation string `json:"operation"`
|
||||
QueueName string `json:"queueName,omitempty"`
|
||||
Config *host.QueueConfig `json:"config,omitempty"`
|
||||
Payload []byte `json:"payload,omitempty"`
|
||||
TaskID string `json:"taskId,omitempty"`
|
||||
}
|
||||
|
||||
type TestOutput struct {
|
||||
TaskID string `json:"taskId,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Error *string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
//go:wasmexport nd_test_taskqueue
|
||||
func ndTestTaskQueue() int32 {
|
||||
var input TestInput
|
||||
if err := pdk.InputJSON(&input); err != nil {
|
||||
errStr := err.Error()
|
||||
pdk.OutputJSON(TestOutput{Error: &errStr})
|
||||
return 0
|
||||
}
|
||||
|
||||
switch input.Operation {
|
||||
case "create_queue":
|
||||
config := host.QueueConfig{}
|
||||
if input.Config != nil {
|
||||
config = *input.Config
|
||||
}
|
||||
err := host.TaskQueueCreateQueue(input.QueueName, config)
|
||||
if err != nil {
|
||||
errStr := err.Error()
|
||||
pdk.OutputJSON(TestOutput{Error: &errStr})
|
||||
return 0
|
||||
}
|
||||
pdk.OutputJSON(TestOutput{})
|
||||
|
||||
case "enqueue":
|
||||
taskID, err := host.TaskQueueEnqueue(input.QueueName, input.Payload)
|
||||
if err != nil {
|
||||
errStr := err.Error()
|
||||
pdk.OutputJSON(TestOutput{Error: &errStr})
|
||||
return 0
|
||||
}
|
||||
pdk.OutputJSON(TestOutput{TaskID: taskID})
|
||||
|
||||
case "get_task_status":
|
||||
status, err := host.TaskQueueGetTaskStatus(input.TaskID)
|
||||
if err != nil {
|
||||
errStr := err.Error()
|
||||
pdk.OutputJSON(TestOutput{Error: &errStr})
|
||||
return 0
|
||||
}
|
||||
pdk.OutputJSON(TestOutput{Status: status})
|
||||
|
||||
case "cancel_task":
|
||||
err := host.TaskQueueCancelTask(input.TaskID)
|
||||
if err != nil {
|
||||
errStr := err.Error()
|
||||
pdk.OutputJSON(TestOutput{Error: &errStr})
|
||||
return 0
|
||||
}
|
||||
pdk.OutputJSON(TestOutput{})
|
||||
|
||||
default:
|
||||
errStr := "unknown operation: " + input.Operation
|
||||
pdk.OutputJSON(TestOutput{Error: &errStr})
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func main() {}
|
||||
12
plugins/testdata/test-taskqueue/manifest.json
vendored
Normal file
12
plugins/testdata/test-taskqueue/manifest.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "Test TaskQueue Plugin",
|
||||
"author": "Navidrome Test",
|
||||
"version": "1.0.0",
|
||||
"description": "A test plugin for TaskQueue integration testing",
|
||||
"permissions": {
|
||||
"taskqueue": {
|
||||
"reason": "For testing task queue operations",
|
||||
"maxConcurrency": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,13 +31,13 @@
|
||||
"mood": "Настроение",
|
||||
"participants": "Допълнителни участници",
|
||||
"tags": "Допълнителни етикети",
|
||||
"mappedTags": "Картирани тагове",
|
||||
"rawTags": "Сурови тагове",
|
||||
"mappedTags": "",
|
||||
"rawTags": "",
|
||||
"bitDepth": "Битова дълбочина",
|
||||
"sampleRate": "Честота на семплиране",
|
||||
"sampleRate": "",
|
||||
"missing": "Липсва",
|
||||
"libraryName": "Библиотека",
|
||||
"composer": "Композитор"
|
||||
"libraryName": "",
|
||||
"composer": ""
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Пусни по-късно",
|
||||
@@ -47,8 +47,8 @@
|
||||
"download": "Свали",
|
||||
"playNext": "Следваща",
|
||||
"info": "Информация",
|
||||
"showInPlaylist": "Показване в плейлиста",
|
||||
"instantMix": "Незабавен микс"
|
||||
"showInPlaylist": "",
|
||||
"instantMix": ""
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -80,7 +80,7 @@
|
||||
"mood": "Настроение",
|
||||
"date": "Дата на запис",
|
||||
"missing": "Липсва",
|
||||
"libraryName": "Библиотека"
|
||||
"libraryName": ""
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Пусни",
|
||||
@@ -129,12 +129,12 @@
|
||||
"remixer": "Ремиксер |||| Ремиксери",
|
||||
"djmixer": "DJ миксер |||| DJ миксери",
|
||||
"performer": "Изпълнител |||| Изпълнители",
|
||||
"maincredit": "Изпълнител на албума или изпълнител |||| Изпълнители на албума или изпълнители"
|
||||
"maincredit": ""
|
||||
},
|
||||
"actions": {
|
||||
"shuffle": "Разбъркване",
|
||||
"radio": "Радио",
|
||||
"topSongs": "Топ песни"
|
||||
"shuffle": "",
|
||||
"radio": "",
|
||||
"topSongs": ""
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -152,11 +152,11 @@
|
||||
"newPassword": "Нова парола",
|
||||
"token": "Токен",
|
||||
"lastAccessAt": "Последен достъп",
|
||||
"libraries": "Библиотеки"
|
||||
"libraries": ""
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Промените в името ще бъдат отразени при следващото влизане",
|
||||
"libraries": "Изберете конкретни библиотеки за този потребител или оставете празно, за да използвате библиотеки по подразбиране"
|
||||
"libraries": ""
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Потребителят е създаден",
|
||||
@@ -166,11 +166,11 @@
|
||||
"message": {
|
||||
"listenBrainzToken": "Въведете Вашия токен за ListenBrainz.",
|
||||
"clickHereForToken": "Кликнете тук, за да получите Вашия токен",
|
||||
"selectAllLibraries": "Изберете всички библиотеки",
|
||||
"adminAutoLibraries": "Администраторите автоматично получават достъп до всички библиотеки"
|
||||
"selectAllLibraries": "",
|
||||
"adminAutoLibraries": ""
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "Трябва да бъде избрана поне една библиотека за потребители без администраторски права"
|
||||
"librariesRequired": ""
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -215,16 +215,16 @@
|
||||
"export": "Експорт",
|
||||
"makePublic": "Направи публичен",
|
||||
"makePrivate": "Направи личен",
|
||||
"saveQueue": "Запазване на опашката в плейлист",
|
||||
"searchOrCreate": "Търсете в плейлисти или пишете, за да създадете нови...",
|
||||
"pressEnterToCreate": "Натиснете Enter, за да създадете нов плейлист",
|
||||
"removeFromSelection": "Премахване от селекцията"
|
||||
"saveQueue": "",
|
||||
"searchOrCreate": "",
|
||||
"pressEnterToCreate": "",
|
||||
"removeFromSelection": ""
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Добави дублирани песни",
|
||||
"song_exist": "Към плейлиста се добавят дублиращи. Желаете ли да ги добавите или предпочитате да ги пропуснете?",
|
||||
"noPlaylistsFound": "Няма намерени плейлисти",
|
||||
"noPlaylists": "Няма налични плейлисти"
|
||||
"noPlaylistsFound": "",
|
||||
"noPlaylists": ""
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
@@ -263,7 +263,7 @@
|
||||
"path": "Път",
|
||||
"size": "Размер",
|
||||
"updatedAt": "Изчезнал на",
|
||||
"libraryName": "Библиотека"
|
||||
"libraryName": ""
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Премахни",
|
||||
@@ -275,134 +275,134 @@
|
||||
"empty": "Няма липсващи файлове"
|
||||
},
|
||||
"library": {
|
||||
"name": "Библиотека |||| Библиотеки",
|
||||
"name": "",
|
||||
"fields": {
|
||||
"name": "Име",
|
||||
"path": "Път",
|
||||
"remotePath": "Отдалечен път",
|
||||
"lastScanAt": "Последно сканиране",
|
||||
"songCount": "Песни",
|
||||
"albumCount": "Албуми",
|
||||
"artistCount": "Изпълнители",
|
||||
"totalSongs": "Песни",
|
||||
"totalAlbums": "Албуми",
|
||||
"totalArtists": "Изпълнители",
|
||||
"totalFolders": "Папки",
|
||||
"totalFiles": "Файлове",
|
||||
"totalMissingFiles": "Липсващи файлове",
|
||||
"totalSize": "Общ размер",
|
||||
"totalDuration": "Продължителност",
|
||||
"defaultNewUsers": "По подразбиране за нови потребители",
|
||||
"createdAt": "Създаден",
|
||||
"updatedAt": "Актуализиран"
|
||||
"name": "",
|
||||
"path": "",
|
||||
"remotePath": "",
|
||||
"lastScanAt": "",
|
||||
"songCount": "",
|
||||
"albumCount": "",
|
||||
"artistCount": "",
|
||||
"totalSongs": "",
|
||||
"totalAlbums": "",
|
||||
"totalArtists": "",
|
||||
"totalFolders": "",
|
||||
"totalFiles": "",
|
||||
"totalMissingFiles": "",
|
||||
"totalSize": "",
|
||||
"totalDuration": "",
|
||||
"defaultNewUsers": "",
|
||||
"createdAt": "",
|
||||
"updatedAt": ""
|
||||
},
|
||||
"sections": {
|
||||
"basic": "Основна информация",
|
||||
"statistics": "Статистика"
|
||||
"basic": "",
|
||||
"statistics": ""
|
||||
},
|
||||
"actions": {
|
||||
"scan": "Сканирай библиотеката",
|
||||
"manageUsers": "Управление на потребителския достъп",
|
||||
"viewDetails": "Преглед на подробности",
|
||||
"scan": "",
|
||||
"manageUsers": "",
|
||||
"viewDetails": "",
|
||||
"quickScan": "Quick Scan",
|
||||
"fullScan": "Пълно сканиране"
|
||||
"fullScan": ""
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Библиотеката е създадена успешно",
|
||||
"updated": "Библиотеката е актуализирана успешно",
|
||||
"deleted": "Библиотеката е изтрита успешно",
|
||||
"scanStarted": "Сканирането на библиотеката започна",
|
||||
"scanCompleted": "Сканирането на библиотеката е завършено",
|
||||
"quickScanStarted": "Бързото сканиране започна",
|
||||
"fullScanStarted": "Пълното сканиране започна",
|
||||
"scanError": "Грешка при стартиране на сканирането. Проверете лог файловете"
|
||||
"created": "",
|
||||
"updated": "",
|
||||
"deleted": "",
|
||||
"scanStarted": "",
|
||||
"scanCompleted": "",
|
||||
"quickScanStarted": "",
|
||||
"fullScanStarted": "",
|
||||
"scanError": ""
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Името на библиотеката е задължително",
|
||||
"pathRequired": "Пътят към библиотеката е задължителен",
|
||||
"pathNotDirectory": "Пътят до библиотеката трябва да е директория",
|
||||
"pathNotFound": "Пътят към библиотеката не е намерен",
|
||||
"pathNotAccessible": "Пътят до библиотеката не е достъпен",
|
||||
"pathInvalid": "Невалиден път към библиотеката"
|
||||
"nameRequired": "",
|
||||
"pathRequired": "",
|
||||
"pathNotDirectory": "",
|
||||
"pathNotFound": "",
|
||||
"pathNotAccessible": "",
|
||||
"pathInvalid": ""
|
||||
},
|
||||
"messages": {
|
||||
"deleteConfirm": "Сигурни ли сте, че желаете да изтриете тази библиотека? Това ще премахне всички свързани данни и потребителски достъп.",
|
||||
"scanInProgress": "Сканирането е в ход...",
|
||||
"noLibrariesAssigned": "Няма библиотеки, присвоени на този потребител"
|
||||
"deleteConfirm": "",
|
||||
"scanInProgress": "",
|
||||
"noLibrariesAssigned": ""
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "Плъгин |||| Плъгини",
|
||||
"name": "",
|
||||
"fields": {
|
||||
"id": "ID номер",
|
||||
"name": "Име",
|
||||
"description": "Описание",
|
||||
"version": "Версия",
|
||||
"author": "Автор",
|
||||
"website": "Уебсайт",
|
||||
"permissions": "Разрешения",
|
||||
"enabled": "Активирано",
|
||||
"status": "Статус",
|
||||
"path": "Път",
|
||||
"lastError": "Грешка",
|
||||
"hasError": "Грешка",
|
||||
"updatedAt": "Актуализирано",
|
||||
"createdAt": "Инсталирано",
|
||||
"configKey": "Ключ",
|
||||
"configValue": "Стойност",
|
||||
"allUsers": "Разрешаване на всички потребители",
|
||||
"selectedUsers": "Избрани потребители",
|
||||
"allLibraries": "Разрешаване на всички библиотеки",
|
||||
"selectedLibraries": "Избрани библиотеки"
|
||||
"id": "",
|
||||
"name": "",
|
||||
"description": "",
|
||||
"version": "",
|
||||
"author": "",
|
||||
"website": "",
|
||||
"permissions": "",
|
||||
"enabled": "",
|
||||
"status": "",
|
||||
"path": "",
|
||||
"lastError": "",
|
||||
"hasError": "",
|
||||
"updatedAt": "",
|
||||
"createdAt": "",
|
||||
"configKey": "",
|
||||
"configValue": "",
|
||||
"allUsers": "",
|
||||
"selectedUsers": "",
|
||||
"allLibraries": "",
|
||||
"selectedLibraries": ""
|
||||
},
|
||||
"sections": {
|
||||
"status": "Статус",
|
||||
"info": "Информация за плъгина",
|
||||
"configuration": "Конфигурация",
|
||||
"manifest": "Манифест",
|
||||
"usersPermission": "Права за потребители",
|
||||
"libraryPermission": "Права за библиотека"
|
||||
"status": "",
|
||||
"info": "",
|
||||
"configuration": "",
|
||||
"manifest": "",
|
||||
"usersPermission": "",
|
||||
"libraryPermission": ""
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Активирано",
|
||||
"disabled": "Деактивирано"
|
||||
"enabled": "",
|
||||
"disabled": ""
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Активирай",
|
||||
"disable": "Деактивирай",
|
||||
"disabledDueToError": "Поправете грешката преди активиране",
|
||||
"disabledUsersRequired": "Изберете потребители преди активиране",
|
||||
"disabledLibrariesRequired": "Изберете библиотеки преди активиране",
|
||||
"addConfig": "Добавяне на конфигурация",
|
||||
"rescan": "Повторно сканиране"
|
||||
"enable": "",
|
||||
"disable": "",
|
||||
"disabledDueToError": "",
|
||||
"disabledUsersRequired": "",
|
||||
"disabledLibrariesRequired": "",
|
||||
"addConfig": "",
|
||||
"rescan": ""
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "Плъгинът е активиран",
|
||||
"disabled": "Плъгинът е деактивиран",
|
||||
"updated": "Плъгинът е актуализиран",
|
||||
"error": "Грешка при актуализиране на плъгина"
|
||||
"enabled": "",
|
||||
"disabled": "",
|
||||
"updated": "",
|
||||
"error": ""
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "Конфигурацията трябва да е валиден JSON"
|
||||
"invalidJson": ""
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "Конфигурирайте плъгина, използвайки двойки ключ-стойност. Оставете празно, ако плъгинът не изисква конфигурация.",
|
||||
"clickPermissions": "Кликнете върху разрешение за подробности",
|
||||
"noConfig": "Няма зададена конфигурация",
|
||||
"allUsersHelp": "Когато е активиран, плъгинът ще има достъп до всички потребители, включително тези, създадени в бъдеще.",
|
||||
"noUsers": "Няма избрани потребители",
|
||||
"permissionReason": "Причина",
|
||||
"usersRequired": "Този плъгин изисква достъп до потребителска информация. Изберете до кои потребители плъгинът може да има достъп или активирайте „Разрешаване на всички потребители“.",
|
||||
"allLibrariesHelp": "Когато е активиран, плъгинът ще има достъп до всички библиотеки, включително тези, създадени в бъдеще.",
|
||||
"noLibraries": "Няма избрани библиотеки",
|
||||
"librariesRequired": "Този плъгин изисква достъп до информация за библиотеката. Изберете до кои библиотеки плъгинът може да има достъп или активирайте „Разрешаване на всички библиотеки“.",
|
||||
"requiredHosts": "Необходими хостове",
|
||||
"configValidationError": "Валидирането на конфигурацията не бе успешно:",
|
||||
"schemaRenderError": "Не може да се изобрази формята за конфигурация. Схемата на плъгина може да е невалидна."
|
||||
"configHelp": "",
|
||||
"clickPermissions": "",
|
||||
"noConfig": "",
|
||||
"allUsersHelp": "",
|
||||
"noUsers": "",
|
||||
"permissionReason": "",
|
||||
"usersRequired": "",
|
||||
"allLibrariesHelp": "",
|
||||
"noLibraries": "",
|
||||
"librariesRequired": "",
|
||||
"requiredHosts": "",
|
||||
"configValidationError": "",
|
||||
"schemaRenderError": ""
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "ключ",
|
||||
"configValue": "стойност"
|
||||
"configKey": "",
|
||||
"configValue": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -586,9 +586,9 @@
|
||||
"remove_missing_content": "Сигурни ли сте, че желаете да премахнете избраните липсващи файлове от базата данни? Това ще премахне завинаги всички препратки към тях, включително броя на възпроизвежданията и оценките им.",
|
||||
"remove_all_missing_title": "Премахни всички липсващи файлове",
|
||||
"remove_all_missing_content": "Сигурни ли сте, че желаете да премахнете всички липсващи файлове от базата данни? Това ще премахне завинаги всички препратки към тях, включително броя на възпроизвежданията и оценките им.",
|
||||
"noSimilarSongsFound": "Не са намерени подобни песни",
|
||||
"noTopSongsFound": "Няма намерени топ песни",
|
||||
"startingInstantMix": "Зареждане на незабавен микс..."
|
||||
"noSimilarSongsFound": "",
|
||||
"noTopSongsFound": "",
|
||||
"startingInstantMix": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Библиотека",
|
||||
@@ -619,10 +619,10 @@
|
||||
"playlists": "Плейлисти",
|
||||
"sharedPlaylists": "Споделени плейлисти",
|
||||
"librarySelector": {
|
||||
"allLibraries": "Всички библиотеки (%{count})",
|
||||
"multipleLibraries": "%{selected} от %{total} библиотеки",
|
||||
"selectLibraries": "Изберете библиотеки",
|
||||
"none": "Няма"
|
||||
"allLibraries": "",
|
||||
"multipleLibraries": "",
|
||||
"selectLibraries": "",
|
||||
"none": ""
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -655,7 +655,7 @@
|
||||
"homepage": "Начална страница",
|
||||
"source": "Програмен код",
|
||||
"featureRequests": "Заявете функционалност",
|
||||
"lastInsightsCollection": "Последна колекция от анализи",
|
||||
"lastInsightsCollection": "",
|
||||
"insights": {
|
||||
"disabled": "Деактивиран",
|
||||
"waiting": "Изчакване"
|
||||
@@ -669,13 +669,12 @@
|
||||
"configName": "Име на конфигурация",
|
||||
"environmentVariable": "Променлива на средата",
|
||||
"currentValue": "Текуща стойност",
|
||||
"configurationFile": "Конфигурационен файл",
|
||||
"configurationFile": "",
|
||||
"exportToml": "Експортиране на конфигурация (TOML)",
|
||||
"exportSuccess": "Конфигурация, експортирана в клипборда във формат TOML",
|
||||
"exportFailed": "Неуспешно копиране на конфигурация",
|
||||
"devFlagsHeader": "Флагове за разработка (подлежащи на промяна/премахване)",
|
||||
"devFlagsComment": "Това са експериментални настройки и е възможно да бъдат премахнати в бъдещи версии.",
|
||||
"downloadToml": "Изтегляне на конфигурация (TOML)"
|
||||
"devFlagsHeader": "",
|
||||
"devFlagsComment": ""
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
@@ -688,7 +687,7 @@
|
||||
"scanType": "Последно сканиране",
|
||||
"status": "Грешка при сканиране",
|
||||
"elapsedTime": "Изминало време",
|
||||
"selectiveScan": "Селективен"
|
||||
"selectiveScan": ""
|
||||
},
|
||||
"help": {
|
||||
"title": "Бързи клавиши на Navidrome",
|
||||
@@ -705,8 +704,8 @@
|
||||
}
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "Сега свири",
|
||||
"empty": "Нищо не се възпроизвежда",
|
||||
"minutesAgo": "преди %{smart_count} минута |||| преди %{smart_count} минути"
|
||||
"title": "",
|
||||
"empty": "",
|
||||
"minutesAgo": ""
|
||||
}
|
||||
}
|
||||
@@ -674,8 +674,7 @@
|
||||
"exportSuccess": "Configuració exportada al porta-retalls en format TOML",
|
||||
"exportFailed": "La còpia de la configuració ha fallat",
|
||||
"devFlagsHeader": "Indicadors de desenvolupament (subjecte a canvis o eliminació)",
|
||||
"devFlagsComment": "Aquests paràmetres són experimentals i és possible que s'eliminin en versions futures",
|
||||
"downloadToml": "Descarrega la configuració (TOML)"
|
||||
"devFlagsComment": "Aquests paràmetres són experimentals i és possible que s'eliminin en versions futures"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -675,7 +675,7 @@
|
||||
"exportFailed": "Kunne ikke kopiere konfigurationen",
|
||||
"devFlagsHeader": "Udviklingsflagget (med forbehold for ændring/fjernelse)",
|
||||
"devFlagsComment": "Disse er eksperimental-indstillinger og kan blive fjernet i fremtidige udgaver",
|
||||
"downloadToml": "Download konfigurationen (TOML)"
|
||||
"downloadToml": ""
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -674,8 +674,7 @@
|
||||
"exportSuccess": "Konfiguration im TOML Format in die Zwischenablage kopiert",
|
||||
"exportFailed": "Fehler beim Kopieren der Konfiguration",
|
||||
"devFlagsHeader": "Entwicklungseinstellungen (können sich ändern)",
|
||||
"devFlagsComment": "Experimentelle Einstellungen, die eventuell in Zukunft entfernt oder geändert werden",
|
||||
"downloadToml": "Konfiguration Herunterladen (TOML)"
|
||||
"devFlagsComment": "Experimentelle Einstellungen, die eventuell in Zukunft entfernt oder geändert werden"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -674,8 +674,7 @@
|
||||
"exportSuccess": "Η διαμόρφωση εξήχθη στο πρόχειρο σε μορφή TOML",
|
||||
"exportFailed": "Η αντιγραφή της διαμόρφωσης απέτυχε",
|
||||
"devFlagsHeader": "Σημαίες Ανάπτυξης (υπόκειται σε αλλαγές / αφαίρεση)",
|
||||
"devFlagsComment": "Αυτές είναι πειραματικές ρυθμίσεις και ενδέχεται να καταργηθούν σε μελλοντικές εκδόσεις",
|
||||
"downloadToml": "Λήψη διαμόρφωσης (TOML)"
|
||||
"devFlagsComment": "Αυτές είναι πειραματικές ρυθμίσεις και ενδέχεται να καταργηθούν σε μελλοντικές εκδόσεις"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -674,8 +674,7 @@
|
||||
"exportSuccess": "Configuración exportada al portapapeles en formato TOML",
|
||||
"exportFailed": "Error al copiar la configuración",
|
||||
"devFlagsHeader": "Indicadores de desarrollo (sujetos a cambios o eliminación)",
|
||||
"devFlagsComment": "Estas son configuraciones experimentales y pueden eliminarse en versiones futuras",
|
||||
"downloadToml": "Descargar la configuración (TOML)"
|
||||
"devFlagsComment": "Estas son configuraciones experimentales y pueden eliminarse en versiones futuras"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -674,8 +674,7 @@
|
||||
"exportSuccess": "Määritykset viety leikepöydälle TOML-muodossa",
|
||||
"exportFailed": "Määritysten kopiointi epäonnistui",
|
||||
"devFlagsHeader": "Kehitysliput (voivat muuttua/poistua)",
|
||||
"devFlagsComment": "Nämä ovat kokeellisia asetuksia ja ne voidaan poistaa tulevissa versioissa",
|
||||
"downloadToml": "Lataa määritykset (TOML)"
|
||||
"devFlagsComment": "Nämä ovat kokeellisia asetuksia ja ne voidaan poistaa tulevissa versioissa"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -674,8 +674,7 @@
|
||||
"exportSuccess": "La configuration a été copiée vers le presse-papier au format TOML",
|
||||
"exportFailed": "Une erreur est survenue en copiant la configuration",
|
||||
"devFlagsHeader": "Options de développement (peuvent être amenés à changer / être supprimés)",
|
||||
"devFlagsComment": "Ces paramètres sont expérimentaux et peuvent être amenés à changer dans le futur",
|
||||
"downloadToml": "Télécharger la configuration (TOML)"
|
||||
"devFlagsComment": "Ces paramètres sont expérimentaux et peuvent être amenés à changer dans le futur"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -674,8 +674,7 @@
|
||||
"exportSuccess": "Configuración exportada ao portapapeis no formato TOML",
|
||||
"exportFailed": "Fallou a copia da configuración",
|
||||
"devFlagsHeader": "Configuracións de Desenvolvemento (suxeitas a cambio/retirada)",
|
||||
"devFlagsComment": "Son axustes experimentais e poden retirarse en futuras versións",
|
||||
"downloadToml": "Descargar configuración (TOML)"
|
||||
"devFlagsComment": "Son axustes experimentais e poden retirarse en futuras versións"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -674,8 +674,7 @@
|
||||
"exportSuccess": "Конфигурация экспортирована в буфер обмена в формате TOML",
|
||||
"exportFailed": "Не удалось скопировать конфигурацию",
|
||||
"devFlagsHeader": "Флаги разработки (могут быть изменены/удалены)",
|
||||
"devFlagsComment": "Это экспериментальные настройки, которые могут быть удалены в будущих версиях.",
|
||||
"downloadToml": "Скачать конфигурацию (TOML)"
|
||||
"devFlagsComment": "Это экспериментальные настройки, которые могут быть удалены в будущих версиях."
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"playNext": "Naslednji",
|
||||
"info": "Več informacij",
|
||||
"showInPlaylist": "Prikaži na seznamu predvajanja",
|
||||
"instantMix": "Instant Mix"
|
||||
"instantMix": ""
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -397,8 +397,8 @@
|
||||
"noLibraries": "Ni izbranih knjižnic",
|
||||
"librariesRequired": "Vtičnik zahteva dostop do knjižnih informacij. Izberi do katerih knjižnic lahko dostopa, ali vključi dostop do vseh knjižnic.",
|
||||
"requiredHosts": "Zahtevani gostitelji",
|
||||
"configValidationError": "Validacija konfiguracije neuspešna:",
|
||||
"schemaRenderError": "Konfiguracijskega obrazca ni mogoče upodobiti. Shema vtičnika je morda neveljavna."
|
||||
"configValidationError": "",
|
||||
"schemaRenderError": ""
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "ključ",
|
||||
@@ -588,7 +588,7 @@
|
||||
"remove_all_missing_content": "Ste prepričani, da želite odstraniti vse manjkajoče datoteke iz baze? Trajno boste odstranili vse reference nanje, vključno s številom predvajanj in ocenami.",
|
||||
"noSimilarSongsFound": "Ni najdenih podobnih pesmi",
|
||||
"noTopSongsFound": "Ni najdenih najboljših pesmi",
|
||||
"startingInstantMix": "Nalaganje Instant Mix..."
|
||||
"startingInstantMix": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Knjižnica",
|
||||
@@ -674,8 +674,7 @@
|
||||
"exportSuccess": "Konfiguracija izvožena v odložišče v formatu TOML",
|
||||
"exportFailed": "Kopiranje konfiguracije ni uspelo",
|
||||
"devFlagsHeader": "Razvojne zastavice (lahko se spremenijo/odstranijo)",
|
||||
"devFlagsComment": "To so eksperimentalne nastavitve in bodo morda odstranjene v prihodnjih različicah",
|
||||
"downloadToml": "Naloži konfiguracijo (TOML)"
|
||||
"devFlagsComment": "To so eksperimentalne nastavitve in bodo morda odstranjene v prihodnjih različicah"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -674,8 +674,7 @@
|
||||
"exportSuccess": "Inställningarna kopierade till urklippet i TOML-format",
|
||||
"exportFailed": "Kopiering av inställningarna misslyckades",
|
||||
"devFlagsHeader": "Utvecklingsflaggor (kan ändras eller tas bort)",
|
||||
"devFlagsComment": "Dessa inställningar är experimentella och kan tas bort i framtida versioner",
|
||||
"downloadToml": "Ladda ner konfiguration (TOML)"
|
||||
"devFlagsComment": "Dessa inställningar är experimentella och kan tas bort i framtida versioner"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"playNext": "เล่นถัดไป",
|
||||
"info": "ดูรายละเอียด",
|
||||
"showInPlaylist": "แสดงในเพลย์ลิสต์",
|
||||
"instantMix": "อินสแตนต์ มิก"
|
||||
"instantMix": ""
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -588,7 +588,7 @@
|
||||
"remove_all_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร",
|
||||
"noSimilarSongsFound": "ไม่มีเพลงคล้ายกัน",
|
||||
"noTopSongsFound": "ไม่พบเพลงยอดนิยม",
|
||||
"startingInstantMix": "กำลังโหลดอินสแตนท์ มิก..."
|
||||
"startingInstantMix": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "ห้องสมุดเพลง",
|
||||
@@ -674,8 +674,7 @@
|
||||
"exportSuccess": "นำออกการตั้งค่าไปยังคลิปบอร์ดในรูปแบบ TOML แล้ว",
|
||||
"exportFailed": "คัดลอกการตั้งค่าล้มเหลว",
|
||||
"devFlagsHeader": "ปักธงการพัฒนา (อาจมีการเปลี่ยน/เอาออก)",
|
||||
"devFlagsComment": "การตั้งค่านี้อยู่ในช่วงทดลองและอาจจะมีการเอาออกในเวอร์ชั่นหลัง",
|
||||
"downloadToml": "ดาวน์โหลดการตั้งค่า (TOML)"
|
||||
"devFlagsComment": "การตั้งค่านี้อยู่ในช่วงทดลองและอาจจะมีการเอาออกในเวอร์ชั่นหลัง"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -10,14 +10,19 @@
|
||||
"playCount": "播放次數",
|
||||
"title": "標題",
|
||||
"artist": "藝人",
|
||||
"composer": "作曲者",
|
||||
"album": "專輯",
|
||||
"path": "檔案路徑",
|
||||
"libraryName": "媒體庫",
|
||||
"genre": "曲風",
|
||||
"compilation": "合輯",
|
||||
"year": "發行年份",
|
||||
"size": "檔案大小",
|
||||
"updatedAt": "更新於",
|
||||
"bitRate": "位元率",
|
||||
"bitDepth": "位元深度",
|
||||
"sampleRate": "取樣率",
|
||||
"channels": "聲道",
|
||||
"discSubtitle": "光碟副標題",
|
||||
"starred": "收藏",
|
||||
"comment": "註解",
|
||||
@@ -25,7 +30,6 @@
|
||||
"quality": "品質",
|
||||
"bpm": "BPM",
|
||||
"playDate": "上次播放",
|
||||
"channels": "聲道",
|
||||
"createdAt": "建立於",
|
||||
"grouping": "分組",
|
||||
"mood": "情緒",
|
||||
@@ -33,21 +37,17 @@
|
||||
"tags": "額外標籤",
|
||||
"mappedTags": "分類後標籤",
|
||||
"rawTags": "原始標籤",
|
||||
"bitDepth": "位元深度",
|
||||
"sampleRate": "取樣率",
|
||||
"missing": "遺失",
|
||||
"libraryName": "媒體庫",
|
||||
"composer": "作曲者"
|
||||
"missing": "遺失"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "加入至播放佇列",
|
||||
"playNow": "立即播放",
|
||||
"addToPlaylist": "加入至播放清單",
|
||||
"showInPlaylist": "在播放清單中顯示",
|
||||
"shuffleAll": "全部隨機播放",
|
||||
"download": "下載",
|
||||
"playNext": "下一首播放",
|
||||
"info": "取得資訊",
|
||||
"showInPlaylist": "在播放清單中顯示",
|
||||
"instantMix": "即時混音"
|
||||
}
|
||||
},
|
||||
@@ -59,38 +59,38 @@
|
||||
"duration": "長度",
|
||||
"songCount": "歌曲數",
|
||||
"playCount": "播放次數",
|
||||
"size": "檔案大小",
|
||||
"name": "名稱",
|
||||
"libraryName": "媒體庫",
|
||||
"genre": "曲風",
|
||||
"compilation": "合輯",
|
||||
"year": "發行年份",
|
||||
"updatedAt": "更新於",
|
||||
"comment": "註解",
|
||||
"rating": "評分",
|
||||
"createdAt": "建立於",
|
||||
"size": "檔案大小",
|
||||
"date": "錄製日期",
|
||||
"originalDate": "原始日期",
|
||||
"releaseDate": "發行日期",
|
||||
"releases": "發行",
|
||||
"released": "已發行",
|
||||
"updatedAt": "更新於",
|
||||
"comment": "註解",
|
||||
"rating": "評分",
|
||||
"createdAt": "建立於",
|
||||
"recordLabel": "唱片公司",
|
||||
"catalogNum": "目錄編號",
|
||||
"releaseType": "發行類型",
|
||||
"grouping": "分組",
|
||||
"media": "媒體類型",
|
||||
"mood": "情緒",
|
||||
"date": "錄製日期",
|
||||
"missing": "遺失",
|
||||
"libraryName": "媒體庫"
|
||||
"missing": "遺失"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "播放全部",
|
||||
"playNext": "下一首播放",
|
||||
"addToQueue": "加入至播放佇列",
|
||||
"share": "分享",
|
||||
"shuffle": "隨機播放",
|
||||
"addToPlaylist": "加入至播放清單",
|
||||
"download": "下載",
|
||||
"info": "取得資訊",
|
||||
"share": "分享"
|
||||
"info": "取得資訊"
|
||||
},
|
||||
"lists": {
|
||||
"all": "所有",
|
||||
@@ -108,10 +108,10 @@
|
||||
"name": "名稱",
|
||||
"albumCount": "專輯數",
|
||||
"songCount": "歌曲數",
|
||||
"size": "檔案大小",
|
||||
"playCount": "播放次數",
|
||||
"rating": "評分",
|
||||
"genre": "曲風",
|
||||
"size": "檔案大小",
|
||||
"role": "參與角色",
|
||||
"missing": "遺失"
|
||||
},
|
||||
@@ -132,9 +132,9 @@
|
||||
"maincredit": "專輯藝人或藝人 |||| 專輯藝人或藝人"
|
||||
},
|
||||
"actions": {
|
||||
"topSongs": "熱門歌曲",
|
||||
"shuffle": "隨機播放",
|
||||
"radio": "電台",
|
||||
"topSongs": "熱門歌曲"
|
||||
"radio": "電台"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -143,6 +143,7 @@
|
||||
"userName": "使用者名稱",
|
||||
"isAdmin": "管理員",
|
||||
"lastLoginAt": "上次登入",
|
||||
"lastAccessAt": "上次存取",
|
||||
"updatedAt": "更新於",
|
||||
"name": "名稱",
|
||||
"password": "密碼",
|
||||
@@ -151,7 +152,6 @@
|
||||
"currentPassword": "目前密碼",
|
||||
"newPassword": "新密碼",
|
||||
"token": "權杖",
|
||||
"lastAccessAt": "上次存取",
|
||||
"libraries": "媒體庫"
|
||||
},
|
||||
"helperTexts": {
|
||||
@@ -163,14 +163,14 @@
|
||||
"updated": "使用者已更新",
|
||||
"deleted": "使用者已刪除"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "非管理員使用者必須至少選擇一個媒體庫"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "輸入您的 ListenBrainz 使用者權杖",
|
||||
"clickHereForToken": "點擊此處來獲得您的 ListenBrainz 權杖",
|
||||
"selectAllLibraries": "選取全部媒體庫",
|
||||
"adminAutoLibraries": "管理員預設可存取所有媒體庫"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "非管理員使用者必須至少選擇一個媒體庫"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -213,9 +213,9 @@
|
||||
"selectPlaylist": "選取播放清單:",
|
||||
"addNewPlaylist": "建立「%{name}」",
|
||||
"export": "匯出",
|
||||
"saveQueue": "將播放佇列儲存到播放清單",
|
||||
"makePublic": "設為公開",
|
||||
"makePrivate": "設為私人",
|
||||
"saveQueue": "將播放佇列儲存到播放清單",
|
||||
"searchOrCreate": "搜尋播放清單,或輸入名稱來新建…",
|
||||
"pressEnterToCreate": "按 Enter 鍵建立新的播放清單",
|
||||
"removeFromSelection": "移除選取項目"
|
||||
@@ -246,6 +246,7 @@
|
||||
"username": "分享者",
|
||||
"url": "網址",
|
||||
"description": "描述",
|
||||
"downloadable": "允許下載?",
|
||||
"contents": "內容",
|
||||
"expiresAt": "過期時間",
|
||||
"lastVisitedAt": "上次造訪時間",
|
||||
@@ -253,17 +254,19 @@
|
||||
"format": "格式",
|
||||
"maxBitRate": "最大位元率",
|
||||
"updatedAt": "更新於",
|
||||
"createdAt": "建立於",
|
||||
"downloadable": "允許下載?"
|
||||
}
|
||||
"createdAt": "建立於"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
},
|
||||
"missing": {
|
||||
"name": "遺失檔案 |||| 遺失檔案",
|
||||
"empty": "無遺失檔案",
|
||||
"fields": {
|
||||
"path": "路徑",
|
||||
"size": "檔案大小",
|
||||
"updatedAt": "遺失於",
|
||||
"libraryName": "媒體庫"
|
||||
"libraryName": "媒體庫",
|
||||
"updatedAt": "遺失於"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "刪除",
|
||||
@@ -271,8 +274,7 @@
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "遺失檔案已刪除"
|
||||
},
|
||||
"empty": "無遺失檔案"
|
||||
}
|
||||
},
|
||||
"library": {
|
||||
"name": "媒體庫 |||| 媒體庫",
|
||||
@@ -302,20 +304,20 @@
|
||||
},
|
||||
"actions": {
|
||||
"scan": "掃描媒體庫",
|
||||
"manageUsers": "管理使用者權限",
|
||||
"viewDetails": "查看詳細資料",
|
||||
"quickScan": "快速掃描",
|
||||
"fullScan": "完整掃描"
|
||||
"fullScan": "完整掃描",
|
||||
"manageUsers": "管理使用者權限",
|
||||
"viewDetails": "查看詳細資料"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "成功建立媒體庫",
|
||||
"updated": "成功更新媒體庫",
|
||||
"deleted": "成功刪除媒體庫",
|
||||
"scanStarted": "開始掃描媒體庫",
|
||||
"scanCompleted": "媒體庫掃描完成",
|
||||
"quickScanStarted": "快速掃描已開始",
|
||||
"fullScanStarted": "完整掃描已開始",
|
||||
"scanError": "掃描啟動失敗,請檢查日誌"
|
||||
"scanError": "掃描啟動失敗,請檢查日誌",
|
||||
"scanCompleted": "媒體庫掃描完成"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "請輸入媒體庫名稱",
|
||||
@@ -387,6 +389,8 @@
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "使用鍵值對設定插件。若插件無需設定則留空。",
|
||||
"configValidationError": "設定驗證失敗:",
|
||||
"schemaRenderError": "無法顯示設定表單。插件的 schema 可能無效。",
|
||||
"clickPermissions": "點擊權限以查看詳細資訊",
|
||||
"noConfig": "無設定",
|
||||
"allUsersHelp": "啟用後,插件將可存取所有使用者,包含未來建立的使用者。",
|
||||
@@ -396,9 +400,7 @@
|
||||
"allLibrariesHelp": "啟用後,插件將可存取所有媒體庫,包含未來建立的媒體庫。",
|
||||
"noLibraries": "未選擇媒體庫",
|
||||
"librariesRequired": "此插件需要存取媒體庫資訊。請選擇插件可存取的媒體庫,或啟用「允許所有媒體庫」。",
|
||||
"requiredHosts": "必要的 Hosts",
|
||||
"configValidationError": "設定驗證失敗:",
|
||||
"schemaRenderError": "無法顯示設定表單。插件的 schema 可能無效。"
|
||||
"requiredHosts": "必要的 Hosts"
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "鍵",
|
||||
@@ -441,6 +443,7 @@
|
||||
"add": "加入",
|
||||
"back": "返回",
|
||||
"bulk_actions": "選中 1 項 |||| 選中 %{smart_count} 項",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"cancel": "取消",
|
||||
"clear_input_value": "清除",
|
||||
"clone": "複製",
|
||||
@@ -464,7 +467,6 @@
|
||||
"close_menu": "關閉選單",
|
||||
"unselect": "取消選取",
|
||||
"skip": "略過",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"share": "分享",
|
||||
"download": "下載"
|
||||
},
|
||||
@@ -556,42 +558,48 @@
|
||||
"transcodingDisabled": "出於安全原因,已禁用了從 Web 介面更改參數。要更改(編輯或新增)轉碼選項,請在啟用 %{config} 設定選項的情況下重新啟動伺服器。",
|
||||
"transcodingEnabled": "Navidrome 目前與 %{config} 一起使用,因此可以透過 Web 介面從轉碼設定中執行系統命令。出於安全考慮,我們建議停用此功能,並僅在設定轉碼選項時啟用。",
|
||||
"songsAddedToPlaylist": "已加入一首歌到播放清單 |||| 已新增 %{smart_count} 首歌到播放清單",
|
||||
"noSimilarSongsFound": "找不到相似歌曲",
|
||||
"startingInstantMix": "正在載入即時混音...",
|
||||
"noTopSongsFound": "找不到熱門歌曲",
|
||||
"noPlaylistsAvailable": "沒有可用的播放清單",
|
||||
"delete_user_title": "刪除使用者「%{name}」",
|
||||
"delete_user_content": "您確定要刪除此使用者及其所有資料(包括播放清單和偏好設定)嗎?",
|
||||
"remove_missing_title": "刪除遺失檔案",
|
||||
"remove_missing_content": "您確定要從媒體庫中刪除所選的遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括其播放次數和評分。",
|
||||
"remove_all_missing_title": "刪除所有遺失檔案",
|
||||
"remove_all_missing_content": "您確定要從媒體庫中刪除所有遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括它們的播放次數和評分。",
|
||||
"notifications_blocked": "您已在瀏覽器設定中封鎖了此網站的通知",
|
||||
"notifications_not_available": "此瀏覽器不支援桌面通知,或您並非透過 HTTPS 存取 Navidrome",
|
||||
"lastfmLinkSuccess": "已成功連接 Last.fm 並開啟音樂記錄",
|
||||
"lastfmLinkFailure": "無法連接 Last.fm",
|
||||
"lastfmUnlinkSuccess": "已取消與 Last.fm 的連接並停用音樂記錄",
|
||||
"lastfmUnlinkFailure": "無法取消與 Last.fm 的連接",
|
||||
"listenBrainzLinkSuccess": "已成功以 %{user} 的身份連接 ListenBrainz 並開啟音樂記錄",
|
||||
"listenBrainzLinkFailure": "無法連接 ListenBrainz:%{error}",
|
||||
"listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連接並停用音樂記錄",
|
||||
"listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連接",
|
||||
"openIn": {
|
||||
"lastfm": "在 Last.fm 中開啟",
|
||||
"musicbrainz": "在 MusicBrainz 中開啟"
|
||||
},
|
||||
"lastfmLink": "查看更多…",
|
||||
"listenBrainzLinkSuccess": "已成功以 %{user} 的身份連接 ListenBrainz 並開啟音樂記錄",
|
||||
"listenBrainzLinkFailure": "無法連接 ListenBrainz:%{error}",
|
||||
"listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連接並停用音樂記錄",
|
||||
"listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連接",
|
||||
"downloadOriginalFormat": "下載原始格式",
|
||||
"shareOriginalFormat": "分享原始格式",
|
||||
"shareDialogTitle": "分享 %{resource} '%{name}'",
|
||||
"shareBatchDialogTitle": "分享 1 個%{resource} |||| 分享 %{smart_count} 個%{resource}",
|
||||
"shareCopyToClipboard": "複製到剪貼簿:Ctrl+C, Enter",
|
||||
"shareSuccess": "分享成功,連結已複製到剪貼簿:%{url}",
|
||||
"shareFailure": "分享連結複製失敗:%{url}",
|
||||
"downloadDialogTitle": "下載 %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "複製到剪貼簿:Ctrl+C, Enter",
|
||||
"remove_missing_title": "刪除遺失檔案",
|
||||
"remove_missing_content": "您確定要從媒體庫中刪除所選的遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括其播放次數和評分。",
|
||||
"remove_all_missing_title": "刪除所有遺失檔案",
|
||||
"remove_all_missing_content": "您確定要從媒體庫中刪除所有遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括它們的播放次數和評分。",
|
||||
"noSimilarSongsFound": "找不到相似歌曲",
|
||||
"noTopSongsFound": "找不到熱門歌曲",
|
||||
"startingInstantMix": "正在載入即時混音..."
|
||||
"downloadOriginalFormat": "下載原始格式"
|
||||
},
|
||||
"menu": {
|
||||
"library": "媒體庫",
|
||||
"librarySelector": {
|
||||
"allLibraries": "所有媒體庫 (%{count})",
|
||||
"multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫",
|
||||
"selectLibraries": "選取媒體庫",
|
||||
"none": "無"
|
||||
},
|
||||
"settings": "設定",
|
||||
"version": "版本",
|
||||
"theme": "主題",
|
||||
@@ -602,6 +610,7 @@
|
||||
"language": "語言",
|
||||
"defaultView": "預設畫面",
|
||||
"desktop_notifications": "桌面通知",
|
||||
"lastfmNotConfigured": "Last.fm API 金鑰未設定",
|
||||
"lastfmScrobbling": "啟用 Last.fm 音樂記錄",
|
||||
"listenBrainzScrobbling": "啟用 ListenBrainz 音樂記錄",
|
||||
"replaygain": "重播增益模式",
|
||||
@@ -610,20 +619,13 @@
|
||||
"none": "無",
|
||||
"album": "專輯增益",
|
||||
"track": "曲目增益"
|
||||
},
|
||||
"lastfmNotConfigured": "Last.fm API 金鑰未設定"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "專輯",
|
||||
"about": "關於",
|
||||
"playlists": "播放清單",
|
||||
"sharedPlaylists": "分享的播放清單",
|
||||
"librarySelector": {
|
||||
"allLibraries": "所有媒體庫 (%{count})",
|
||||
"multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫",
|
||||
"selectLibraries": "選取媒體庫",
|
||||
"none": "無"
|
||||
}
|
||||
"about": "關於"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "播放佇列",
|
||||
@@ -674,8 +676,7 @@
|
||||
"exportSuccess": "設定已以 TOML 格式匯出至剪貼簿",
|
||||
"exportFailed": "設定複製失敗",
|
||||
"devFlagsHeader": "開發旗標(可能會更改/刪除)",
|
||||
"devFlagsComment": "這些是實驗性設定,可能會在未來版本中刪除",
|
||||
"downloadToml": "下載設定檔 (TOML)"
|
||||
"devFlagsComment": "這些是實驗性設定,可能會在未來版本中刪除"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
@@ -683,12 +684,17 @@
|
||||
"totalScanned": "已掃描的資料夾總數",
|
||||
"quickScan": "快速掃描",
|
||||
"fullScan": "完全掃描",
|
||||
"selectiveScan": "選擇性掃描",
|
||||
"serverUptime": "伺服器運作時間",
|
||||
"serverDown": "伺服器已離線",
|
||||
"scanType": "掃描類型",
|
||||
"status": "掃描錯誤",
|
||||
"elapsedTime": "經過時間",
|
||||
"selectiveScan": "選擇性掃描"
|
||||
"elapsedTime": "經過時間"
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "正在播放",
|
||||
"empty": "無播放內容",
|
||||
"minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome 快捷鍵",
|
||||
@@ -698,15 +704,10 @@
|
||||
"toggle_play": "播放/暫停",
|
||||
"prev_song": "上一首歌",
|
||||
"next_song": "下一首歌",
|
||||
"current_song": "前往目前歌曲",
|
||||
"vol_up": "提高音量",
|
||||
"vol_down": "降低音量",
|
||||
"toggle_love": "新增此歌曲至收藏",
|
||||
"current_song": "前往目前歌曲"
|
||||
"toggle_love": "新增此歌曲至收藏"
|
||||
}
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "正在播放",
|
||||
"empty": "無播放內容",
|
||||
"minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,7 +443,7 @@ func (api *Router) buildArtist(r *http.Request, artist *model.Artist) (*response
|
||||
func (api *Router) buildAlbumDirectory(ctx context.Context, album *model.Album) (*responses.Directory, error) {
|
||||
dir := &responses.Directory{}
|
||||
dir.Id = album.ID
|
||||
dir.Name = album.Name
|
||||
dir.Name = album.FullName()
|
||||
dir.Parent = album.AlbumArtistID
|
||||
dir.PlayCount = album.PlayCount
|
||||
if album.PlayCount > 0 {
|
||||
|
||||
@@ -197,7 +197,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child
|
||||
}
|
||||
|
||||
child.Parent = mf.AlbumID
|
||||
child.Album = mf.Album
|
||||
child.Album = mf.FullAlbumName()
|
||||
child.Year = int32(mf.Year)
|
||||
child.Artist = mf.Artist
|
||||
child.Genre = mf.Genre
|
||||
@@ -302,7 +302,7 @@ func artistRefs(participants model.ParticipantList) []responses.ArtistID3Ref {
|
||||
func fakePath(mf model.MediaFile) string {
|
||||
builder := strings.Builder{}
|
||||
|
||||
builder.WriteString(fmt.Sprintf("%s/%s/", sanitizeSlashes(mf.AlbumArtist), sanitizeSlashes(mf.Album)))
|
||||
builder.WriteString(fmt.Sprintf("%s/%s/", sanitizeSlashes(mf.AlbumArtist), sanitizeSlashes(mf.FullAlbumName())))
|
||||
if mf.DiscNumber != 0 {
|
||||
builder.WriteString(fmt.Sprintf("%02d-", mf.DiscNumber))
|
||||
}
|
||||
@@ -321,9 +321,10 @@ func childFromAlbum(ctx context.Context, al model.Album) responses.Child {
|
||||
child := responses.Child{}
|
||||
child.Id = al.ID
|
||||
child.IsDir = true
|
||||
child.Title = al.Name
|
||||
child.Name = al.Name
|
||||
child.Album = al.Name
|
||||
fullName := al.FullName()
|
||||
child.Title = fullName
|
||||
child.Name = fullName
|
||||
child.Album = fullName
|
||||
child.Artist = al.AlbumArtist
|
||||
child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear))
|
||||
child.Genre = al.Genre
|
||||
@@ -405,7 +406,7 @@ func buildDiscSubtitles(a model.Album) []responses.DiscTitle {
|
||||
func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 {
|
||||
dir := responses.AlbumID3{}
|
||||
dir.Id = album.ID
|
||||
dir.Name = album.Name
|
||||
dir.Name = album.FullName()
|
||||
dir.Artist = album.AlbumArtist
|
||||
dir.ArtistId = album.AlbumArtistID
|
||||
dir.CoverArt = album.CoverArtID().String()
|
||||
|
||||
Reference in New Issue
Block a user