Compare commits

..

21 Commits

Author SHA1 Message Date
Deluan
b33d831a1d fix: small issues
Updated mock AAC transcoding command to use the new default (ipod with
fragmented MP4) matching the migration, ensuring tests exercise the same
buildDynamicArgs code path as production. Improved archiver test mock to
match on the whole StreamRequest struct instead of decomposing fields,
making it resilient to future field additions. Added named constants for
JWT claim keys in the transcode token and wrapped ParseTranscodeParams
errors with ErrTokenInvalid for consistency. Documented the IsLossless
BitDepth fallback heuristic as temporary until Codec column is populated.

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 20:17:31 -05:00
Deluan
3b1bd2c265 feat(transcoding): add sourceUpdatedAt to decision and validate transcode parameters
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
0c55c7ce89 fix: address review findings for OpenSubsonic transcoding PR
Fix multiple issues identified during code review of the transcoding
extension: add missing return after error in shared stream handler
preventing nil pointer panic, replace dead r.Body nil check with
MaxBytesReader size limit, distinguish not-found from other DB errors,
fix bpsToKbps integer truncation with rounding, add "pcm" to
isLosslessFormat for consistency with model.IsLossless(), add
sampleRate/bitDepth/channels to streaming log, fix outdated test
comment, and add tests for conversion functions and GetTranscodeStream
parameter passing.
2026-02-09 16:46:46 -05:00
Deluan
fab2acfe36 fix: implement noopDecider for transcoding decision handling in tests
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
22dba77509 refactor(transcoding): update default command handling and add codec support for transcoding
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
5107492059 refactor(transcoding): streamline transcoding logic by consolidating stream parameter handling and enhancing alias mapping
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
01b1fc90a9 refactor(transcoding): enhance AAC command handling and support for audio channels in streaming
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
4a50142dd6 refactor(transcoding): add bit depth support for audio transcoding and enhance related logic
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
e843b918b2 refactor(transcoding): enhance transcoding options with sample rate support and improve command handling
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
39e341e863 refactor(transcoding): enhance transcoding config lookup logic for audio codecs
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
7ca0eade80 refactor(transcoding): rename TranscodeDecision to Decider and update related methods for clarity
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
2e02e92cc4 refactor(transcoding): enhance logging for transcode decision process and client info conversion
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
216d0c6c6c refactor(transcoding): rename token methods to CreateTranscodeParams and ParseTranscodeParams for clarity
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
c26cc0f5b9 refactor(transcoding): replace strings.EqualFold with direct comparison for protocol and limitation checks
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
4bb6802922 refactor(transcoding): streamline limitation checks and applyLimitation logic for improved readability and maintainability
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
2e00479a8b feat(transcoding): add enums for protocol, comparison operators, limitations, and codec profiles in transcode decision logic
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
07e2f699da fix(transcoding): enforce POST method for GetTranscodeDecision and handle non-POST requests
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:46 -05:00
Deluan
ff57efa170 refactor(transcoding): simplify container alias handling in matchesContainer function
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:45 -05:00
Deluan
0658e1f824 fix(transcoding): update bitrate handling to use kilobits per second (kbps) across transcode decision logic
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:45 -05:00
Deluan
a88ab9f16c fix(subsonic): update codec limitation structure and decision logic for improved clarity
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:45 -05:00
Deluan
b5621b9784 feat(subsonic): implement transcode decision logic and codec handling for media files
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-09 16:46:45 -05:00
64 changed files with 4411 additions and 2053 deletions

View File

@@ -1,138 +0,0 @@
#!/bin/sh
set -e
I18N_DIR=resources/i18n
# Normalize JSON for deterministic comparison:
# remove empty/null attributes, sort keys alphabetically
process_json() {
jq 'walk(if type == "object" then with_entries(select(.value != null and .value != "" and .value != [] and .value != {})) | to_entries | sort_by(.key) | from_entries else . end)' "$1"
}
# Get list of all languages configured in the POEditor project
get_language_list() {
curl -s -X POST https://api.poeditor.com/v2/languages/list \
-d api_token="${POEDITOR_APIKEY}" \
-d id="${POEDITOR_PROJECTID}"
}
# Extract language name from the language list JSON given a language code
get_language_name() {
lang_code="$1"
lang_list="$2"
echo "$lang_list" | jq -r ".result.languages[] | select(.code == \"$lang_code\") | .name"
}
# Extract language code from a file path (e.g., "resources/i18n/fr.json" -> "fr")
get_lang_code() {
filepath="$1"
filename=$(basename "$filepath")
echo "${filename%.*}"
}
# Export the current translation for a language from POEditor (v2 API)
export_language() {
lang_code="$1"
response=$(curl -s -X POST https://api.poeditor.com/v2/projects/export \
-d api_token="${POEDITOR_APIKEY}" \
-d id="${POEDITOR_PROJECTID}" \
-d language="$lang_code" \
-d type="key_value_json")
url=$(echo "$response" | jq -r '.result.url')
if [ -z "$url" ] || [ "$url" = "null" ]; then
echo "Failed to export $lang_code: $response" >&2
return 1
fi
echo "$url"
}
# Flatten nested JSON to POEditor languages/update format.
# POEditor uses term + context pairs, where:
# term = the leaf key name
# context = the parent path as "key1"."key2"."key3" (empty for root keys)
flatten_to_poeditor() {
jq -c '[paths(scalars) as $p |
{
"term": ($p | last | tostring),
"context": (if ($p | length) > 1 then ($p[:-1] | map("\"" + tostring + "\"") | join(".")) else "" end),
"translation": {"content": getpath($p)}
}
]' "$1"
}
# Update translations for a language in POEditor via languages/update API
update_language() {
lang_code="$1"
file="$2"
flatten_to_poeditor "$file" > /tmp/poeditor_data.json
response=$(curl -s -X POST https://api.poeditor.com/v2/languages/update \
-d api_token="${POEDITOR_APIKEY}" \
-d id="${POEDITOR_PROJECTID}" \
-d language="$lang_code" \
--data-urlencode data@/tmp/poeditor_data.json)
rm -f /tmp/poeditor_data.json
status=$(echo "$response" | jq -r '.response.status')
if [ "$status" != "success" ]; then
echo "Failed to update $lang_code: $response" >&2
return 1
fi
parsed=$(echo "$response" | jq -r '.result.translations.parsed')
added=$(echo "$response" | jq -r '.result.translations.added')
updated=$(echo "$response" | jq -r '.result.translations.updated')
echo " Translations - parsed: $parsed, added: $added, updated: $updated"
}
# --- Main ---
if [ $# -eq 0 ]; then
echo "Usage: $0 <file1> [file2] ..."
echo "No files specified. Nothing to do."
exit 0
fi
lang_list=$(get_language_list)
upload_count=0
for file in "$@"; do
if [ ! -f "$file" ]; then
echo "Warning: File not found: $file, skipping"
continue
fi
lang_code=$(get_lang_code "$file")
lang_name=$(get_language_name "$lang_code" "$lang_list")
if [ -z "$lang_name" ]; then
echo "Warning: Language code '$lang_code' not found in POEditor, skipping $file"
continue
fi
echo "Processing $lang_name ($lang_code)..."
# Export current state from POEditor
url=$(export_language "$lang_code")
curl -sSL "$url" -o poeditor_export.json
# Normalize both files for comparison
process_json "$file" > local_normalized.json
process_json poeditor_export.json > remote_normalized.json
# Compare normalized versions
if diff -q local_normalized.json remote_normalized.json > /dev/null 2>&1; then
echo " No differences, skipping"
else
echo " Differences found, updating POEditor..."
update_language "$lang_code" "$file"
upload_count=$((upload_count + 1))
fi
rm -f poeditor_export.json local_normalized.json remote_normalized.json
done
echo ""
echo "Done. Updated $upload_count translation(s) in POEditor."

View File

@@ -1,32 +0,0 @@
name: POEditor export
on:
push:
branches:
- master
paths:
- 'resources/i18n/*.json'
jobs:
push-translations:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'navidrome' }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 2
- name: Detect changed translation files
id: changed
run: |
CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD -- 'resources/i18n/*.json' | tr '\n' ' ')
echo "files=$CHANGED_FILES" >> $GITHUB_OUTPUT
echo "Changed translation files: $CHANGED_FILES"
- name: Push translations to POEditor
if: ${{ steps.changed.outputs.files != '' }}
env:
POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }}
POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
run: |
.github/workflows/push-translations.sh ${{ steps.changed.outputs.files }}

View File

@@ -20,7 +20,7 @@ DOCKER_TAG ?= deluan/navidrome:develop
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
CROSS_TAGLIB_VERSION ?= 2.1.1-2
GOLANGCI_LINT_VERSION ?= v2.9.0
GOLANGCI_LINT_VERSION ?= v2.8.0
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")

View File

@@ -65,6 +65,7 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
Channels: int(props.Channels),
SampleRate: int(props.SampleRate),
BitDepth: int(props.BitsPerSample),
Codec: props.Codec,
}
// Convert normalized tags to lowercase keys (go-taglib returns UPPERCASE keys)

View File

@@ -19,6 +19,7 @@ import (
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/transcode"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
@@ -102,7 +103,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
decider := transcode.NewDecider(dataStore)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics, decider)
return router
}

View File

@@ -151,7 +151,13 @@ var (
Name: "aac audio",
TargetFormat: "aac",
DefaultBitRate: 256,
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -",
},
{
Name: "flac audio",
TargetFormat: "flac",
DefaultBitRate: 0,
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -",
},
}
)

View File

@@ -176,7 +176,7 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
var r io.ReadCloser
if format != "raw" && format != "" {
r, err = a.ms.DoStream(ctx, &mf, format, bitrate, 0)
r, err = a.ms.DoStream(ctx, &mf, StreamRequest{Format: format, BitRate: bitrate})
} else {
r, err = os.Open(path)
}

View File

@@ -44,7 +44,7 @@ var _ = Describe("Archiver", func() {
}}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
ms.On("DoStream", mock.Anything, mock.Anything, core.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
out := new(bytes.Buffer)
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
@@ -73,7 +73,7 @@ var _ = Describe("Archiver", func() {
}}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
ms.On("DoStream", mock.Anything, mock.Anything, core.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out)
@@ -104,7 +104,7 @@ var _ = Describe("Archiver", func() {
}
sh.On("Load", mock.Anything, "1").Return(share, nil)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
ms.On("DoStream", mock.Anything, mock.Anything, core.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipShare(context.Background(), "1", out)
@@ -136,7 +136,7 @@ var _ = Describe("Archiver", func() {
plRepo := &mockPlaylistRepository{}
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
ds.On("Playlist", mock.Anything).Return(plRepo)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
ms.On("DoStream", mock.Anything, mock.Anything, core.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)
@@ -217,8 +217,8 @@ type mockMediaStreamer struct {
core.MediaStreamer
}
func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*core.Stream, error) {
args := m.Called(ctx, mf, reqFormat, reqBitRate, reqOffset)
func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, req core.StreamRequest) (*core.Stream, error) {
args := m.Called(ctx, mf, req)
if args.Error(1) != nil {
return nil, args.Error(1)
}

View File

@@ -12,11 +12,24 @@ import (
"sync"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
)
// TranscodeOptions contains all parameters for a transcoding operation.
type TranscodeOptions struct {
Command string // DB command template (used to detect custom vs default)
Format string // Target format (mp3, opus, aac, flac)
FilePath string
BitRate int // kbps, 0 = codec default
SampleRate int // 0 = no constraint
Channels int // 0 = no constraint
BitDepth int // 0 = no constraint; valid values: 16, 24, 32
Offset int // seconds
}
type FFmpeg interface {
Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error)
Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error)
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
Probe(ctx context.Context, files []string) (string, error)
CmdPath() (string, error)
@@ -35,15 +48,19 @@ const (
type ffmpeg struct{}
func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error) {
func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error) {
if _, err := ffmpegCmd(); err != nil {
return nil, err
}
// First make sure the file exists
if err := fileExists(path); err != nil {
if err := fileExists(opts.FilePath); err != nil {
return nil, err
}
args := createFFmpegCommand(command, path, maxBitRate, offset)
var args []string
if isDefaultCommand(opts.Format, opts.Command) {
args = buildDynamicArgs(opts)
} else {
args = buildTemplateArgs(opts)
}
return e.start(ctx, args)
}
@@ -51,7 +68,6 @@ func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser,
if _, err := ffmpegCmd(); err != nil {
return nil, err
}
// First make sure the file exists
if err := fileExists(path); err != nil {
return nil, err
}
@@ -156,6 +172,139 @@ func (j *ffCmd) wait() {
_ = j.out.Close()
}
// formatCodecMap maps target format to ffmpeg codec flag.
var formatCodecMap = map[string]string{
"mp3": "libmp3lame",
"opus": "libopus",
"aac": "aac",
"flac": "flac",
}
// formatOutputMap maps target format to ffmpeg output format flag (-f).
var formatOutputMap = map[string]string{
"mp3": "mp3",
"opus": "opus",
"aac": "ipod",
"flac": "flac",
}
// defaultCommands is used to detect whether a user has customized their transcoding command.
var defaultCommands = func() map[string]string {
m := make(map[string]string, len(consts.DefaultTranscodings))
for _, t := range consts.DefaultTranscodings {
m[t.TargetFormat] = t.Command
}
return m
}()
// isDefaultCommand returns true if the command matches the known default for this format.
func isDefaultCommand(format, command string) bool {
return defaultCommands[format] == command
}
// buildDynamicArgs programmatically constructs ffmpeg arguments for known formats,
// including all transcoding parameters (bitrate, sample rate, channels).
func buildDynamicArgs(opts TranscodeOptions) []string {
cmdPath, _ := ffmpegCmd()
args := []string{cmdPath, "-i", opts.FilePath}
if opts.Offset > 0 {
args = append(args, "-ss", strconv.Itoa(opts.Offset))
}
args = append(args, "-map", "0:a:0")
if codec, ok := formatCodecMap[opts.Format]; ok {
args = append(args, "-c:a", codec)
}
if opts.BitRate > 0 {
args = append(args, "-b:a", strconv.Itoa(opts.BitRate)+"k")
}
if opts.SampleRate > 0 {
args = append(args, "-ar", strconv.Itoa(opts.SampleRate))
}
if opts.Channels > 0 {
args = append(args, "-ac", strconv.Itoa(opts.Channels))
}
// Only pass -sample_fmt for lossless output formats where bit depth matters.
// Lossy codecs (mp3, aac, opus) handle sample format conversion internally,
// and passing interleaved formats like "s16" causes silent failures.
if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) {
args = append(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth))
}
args = append(args, "-v", "0")
if outputFmt, ok := formatOutputMap[opts.Format]; ok {
args = append(args, "-f", outputFmt)
}
// For AAC in MP4 container, enable fragmented MP4 for pipe-safe streaming
if opts.Format == "aac" {
args = append(args, "-movflags", "frag_keyframe+empty_moov")
}
args = append(args, "-")
return args
}
// buildTemplateArgs handles user-customized command templates, with dynamic injection
// of sample rate and channels when the template doesn't already include them.
func buildTemplateArgs(opts TranscodeOptions) []string {
args := createFFmpegCommand(opts.Command, opts.FilePath, opts.BitRate, opts.Offset)
// Dynamically inject -ar, -ac, and -sample_fmt for custom templates that don't include them
if opts.SampleRate > 0 {
args = injectBeforeOutput(args, "-ar", strconv.Itoa(opts.SampleRate))
}
if opts.Channels > 0 {
args = injectBeforeOutput(args, "-ac", strconv.Itoa(opts.Channels))
}
if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) {
args = injectBeforeOutput(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth))
}
return args
}
// injectBeforeOutput inserts a flag and value before the trailing "-" (stdout output).
func injectBeforeOutput(args []string, flag, value string) []string {
if len(args) > 0 && args[len(args)-1] == "-" {
result := make([]string, 0, len(args)+2)
result = append(result, args[:len(args)-1]...)
result = append(result, flag, value, "-")
return result
}
return append(args, flag, value)
}
// isLosslessOutputFormat returns true if the format is a lossless audio format
// where preserving bit depth via -sample_fmt is meaningful.
// Note: this covers only formats ffmpeg can produce as output. For the full set of
// lossless formats used in transcoding decisions, see core/transcode/codec.go:isLosslessFormat.
func isLosslessOutputFormat(format string) bool {
switch strings.ToLower(format) {
case "flac", "alac", "wav", "aiff":
return true
}
return false
}
// bitDepthToSampleFmt converts a bit depth value to the ffmpeg sample_fmt string.
// FLAC only supports s16 and s32; for 24-bit sources, s32 is the correct format
// (ffmpeg packs 24-bit samples into 32-bit containers).
func bitDepthToSampleFmt(bitDepth int) string {
switch bitDepth {
case 16:
return "s16"
case 32:
return "s32"
default:
// 24-bit and other depths: use s32 (the next valid container size)
return "s32"
}
}
// Path will always be an absolute path
func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
var args []string

View File

@@ -2,19 +2,27 @@ package ffmpeg
import (
"context"
"os"
"path/filepath"
"runtime"
sync "sync"
"testing"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestFFmpeg(t *testing.T) {
tests.Init(t, false)
// Inline test init to avoid import cycle with tests package
//nolint:dogsled
_, file, _, _ := runtime.Caller(0)
appPath, _ := filepath.Abs(filepath.Join(filepath.Dir(file), "..", ".."))
confPath := filepath.Join(appPath, "tests", "navidrome-test.toml")
_ = os.Chdir(appPath)
conf.LoadFromFile(confPath)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "FFmpeg Suite")
@@ -70,6 +78,286 @@ var _ = Describe("ffmpeg", func() {
})
})
Describe("isDefaultCommand", func() {
It("returns true for known default mp3 command", func() {
Expect(isDefaultCommand("mp3", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue())
})
It("returns true for known default opus command", func() {
Expect(isDefaultCommand("opus", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue())
})
It("returns true for known default aac command", func() {
Expect(isDefaultCommand("aac", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -")).To(BeTrue())
})
It("returns true for known default flac command", func() {
Expect(isDefaultCommand("flac", "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue())
})
It("returns false for a custom command", func() {
Expect(isDefaultCommand("mp3", "ffmpeg -i %s -b:a %bk -custom-flag -f mp3 -")).To(BeFalse())
})
It("returns false for unknown format", func() {
Expect(isDefaultCommand("wav", "ffmpeg -i %s -f wav -")).To(BeFalse())
})
})
Describe("buildDynamicArgs", func() {
It("builds mp3 args with bitrate, samplerate, and channels", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "mp3",
FilePath: "/music/file.flac",
BitRate: 256,
SampleRate: 48000,
Channels: 2,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-map", "0:a:0",
"-c:a", "libmp3lame",
"-b:a", "256k",
"-ar", "48000",
"-ac", "2",
"-v", "0",
"-f", "mp3",
"-",
}))
})
It("builds flac args without bitrate", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "flac",
FilePath: "/music/file.dsf",
SampleRate: 48000,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.dsf",
"-map", "0:a:0",
"-c:a", "flac",
"-ar", "48000",
"-v", "0",
"-f", "flac",
"-",
}))
})
It("builds opus args with bitrate only", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "opus",
FilePath: "/music/file.flac",
BitRate: 128,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-map", "0:a:0",
"-c:a", "libopus",
"-b:a", "128k",
"-v", "0",
"-f", "opus",
"-",
}))
})
It("includes offset when specified", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "mp3",
FilePath: "/music/file.mp3",
BitRate: 192,
Offset: 30,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.mp3",
"-ss", "30",
"-map", "0:a:0",
"-c:a", "libmp3lame",
"-b:a", "192k",
"-v", "0",
"-f", "mp3",
"-",
}))
})
It("builds aac args with fragmented MP4 container", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "aac",
FilePath: "/music/file.flac",
BitRate: 256,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-map", "0:a:0",
"-c:a", "aac",
"-b:a", "256k",
"-v", "0",
"-f", "ipod",
"-movflags", "frag_keyframe+empty_moov",
"-",
}))
})
It("builds flac args with bit depth", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "flac",
FilePath: "/music/file.dsf",
BitDepth: 24,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.dsf",
"-map", "0:a:0",
"-c:a", "flac",
"-sample_fmt", "s32",
"-v", "0",
"-f", "flac",
"-",
}))
})
It("omits -sample_fmt when bit depth is 0", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "flac",
FilePath: "/music/file.flac",
BitDepth: 0,
})
Expect(args).ToNot(ContainElement("-sample_fmt"))
})
It("omits -sample_fmt when bit depth is too low (DSD)", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "flac",
FilePath: "/music/file.dsf",
BitDepth: 1,
})
Expect(args).ToNot(ContainElement("-sample_fmt"))
})
It("omits -sample_fmt for mp3 even when bit depth >= 16", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "mp3",
FilePath: "/music/file.flac",
BitRate: 256,
BitDepth: 16,
})
Expect(args).ToNot(ContainElement("-sample_fmt"))
})
It("omits -sample_fmt for aac even when bit depth >= 16", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "aac",
FilePath: "/music/file.flac",
BitRate: 256,
BitDepth: 16,
})
Expect(args).ToNot(ContainElement("-sample_fmt"))
})
It("omits -sample_fmt for opus even when bit depth >= 16", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "opus",
FilePath: "/music/file.flac",
BitRate: 128,
BitDepth: 16,
})
Expect(args).ToNot(ContainElement("-sample_fmt"))
})
})
Describe("bitDepthToSampleFmt", func() {
It("converts 16-bit", func() {
Expect(bitDepthToSampleFmt(16)).To(Equal("s16"))
})
It("converts 24-bit to s32 (FLAC only supports s16/s32)", func() {
Expect(bitDepthToSampleFmt(24)).To(Equal("s32"))
})
It("converts 32-bit", func() {
Expect(bitDepthToSampleFmt(32)).To(Equal("s32"))
})
})
Describe("buildTemplateArgs", func() {
It("injects -ar and -ac into custom template", func() {
args := buildTemplateArgs(TranscodeOptions{
Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
FilePath: "/music/file.flac",
BitRate: 192,
SampleRate: 44100,
Channels: 2,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-b:a", "192k", "-v", "0", "-f", "mp3",
"-ar", "44100", "-ac", "2",
"-",
}))
})
It("injects only -ar when channels is 0", func() {
args := buildTemplateArgs(TranscodeOptions{
Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
FilePath: "/music/file.flac",
BitRate: 192,
SampleRate: 48000,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-b:a", "192k", "-v", "0", "-f", "mp3",
"-ar", "48000",
"-",
}))
})
It("does not inject anything when sample rate and channels are 0", func() {
args := buildTemplateArgs(TranscodeOptions{
Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
FilePath: "/music/file.flac",
BitRate: 192,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-b:a", "192k", "-v", "0", "-f", "mp3",
"-",
}))
})
It("injects -sample_fmt for lossless output format with bit depth", func() {
args := buildTemplateArgs(TranscodeOptions{
Command: "ffmpeg -i %s -v 0 -c:a flac -f flac -",
Format: "flac",
FilePath: "/music/file.dsf",
BitDepth: 24,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.dsf",
"-v", "0", "-c:a", "flac", "-f", "flac",
"-sample_fmt", "s32",
"-",
}))
})
It("does not inject -sample_fmt for lossy output format even with bit depth", func() {
args := buildTemplateArgs(TranscodeOptions{
Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
Format: "mp3",
FilePath: "/music/file.flac",
BitRate: 192,
BitDepth: 16,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-b:a", "192k", "-v", "0", "-f", "mp3",
"-",
}))
})
})
Describe("injectBeforeOutput", func() {
It("inserts flag before trailing dash", func() {
args := injectBeforeOutput([]string{"ffmpeg", "-i", "file.mp3", "-f", "mp3", "-"}, "-ar", "48000")
Expect(args).To(Equal([]string{"ffmpeg", "-i", "file.mp3", "-f", "mp3", "-ar", "48000", "-"}))
})
It("appends when no trailing dash", func() {
args := injectBeforeOutput([]string{"ffmpeg", "-i", "file.mp3"}, "-ar", "48000")
Expect(args).To(Equal([]string{"ffmpeg", "-i", "file.mp3", "-ar", "48000"}))
})
})
Describe("FFmpeg", func() {
Context("when FFmpeg is available", func() {
var ff FFmpeg
@@ -93,7 +381,12 @@ var _ = Describe("ffmpeg", func() {
command := "ffmpeg -f lavfi -i sine=frequency=1000:duration=0 -f mp3 -"
// The input file is not used here, but we need to provide a valid path to the Transcode function
stream, err := ff.Transcode(ctx, command, "tests/fixtures/test.mp3", 128, 0)
stream, err := ff.Transcode(ctx, TranscodeOptions{
Command: command,
Format: "mp3",
FilePath: "tests/fixtures/test.mp3",
BitRate: 128,
})
Expect(err).ToNot(HaveOccurred())
defer stream.Close()
@@ -115,7 +408,12 @@ var _ = Describe("ffmpeg", func() {
cancel() // Cancel immediately
// This should fail immediately
_, err := ff.Transcode(ctx, "ffmpeg -i %s -f mp3 -", "tests/fixtures/test.mp3", 128, 0)
_, err := ff.Transcode(ctx, TranscodeOptions{
Command: "ffmpeg -i %s -f mp3 -",
Format: "mp3",
FilePath: "tests/fixtures/test.mp3",
BitRate: 128,
})
Expect(err).To(MatchError(context.Canceled))
})
})
@@ -142,7 +440,10 @@ var _ = Describe("ffmpeg", func() {
defer cancel()
// Start a process that will run for a while
stream, err := ff.Transcode(ctx, longRunningCmd, "tests/fixtures/test.mp3", 0, 0)
stream, err := ff.Transcode(ctx, TranscodeOptions{
Command: longRunningCmd,
FilePath: "tests/fixtures/test.mp3",
})
Expect(err).ToNot(HaveOccurred())
defer stream.Close()

View File

@@ -18,9 +18,20 @@ import (
"github.com/navidrome/navidrome/utils/cache"
)
// StreamRequest contains all parameters for creating a media stream.
type StreamRequest struct {
ID string
Format string
BitRate int // kbps
SampleRate int
BitDepth int
Channels int
Offset int // seconds
}
type MediaStreamer interface {
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, offset int) (*Stream, error)
DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error)
NewStream(ctx context.Context, req StreamRequest) (*Stream, error)
DoStream(ctx context.Context, mf *model.MediaFile, req StreamRequest) (*Stream, error)
}
type TranscodingCache cache.FileCache
@@ -36,44 +47,48 @@ type mediaStreamer struct {
}
type streamJob struct {
ms *mediaStreamer
mf *model.MediaFile
filePath string
format string
bitRate int
offset int
ms *mediaStreamer
mf *model.MediaFile
filePath string
format string
bitRate int
sampleRate int
bitDepth int
channels int
offset int
}
func (j *streamJob) Key() string {
return fmt.Sprintf("%s.%s.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format, j.offset)
return fmt.Sprintf("%s.%s.%d.%d.%d.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.sampleRate, j.bitDepth, j.channels, j.format, j.offset)
}
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
mf, err := ms.ds.MediaFile(ctx).Get(id)
func (ms *mediaStreamer) NewStream(ctx context.Context, req StreamRequest) (*Stream, error) {
mf, err := ms.ds.MediaFile(ctx).Get(req.ID)
if err != nil {
return nil, err
}
return ms.DoStream(ctx, mf, reqFormat, reqBitRate, reqOffset)
return ms.DoStream(ctx, mf, req)
}
func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, req StreamRequest) (*Stream, error) {
var format string
var bitRate int
var cached bool
defer func() {
log.Info(ctx, "Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format, "cached", cached,
"bitRate", bitRate, "user", userName(ctx), "transcoding", format != "raw",
"bitRate", bitRate, "sampleRate", req.SampleRate, "bitDepth", req.BitDepth, "channels", req.Channels,
"user", userName(ctx), "transcoding", format != "raw",
"originalFormat", mf.Suffix, "originalBitRate", mf.BitRate)
}()
format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate)
format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, req.Format, req.BitRate, req.SampleRate)
s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate}
filePath := mf.AbsolutePath()
if format == "raw" {
log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", filePath,
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
"requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
"selectedBitrate", bitRate, "selectedFormat", format)
f, err := os.Open(filePath)
@@ -87,12 +102,15 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF
}
job := &streamJob{
ms: ms,
mf: mf,
filePath: filePath,
format: format,
bitRate: bitRate,
offset: reqOffset,
ms: ms,
mf: mf,
filePath: filePath,
format: format,
bitRate: bitRate,
sampleRate: req.SampleRate,
bitDepth: req.BitDepth,
channels: req.Channels,
offset: req.Offset,
}
r, err := ms.cache.Get(ctx, job)
if err != nil {
@@ -105,7 +123,7 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF
s.Seeker = r.Seeker
log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", filePath,
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
"requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
"selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable())
@@ -131,12 +149,13 @@ func (s *Stream) EstimatedContentLength() int {
}
// TODO This function deserves some love (refactoring)
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) {
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int, reqSampleRate int) (format string, bitRate int) {
format = "raw"
if reqFormat == "raw" {
return format, 0
}
if reqFormat == mf.Suffix && reqBitRate == 0 {
needsResample := reqSampleRate > 0 && reqSampleRate < mf.SampleRate
if reqFormat == mf.Suffix && reqBitRate == 0 && !needsResample {
bitRate = mf.BitRate
return format, bitRate
}
@@ -175,7 +194,7 @@ func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model
bitRate = t.DefaultBitRate
}
}
if format == mf.Suffix && bitRate >= mf.BitRate {
if format == mf.Suffix && bitRate >= mf.BitRate && !needsResample {
format = "raw"
bitRate = 0
}
@@ -217,7 +236,16 @@ func NewTranscodingCache() TranscodingCache {
transcodingCtx = request.AddValues(context.Background(), ctx)
}
out, err := job.ms.transcoder.Transcode(transcodingCtx, t.Command, job.filePath, job.bitRate, job.offset)
out, err := job.ms.transcoder.Transcode(transcodingCtx, ffmpeg.TranscodeOptions{
Command: t.Command,
Format: job.format,
FilePath: job.filePath,
BitRate: job.bitRate,
SampleRate: job.sampleRate,
BitDepth: job.bitDepth,
Channels: job.channels,
Offset: job.offset,
})
if err != nil {
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
return nil, os.ErrInvalid

View File

@@ -26,42 +26,64 @@ var _ = Describe("MediaStreamer", func() {
It("returns raw if raw is requested", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0, 0)
Expect(format).To(Equal("raw"))
})
It("returns raw if a transcoder does not exists", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 0)
format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 0, 0)
Expect(format).To(Equal("raw"))
})
It("returns the requested format if a transcoder exists", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0, 0)
Expect(format).To(Equal("mp3"))
Expect(bitRate).To(Equal(160)) // Default Bit Rate
})
It("returns raw if requested format is the same as the original and it is not necessary to downsample", func() {
mf.Suffix = "mp3"
mf.BitRate = 112
format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128)
format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128, 0)
Expect(format).To(Equal("raw"))
})
It("returns the requested format if requested BitRate is lower than original", func() {
mf.Suffix = "mp3"
mf.BitRate = 320
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192, 0)
Expect(format).To(Equal("mp3"))
Expect(bitRate).To(Equal(192))
})
It("returns raw if requested format is the same as the original, but requested BitRate is 0", func() {
mf.Suffix = "mp3"
mf.BitRate = 320
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0, 0)
Expect(format).To(Equal("raw"))
Expect(bitRate).To(Equal(320))
})
It("returns the format when same format is requested but with a lower sample rate", func() {
mf.Suffix = "flac"
mf.BitRate = 2118
mf.SampleRate = 96000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "flac", 0, 48000)
Expect(format).To(Equal("flac"))
Expect(bitRate).To(Equal(0))
})
It("returns raw when same format is requested with same sample rate", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
mf.SampleRate = 48000
format, _ := selectTranscodingOptions(ctx, ds, mf, "flac", 0, 48000)
Expect(format).To(Equal("raw"))
})
It("returns raw when same format is requested with no sample rate constraint", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
mf.SampleRate = 96000
format, _ := selectTranscodingOptions(ctx, ds, mf, "flac", 0, 0)
Expect(format).To(Equal("raw"))
})
Context("Downsampling", func() {
BeforeEach(func() {
conf.Server.DefaultDownsamplingFormat = "opus"
@@ -69,13 +91,13 @@ var _ = Describe("MediaStreamer", func() {
mf.BitRate = 960
})
It("returns the DefaultDownsamplingFormat if a maxBitrate is requested but not the format", func() {
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 128)
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 128, 0)
Expect(format).To(Equal("opus"))
Expect(bitRate).To(Equal(128))
})
It("returns raw if maxBitrate is equal or greater than original", func() {
// This happens with DSub (and maybe other clients?). See https://github.com/navidrome/navidrome/issues/2066
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 960)
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 960, 0)
Expect(format).To(Equal("raw"))
Expect(bitRate).To(Equal(0))
})
@@ -90,34 +112,34 @@ var _ = Describe("MediaStreamer", func() {
It("returns raw if raw is requested", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0, 0)
Expect(format).To(Equal("raw"))
})
It("returns configured format/bitrate as default", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0, 0)
Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(96))
})
It("returns requested format", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0, 0)
Expect(format).To(Equal("mp3"))
Expect(bitRate).To(Equal(160)) // Default Bit Rate
})
It("returns requested bitrate", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80)
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80, 0)
Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(80))
})
It("returns raw if selected bitrate and format is the same as original", func() {
mf.Suffix = "mp3"
mf.BitRate = 192
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192, 0)
Expect(format).To(Equal("raw"))
Expect(bitRate).To(Equal(0))
})
@@ -133,27 +155,27 @@ var _ = Describe("MediaStreamer", func() {
It("returns raw if raw is requested", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0, 0)
Expect(format).To(Equal("raw"))
})
It("returns configured format/bitrate as default", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0, 0)
Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(192))
})
It("returns requested format", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0, 0)
Expect(format).To(Equal("mp3"))
Expect(bitRate).To(Equal(160)) // Default Bit Rate
})
It("returns requested bitrate", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 160)
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 160, 0)
Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(160))
})

View File

@@ -39,34 +39,34 @@ var _ = Describe("MediaStreamer", func() {
Context("NewStream", func() {
It("returns a seekable stream if format is 'raw'", func() {
s, err := streamer.NewStream(ctx, "123", "raw", 0, 0)
s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "raw"})
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a seekable stream if maxBitRate is 0", func() {
s, err := streamer.NewStream(ctx, "123", "mp3", 0, 0)
s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3"})
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a seekable stream if maxBitRate is higher than file bitRate", func() {
s, err := streamer.NewStream(ctx, "123", "mp3", 320, 0)
s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 320})
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a NON seekable stream if transcode is required", func() {
s, err := streamer.NewStream(ctx, "123", "mp3", 64, 0)
s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 64})
Expect(err).To(BeNil())
Expect(s.Seekable()).To(BeFalse())
Expect(s.Duration()).To(Equal(float32(257.0)))
})
It("returns a seekable stream if the file is complete in the cache", func() {
s, err := streamer.NewStream(ctx, "123", "mp3", 32, 0)
s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 32})
Expect(err).To(BeNil())
_, _ = io.ReadAll(s)
_ = s.Close()
Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue())
s, err = streamer.NewStream(ctx, "123", "mp3", 32, 0)
s, err = streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 32})
Expect(err).To(BeNil())
Expect(s.Seekable()).To(BeTrue())
})

View File

@@ -220,7 +220,7 @@ var staticData = sync.OnceValue(func() insights.Data {
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != ""
data.Config.HasCustomPID = conf.Server.PID.Track != consts.DefaultTrackPID || conf.Server.PID.Album != consts.DefaultAlbumPID
data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != ""
data.Config.HasCustomTags = len(conf.Server.Tags) > 0
return data

87
core/transcode/aliases.go Normal file
View File

@@ -0,0 +1,87 @@
package transcode
import (
"slices"
"strings"
)
// containerAliasGroups maps each container alias to a canonical group name.
var containerAliasGroups = func() map[string]string {
groups := [][]string{
{"aac", "adts", "m4a", "mp4", "m4b", "m4p"},
{"mpeg", "mp3", "mp2"},
{"ogg", "oga"},
{"aif", "aiff"},
{"asf", "wma"},
{"mpc", "mpp"},
{"wv"},
}
m := make(map[string]string)
for _, g := range groups {
canonical := g[0]
for _, name := range g {
m[name] = canonical
}
}
return m
}()
// codecAliasGroups maps each codec alias to a canonical group name.
// Codecs within the same group are considered equivalent.
var codecAliasGroups = func() map[string]string {
groups := [][]string{
{"aac", "adts"},
{"ac3", "ac-3"},
{"eac3", "e-ac3", "e-ac-3", "eac-3"},
{"mpc7", "musepack7"},
{"mpc8", "musepack8"},
{"wma1", "wmav1"},
{"wma2", "wmav2"},
{"wmalossless", "wma9lossless"},
{"wmapro", "wma9pro"},
{"shn", "shorten"},
{"mp4als", "als"},
}
m := make(map[string]string)
for _, g := range groups {
for _, name := range g {
m[name] = g[0] // canonical = first entry
}
}
return m
}()
// matchesWithAliases checks if a value matches any entry in candidates,
// consulting the alias map for equivalent names.
func matchesWithAliases(value string, candidates []string, aliases map[string]string) bool {
value = strings.ToLower(value)
canonical := aliases[value]
for _, c := range candidates {
c = strings.ToLower(c)
if c == value {
return true
}
if canonical != "" && aliases[c] == canonical {
return true
}
}
return false
}
// matchesContainer checks if a file suffix matches any of the container names,
// including common aliases.
func matchesContainer(suffix string, containers []string) bool {
return matchesWithAliases(suffix, containers, containerAliasGroups)
}
// matchesCodec checks if a codec matches any of the codec names,
// including common aliases.
func matchesCodec(codec string, codecs []string) bool {
return matchesWithAliases(codec, codecs, codecAliasGroups)
}
func containsIgnoreCase(slice []string, s string) bool {
return slices.ContainsFunc(slice, func(item string) bool {
return strings.EqualFold(item, s)
})
}

59
core/transcode/codec.go Normal file
View File

@@ -0,0 +1,59 @@
package transcode
import "strings"
// isLosslessFormat returns true if the format is a lossless audio codec/format.
// Note: core/ffmpeg has a separate isLosslessOutputFormat that covers only formats
// ffmpeg can produce as output (a smaller set). This function covers all known lossless formats
// for transcoding decision purposes.
func isLosslessFormat(format string) bool {
switch strings.ToLower(format) {
case "flac", "alac", "wav", "aiff", "ape", "wv", "tta", "tak", "shn", "dsd", "pcm":
return true
}
return false
}
// normalizeSourceSampleRate adjusts the source sample rate for codecs that store
// it differently than PCM. Currently handles DSD (÷8):
// DSD64=2822400→352800, DSD128=5644800→705600, etc.
// For other codecs, returns the rate unchanged.
func normalizeSourceSampleRate(sampleRate int, codec string) int {
if strings.EqualFold(codec, "dsd") && sampleRate > 0 {
return sampleRate / 8
}
return sampleRate
}
// normalizeSourceBitDepth adjusts the source bit depth for codecs that use
// non-standard bit depths. Currently handles DSD (1-bit → 24-bit PCM, which is
// what ffmpeg produces). For other codecs, returns the depth unchanged.
func normalizeSourceBitDepth(bitDepth int, codec string) int {
if strings.EqualFold(codec, "dsd") && bitDepth == 1 {
return 24
}
return bitDepth
}
// codecFixedOutputSampleRate returns the mandatory output sample rate for codecs
// that always resample regardless of input (e.g., Opus always outputs 48000Hz).
// Returns 0 if the codec has no fixed output rate.
func codecFixedOutputSampleRate(codec string) int {
switch strings.ToLower(codec) {
case "opus":
return 48000
}
return 0
}
// codecMaxSampleRate returns the hard maximum output sample rate for a codec.
// Returns 0 if the codec has no hard limit.
func codecMaxSampleRate(codec string) int {
switch strings.ToLower(codec) {
case "mp3":
return 48000
case "aac":
return 96000
}
return 0
}

View File

@@ -0,0 +1,206 @@
package transcode
import (
"strconv"
"strings"
"github.com/navidrome/navidrome/model"
)
// adjustResult represents the outcome of applying a limitation to a transcoded stream value
type adjustResult int
const (
adjustNone adjustResult = iota // Value already satisfies the limitation
adjustAdjusted // Value was changed to fit the limitation
adjustCannotFit // Cannot satisfy the limitation (reject this profile)
)
// checkLimitations checks codec profile limitations against source media.
// Returns "" if all limitations pass, or a typed reason string for the first failure.
func checkLimitations(mf *model.MediaFile, sourceBitrate int, limitations []Limitation) string {
for _, lim := range limitations {
var ok bool
var reason string
switch lim.Name {
case LimitationAudioChannels:
ok = checkIntLimitation(mf.Channels, lim.Comparison, lim.Values)
reason = "audio channels not supported"
case LimitationAudioSamplerate:
ok = checkIntLimitation(mf.SampleRate, lim.Comparison, lim.Values)
reason = "audio samplerate not supported"
case LimitationAudioBitrate:
ok = checkIntLimitation(sourceBitrate, lim.Comparison, lim.Values)
reason = "audio bitrate not supported"
case LimitationAudioBitdepth:
ok = checkIntLimitation(mf.BitDepth, lim.Comparison, lim.Values)
reason = "audio bitdepth not supported"
case LimitationAudioProfile:
// TODO: populate source profile when MediaFile has audio profile info
ok = checkStringLimitation("", lim.Comparison, lim.Values)
reason = "audio profile not supported"
default:
continue
}
if !ok && lim.Required {
return reason
}
}
return ""
}
// applyLimitation adjusts a transcoded stream parameter to satisfy the limitation.
// Returns the adjustment result.
func applyLimitation(sourceBitrate int, lim *Limitation, ts *StreamDetails) adjustResult {
switch lim.Name {
case LimitationAudioChannels:
return applyIntLimitation(lim.Comparison, lim.Values, ts.Channels, func(v int) { ts.Channels = v })
case LimitationAudioBitrate:
current := ts.Bitrate
if current == 0 {
current = sourceBitrate
}
return applyIntLimitation(lim.Comparison, lim.Values, current, func(v int) { ts.Bitrate = v })
case LimitationAudioSamplerate:
return applyIntLimitation(lim.Comparison, lim.Values, ts.SampleRate, func(v int) { ts.SampleRate = v })
case LimitationAudioBitdepth:
if ts.BitDepth > 0 {
return applyIntLimitation(lim.Comparison, lim.Values, ts.BitDepth, func(v int) { ts.BitDepth = v })
}
case LimitationAudioProfile:
// TODO: implement when audio profile data is available
}
return adjustNone
}
// applyIntLimitation applies a limitation comparison to a value.
// If the value needs adjusting, calls the setter and returns the result.
func applyIntLimitation(comparison string, values []string, current int, setter func(int)) adjustResult {
if len(values) == 0 {
return adjustNone
}
switch comparison {
case ComparisonLessThanEqual:
limit, ok := parseInt(values[0])
if !ok {
return adjustNone
}
if current <= limit {
return adjustNone
}
setter(limit)
return adjustAdjusted
case ComparisonGreaterThanEqual:
limit, ok := parseInt(values[0])
if !ok {
return adjustNone
}
if current >= limit {
return adjustNone
}
// Cannot upscale
return adjustCannotFit
case ComparisonEquals:
// Check if current value matches any allowed value
for _, v := range values {
if limit, ok := parseInt(v); ok && current == limit {
return adjustNone
}
}
// Find the closest allowed value below current (don't upscale)
var closest int
found := false
for _, v := range values {
if limit, ok := parseInt(v); ok && limit < current {
if !found || limit > closest {
closest = limit
found = true
}
}
}
if found {
setter(closest)
return adjustAdjusted
}
return adjustCannotFit
case ComparisonNotEquals:
for _, v := range values {
if limit, ok := parseInt(v); ok && current == limit {
return adjustCannotFit
}
}
return adjustNone
}
return adjustNone
}
func checkIntLimitation(value int, comparison string, values []string) bool {
if len(values) == 0 {
return true
}
switch comparison {
case ComparisonLessThanEqual:
limit, ok := parseInt(values[0])
if !ok {
return true
}
return value <= limit
case ComparisonGreaterThanEqual:
limit, ok := parseInt(values[0])
if !ok {
return true
}
return value >= limit
case ComparisonEquals:
for _, v := range values {
if limit, ok := parseInt(v); ok && value == limit {
return true
}
}
return false
case ComparisonNotEquals:
for _, v := range values {
if limit, ok := parseInt(v); ok && value == limit {
return false
}
}
return true
}
return true
}
// checkStringLimitation checks a string value against a limitation.
// Only Equals and NotEquals comparisons are meaningful for strings.
// LessThanEqual/GreaterThanEqual are not applicable and always pass.
func checkStringLimitation(value string, comparison string, values []string) bool {
switch comparison {
case ComparisonEquals:
for _, v := range values {
if strings.EqualFold(value, v) {
return true
}
}
return false
case ComparisonNotEquals:
for _, v := range values {
if strings.EqualFold(value, v) {
return false
}
}
return true
}
return true
}
func parseInt(s string) (int, bool) {
v, err := strconv.Atoi(s)
if err != nil || v < 0 {
return 0, false
}
return v, true
}

400
core/transcode/transcode.go Normal file
View File

@@ -0,0 +1,400 @@
package transcode
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
const (
tokenTTL = 12 * time.Hour
defaultBitrate = 256 // kbps
// JWT claim keys for transcode params tokens
claimMediaID = "mid" // Media file ID
claimDirectPlay = "dp" // Direct play flag (bool)
claimUpdatedAt = "ua" // Source file updated-at (Unix seconds)
claimFormat = "fmt" // Target transcoding format
claimBitrate = "br" // Target bitrate (kbps)
claimChannels = "ch" // Target channels
claimSampleRate = "sr" // Target sample rate (Hz)
claimBitDepth = "bd" // Target bit depth
)
func NewDecider(ds model.DataStore) Decider {
return &deciderService{
ds: ds,
}
}
type deciderService struct {
ds model.DataStore
}
func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo) (*Decision, error) {
decision := &Decision{
MediaID: mf.ID,
SourceUpdatedAt: mf.UpdatedAt,
}
sourceBitrate := mf.BitRate // kbps
log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", mf.Suffix,
"codec", mf.AudioCodec(), "bitrate", sourceBitrate, "channels", mf.Channels,
"sampleRate", mf.SampleRate, "lossless", mf.IsLossless(), "client", clientInfo.Name)
// Build source stream details
decision.SourceStream = buildSourceStream(mf)
// Check global bitrate constraint first.
if clientInfo.MaxAudioBitrate > 0 && sourceBitrate > clientInfo.MaxAudioBitrate {
log.Trace(ctx, "Global bitrate constraint exceeded, skipping direct play",
"sourceBitrate", sourceBitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate)
decision.TranscodeReasons = append(decision.TranscodeReasons, "audio bitrate not supported")
// Skip direct play profiles entirely — global constraint fails
} else {
// Try direct play profiles, collecting reasons for each failure
for _, profile := range clientInfo.DirectPlayProfiles {
if reason := s.checkDirectPlayProfile(mf, sourceBitrate, &profile, clientInfo); reason == "" {
decision.CanDirectPlay = true
decision.TranscodeReasons = nil // Clear any previously collected reasons
break
} else {
decision.TranscodeReasons = append(decision.TranscodeReasons, reason)
}
}
}
// If direct play is possible, we're done
if decision.CanDirectPlay {
log.Debug(ctx, "Transcode decision: direct play", "mediaID", mf.ID, "container", mf.Suffix, "codec", mf.AudioCodec())
return decision, nil
}
// Try transcoding profiles (in order of preference)
for _, profile := range clientInfo.TranscodingProfiles {
if ts, transcodeFormat := s.computeTranscodedStream(ctx, mf, sourceBitrate, &profile, clientInfo); ts != nil {
decision.CanTranscode = true
decision.TargetFormat = transcodeFormat
decision.TargetBitrate = ts.Bitrate
decision.TargetChannels = ts.Channels
decision.TargetSampleRate = ts.SampleRate
decision.TargetBitDepth = ts.BitDepth
decision.TranscodeStream = ts
break
}
}
if decision.CanTranscode {
log.Debug(ctx, "Transcode decision: transcode", "mediaID", mf.ID,
"targetFormat", decision.TargetFormat, "targetBitrate", decision.TargetBitrate,
"targetChannels", decision.TargetChannels, "reasons", decision.TranscodeReasons)
}
// If neither direct play nor transcode is possible
if !decision.CanDirectPlay && !decision.CanTranscode {
decision.ErrorReason = "no compatible playback profile found"
log.Warn(ctx, "Transcode decision: no compatible profile", "mediaID", mf.ID,
"container", mf.Suffix, "codec", mf.AudioCodec(), "reasons", decision.TranscodeReasons)
}
return decision, nil
}
func buildSourceStream(mf *model.MediaFile) StreamDetails {
return StreamDetails{
Container: mf.Suffix,
Codec: mf.AudioCodec(),
Bitrate: mf.BitRate,
SampleRate: mf.SampleRate,
BitDepth: mf.BitDepth,
Channels: mf.Channels,
Duration: mf.Duration,
Size: mf.Size,
IsLossless: mf.IsLossless(),
}
}
// checkDirectPlayProfile returns "" if the profile matches (direct play OK),
// or a typed reason string if it doesn't match.
func (s *deciderService) checkDirectPlayProfile(mf *model.MediaFile, sourceBitrate int, profile *DirectPlayProfile, clientInfo *ClientInfo) string {
// Check protocol (only http for now)
if len(profile.Protocols) > 0 && !containsIgnoreCase(profile.Protocols, ProtocolHTTP) {
return "protocol not supported"
}
// Check container
if len(profile.Containers) > 0 && !matchesContainer(mf.Suffix, profile.Containers) {
return "container not supported"
}
// Check codec
if len(profile.AudioCodecs) > 0 && !matchesCodec(mf.AudioCodec(), profile.AudioCodecs) {
return "audio codec not supported"
}
// Check channels
if profile.MaxAudioChannels > 0 && mf.Channels > profile.MaxAudioChannels {
return "audio channels not supported"
}
// Check codec-specific limitations
for _, codecProfile := range clientInfo.CodecProfiles {
if strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) && matchesCodec(mf.AudioCodec(), []string{codecProfile.Name}) {
if reason := checkLimitations(mf, sourceBitrate, codecProfile.Limitations); reason != "" {
return reason
}
}
}
return ""
}
// computeTranscodedStream attempts to build a valid transcoded stream for the given profile.
// Returns the stream details and the internal transcoding format (which may differ from the
// response container when a codec fallback occurs, e.g., "mp4"→"aac").
// Returns nil, "" if the profile cannot produce a valid output.
func (s *deciderService) computeTranscodedStream(ctx context.Context, mf *model.MediaFile, sourceBitrate int, profile *Profile, clientInfo *ClientInfo) (*StreamDetails, string) {
// Check protocol (only http for now)
if profile.Protocol != "" && !strings.EqualFold(profile.Protocol, ProtocolHTTP) {
log.Trace(ctx, "Skipping transcoding profile: unsupported protocol", "protocol", profile.Protocol)
return nil, ""
}
responseContainer, targetFormat := s.resolveTargetFormat(ctx, profile)
if targetFormat == "" {
return nil, ""
}
targetIsLossless := isLosslessFormat(targetFormat)
// Reject lossy to lossless conversion
if !mf.IsLossless() && targetIsLossless {
log.Trace(ctx, "Skipping transcoding profile: lossy to lossless not allowed", "targetFormat", targetFormat)
return nil, ""
}
ts := &StreamDetails{
Container: responseContainer,
Codec: strings.ToLower(profile.AudioCodec),
SampleRate: normalizeSourceSampleRate(mf.SampleRate, mf.AudioCodec()),
Channels: mf.Channels,
BitDepth: normalizeSourceBitDepth(mf.BitDepth, mf.AudioCodec()),
IsLossless: targetIsLossless,
}
if ts.Codec == "" {
ts.Codec = targetFormat
}
// Apply codec-intrinsic sample rate adjustments before codec profile limitations
if fixedRate := codecFixedOutputSampleRate(ts.Codec); fixedRate > 0 {
ts.SampleRate = fixedRate
}
if maxRate := codecMaxSampleRate(ts.Codec); maxRate > 0 && ts.SampleRate > maxRate {
ts.SampleRate = maxRate
}
// Determine target bitrate (all in kbps)
if ok := s.computeBitrate(ctx, mf, sourceBitrate, targetFormat, targetIsLossless, clientInfo, ts); !ok {
return nil, ""
}
// Apply MaxAudioChannels from the transcoding profile
if profile.MaxAudioChannels > 0 && mf.Channels > profile.MaxAudioChannels {
ts.Channels = profile.MaxAudioChannels
}
// Apply codec profile limitations to the TARGET codec
if ok := s.applyCodecLimitations(ctx, sourceBitrate, targetFormat, targetIsLossless, clientInfo, ts); !ok {
return nil, ""
}
return ts, targetFormat
}
// resolveTargetFormat determines the response container and internal target format
// by looking up transcoding configs. Returns ("", "") if no config found.
func (s *deciderService) resolveTargetFormat(ctx context.Context, profile *Profile) (responseContainer, targetFormat string) {
responseContainer = strings.ToLower(profile.Container)
targetFormat = responseContainer
if targetFormat == "" {
targetFormat = strings.ToLower(profile.AudioCodec)
responseContainer = targetFormat
}
// Try the container first, then fall back to the audioCodec (e.g. "ogg" → "opus", "mp4" → "aac").
_, err := s.ds.Transcoding(ctx).FindByFormat(targetFormat)
if errors.Is(err, model.ErrNotFound) && profile.AudioCodec != "" && !strings.EqualFold(targetFormat, profile.AudioCodec) {
codec := strings.ToLower(profile.AudioCodec)
log.Trace(ctx, "No transcoding config for container, trying audioCodec", "container", targetFormat, "audioCodec", codec)
_, err = s.ds.Transcoding(ctx).FindByFormat(codec)
if err == nil {
targetFormat = codec
}
}
if err != nil {
if !errors.Is(err, model.ErrNotFound) {
log.Error(ctx, "Error looking up transcoding config", "format", targetFormat, err)
} else {
log.Trace(ctx, "Skipping transcoding profile: no transcoding config", "targetFormat", targetFormat)
}
return "", ""
}
return responseContainer, targetFormat
}
// computeBitrate determines the target bitrate for the transcoded stream.
// Returns false if the profile should be rejected.
func (s *deciderService) computeBitrate(ctx context.Context, mf *model.MediaFile, sourceBitrate int, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *StreamDetails) bool {
if mf.IsLossless() {
if !targetIsLossless {
if clientInfo.MaxTranscodingAudioBitrate > 0 {
ts.Bitrate = clientInfo.MaxTranscodingAudioBitrate
} else {
ts.Bitrate = defaultBitrate
}
} else {
if clientInfo.MaxAudioBitrate > 0 && sourceBitrate > clientInfo.MaxAudioBitrate {
log.Trace(ctx, "Skipping transcoding profile: lossless target exceeds bitrate limit",
"targetFormat", targetFormat, "sourceBitrate", sourceBitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate)
return false
}
}
} else {
ts.Bitrate = sourceBitrate
}
// Apply maxAudioBitrate as final cap
if clientInfo.MaxAudioBitrate > 0 && ts.Bitrate > 0 && ts.Bitrate > clientInfo.MaxAudioBitrate {
ts.Bitrate = clientInfo.MaxAudioBitrate
}
return true
}
// applyCodecLimitations applies codec profile limitations to the transcoded stream.
// Returns false if the profile should be rejected.
func (s *deciderService) applyCodecLimitations(ctx context.Context, sourceBitrate int, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *StreamDetails) bool {
targetCodec := ts.Codec
for _, codecProfile := range clientInfo.CodecProfiles {
if !strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) {
continue
}
if !matchesCodec(targetCodec, []string{codecProfile.Name}) {
continue
}
for _, lim := range codecProfile.Limitations {
result := applyLimitation(sourceBitrate, &lim, ts)
if strings.EqualFold(lim.Name, LimitationAudioBitrate) && targetIsLossless && result == adjustAdjusted {
log.Trace(ctx, "Skipping transcoding profile: cannot adjust bitrate for lossless target",
"targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name)
return false
}
if result == adjustCannotFit {
log.Trace(ctx, "Skipping transcoding profile: codec limitation cannot be satisfied",
"targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name,
"comparison", lim.Comparison, "values", lim.Values)
return false
}
}
}
return true
}
func (s *deciderService) CreateTranscodeParams(decision *Decision) (string, error) {
exp := time.Now().Add(tokenTTL)
claims := map[string]any{
claimMediaID: decision.MediaID,
claimDirectPlay: decision.CanDirectPlay,
claimUpdatedAt: decision.SourceUpdatedAt.Truncate(time.Second).Unix(),
}
if decision.CanTranscode && decision.TargetFormat != "" {
claims[claimFormat] = decision.TargetFormat
claims[claimBitrate] = decision.TargetBitrate
if decision.TargetChannels > 0 {
claims[claimChannels] = decision.TargetChannels
}
if decision.TargetSampleRate > 0 {
claims[claimSampleRate] = decision.TargetSampleRate
}
if decision.TargetBitDepth > 0 {
claims[claimBitDepth] = decision.TargetBitDepth
}
}
return auth.CreateExpiringPublicToken(exp, claims)
}
func (s *deciderService) ParseTranscodeParams(token string) (*Params, error) {
claims, err := auth.Validate(token)
if err != nil {
return nil, err
}
params := &Params{}
// Required claims
mid, ok := claims[claimMediaID].(string)
if !ok || mid == "" {
return nil, fmt.Errorf("%w: invalid transcode token: missing media ID", ErrTokenInvalid)
}
params.MediaID = mid
dp, ok := claims[claimDirectPlay].(bool)
if !ok {
return nil, fmt.Errorf("%w: invalid transcode token: missing direct play flag", ErrTokenInvalid)
}
params.DirectPlay = dp
// Optional claims (legitimately absent for direct-play tokens)
if f, ok := claims[claimFormat].(string); ok {
params.TargetFormat = f
}
if br, ok := claims[claimBitrate].(float64); ok {
params.TargetBitrate = int(br)
}
if ch, ok := claims[claimChannels].(float64); ok {
params.TargetChannels = int(ch)
}
if sr, ok := claims[claimSampleRate].(float64); ok {
params.TargetSampleRate = int(sr)
}
if bd, ok := claims[claimBitDepth].(float64); ok {
params.TargetBitDepth = int(bd)
}
ua, ok := claims[claimUpdatedAt].(float64)
if !ok {
return nil, fmt.Errorf("%w: invalid transcode token: missing source timestamp", ErrTokenInvalid)
}
params.SourceUpdatedAt = time.Unix(int64(ua), 0)
return params, nil
}
func (s *deciderService) ValidateTranscodeParams(ctx context.Context, token string, mediaID string) (*Params, *model.MediaFile, error) {
params, err := s.ParseTranscodeParams(token)
if err != nil {
return nil, nil, errors.Join(ErrTokenInvalid, err)
}
if params.MediaID != mediaID {
return nil, nil, fmt.Errorf("%w: token mediaID %q does not match %q", ErrTokenInvalid, params.MediaID, mediaID)
}
mf, err := s.ds.MediaFile(ctx).Get(mediaID)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return nil, nil, ErrMediaNotFound
}
return nil, nil, err
}
if !mf.UpdatedAt.Truncate(time.Second).Equal(params.SourceUpdatedAt) {
log.Info(ctx, "Transcode token is stale", "mediaID", mediaID,
"tokenUpdatedAt", params.SourceUpdatedAt, "fileUpdatedAt", mf.UpdatedAt)
return nil, nil, ErrTokenStale
}
return params, mf, nil
}

View File

@@ -0,0 +1,17 @@
package transcode
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestTranscode(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Transcode Suite")
}

View File

File diff suppressed because it is too large Load Diff

140
core/transcode/types.go Normal file
View File

@@ -0,0 +1,140 @@
package transcode
import (
"context"
"errors"
"time"
"github.com/navidrome/navidrome/model"
)
var (
ErrTokenInvalid = errors.New("invalid or expired transcode token")
ErrMediaNotFound = errors.New("media file not found")
ErrTokenStale = errors.New("transcode token is stale: media file has changed")
)
// Decider is the core service interface for making transcoding decisions
type Decider interface {
MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo) (*Decision, error)
CreateTranscodeParams(decision *Decision) (string, error)
ParseTranscodeParams(token string) (*Params, error)
ValidateTranscodeParams(ctx context.Context, token string, mediaID string) (*Params, *model.MediaFile, error)
}
// ClientInfo represents client playback capabilities.
// All bitrate values are in kilobits per second (kbps)
type ClientInfo struct {
Name string
Platform string
MaxAudioBitrate int
MaxTranscodingAudioBitrate int
DirectPlayProfiles []DirectPlayProfile
TranscodingProfiles []Profile
CodecProfiles []CodecProfile
}
// DirectPlayProfile describes a format the client can play directly
type DirectPlayProfile struct {
Containers []string
AudioCodecs []string
Protocols []string
MaxAudioChannels int
}
// Profile describes a transcoding target the client supports
type Profile struct {
Container string
AudioCodec string
Protocol string
MaxAudioChannels int
}
// CodecProfile describes codec-specific limitations
type CodecProfile struct {
Type string
Name string
Limitations []Limitation
}
// Limitation describes a specific codec limitation
type Limitation struct {
Name string
Comparison string
Values []string
Required bool
}
// Protocol values (OpenSubsonic spec enum)
const (
ProtocolHTTP = "http"
ProtocolHLS = "hls"
)
// Comparison operators (OpenSubsonic spec enum)
const (
ComparisonEquals = "Equals"
ComparisonNotEquals = "NotEquals"
ComparisonLessThanEqual = "LessThanEqual"
ComparisonGreaterThanEqual = "GreaterThanEqual"
)
// Limitation names (OpenSubsonic spec enum)
const (
LimitationAudioChannels = "audioChannels"
LimitationAudioBitrate = "audioBitrate"
LimitationAudioProfile = "audioProfile"
LimitationAudioSamplerate = "audioSamplerate"
LimitationAudioBitdepth = "audioBitdepth"
)
// Codec profile types (OpenSubsonic spec enum)
const (
CodecProfileTypeAudio = "AudioCodec"
)
// Decision represents the internal decision result.
// All bitrate values are in kilobits per second (kbps).
type Decision struct {
MediaID string
CanDirectPlay bool
CanTranscode bool
TranscodeReasons []string
ErrorReason string
TargetFormat string
TargetBitrate int
TargetChannels int
TargetSampleRate int
TargetBitDepth int
SourceStream StreamDetails
SourceUpdatedAt time.Time
TranscodeStream *StreamDetails
}
// StreamDetails describes audio stream properties.
// Bitrate is in kilobits per second (kbps).
type StreamDetails struct {
Container string
Codec string
Profile string // Audio profile (e.g., "LC", "HE-AAC"). Empty until scanner support is added.
Bitrate int
SampleRate int
BitDepth int
Channels int
Duration float32
Size int64
IsLossless bool
}
// Params contains the parameters extracted from a transcode token.
// TargetBitrate is in kilobits per second (kbps).
type Params struct {
MediaID string
DirectPlay bool
TargetFormat string
TargetBitrate int
TargetChannels int
TargetSampleRate int
TargetBitDepth int
SourceUpdatedAt time.Time
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/transcode"
)
var Set = wire.NewSet(
@@ -20,6 +21,7 @@ var Set = wire.NewSet(
NewLibrary,
NewUser,
NewMaintenance,
transcode.NewDecider,
agents.GetAgents,
external.NewProvider,
wire.Bind(new(external.Agents), new(*agents.Agents)),

View File

@@ -0,0 +1,63 @@
package migrations
import (
"context"
"database/sql"
"github.com/navidrome/navidrome/model/id"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddCodecAndUpdateTranscodings, downAddCodecAndUpdateTranscodings)
}
func upAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error {
// Add codec column to media_file.
_, err := tx.Exec(`ALTER TABLE media_file ADD COLUMN codec VARCHAR(255) DEFAULT '' NOT NULL`)
if err != nil {
return err
}
_, err = tx.Exec(`CREATE INDEX IF NOT EXISTS media_file_codec ON media_file(codec)`)
if err != nil {
return err
}
// Update old AAC default (adts) to new default (ipod with fragmented MP4).
// Only affects users who still have the unmodified old default command.
_, err = tx.Exec(
`UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND command = ?`,
"ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -",
"ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
)
if err != nil {
return err
}
// Add FLAC transcoding for existing installations that were seeded before FLAC was added.
var count int
err = tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = 'flac'").Scan(&count)
if err != nil {
return err
}
if count == 0 {
_, err = tx.Exec(
"INSERT INTO transcoding (id, name, target_format, default_bit_rate, command) VALUES (?, ?, ?, ?, ?)",
id.NewRandom(), "flac audio", "flac", 0,
"ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -",
)
if err != nil {
return err
}
}
return nil
}
func downAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`DROP INDEX IF EXISTS media_file_codec`)
if err != nil {
return err
}
_, err = tx.Exec(`ALTER TABLE media_file DROP COLUMN codec`)
return err
}

2
go.mod
View File

@@ -7,7 +7,7 @@ replace (
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
// Fork to implement raw tags support
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260212150743-3f1b97cb0d1e
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260209170351-c057626454d0
)
require (

4
go.sum
View File

@@ -36,8 +36,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/deluan/go-taglib v0.0.0-20260212150743-3f1b97cb0d1e h1:pwx3kmHzl1N28coJV2C1zfm2ZF0qkQcGX+Z6BvXteB4=
github.com/deluan/go-taglib v0.0.0-20260212150743-3f1b97cb0d1e/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
github.com/deluan/go-taglib v0.0.0-20260209170351-c057626454d0 h1:R8fMzz++cqdQ3DVjzrmAKmZFr2PT8vT8pQEfRzxms00=
github.com/deluan/go-taglib v0.0.0-20260209170351-c057626454d0/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=

View File

@@ -23,7 +23,6 @@ var fieldMap = map[string]*mappedField{
"releasedate": {field: "media_file.release_date"},
"size": {field: "media_file.size"},
"compilation": {field: "media_file.compilation"},
"explicitstatus": {field: "media_file.explicit_status"},
"dateadded": {field: "media_file.created_at"},
"datemodified": {field: "media_file.updated_at"},
"discsubtitle": {field: "media_file.disc_subtitle"},

View File

@@ -14,6 +14,7 @@ import (
"github.com/gohugoio/hashstructure"
"github.com/navidrome/navidrome/conf"
confmime "github.com/navidrome/navidrome/conf/mime"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/slice"
@@ -56,6 +57,7 @@ type MediaFile struct {
SampleRate int `structs:"sample_rate" json:"sampleRate"`
BitDepth int `structs:"bit_depth" json:"bitDepth"`
Channels int `structs:"channels" json:"channels"`
Codec string `structs:"codec" json:"codec"`
Genre string `structs:"genre" json:"genre"`
Genres Genres `structs:"-" json:"genres,omitempty"`
SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"`
@@ -161,6 +163,81 @@ func (mf MediaFile) AbsolutePath() string {
return filepath.Join(mf.LibraryPath, mf.Path)
}
// AudioCodec returns the audio codec for this file.
// Uses the stored Codec field if available, otherwise infers from Suffix and audio properties.
func (mf MediaFile) AudioCodec() string {
// If we have a stored codec from scanning, normalize and return it
if mf.Codec != "" {
return strings.ToLower(mf.Codec)
}
// Fallback: infer from Suffix + BitDepth
return mf.inferCodecFromSuffix()
}
// inferCodecFromSuffix infers the codec from the file extension when Codec field is empty.
func (mf MediaFile) inferCodecFromSuffix() string {
switch strings.ToLower(mf.Suffix) {
case "mp3", "mpga":
return "mp3"
case "mp2":
return "mp2"
case "ogg", "oga":
return "vorbis"
case "opus":
return "opus"
case "mpc":
return "mpc"
case "wma":
return "wma"
case "flac":
return "flac"
case "wav":
return "pcm"
case "aif", "aiff", "aifc":
return "pcm"
case "ape":
return "ape"
case "wv", "wvp":
return "wv"
case "tta":
return "tta"
case "tak":
return "tak"
case "shn":
return "shn"
case "dsf", "dff":
return "dsd"
case "m4a":
// AAC if BitDepth==0, ALAC if BitDepth>0
if mf.BitDepth > 0 {
return "alac"
}
return "aac"
case "m4b", "m4p", "m4r":
return "aac"
default:
return ""
}
}
// IsLossless returns true if this file uses a lossless codec.
func (mf MediaFile) IsLossless() bool {
codec := mf.AudioCodec()
// Primary: codec-based check (most accurate for containers like M4A)
switch codec {
case "flac", "alac", "pcm", "ape", "wv", "tta", "tak", "shn", "dsd":
return true
}
// Secondary: suffix-based check using configurable list from YAML
if slices.Contains(confmime.LosslessFormats, mf.Suffix) {
return true
}
// Fallback heuristic: if BitDepth is set, it's likely lossless.
// This may produce false positives for lossy formats that report bit depth,
// but it becomes irrelevant once the Codec column is populated after a full rescan.
return mf.BitDepth > 0
}
type MediaFiles []MediaFile
// ToAlbum creates an Album object based on the attributes of this MediaFiles collection.

View File

@@ -475,7 +475,7 @@ var _ = Describe("MediaFile", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.EnableMediaFileCoverArt = true
})
Describe(".CoverArtId()", func() {
Describe("CoverArtId", func() {
It("returns its own id if it HasCoverArt", func() {
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
id := mf.CoverArtID()
@@ -496,6 +496,94 @@ var _ = Describe("MediaFile", func() {
Expect(id.ID).To(Equal(mf.AlbumID))
})
})
Describe("AudioCodec", func() {
It("returns normalized stored codec when available", func() {
mf := MediaFile{Codec: "AAC", Suffix: "m4a"}
Expect(mf.AudioCodec()).To(Equal("aac"))
})
It("returns stored codec lowercased", func() {
mf := MediaFile{Codec: "ALAC", Suffix: "m4a"}
Expect(mf.AudioCodec()).To(Equal("alac"))
})
DescribeTable("infers codec from suffix when Codec field is empty",
func(suffix string, bitDepth int, expected string) {
mf := MediaFile{Suffix: suffix, BitDepth: bitDepth}
Expect(mf.AudioCodec()).To(Equal(expected))
},
Entry("mp3", "mp3", 0, "mp3"),
Entry("mpga", "mpga", 0, "mp3"),
Entry("mp2", "mp2", 0, "mp2"),
Entry("ogg", "ogg", 0, "vorbis"),
Entry("oga", "oga", 0, "vorbis"),
Entry("opus", "opus", 0, "opus"),
Entry("mpc", "mpc", 0, "mpc"),
Entry("wma", "wma", 0, "wma"),
Entry("flac", "flac", 0, "flac"),
Entry("wav", "wav", 0, "pcm"),
Entry("aif", "aif", 0, "pcm"),
Entry("aiff", "aiff", 0, "pcm"),
Entry("aifc", "aifc", 0, "pcm"),
Entry("ape", "ape", 0, "ape"),
Entry("wv", "wv", 0, "wv"),
Entry("wvp", "wvp", 0, "wv"),
Entry("tta", "tta", 0, "tta"),
Entry("tak", "tak", 0, "tak"),
Entry("shn", "shn", 0, "shn"),
Entry("dsf", "dsf", 0, "dsd"),
Entry("dff", "dff", 0, "dsd"),
Entry("m4a with BitDepth=0 (AAC)", "m4a", 0, "aac"),
Entry("m4a with BitDepth>0 (ALAC)", "m4a", 16, "alac"),
Entry("m4b", "m4b", 0, "aac"),
Entry("m4p", "m4p", 0, "aac"),
Entry("m4r", "m4r", 0, "aac"),
Entry("unknown suffix", "xyz", 0, ""),
)
It("prefers stored codec over suffix inference", func() {
mf := MediaFile{Codec: "ALAC", Suffix: "m4a", BitDepth: 0}
Expect(mf.AudioCodec()).To(Equal("alac"))
})
})
Describe("IsLossless", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
DescribeTable("detects lossless codecs",
func(codec string, suffix string, bitDepth int, expected bool) {
mf := MediaFile{Codec: codec, Suffix: suffix, BitDepth: bitDepth}
Expect(mf.IsLossless()).To(Equal(expected))
},
Entry("flac", "FLAC", "flac", 16, true),
Entry("alac", "ALAC", "m4a", 24, true),
Entry("pcm via wav", "", "wav", 16, true),
Entry("pcm via aiff", "", "aiff", 24, true),
Entry("ape", "", "ape", 16, true),
Entry("wv", "", "wv", 0, true),
Entry("tta", "", "tta", 0, true),
Entry("tak", "", "tak", 0, true),
Entry("shn", "", "shn", 0, true),
Entry("dsd", "", "dsf", 0, true),
Entry("mp3 is lossy", "MP3", "mp3", 0, false),
Entry("aac is lossy", "AAC", "m4a", 0, false),
Entry("vorbis is lossy", "", "ogg", 0, false),
Entry("opus is lossy", "", "opus", 0, false),
)
It("detects lossless via BitDepth fallback when codec is unknown", func() {
mf := MediaFile{Suffix: "xyz", BitDepth: 24}
Expect(mf.IsLossless()).To(BeTrue())
})
It("returns false for unknown with no BitDepth", func() {
mf := MediaFile{Suffix: "xyz", BitDepth: 0}
Expect(mf.IsLossless()).To(BeFalse())
})
})
})
func t(v string) time.Time {

View File

@@ -65,6 +65,7 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
mf.SampleRate = md.AudioProperties().SampleRate
mf.BitDepth = md.AudioProperties().BitDepth
mf.Channels = md.AudioProperties().Channels
mf.Codec = md.AudioProperties().Codec
mf.Path = md.FilePath()
mf.Suffix = md.Suffix()
mf.Size = md.Size()

View File

@@ -35,6 +35,7 @@ type AudioProperties struct {
BitDepth int
SampleRate int
Channels int
Codec string
}
type Date string

View File

@@ -23,8 +23,6 @@ if [ ! -f "$postinstall_flag" ]; then
# and not by root
chown navidrome:navidrome /var/lib/navidrome/cache
touch "$postinstall_flag"
else
navidrome service stop --configfile /etc/navidrome/navidrome.toml && navidrome service start --configfile /etc/navidrome/navidrome.toml
fi

View File

@@ -36,8 +36,7 @@
"bitDepth": "Bitdybde",
"sampleRate": "Samplingfrekvens",
"missing": "Manglende",
"libraryName": "Bibliotek",
"composer": "Komponist"
"libraryName": "Bibliotek"
},
"actions": {
"addToQueue": "Afspil senere",
@@ -47,8 +46,7 @@
"download": "Download",
"playNext": "Afspil næste",
"info": "Hent info",
"showInPlaylist": "Vis i afspilningsliste",
"instantMix": "Instant Mix"
"showInPlaylist": "Vis i afspilningsliste"
}
},
"album": {
@@ -330,80 +328,6 @@
"scanInProgress": "Scanning i gang...",
"noLibrariesAssigned": "Ingen biblioteker tildelt denne bruger"
}
},
"plugin": {
"name": "Plugin |||| Plugins",
"fields": {
"id": "ID",
"name": "Navn",
"description": "Beskrivelse",
"version": "Version",
"author": "Forfatter",
"website": "Hjemmeside",
"permissions": "Tilladelser",
"enabled": "Aktiveret",
"status": "Status",
"path": "Sti",
"lastError": "Fejl",
"hasError": "Fejl",
"updatedAt": "Opdateret",
"createdAt": "Installeret",
"configKey": "Nøgle",
"configValue": "Værdi",
"allUsers": "Tillad alle brugere",
"selectedUsers": "Valgte brugere",
"allLibraries": "Tillad alle biblioteker",
"selectedLibraries": "Valgte biblioteker"
},
"sections": {
"status": "Status",
"info": "Pluginoplysninger",
"configuration": "Konfiguration",
"manifest": "Manifest",
"usersPermission": "Brugertilladelse",
"libraryPermission": "Bibliotekstilladelse"
},
"status": {
"enabled": "Aktiveret",
"disabled": "Deaktiveret"
},
"actions": {
"enable": "Aktivér",
"disable": "Deaktivér",
"disabledDueToError": "Ret fejlen før aktivering",
"disabledUsersRequired": "Vælg brugere før aktivering",
"disabledLibrariesRequired": "Vælg biblioteker før aktivering",
"addConfig": "Tilføj konfiguration",
"rescan": "Genskan"
},
"notifications": {
"enabled": "Plugin aktiveret",
"disabled": "Plugin deaktiveret",
"updated": "Plugin opdateret",
"error": "Fejl ved opdatering af plugin"
},
"validation": {
"invalidJson": "Konfigurationen skal være gyldig JSON"
},
"messages": {
"configHelp": "Konfigurér pluginet med nøgle-værdi-par. Lad stå tomt, hvis pluginet ikke kræver konfiguration.",
"clickPermissions": "Klik på en tilladelse for detaljer",
"noConfig": "Ingen konfiguration angivet",
"allUsersHelp": "Når aktiveret, vil pluginet have adgang til alle brugere, inklusiv dem der oprettes i fremtiden.",
"noUsers": "Ingen brugere valgt",
"permissionReason": "Årsag",
"usersRequired": "Dette plugin kræver adgang til brugeroplysninger. Vælg hvilke brugere pluginet kan tilgå, eller aktivér 'Tillad alle brugere'.",
"allLibrariesHelp": "Når aktiveret, vil pluginet have adgang til alle biblioteker, inklusiv dem der oprettes i fremtiden.",
"noLibraries": "Ingen biblioteker valgt",
"librariesRequired": "Dette plugin kræver adgang til biblioteksoplysninger. Vælg hvilke biblioteker pluginet kan tilgå, eller aktivér 'Tillad alle biblioteker'.",
"requiredHosts": "Påkrævede hosts",
"configValidationError": "Konfigurationsvalidering mislykkedes:",
"schemaRenderError": "Kan ikke vise konfigurationsformularen. Pluginets skema er muligvis ugyldigt."
},
"placeholders": {
"configKey": "nøgle",
"configValue": "værdi"
}
}
},
"ra": {
@@ -587,8 +511,7 @@
"remove_all_missing_title": "Fjern alle manglende filer",
"remove_all_missing_content": "Er du sikker på, at du vil fjerne alle manglende filer fra databasen? Dét vil permanent fjerne alle referencer til dem, inklusive deres afspilningstællere og vurderinger.",
"noSimilarSongsFound": "Ingen lignende sange fundet",
"noTopSongsFound": "Ingen topsange fundet",
"startingInstantMix": "Indlæser Instant Mix..."
"noTopSongsFound": "Ingen topsange fundet"
},
"menu": {
"library": "Bibliotek",
@@ -674,8 +597,7 @@
"exportSuccess": "Konfigurationen eksporteret til udklipsholder i TOML-format",
"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)"
"devFlagsComment": "Disse er eksperimental-indstillinger og kan blive fjernet i fremtidige udgaver"
}
},
"activity": {

View File

@@ -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": {

View File

@@ -674,8 +674,7 @@
"exportSuccess": "Η διαμόρφωση εξήχθη στο πρόχειρο σε μορφή TOML",
"exportFailed": "Η αντιγραφή της διαμόρφωσης απέτυχε",
"devFlagsHeader": "Σημαίες Ανάπτυξης (υπόκειται σε αλλαγές / αφαίρεση)",
"devFlagsComment": "Αυτές είναι πειραματικές ρυθμίσεις και ενδέχεται να καταργηθούν σε μελλοντικές εκδόσεις",
"downloadToml": "Λήψη διαμόρφωσης (TOML)"
"devFlagsComment": "Αυτές είναι πειραματικές ρυθμίσεις και ενδέχεται να καταργηθούν σε μελλοντικές εκδόσεις"
}
},
"activity": {

View File

@@ -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": {

View File

@@ -2,7 +2,7 @@
"languageName": "Euskara",
"resources": {
"song": {
"name": "Abestia |||| Abesti",
"name": "Abestia |||| Abestiak",
"fields": {
"albumArtist": "Albumaren artista",
"duration": "Iraupena",
@@ -10,7 +10,6 @@
"playCount": "Erreprodukzioak",
"title": "Titulua",
"artist": "Artista",
"composer": "Konpositorea",
"album": "Albuma",
"path": "Fitxategiaren bidea",
"libraryName": "Liburutegia",
@@ -34,9 +33,9 @@
"grouping": "Multzokatzea",
"mood": "Aldartea",
"participants": "Partaide gehiago",
"tags": "Etiketa gehiago",
"mappedTags": "Esleitutako etiketak",
"rawTags": "Etiketa gordinak",
"tags": "Traola gehiago",
"mappedTags": "Esleitutako traolak",
"rawTags": "Traola gordinak",
"missing": "Ez da aurkitu"
},
"actions": {
@@ -47,12 +46,11 @@
"shuffleAll": "Erreprodukzio aleatorioa",
"download": "Deskargatu",
"playNext": "Hurrengoa",
"info": "Erakutsi informazioa",
"instantMix": "Berehalako nahastea"
"info": "Erakutsi informazioa"
}
},
"album": {
"name": "Albuma |||| Album",
"name": "Albuma |||| Albumak",
"fields": {
"albumArtist": "Albumaren artista",
"artist": "Artista",
@@ -68,7 +66,7 @@
"date": "Recording Date",
"originalDate": "Jatorrizkoa",
"releaseDate": "Argitaratze-data",
"releases": "Argitaratzea |||| Argitaratze",
"releases": "Argitaratzea |||| Argitaratzeak",
"released": "Argitaratua",
"updatedAt": "Aktualizatze-data:",
"comment": "Iruzkina",
@@ -103,7 +101,7 @@
}
},
"artist": {
"name": "Artista |||| Artista",
"name": "Artista |||| Artistak",
"fields": {
"name": "Izena",
"albumCount": "Album kopurua",
@@ -332,80 +330,6 @@
"scanInProgress": "Araketa abian da…",
"noLibrariesAssigned": "Ez da liburutegirik egokitu erabiltzaile honentzat"
}
},
"plugin": {
"name": "Plugina |||| Plugin",
"fields": {
"id": "IDa",
"name": "Izena",
"description": "Deskribapena",
"version": "Bertsioa",
"author": "Autorea",
"website": "Webgunea",
"permissions": "Baimenak",
"enabled": "Gaituta",
"status": "Egoera",
"path": "Bidea",
"lastError": "Errorea",
"hasError": "Errorea",
"updatedAt": "Eguneratuta",
"createdAt": "Instalatuta",
"configKey": "Gakoa",
"configValue": "Balioa",
"allUsers": "Baimendu erabiltzaile guztiak",
"selectedUsers": "Hautatutako erabiltzaileak",
"allLibraries": "Baimendu liburutegi guztiak",
"selectedLibraries": "Hautatutako liburutegiak"
},
"sections": {
"status": "Egoera",
"info": "Pluginaren informazioa",
"configuration": "Konfigurazioa",
"manifest": "Manifestua",
"usersPermission": "Erabiltzaileen baimenak",
"libraryPermission": "Liburutegien baimenak"
},
"status": {
"enabled": "Gaituta",
"disabled": "Ezgaituta"
},
"actions": {
"enable": "Gaitu",
"disable": "Ezgaitu",
"disabledDueToError": "Konpondu errorea gaitu baino lehen",
"disabledUsersRequired": "Hautatu erabiltzaileak gaitu baino lehen",
"disabledLibrariesRequired": "Hautatu liburutegiak gaitu baino lehen",
"addConfig": "Gehitu konfigurazioa",
"rescan": "Arakatu berriro"
},
"notifications": {
"enabled": "Plugina gaituta",
"disabled": "Plugina ezgaituta",
"updated": "Plugina eguneratuta",
"error": "Errorea plugina eguneratzean"
},
"validation": {
"invalidJson": "Konfigurazioa baliozko JSON-a izan behar da"
},
"messages": {
"configHelp": "Konfiguratu plugina gako-balio bikoteak erabiliz. Utzi hutsik pluginak konfiguraziorik behar ez badu.",
"configValidationError": "Huts egin du konfigurazioaren balidazioak:",
"schemaRenderError": "Ezin izan da konfigurazioaren formularioa bihurtu. Litekeena da pluginaren eskema baliozkoa ez izatea.",
"clickPermissions": "Sakatu baimen batean xehetasunetarako",
"noConfig": "Ez da konfiguraziorik ezarri",
"allUsersHelp": "Gaituta dagoenean, pluginak erabiltzaile guztiak atzitu ditzazke, baita etorkizunean sortuko direnak ere.",
"noUsers": "Ez da erabiltzailerik hautatu",
"permissionReason": "Arrazoia",
"usersRequired": "Plugin honek erabiltzaileen informaziora sarbidea behar du. Hautatu zein erabiltzaile atzitu dezakeen pluginak, edo gaitu 'Baimendu erabiltzaile guztiak'.",
"allLibrariesHelp": "Gaituta dagoenean, pluginak liburutegi guztietara izango du sarbidea, baita etorkizunean sortuko direnetara ere.",
"noLibraries": "Ez da liburutegirik hautatu",
"librariesRequired": "Plugin honek liburutegien informaziora sarbidea behar du. Hautatu zein liburutegi atzitu dezakeen pluginak, edo gaitu 'Baimendu liburutegi guztiak'.",
"requiredHosts": "Beharrezko ostatatzaileak"
},
"placeholders": {
"configKey": "gakoa",
"configValue": "balioa"
}
}
},
"ra": {
@@ -559,7 +483,6 @@
"transcodingEnabled": "Navidrome %{config}-ekin martxan dago eta, beraz, web-interfazeko transkodeketa-ataletik sistema-komandoak exekuta daitezke. Segurtasun arrazoiak tarteko, ezgaitzea gomendatzen dugu, eta transkodeketa-aukerak konfiguratzen ari zarenean bakarrik gaitzea.",
"songsAddedToPlaylist": "Abesti bat zerrendara gehitu da |||| %{smart_count} abesti zerrendara gehitu dira",
"noSimilarSongsFound": "Ez da antzeko abestirik aurkitu",
"startingInstantMix": "Berehalako nahastea kargatzen…",
"noTopSongsFound": "Ez da aparteko abestirik aurkitu",
"noPlaylistsAvailable": "Ez dago zerrendarik erabilgarri",
"delete_user_title": "Ezabatu '%{name}' erabiltzailea",

View File

@@ -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": {

View File

@@ -10,7 +10,6 @@
"playCount": "Lejátszások",
"title": "Cím",
"artist": "Előadó",
"composer": "Zeneszerző",
"album": "Album",
"path": "Elérési út",
"libraryName": "Könyvtár",
@@ -47,8 +46,7 @@
"shuffleAll": "Keverés",
"download": "Letöltés",
"playNext": "Lejátszás következőként",
"info": "Részletek",
"instantMix": "Instant keverés"
"info": "Részletek"
}
},
"album": {
@@ -327,80 +325,6 @@
"scanInProgress": "Szkennelés folyamatban...",
"noLibrariesAssigned": "Ehhez a felhasználóhoz nincsenek könyvtárak adva"
}
},
"plugin": {
"name": "Kiegészítő |||| Kiegészítők",
"fields": {
"id": "ID",
"name": "Név",
"description": "Leírás",
"version": "Verzió",
"author": "Fejlesztő",
"website": "Weboldal",
"permissions": "Engedélyek",
"enabled": "Engedélyezve",
"status": "Státusz",
"path": "Útvonal",
"lastError": "Hiba",
"hasError": "Hiba",
"updatedAt": "Frissítve",
"createdAt": "Telepítve",
"configKey": "Kulcs",
"configValue": "Érték",
"allUsers": "Összes felhasználó engedélyezése",
"selectedUsers": "Kiválasztott felhasználók engedélyezése",
"allLibraries": "Összes könyvtár engedélyezése",
"selectedLibraries": "Kiválasztott könyvtárak engedélyezése"
},
"sections": {
"status": "Státusz",
"info": "Kiegészítő információi",
"configuration": "Konfiguráció",
"manifest": "Manifest",
"usersPermission": "Felhasználói engedélyek",
"libraryPermission": "Könyvtári engedélyek"
},
"status": {
"enabled": "Engedélyezve",
"disabled": "Letiltva"
},
"actions": {
"enable": "Engedélyezés",
"disable": "Letiltás",
"disabledDueToError": "Javítsd ki a kiegészítő hibáját",
"disabledUsersRequired": "Válassz felhasználókat",
"disabledLibrariesRequired": "Válassz könyvtárakat",
"addConfig": "Konfiguráció hozzáadása",
"rescan": "Újraszkennelés"
},
"notifications": {
"enabled": "Kiegészítő engedélyezve",
"disabled": "Kiegészítő letiltva",
"updated": "Kiegészítő frissítve",
"error": "Hiba történt a kiegészítő frissítése közben"
},
"validation": {
"invalidJson": "A konfigurációs JSON érvénytelen"
},
"messages": {
"configHelp": "Konfiguráld a kiegészítőt kulcs-érték párokkal. Hagyd a mezőt üresen, ha nincs szükség konfigurációra.",
"configValidationError": "Helytelen konfiguráció:",
"schemaRenderError": "Nem sikerült megjeleníteni a konfigurációs űrlapot. A bővítmény sémája érvénytelen lehet.",
"clickPermissions": "Kattints egy engedélyre a részletekért",
"noConfig": "Nincs konfiguráció beállítva",
"allUsersHelp": "Engedélyezés esetén ez a kiegészítő hozzá fog férni minden jelenlegi és jövőben létrehozott felhasználóhoz.",
"noUsers": "Nincsenek kiválasztott felhasználók",
"permissionReason": "Indok",
"usersRequired": "Ez a kiegészítő hozzáférést kér felhasználói információkhoz. Válaszd ki, melyik felhasználókat érheti el, vagy az 'Összes felhasználó engedélyezése' opciót.",
"allLibrariesHelp": "Engedélyezés esetén ez a kiegészítő hozzá fog férni minden jelenlegi és jövőben létrehozott könyvtárhoz.",
"noLibraries": "Nincs kiválasztott könyvtár",
"librariesRequired": "Ez a kiegészítő hozzáférést kér könyvtárinformációkhoz. Válaszd ki, melyik könyvtárakat érheti el, vagy az 'Összes könyvtár engedélyezése' opciót.",
"requiredHosts": "Szükséges hostok"
},
"placeholders": {
"configKey": "kulcs",
"configValue": "érték"
}
}
},
"ra": {
@@ -478,7 +402,7 @@
"loading": "Betöltés",
"not_found": "Nem található",
"show": "%{name} #%{id}",
"empty": "Nincsenek %{name}.",
"empty": "Nincs %{name} még.",
"invite": "Szeretnél egyet hozzáadni?"
},
"input": {
@@ -554,7 +478,6 @@
"transcodingEnabled": "A Navidrome jelenleg a következőkkel fut %{config}, ez lehetővé teszi a rendszerparancsok futtatását az átkódolási beállításokból a webes felület segítségével. Javasoljuk, hogy biztonsági okokból tiltsd ezt le, és csak az átkódolási beállítások konfigurálásának idejére kapcsold be.",
"songsAddedToPlaylist": "1 szám hozzáadva a lejátszási listához |||| %{smart_count} szám hozzáadva a lejátszási listához",
"noSimilarSongsFound": "Nem találhatóak hasonló számok",
"startingInstantMix": "Instant keverés töltődik...",
"noTopSongsFound": "Nincsenek top számok",
"noPlaylistsAvailable": "Nem áll rendelkezésre",
"delete_user_title": "Felhasználó törlése '%{name}'",
@@ -668,7 +591,6 @@
"currentValue": "Jelenlegi érték",
"configurationFile": "Konfigurációs fájl",
"exportToml": "Konfiguráció exportálása (TOML)",
"downloadToml": "Konfiguráció letöltése (TOML)",
"exportSuccess": "Konfiguráció kiexportálva a vágólapra, TOML formában",
"exportFailed": "Nem sikerült kimásolni a konfigurációt",
"devFlagsHeader": "Fejlesztői beállítások (változások/eltávolítás jogát fenntartjuk)",

View File

@@ -674,8 +674,7 @@
"exportSuccess": "Configuração exportada para o clipboard em formato TOML",
"exportFailed": "Falha ao copiar configuração",
"devFlagsHeader": "Flags de Desenvolvimento (sujeitas a mudança/remoção)",
"devFlagsComment": "Estas são configurações experimentais e podem ser removidas em versões futuras",
"downloadToml": "Baixar configuração (TOML)"
"devFlagsComment": "Estas são configurações experimentais e podem ser removidas em versões futuras"
}
},
"activity": {

View File

@@ -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": {

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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} 分鐘前"
}
}
}

View File

@@ -1,113 +0,0 @@
// Package e2e provides end-to-end integration tests for the Navidrome Subsonic API.
//
// These tests exercise the full HTTP request/response cycle through the Subsonic API router,
// using a real SQLite database and real repository implementations while stubbing out external
// services (artwork, streaming, scrobbling, etc.) with noop implementations.
//
// # Test Infrastructure
//
// The suite uses [Ginkgo] v2 as the test runner and [Gomega] for assertions. It is invoked
// through the standard Go test entry point [TestSubsonicE2E], which initializes the test
// environment, creates a temporary SQLite database, and runs the specs.
//
// # Setup and Teardown
//
// During [BeforeSuite], the test infrastructure:
//
// 1. Creates a temporary SQLite database with WAL journal mode.
// 2. Initializes the schema via [db.Init].
// 3. Creates two test users: an admin ("admin") and a regular user ("regular"),
// both with the password "password".
// 4. Creates a single library ("Music Library") backed by a fake in-memory filesystem
// (scheme "fake:///music") using the [storagetest] package.
// 5. Populates the filesystem with a set of test tracks spanning multiple artists,
// albums, genres, and years.
// 6. Runs the scanner to import all metadata into the database.
// 7. Takes a snapshot of the database to serve as a golden baseline for test isolation.
//
// # Test Data
//
// The fake filesystem contains the following music library structure:
//
// Rock/The Beatles/Abbey Road/
// 01 - Come Together.mp3 (1969, Rock)
// 02 - Something.mp3 (1969, Rock)
// Rock/The Beatles/Help!/
// 01 - Help.mp3 (1965, Rock)
// Rock/Led Zeppelin/IV/
// 01 - Stairway To Heaven.mp3 (1971, Rock)
// Jazz/Miles Davis/Kind of Blue/
// 01 - So What.mp3 (1959, Jazz)
// Pop/
// 01 - Standalone Track.mp3 (2020, Pop)
//
// # Database Isolation
//
// Before each top-level Describe block, the [setupTestDB] function restores the database
// to its golden snapshot state using SQLite's ATTACH DATABASE mechanism. This copies all
// table data from the snapshot back into the main database, providing each test group with
// a clean, consistent starting state without the overhead of re-scanning the filesystem.
//
// A fresh [subsonic.Router] is also created for each test group, wired with real data store
// repositories and noop stubs for external services:
//
// - noopArtwork: returns [model.ErrNotFound] for all artwork requests.
// - noopStreamer: returns [model.ErrNotFound] for all stream requests.
// - noopArchiver: returns [model.ErrNotFound] for all archive requests.
// - noopProvider: returns empty results for all external metadata lookups.
// - noopPlayTracker: silently discards all scrobble events.
//
// # Request Helpers
//
// Tests build HTTP requests using the [buildReq] helper, which constructs a Subsonic API
// request with authentication parameters (username, password, API version "1.16.1", client
// name "test-client", and JSON format). Convenience wrappers include:
//
// - [doReq]: sends a request as the admin user and returns the parsed JSON response.
// - [doReqWithUser]: sends a request as a specific user.
// - [doRawReq] / [doRawReqWithUser]: returns the raw [httptest.ResponseRecorder] for
// binary content or status code inspection.
//
// Responses are parsed via [parseJSONResponse], which unwraps the Subsonic JSON envelope
// and returns the inner response map.
//
// # Test Organization
//
// Each test file covers a logical group of Subsonic API endpoints:
//
// - subsonic_system_test.go: ping, getLicense, getOpenSubsonicExtensions
// - subsonic_browsing_test.go: getMusicFolders, getIndexes, getArtists, getMusicDirectory,
// getArtist, getAlbum, getSong, getGenres
// - subsonic_searching_test.go: search2, search3
// - subsonic_album_lists_test.go: getAlbumList, getAlbumList2
// - subsonic_playlists_test.go: createPlaylist, getPlaylist, getPlaylists,
// updatePlaylist, deletePlaylist
// - subsonic_media_annotation_test.go: star, unstar, getStarred, setRating, scrobble
// - subsonic_media_retrieval_test.go: stream, download, getCoverArt, getAvatar,
// getLyrics, getLyricsBySongId
// - subsonic_bookmarks_test.go: createBookmark, getBookmarks, deleteBookmark,
// savePlayQueue, getPlayQueue
// - subsonic_radio_test.go: getInternetRadioStations, createInternetRadioStation,
// updateInternetRadioStation, deleteInternetRadioStation
// - subsonic_sharing_test.go: createShare, getShares, updateShare, deleteShare
// - subsonic_users_test.go: getUser, getUsers
// - subsonic_scan_test.go: getScanStatus, startScan
// - subsonic_multiuser_test.go: multi-user isolation and permission enforcement
// - subsonic_multilibrary_test.go: multi-library access control and data isolation
//
// Some test groups use Ginkgo's Ordered decorator to run tests sequentially within a block,
// allowing later tests to depend on state created by earlier ones (e.g., creating a playlist
// and then verifying it can be retrieved).
//
// # Running
//
// The e2e tests are included in the standard test suite and can be run with:
//
// make test PKG=./server/e2e # Run only e2e tests
// make test # Run all tests including e2e
// make test-race # Run with race detector
//
// [Ginkgo]: https://onsi.github.io/ginkgo/
// [Gomega]: https://onsi.github.io/gomega/
// [storagetest]: /core/storage/storagetest
package e2e

View File

@@ -23,6 +23,7 @@ import (
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/core/transcode"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -189,14 +190,33 @@ func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool
// noopStreamer implements core.MediaStreamer
type noopStreamer struct{}
func (n noopStreamer) NewStream(context.Context, string, string, int, int) (*core.Stream, error) {
func (n noopStreamer) NewStream(context.Context, core.StreamRequest) (*core.Stream, error) {
return nil, model.ErrNotFound
}
func (n noopStreamer) DoStream(context.Context, *model.MediaFile, string, int, int) (*core.Stream, error) {
func (n noopStreamer) DoStream(context.Context, *model.MediaFile, core.StreamRequest) (*core.Stream, error) {
return nil, model.ErrNotFound
}
// noopDecider implements transcode.Decider
type noopDecider struct{}
func (n noopDecider) MakeDecision(context.Context, *model.MediaFile, *transcode.ClientInfo) (*transcode.Decision, error) {
return nil, nil
}
func (n noopDecider) CreateTranscodeParams(*transcode.Decision) (string, error) {
return "", nil
}
func (n noopDecider) ParseTranscodeParams(string) (*transcode.Params, error) {
return nil, nil
}
func (n noopDecider) ValidateTranscodeParams(context.Context, string, string) (*transcode.Params, *model.MediaFile, error) {
return nil, nil, nil
}
// noopArchiver implements core.Archiver
type noopArchiver struct{}
@@ -265,6 +285,7 @@ var (
_ core.Archiver = noopArchiver{}
_ external.Provider = noopProvider{}
_ scrobbler.PlayTracker = noopPlayTracker{}
_ transcode.Decider = noopDecider{}
)
var _ = BeforeSuite(func() {
@@ -318,11 +339,6 @@ func setupTestDB() {
ctx = request.WithUser(GinkgoT().Context(), adminUser)
DeferCleanup(configtest.SetupConfig())
DeferCleanup(func() {
// Wait for any background scan (e.g. from startScan endpoint) to finish
// before config cleanup runs, to avoid a data race on conf.Server.
Eventually(scanner.IsScanning).Should(BeFalse())
})
conf.Server.MusicFolder = "fake:///music"
conf.Server.DevExternalScanner = false
@@ -349,6 +365,7 @@ func setupTestDB() {
core.NewShare(ds),
playback.PlaybackServer(nil),
metrics.NewNoopInstance(),
noopDecider{},
)
}

View File

@@ -8,6 +8,7 @@ import (
"strconv"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils/req"
@@ -24,10 +25,13 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) {
return
}
stream, err := pub.streamer.NewStream(ctx, info.id, info.format, info.bitrate, 0)
stream, err := pub.streamer.NewStream(ctx, core.StreamRequest{
ID: info.id, Format: info.format, BitRate: info.bitrate,
})
if err != nil {
log.Error(ctx, "Error starting shared stream", err)
http.Error(w, "invalid request", http.StatusInternalServerError)
return
}
// Make sure the stream will be closed at the end, to avoid leakage

View File

@@ -27,7 +27,7 @@ var _ = Describe("Album Lists", func() {
ds = &tests.MockDataStore{}
auth.Init(ds)
mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w = httptest.NewRecorder()
})

View File

@@ -17,6 +17,7 @@ import (
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/transcode"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
@@ -34,40 +35,42 @@ type handlerRaw = func(http.ResponseWriter, *http.Request) (*responses.Subsonic,
type Router struct {
http.Handler
ds model.DataStore
artwork artwork.Artwork
streamer core.MediaStreamer
archiver core.Archiver
players core.Players
provider external.Provider
playlists core.Playlists
scanner model.Scanner
broker events.Broker
scrobbler scrobbler.PlayTracker
share core.Share
playback playback.PlaybackServer
metrics metrics.Metrics
ds model.DataStore
artwork artwork.Artwork
streamer core.MediaStreamer
archiver core.Archiver
players core.Players
provider external.Provider
playlists core.Playlists
scanner model.Scanner
broker events.Broker
scrobbler scrobbler.PlayTracker
share core.Share
playback playback.PlaybackServer
metrics metrics.Metrics
transcodeDecision transcode.Decider
}
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver,
players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker,
playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
metrics metrics.Metrics,
metrics metrics.Metrics, transcodeDecision transcode.Decider,
) *Router {
r := &Router{
ds: ds,
artwork: artwork,
streamer: streamer,
archiver: archiver,
players: players,
provider: provider,
playlists: playlists,
scanner: scanner,
broker: broker,
scrobbler: scrobbler,
share: share,
playback: playback,
metrics: metrics,
ds: ds,
artwork: artwork,
streamer: streamer,
archiver: archiver,
players: players,
provider: provider,
playlists: playlists,
scanner: scanner,
broker: broker,
scrobbler: scrobbler,
share: share,
playback: playback,
metrics: metrics,
transcodeDecision: transcodeDecision,
}
r.Handler = r.routes()
return r
@@ -172,6 +175,8 @@ func (api *Router) routes() http.Handler {
h(r, "getLyricsBySongId", api.GetLyricsBySongId)
hr(r, "stream", api.Stream)
hr(r, "download", api.Download)
hr(r, "getTranscodeDecision", api.GetTranscodeDecision)
hr(r, "getTranscodeStream", api.GetTranscodeStream)
})
r.Group(func(r chi.Router) {
// configure request throttling

View File

@@ -27,7 +27,7 @@ var _ = Describe("MediaAnnotationController", func() {
ds = &tests.MockDataStore{}
playTracker = &fakePlayTracker{}
eventBroker = &fakeEventBroker{}
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil)
})
Describe("Scrobble", func() {

View File

@@ -33,7 +33,7 @@ var _ = Describe("MediaRetrievalController", func() {
MockedMediaFile: mockRepo,
}
artwork = &fakeArtwork{data: "image data"}
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w = httptest.NewRecorder()
DeferCleanup(configtest.SetupConfig())
conf.Server.LyricsPriority = "embedded,.lrc"

View File

@@ -13,6 +13,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson
{Name: "formPost", Versions: []int32{1}},
{Name: "songLyrics", Versions: []int32{1}},
{Name: "indexBasedQueue", Versions: []int32{1}},
{Name: "transcoding", Versions: []int32{1}},
}
return response, nil
}

View File

@@ -19,7 +19,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
)
BeforeEach(func() {
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w = httptest.NewRecorder()
r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil)
})
@@ -35,11 +35,12 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
HaveLen(4),
HaveLen(5),
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}),
))
})
})

View File

@@ -24,7 +24,7 @@ var _ = Describe("buildPlaylist", func() {
BeforeEach(func() {
ds = &tests.MockDataStore{}
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
ctx = context.Background()
})
@@ -224,7 +224,7 @@ var _ = Describe("UpdatePlaylist", func() {
BeforeEach(func() {
ds = &tests.MockDataStore{}
playlists = &fakePlaylists{}
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil)
})
It("clears the comment when parameter is empty", func() {

View File

@@ -61,6 +61,7 @@ type Subsonic struct {
OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"`
LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"`
PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"`
TranscodeDecision *TranscodeDecision `xml:"transcodeDecision,omitempty" json:"transcodeDecision,omitempty"`
}
const (
@@ -617,3 +618,26 @@ func marshalJSONArray[T any](v []T) ([]byte, error) {
}
return json.Marshal(v)
}
// TranscodeDecision represents the response for getTranscodeDecision (OpenSubsonic transcoding extension)
type TranscodeDecision struct {
CanDirectPlay bool `xml:"canDirectPlay,attr" json:"canDirectPlay"`
CanTranscode bool `xml:"canTranscode,attr" json:"canTranscode"`
TranscodeReasons []string `xml:"transcodeReason,omitempty" json:"transcodeReason,omitempty"`
ErrorReason string `xml:"errorReason,attr,omitempty" json:"errorReason,omitempty"`
TranscodeParams string `xml:"transcodeParams,attr,omitempty" json:"transcodeParams,omitempty"`
SourceStream *StreamDetails `xml:"sourceStream,omitempty" json:"sourceStream,omitempty"`
TranscodeStream *StreamDetails `xml:"transcodeStream,omitempty" json:"transcodeStream,omitempty"`
}
// StreamDetails describes audio stream properties for transcoding decisions
type StreamDetails struct {
Protocol string `xml:"protocol,attr,omitempty" json:"protocol,omitempty"`
Container string `xml:"container,attr,omitempty" json:"container,omitempty"`
Codec string `xml:"codec,attr,omitempty" json:"codec,omitempty"`
AudioChannels int32 `xml:"audioChannels,attr,omitempty" json:"audioChannels,omitempty"`
AudioBitrate int32 `xml:"audioBitrate,attr,omitempty" json:"audioBitrate,omitempty"`
AudioProfile string `xml:"audioProfile,attr,omitempty" json:"audioProfile,omitempty"`
AudioSamplerate int32 `xml:"audioSamplerate,attr,omitempty" json:"audioSamplerate,omitempty"`
AudioBitdepth int32 `xml:"audioBitdepth,attr,omitempty" json:"audioBitdepth,omitempty"`
}

View File

@@ -21,7 +21,7 @@ var _ = Describe("Search", func() {
ds = &tests.MockDataStore{}
auth.Init(ds)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
// Get references to the mock repositories so we can inspect their Options
mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo)

View File

@@ -60,7 +60,9 @@ func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Su
format, _ := p.String("format")
timeOffset := p.IntOr("timeOffset", 0)
stream, err := api.streamer.NewStream(ctx, id, format, maxBitRate, timeOffset)
stream, err := api.streamer.NewStream(ctx, core.StreamRequest{
ID: id, Format: format, BitRate: maxBitRate, Offset: timeOffset,
})
if err != nil {
return nil, err
}
@@ -129,7 +131,9 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses.
switch v := entity.(type) {
case *model.MediaFile:
stream, err := api.streamer.NewStream(ctx, id, format, maxBitRate, 0)
stream, err := api.streamer.NewStream(ctx, core.StreamRequest{
ID: id, Format: format, BitRate: maxBitRate,
})
if err != nil {
return nil, err
}

View File

@@ -0,0 +1,383 @@
package subsonic
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"slices"
"strconv"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/transcode"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/req"
)
// API-layer request structs for JSON unmarshaling (decoupled from core structs)
// clientInfoRequest represents client playback capabilities from the request body
type clientInfoRequest struct {
Name string `json:"name,omitempty"`
Platform string `json:"platform,omitempty"`
MaxAudioBitrate int `json:"maxAudioBitrate,omitempty"`
MaxTranscodingAudioBitrate int `json:"maxTranscodingAudioBitrate,omitempty"`
DirectPlayProfiles []directPlayProfileRequest `json:"directPlayProfiles,omitempty"`
TranscodingProfiles []transcodingProfileRequest `json:"transcodingProfiles,omitempty"`
CodecProfiles []codecProfileRequest `json:"codecProfiles,omitempty"`
}
// directPlayProfileRequest describes a format the client can play directly
type directPlayProfileRequest struct {
Containers []string `json:"containers,omitempty"`
AudioCodecs []string `json:"audioCodecs,omitempty"`
Protocols []string `json:"protocols,omitempty"`
MaxAudioChannels int `json:"maxAudioChannels,omitempty"`
}
// transcodingProfileRequest describes a transcoding target the client supports
type transcodingProfileRequest struct {
Container string `json:"container,omitempty"`
AudioCodec string `json:"audioCodec,omitempty"`
Protocol string `json:"protocol,omitempty"`
MaxAudioChannels int `json:"maxAudioChannels,omitempty"`
}
// codecProfileRequest describes codec-specific limitations
type codecProfileRequest struct {
Type string `json:"type,omitempty"`
Name string `json:"name,omitempty"`
Limitations []limitationRequest `json:"limitations,omitempty"`
}
// limitationRequest describes a specific codec limitation
type limitationRequest struct {
Name string `json:"name,omitempty"`
Comparison string `json:"comparison,omitempty"`
Values []string `json:"values,omitempty"`
Required bool `json:"required,omitempty"`
}
// toCoreClientInfo converts the API request struct to the transcode.ClientInfo struct.
// The OpenSubsonic spec uses bps for bitrate values; core uses kbps.
func (r *clientInfoRequest) toCoreClientInfo() *transcode.ClientInfo {
ci := &transcode.ClientInfo{
Name: r.Name,
Platform: r.Platform,
MaxAudioBitrate: bpsToKbps(r.MaxAudioBitrate),
MaxTranscodingAudioBitrate: bpsToKbps(r.MaxTranscodingAudioBitrate),
}
for _, dp := range r.DirectPlayProfiles {
ci.DirectPlayProfiles = append(ci.DirectPlayProfiles, transcode.DirectPlayProfile{
Containers: dp.Containers,
AudioCodecs: dp.AudioCodecs,
Protocols: dp.Protocols,
MaxAudioChannels: dp.MaxAudioChannels,
})
}
for _, tp := range r.TranscodingProfiles {
ci.TranscodingProfiles = append(ci.TranscodingProfiles, transcode.Profile{
Container: tp.Container,
AudioCodec: tp.AudioCodec,
Protocol: tp.Protocol,
MaxAudioChannels: tp.MaxAudioChannels,
})
}
for _, cp := range r.CodecProfiles {
coreCP := transcode.CodecProfile{
Type: cp.Type,
Name: cp.Name,
}
for _, lim := range cp.Limitations {
coreLim := transcode.Limitation{
Name: lim.Name,
Comparison: lim.Comparison,
Values: lim.Values,
Required: lim.Required,
}
// Convert audioBitrate limitation values from bps to kbps
if lim.Name == transcode.LimitationAudioBitrate {
coreLim.Values = convertBitrateValues(lim.Values)
}
coreCP.Limitations = append(coreCP.Limitations, coreLim)
}
ci.CodecProfiles = append(ci.CodecProfiles, coreCP)
}
return ci
}
// bpsToKbps converts bits per second to kilobits per second (rounded).
func bpsToKbps(bps int) int {
return (bps + 500) / 1000
}
// kbpsToBps converts kilobits per second to bits per second.
func kbpsToBps(kbps int) int {
return kbps * 1000
}
// convertBitrateValues converts a slice of bps string values to kbps string values.
func convertBitrateValues(bpsValues []string) []string {
result := make([]string, len(bpsValues))
for i, v := range bpsValues {
n, err := strconv.Atoi(v)
if err == nil {
result[i] = strconv.Itoa(bpsToKbps(n))
} else {
result[i] = v // preserve unparseable values as-is
}
}
return result
}
// validate checks that all enum fields in the request contain valid values per the OpenSubsonic spec.
func (r *clientInfoRequest) validate() error {
for _, dp := range r.DirectPlayProfiles {
for _, p := range dp.Protocols {
if !isValidProtocol(p) {
return fmt.Errorf("invalid protocol: %s", p)
}
}
}
for _, tp := range r.TranscodingProfiles {
if tp.Protocol != "" && !isValidProtocol(tp.Protocol) {
return fmt.Errorf("invalid protocol: %s", tp.Protocol)
}
}
for _, cp := range r.CodecProfiles {
if !isValidCodecProfileType(cp.Type) {
return fmt.Errorf("invalid codec profile type: %s", cp.Type)
}
for _, lim := range cp.Limitations {
if !isValidLimitationName(lim.Name) {
return fmt.Errorf("invalid limitation name: %s", lim.Name)
}
if !isValidComparison(lim.Comparison) {
return fmt.Errorf("invalid comparison: %s", lim.Comparison)
}
}
}
return nil
}
var validProtocols = []string{
transcode.ProtocolHTTP,
transcode.ProtocolHLS,
}
func isValidProtocol(p string) bool {
return slices.Contains(validProtocols, p)
}
var validCodecProfileTypes = []string{
transcode.CodecProfileTypeAudio,
}
func isValidCodecProfileType(t string) bool {
return slices.Contains(validCodecProfileTypes, t)
}
var validLimitationNames = []string{
transcode.LimitationAudioChannels,
transcode.LimitationAudioBitrate,
transcode.LimitationAudioProfile,
transcode.LimitationAudioSamplerate,
transcode.LimitationAudioBitdepth,
}
func isValidLimitationName(n string) bool {
return slices.Contains(validLimitationNames, n)
}
var validComparisons = []string{
transcode.ComparisonEquals,
transcode.ComparisonNotEquals,
transcode.ComparisonLessThanEqual,
transcode.ComparisonGreaterThanEqual,
}
func isValidComparison(c string) bool {
return slices.Contains(validComparisons, c)
}
// GetTranscodeDecision handles the OpenSubsonic getTranscodeDecision endpoint.
// It receives client capabilities and returns a decision on whether to direct play or transcode.
func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return nil, nil
}
ctx := r.Context()
p := req.Params(r)
mediaID, err := p.String("mediaId")
if err != nil {
return nil, newError(responses.ErrorMissingParameter, "missing required parameter: mediaId")
}
mediaType, err := p.String("mediaType")
if err != nil {
return nil, newError(responses.ErrorMissingParameter, "missing required parameter: mediaType")
}
// Only support songs for now
if mediaType != "song" {
return nil, newError(responses.ErrorGeneric, "mediaType '%s' is not yet supported", mediaType)
}
// Parse and validate ClientInfo from request body (required per OpenSubsonic spec)
var clientInfoReq clientInfoRequest
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB limit
if err := json.NewDecoder(r.Body).Decode(&clientInfoReq); err != nil {
return nil, newError(responses.ErrorGeneric, "invalid JSON request body")
}
if err := clientInfoReq.validate(); err != nil {
return nil, newError(responses.ErrorGeneric, "%v", err)
}
clientInfo := clientInfoReq.toCoreClientInfo()
// Get media file
mf, err := api.ds.MediaFile(ctx).Get(mediaID)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return nil, newError(responses.ErrorDataNotFound, "media file not found: %s", mediaID)
}
return nil, newError(responses.ErrorGeneric, "error retrieving media file: %v", err)
}
// Make the decision
decision, err := api.transcodeDecision.MakeDecision(ctx, mf, clientInfo)
if err != nil {
return nil, newError(responses.ErrorGeneric, "failed to make transcode decision: %v", err)
}
// Only create a token when there is a valid playback path
var transcodeParams string
if decision.CanDirectPlay || decision.CanTranscode {
transcodeParams, err = api.transcodeDecision.CreateTranscodeParams(decision)
if err != nil {
return nil, newError(responses.ErrorGeneric, "failed to create transcode token: %v", err)
}
}
// Build response (convert kbps from core to bps for the API)
response := newResponse()
response.TranscodeDecision = &responses.TranscodeDecision{
CanDirectPlay: decision.CanDirectPlay,
CanTranscode: decision.CanTranscode,
TranscodeReasons: decision.TranscodeReasons,
ErrorReason: decision.ErrorReason,
TranscodeParams: transcodeParams,
SourceStream: &responses.StreamDetails{
Protocol: "http",
Container: decision.SourceStream.Container,
Codec: decision.SourceStream.Codec,
AudioBitrate: int32(kbpsToBps(decision.SourceStream.Bitrate)),
AudioProfile: decision.SourceStream.Profile,
AudioSamplerate: int32(decision.SourceStream.SampleRate),
AudioBitdepth: int32(decision.SourceStream.BitDepth),
AudioChannels: int32(decision.SourceStream.Channels),
},
}
if decision.TranscodeStream != nil {
response.TranscodeDecision.TranscodeStream = &responses.StreamDetails{
Protocol: "http",
Container: decision.TranscodeStream.Container,
Codec: decision.TranscodeStream.Codec,
AudioBitrate: int32(kbpsToBps(decision.TranscodeStream.Bitrate)),
AudioProfile: decision.TranscodeStream.Profile,
AudioSamplerate: int32(decision.TranscodeStream.SampleRate),
AudioBitdepth: int32(decision.TranscodeStream.BitDepth),
AudioChannels: int32(decision.TranscodeStream.Channels),
}
}
return response, nil
}
// GetTranscodeStream handles the OpenSubsonic getTranscodeStream endpoint.
// It streams media using the decision encoded in the transcodeParams JWT token.
// All errors are returned as proper HTTP status codes (not Subsonic error responses).
func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
ctx := r.Context()
p := req.Params(r)
mediaID, err := p.String("mediaId")
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return nil, nil
}
mediaType, err := p.String("mediaType")
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return nil, nil
}
transcodeParamsToken, err := p.String("transcodeParams")
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return nil, nil
}
// Only support songs for now
if mediaType != "song" {
http.Error(w, "Bad Request", http.StatusBadRequest)
return nil, nil
}
// Validate the token, mediaID match, file existence, and freshness
params, mf, err := api.transcodeDecision.ValidateTranscodeParams(ctx, transcodeParamsToken, mediaID)
if err != nil {
switch {
case errors.Is(err, transcode.ErrMediaNotFound):
http.Error(w, "Not Found", http.StatusNotFound)
case errors.Is(err, transcode.ErrTokenInvalid), errors.Is(err, transcode.ErrTokenStale):
http.Error(w, "Gone", http.StatusGone)
default:
log.Error(ctx, "Error validating transcode params", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return nil, nil
}
// Build streaming parameters from the token
streamReq := core.StreamRequest{ID: mediaID, Offset: p.IntOr("offset", 0)}
if !params.DirectPlay && params.TargetFormat != "" {
streamReq.Format = params.TargetFormat
streamReq.BitRate = params.TargetBitrate // Already in kbps, matching the streamer
streamReq.SampleRate = params.TargetSampleRate
streamReq.BitDepth = params.TargetBitDepth
streamReq.Channels = params.TargetChannels
}
// Create stream (use DoStream to avoid duplicate DB fetch)
stream, err := api.streamer.DoStream(ctx, mf, streamReq)
if err != nil {
log.Error(ctx, "Error creating stream", "mediaID", mediaID, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return nil, nil
}
// Make sure the stream will be closed at the end
defer func() {
if err := stream.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
log.Error("Error closing stream", "id", mediaID, "file", stream.Name(), err)
}
}()
w.Header().Set("X-Content-Type-Options", "nosniff")
api.serveStream(ctx, w, r, stream, mediaID)
return nil, nil
}

View File

@@ -0,0 +1,406 @@
package subsonic
import (
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/transcode"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Transcode endpoints", func() {
var (
router *Router
ds *tests.MockDataStore
mockTD *mockTranscodeDecision
w *httptest.ResponseRecorder
mockMFRepo *tests.MockMediaFileRepo
)
BeforeEach(func() {
mockMFRepo = &tests.MockMediaFileRepo{}
ds = &tests.MockDataStore{MockedMediaFile: mockMFRepo}
mockTD = &mockTranscodeDecision{}
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD)
w = httptest.NewRecorder()
})
Describe("GetTranscodeDecision", func() {
It("returns 405 for non-POST requests", func() {
r := newGetRequest("mediaId=123", "mediaType=song")
resp, err := router.GetTranscodeDecision(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp).To(BeNil())
Expect(w.Code).To(Equal(http.StatusMethodNotAllowed))
Expect(w.Header().Get("Allow")).To(Equal("POST"))
})
It("returns error when mediaId is missing", func() {
r := newJSONPostRequest("mediaType=song", "{}")
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
})
It("returns error when mediaType is missing", func() {
r := newJSONPostRequest("mediaId=123", "{}")
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
})
It("returns error for unsupported mediaType", func() {
r := newJSONPostRequest("mediaId=123&mediaType=podcast", "{}")
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("not yet supported"))
})
It("returns error when media file not found", func() {
mockMFRepo.SetError(true)
r := newJSONPostRequest("mediaId=notfound&mediaType=song", "{}")
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
})
It("returns error when body is empty", func() {
r := newJSONPostRequest("mediaId=song-1&mediaType=song", "")
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
})
It("returns error when body contains invalid JSON", func() {
r := newJSONPostRequest("mediaId=song-1&mediaType=song", "not-json{{{")
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
})
It("returns error for invalid protocol in direct play profile", func() {
body := `{"directPlayProfiles":[{"containers":["mp3"],"audioCodecs":["mp3"],"protocols":["ftp"]}]}`
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid protocol"))
})
It("returns error for invalid comparison operator", func() {
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"audioBitrate","comparison":"InvalidOp","values":["320"]}]}]}`
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid comparison"))
})
It("returns error for invalid limitation name", func() {
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"unknownField","comparison":"Equals","values":["320"]}]}]}`
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid limitation name"))
})
It("returns error for invalid codec profile type", func() {
body := `{"codecProfiles":[{"type":"VideoCodec","name":"mp3"}]}`
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid codec profile type"))
})
It("rejects wrong-case protocol", func() {
body := `{"directPlayProfiles":[{"containers":["mp3"],"audioCodecs":["mp3"],"protocols":["HTTP"]}]}`
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid protocol"))
})
It("rejects wrong-case codec profile type", func() {
body := `{"codecProfiles":[{"type":"audiocodec","name":"mp3"}]}`
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid codec profile type"))
})
It("rejects wrong-case comparison operator", func() {
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"audioBitrate","comparison":"lessthanequal","values":["320"]}]}]}`
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid comparison"))
})
It("rejects wrong-case limitation name", func() {
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"AudioBitrate","comparison":"Equals","values":["320"]}]}]}`
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid limitation name"))
})
It("returns a valid decision response", func() {
mockMFRepo.SetData(model.MediaFiles{
{ID: "song-1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100},
})
mockTD.decision = &transcode.Decision{
MediaID: "song-1",
CanDirectPlay: true,
SourceStream: transcode.StreamDetails{
Container: "mp3", Codec: "mp3", Bitrate: 320,
SampleRate: 44100, Channels: 2,
},
}
mockTD.token = "test-jwt-token"
body := `{"directPlayProfiles":[{"containers":["mp3"],"protocols":["http"]}]}`
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
resp, err := router.GetTranscodeDecision(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeParams).To(Equal("test-jwt-token"))
Expect(resp.TranscodeDecision.SourceStream).ToNot(BeNil())
Expect(resp.TranscodeDecision.SourceStream.Protocol).To(Equal("http"))
Expect(resp.TranscodeDecision.SourceStream.Container).To(Equal("mp3"))
Expect(resp.TranscodeDecision.SourceStream.AudioBitrate).To(Equal(int32(320_000)))
})
It("includes transcode stream when transcoding", func() {
mockMFRepo.SetData(model.MediaFiles{
{ID: "song-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24},
})
mockTD.decision = &transcode.Decision{
MediaID: "song-2",
CanDirectPlay: false,
CanTranscode: true,
TargetFormat: "mp3",
TargetBitrate: 256,
TranscodeReasons: []string{"container not supported"},
SourceStream: transcode.StreamDetails{
Container: "flac", Codec: "flac", Bitrate: 1000,
SampleRate: 96000, BitDepth: 24, Channels: 2,
},
TranscodeStream: &transcode.StreamDetails{
Container: "mp3", Codec: "mp3", Bitrate: 256,
SampleRate: 96000, Channels: 2,
},
}
mockTD.token = "transcode-token"
r := newJSONPostRequest("mediaId=song-2&mediaType=song", "{}")
resp, err := router.GetTranscodeDecision(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeReasons).To(ConsistOf("container not supported"))
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3"))
})
})
Describe("GetTranscodeStream", func() {
It("returns 400 when mediaId is missing", func() {
r := newGetRequest("mediaType=song", "transcodeParams=abc")
resp, err := router.GetTranscodeStream(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp).To(BeNil())
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
It("returns 400 when transcodeParams is missing", func() {
r := newGetRequest("mediaId=123", "mediaType=song")
resp, err := router.GetTranscodeStream(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp).To(BeNil())
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
It("returns 410 for invalid token", func() {
mockTD.validateErr = transcode.ErrTokenInvalid
r := newGetRequest("mediaId=123", "mediaType=song", "transcodeParams=bad-token")
resp, err := router.GetTranscodeStream(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp).To(BeNil())
Expect(w.Code).To(Equal(http.StatusGone))
})
It("returns 410 when mediaId doesn't match token", func() {
mockTD.validateErr = transcode.ErrTokenInvalid
r := newGetRequest("mediaId=wrong-id", "mediaType=song", "transcodeParams=valid-token")
resp, err := router.GetTranscodeStream(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp).To(BeNil())
Expect(w.Code).To(Equal(http.StatusGone))
})
It("returns 404 when media file not found", func() {
mockTD.validateErr = transcode.ErrMediaNotFound
r := newGetRequest("mediaId=gone-id", "mediaType=song", "transcodeParams=valid-token")
resp, err := router.GetTranscodeStream(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp).To(BeNil())
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns 410 when media file has changed (stale token)", func() {
mockTD.validateErr = transcode.ErrTokenStale
r := newGetRequest("mediaId=song-1", "mediaType=song", "transcodeParams=stale-token")
resp, err := router.GetTranscodeStream(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp).To(BeNil())
Expect(w.Code).To(Equal(http.StatusGone))
})
It("builds correct StreamRequest for direct play", func() {
fakeStreamer := &fakeMediaStreamer{}
router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD)
mockTD.validateParams = &transcode.Params{MediaID: "song-1", DirectPlay: true}
mockTD.validateMF = &model.MediaFile{ID: "song-1"}
r := newGetRequest("mediaId=song-1", "mediaType=song", "transcodeParams=valid-token")
_, _ = router.GetTranscodeStream(w, r)
Expect(fakeStreamer.captured).ToNot(BeNil())
Expect(fakeStreamer.captured.ID).To(Equal("song-1"))
Expect(fakeStreamer.captured.Format).To(BeEmpty())
Expect(fakeStreamer.captured.BitRate).To(BeZero())
Expect(fakeStreamer.captured.SampleRate).To(BeZero())
Expect(fakeStreamer.captured.BitDepth).To(BeZero())
Expect(fakeStreamer.captured.Channels).To(BeZero())
})
It("builds correct StreamRequest for transcoding", func() {
fakeStreamer := &fakeMediaStreamer{}
router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD)
mockTD.validateParams = &transcode.Params{
MediaID: "song-2",
DirectPlay: false,
TargetFormat: "mp3",
TargetBitrate: 256,
TargetSampleRate: 44100,
TargetBitDepth: 16,
TargetChannels: 2,
}
mockTD.validateMF = &model.MediaFile{ID: "song-2"}
r := newGetRequest("mediaId=song-2", "mediaType=song", "transcodeParams=valid-token", "offset=10")
_, _ = router.GetTranscodeStream(w, r)
Expect(fakeStreamer.captured).ToNot(BeNil())
Expect(fakeStreamer.captured.ID).To(Equal("song-2"))
Expect(fakeStreamer.captured.Format).To(Equal("mp3"))
Expect(fakeStreamer.captured.BitRate).To(Equal(256))
Expect(fakeStreamer.captured.SampleRate).To(Equal(44100))
Expect(fakeStreamer.captured.BitDepth).To(Equal(16))
Expect(fakeStreamer.captured.Channels).To(Equal(2))
Expect(fakeStreamer.captured.Offset).To(Equal(10))
})
})
Describe("bpsToKbps", func() {
It("converts standard bitrates", func() {
Expect(bpsToKbps(128000)).To(Equal(128))
Expect(bpsToKbps(320000)).To(Equal(320))
Expect(bpsToKbps(256000)).To(Equal(256))
})
It("returns 0 for 0", func() {
Expect(bpsToKbps(0)).To(Equal(0))
})
It("rounds instead of truncating", func() {
Expect(bpsToKbps(999)).To(Equal(1))
Expect(bpsToKbps(500)).To(Equal(1))
Expect(bpsToKbps(499)).To(Equal(0))
})
})
Describe("kbpsToBps", func() {
It("converts standard bitrates", func() {
Expect(kbpsToBps(128)).To(Equal(128000))
Expect(kbpsToBps(320)).To(Equal(320000))
})
It("returns 0 for 0", func() {
Expect(kbpsToBps(0)).To(Equal(0))
})
})
Describe("convertBitrateValues", func() {
It("converts valid bps strings to kbps", func() {
Expect(convertBitrateValues([]string{"128000", "320000"})).To(Equal([]string{"128", "320"}))
})
It("preserves unparseable values", func() {
Expect(convertBitrateValues([]string{"128000", "bad", "320000"})).To(Equal([]string{"128", "bad", "320"}))
})
It("handles empty slice", func() {
Expect(convertBitrateValues([]string{})).To(Equal([]string{}))
})
})
})
// newJSONPostRequest creates an HTTP POST request with JSON body and query params
func newJSONPostRequest(queryParams string, jsonBody string) *http.Request {
r := httptest.NewRequest("POST", "/getTranscodeDecision?"+queryParams, bytes.NewBufferString(jsonBody))
r.Header.Set("Content-Type", "application/json")
return r
}
// mockTranscodeDecision is a test double for transcode.Decider
type mockTranscodeDecision struct {
decision *transcode.Decision
token string
tokenErr error
params *transcode.Params
parseErr error
validateParams *transcode.Params
validateMF *model.MediaFile
validateErr error
}
func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, _ *transcode.ClientInfo) (*transcode.Decision, error) {
if m.decision != nil {
return m.decision, nil
}
return &transcode.Decision{}, nil
}
func (m *mockTranscodeDecision) CreateTranscodeParams(_ *transcode.Decision) (string, error) {
return m.token, m.tokenErr
}
func (m *mockTranscodeDecision) ParseTranscodeParams(_ string) (*transcode.Params, error) {
if m.parseErr != nil {
return nil, m.parseErr
}
return m.params, nil
}
func (m *mockTranscodeDecision) ValidateTranscodeParams(_ context.Context, _ string, _ string) (*transcode.Params, *model.MediaFile, error) {
if m.validateErr != nil {
return nil, nil, m.validateErr
}
return m.validateParams, m.validateMF, nil
}
// fakeMediaStreamer captures the StreamRequest and returns a sentinel error,
// allowing tests to verify parameter passing without constructing a real Stream.
var errStreamCaptured = errors.New("stream request captured")
type fakeMediaStreamer struct {
captured *core.StreamRequest
}
func (f *fakeMediaStreamer) NewStream(_ context.Context, req core.StreamRequest) (*core.Stream, error) {
f.captured = &req
return nil, errStreamCaptured
}
func (f *fakeMediaStreamer) DoStream(_ context.Context, _ *model.MediaFile, req core.StreamRequest) (*core.Stream, error) {
f.captured = &req
return nil, errStreamCaptured
}

View File

@@ -6,6 +6,8 @@ import (
"strings"
"sync"
"sync/atomic"
"github.com/navidrome/navidrome/core/ffmpeg"
)
func NewMockFFmpeg(data string) *MockFFmpeg {
@@ -23,7 +25,7 @@ func (ff *MockFFmpeg) IsAvailable() bool {
return true
}
func (ff *MockFFmpeg) Transcode(context.Context, string, string, int, int) (io.ReadCloser, error) {
func (ff *MockFFmpeg) Transcode(_ context.Context, _ ffmpeg.TranscodeOptions) (io.ReadCloser, error) {
if ff.Error != nil {
return nil, ff.Error
}

View File

@@ -18,6 +18,10 @@ func (m *MockTranscodingRepo) FindByFormat(format string) (*model.Transcoding, e
return &model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 128}, nil
case "opus":
return &model.Transcoding{ID: "opus1", TargetFormat: "opus", DefaultBitRate: 96}, nil
case "flac":
return &model.Transcoding{ID: "flac1", TargetFormat: "flac", DefaultBitRate: 0, Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -"}, nil
case "aac":
return &model.Transcoding{ID: "aac1", TargetFormat: "aac", DefaultBitRate: 256, Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -"}, nil
default:
return nil, model.ErrNotFound
}

View File

@@ -9,7 +9,6 @@ import TableBody from '@material-ui/core/TableBody'
import TableRow from '@material-ui/core/TableRow'
import TableCell from '@material-ui/core/TableCell'
import Paper from '@material-ui/core/Paper'
import CloudDownloadIcon from '@material-ui/icons/CloudDownload'
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
import FileCopyIcon from '@material-ui/icons/FileCopy'
import Button from '@material-ui/core/Button'
@@ -246,21 +245,6 @@ const ConfigTabContent = ({ configData }) => {
}
}
const handleDownloadToml = () => {
const tomlContent = configToToml(configData, translate)
const tomlFile = new File([tomlContent], 'navidrome.toml', {
type: 'text/plain',
})
const tomlFileLink = document.createElement('a')
const tomlFileUrl = URL.createObjectURL(tomlFile)
tomlFileLink.href = tomlFileUrl
tomlFileLink.download = tomlFile.name
tomlFileLink.click()
URL.revokeObjectURL(tomlFileUrl)
}
return (
<div className={classes.configContainer}>
<Button
@@ -268,22 +252,10 @@ const ConfigTabContent = ({ configData }) => {
startIcon={<FileCopyIcon />}
onClick={handleCopyToml}
className={classes.copyButton}
disabled={
!configData || !navigator.clipboard || !window.isSecureContext
}
size="small"
>
{translate('about.config.exportToml')}
</Button>
<Button
variant="outlined"
startIcon={<CloudDownloadIcon />}
onClick={handleDownloadToml}
className={classes.copyButton}
disabled={!configData}
size="small"
>
{translate('about.config.downloadToml')}
{translate('about.config.exportToml')}
</Button>
<TableContainer className={classes.tableContainer}>
<Table size="small" stickyHeader>

View File

@@ -673,7 +673,6 @@
"currentValue": "Current Value",
"configurationFile": "Configuration File",
"exportToml": "Export Configuration (TOML)",
"downloadToml": "Download Configuration (TOML)",
"exportSuccess": "Configuration exported to clipboard in TOML format",
"exportFailed": "Failed to copy configuration",
"devFlagsHeader": "Development Flags (subject to change/removal)",

View File

@@ -1,181 +0,0 @@
const stylesheet = `
/* Icon hover: pink */
.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover {
color: #ff79c6
}
/* Progress bar: purple */
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track {
background-color: #bd93f9
}
/* Volume bar: green */
.sound-operation .rc-slider-handle, .sound-operation .rc-slider-track {
background-color: #50fa7b !important
}
.sound-operation .rc-slider-handle:active {
box-shadow: 0 0 2px #50fa7b !important
}
/* Scrollbar: comment */
.react-jinke-music-player-main ::-webkit-scrollbar-thumb {
background-color: #6272a4;
}
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active {
box-shadow: 0 0 2px #bd93f9
}
/* Now playing icon: cyan */
.react-jinke-music-player-main .audio-item.playing svg {
color: #8be9fd
}
/* Now playing artist: cyan */
.react-jinke-music-player-main .audio-item.playing .player-singer {
color: #8be9fd !important
}
/* Loading spinner: orange */
.react-jinke-music-player-main .loading svg {
color: #ffb86c !important
}
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle {
border: hidden;
box-shadow: rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px;
}
.rc-slider-rail, .rc-slider-track {
height: 6px;
}
.rc-slider {
padding: 3px 0;
}
.sound-operation > div:nth-child(4) {
transform: translateX(-50%) translateY(5%) !important;
}
.sound-operation {
padding: 4px 0;
}
/* Player panel background */
.react-jinke-music-player-main .music-player-panel {
background-color: #282a36;
color: #f8f8f2;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.25);
}
/* Song title in player: foreground */
.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-title {
color: #f8f8f2;
}
/* Duration/time text: yellow */
.react-jinke-music-player-main .music-player-panel .panel-content .player-content .duration, .react-jinke-music-player-main .music-player-panel .panel-content .player-content .current-time {
color: #f1fa8c
}
/* Audio list panel */
.audio-lists-panel {
background-color: #282a36;
bottom: 6.25rem;
box-shadow: rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px;
}
.audio-lists-panel-content .audio-item.playing {
background-color: transparent;
}
.audio-lists-panel-content .audio-item:nth-child(2n+1) {
background-color: transparent;
}
/* Playlist hover: current line */
.audio-lists-panel-content .audio-item:active,
.audio-lists-panel-content .audio-item:hover {
background-color: #44475a;
}
.audio-lists-panel-header {
border-bottom: 1px solid rgba(0, 0, 0, 0.25);
box-shadow: none;
}
/* Playlist header text: orange */
.audio-lists-panel-header-title {
color: #ffb86c;
}
.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn {
background-color: transparent;
box-shadow: none;
}
.audio-lists-panel-content .audio-item {
line-height: 32px;
}
.react-jinke-music-player-main .music-player-panel .panel-content .img-content {
box-shadow: rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px;
}
/* Lyrics: yellow */
.react-jinke-music-player-main .music-player-lyric {
color: #f1fa8c;
-webkit-text-stroke: 0.5px #282a36;
font-weight: bolder;
}
/* Lyric button active: yellow */
.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg {
color: #f1fa8c !important;
}
/* Playlist now playing: cyan */
.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg {
color: #8be9fd
}
/* Playlist hover icons: pink */
.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg {
color: #ff79c6
}
.audio-lists-panel-content .audio-item .player-icons {
scale: 75%;
}
/* Mobile */
.react-jinke-music-player-mobile-cover {
border: none;
box-shadow: rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px;
}
.react-jinke-music-player .music-player-controller {
border: none;
box-shadow: rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px;
color: #bd93f9;
}
.react-jinke-music-player .music-player-controller .music-player-controller-setting {
color: rgba(189, 147, 249, 0.3);
}
/* Mobile progress: green */
.react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track {
background-color: #50fa7b;
}
.react-jinke-music-player-mobile-progress .rc-slider-handle {
border: none;
}
`
export default stylesheet

View File

@@ -1,397 +0,0 @@
import stylesheet from './dracula.css.js'
// Dracula color palette
const background = '#282a36'
const currentLine = '#44475a'
const foreground = '#f8f8f2'
const comment = '#6272a4'
const cyan = '#8be9fd'
const green = '#50fa7b'
const pink = '#ff79c6'
const purple = '#bd93f9'
const orange = '#ffb86c'
const red = '#ff5555'
const yellow = '#f1fa8c'
// Darker shade for surfaces
const surface = '#21222c'
// For Album, Playlist play button
const musicListActions = {
alignItems: 'center',
'@global': {
'button:first-child:not(:only-child)': {
'@media screen and (max-width: 720px)': {
transform: 'scale(1.5)',
margin: '1rem',
'&:hover': {
transform: 'scale(1.6) !important',
},
},
transform: 'scale(2)',
margin: '1.5rem',
minWidth: 0,
padding: 5,
transition: 'transform .3s ease',
backgroundColor: `${green} !important`,
color: background,
borderRadius: 500,
border: 0,
'&:hover': {
transform: 'scale(2.1)',
backgroundColor: `${green} !important`,
border: 0,
},
},
'button:only-child': {
margin: '1.5rem',
},
'button:first-child>span:first-child': {
padding: 0,
},
'button:first-child>span:first-child>span': {
display: 'none',
},
'button>span:first-child>span, button:not(:first-child)>span:first-child>svg':
{
color: foreground,
},
},
}
export default {
themeName: 'Dracula',
palette: {
primary: {
main: purple,
},
secondary: {
main: currentLine,
contrastText: foreground,
},
error: {
main: red,
},
type: 'dark',
background: {
default: background,
paper: surface,
},
},
overrides: {
MuiPaper: {
root: {
color: foreground,
backgroundColor: surface,
},
},
MuiAppBar: {
positionFixed: {
backgroundColor: `${currentLine} !important`,
boxShadow:
'rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px',
},
},
MuiDrawer: {
root: {
background: background,
},
},
MuiButton: {
textPrimary: {
color: purple,
},
textSecondary: {
color: foreground,
},
},
MuiIconButton: {
root: {
color: foreground,
},
},
MuiChip: {
root: {
backgroundColor: currentLine,
},
},
MuiFormGroup: {
root: {
color: foreground,
},
},
MuiFormLabel: {
root: {
color: comment,
'&$focused': {
color: purple,
},
},
},
MuiToolbar: {
root: {
backgroundColor: `${surface} !important`,
},
},
MuiOutlinedInput: {
root: {
'& $notchedOutline': {
borderColor: currentLine,
},
'&:hover $notchedOutline': {
borderColor: comment,
},
'&$focused $notchedOutline': {
borderColor: purple,
},
},
},
MuiFilledInput: {
root: {
backgroundColor: currentLine,
'&:hover': {
backgroundColor: comment,
},
'&$focused': {
backgroundColor: currentLine,
},
},
},
MuiTableRow: {
root: {
transition: 'background-color .3s ease',
'&:hover': {
backgroundColor: `${currentLine} !important`,
},
},
},
MuiTableHead: {
root: {
color: foreground,
background: surface,
},
},
MuiTableCell: {
root: {
color: foreground,
background: `${surface} !important`,
borderBottom: `1px solid ${currentLine}`,
},
head: {
color: `${yellow} !important`,
background: `${currentLine} !important`,
},
body: {
color: `${foreground} !important`,
},
},
MuiSwitch: {
colorSecondary: {
'&$checked': {
color: green,
},
'&$checked + $track': {
backgroundColor: green,
},
},
},
NDAlbumGridView: {
albumName: {
marginTop: '0.5rem',
fontWeight: 700,
color: foreground,
},
albumSubtitle: {
color: comment,
},
albumContainer: {
backgroundColor: surface,
borderRadius: '8px',
padding: '.75rem',
transition: 'background-color .3s ease',
'&:hover': {
backgroundColor: currentLine,
},
},
albumPlayButton: {
backgroundColor: green,
borderRadius: '50%',
boxShadow: '0 8px 8px rgb(0 0 0 / 30%)',
padding: '0.35rem',
transition: 'padding .3s ease',
'&:hover': {
background: `${green} !important`,
padding: '0.45rem',
},
},
},
NDPlaylistDetails: {
container: {
background: `linear-gradient(${currentLine}, transparent)`,
borderRadius: 0,
paddingTop: '2.5rem !important',
boxShadow: 'none',
},
title: {
fontWeight: 700,
color: foreground,
},
details: {
fontSize: '.875rem',
color: comment,
},
},
NDAlbumDetails: {
root: {
background: `linear-gradient(${currentLine}, transparent)`,
borderRadius: 0,
boxShadow: 'none',
},
cardContents: {
alignItems: 'center',
paddingTop: '1.5rem',
},
recordName: {
fontWeight: 700,
color: foreground,
},
recordArtist: {
fontSize: '.875rem',
fontWeight: 700,
color: pink,
},
recordMeta: {
fontSize: '.875rem',
color: comment,
},
},
NDCollapsibleComment: {
commentBlock: {
fontSize: '.875rem',
color: comment,
},
},
NDAlbumShow: {
albumActions: musicListActions,
},
NDPlaylistShow: {
playlistActions: musicListActions,
},
NDAudioPlayer: {
audioTitle: {
color: foreground,
fontSize: '0.875rem',
},
songTitle: {
fontWeight: 400,
},
songInfo: {
fontSize: '0.675rem',
color: comment,
},
},
NDLogin: {
systemNameLink: {
color: purple,
},
welcome: {
color: foreground,
},
card: {
minWidth: 300,
background: background,
},
button: {
boxShadow: '3px 3px 5px #191a21',
},
},
NDMobileArtistDetails: {
bgContainer: {
background: `linear-gradient(to bottom, rgba(40 42 54 / 72%), ${surface})!important`,
},
},
RaLayout: {
content: {
padding: '0 !important',
background: surface,
},
root: {
backgroundColor: background,
},
},
RaList: {
content: {
backgroundColor: surface,
},
},
RaListToolbar: {
toolbar: {
backgroundColor: background,
padding: '0 .55rem !important',
},
},
RaSidebar: {
fixed: {
backgroundColor: background,
},
drawerPaper: {
backgroundColor: `${background} !important`,
},
},
MuiTableSortLabel: {
root: {
color: `${yellow} !important`,
'&:hover': {
color: `${orange} !important`,
},
'&$active': {
color: `${orange} !important`,
'&& $icon': {
color: `${orange} !important`,
},
},
},
},
RaMenuItemLink: {
root: {
color: foreground,
'&[aria-current="page"]': {
color: `${pink} !important`,
},
'&[aria-current="page"] .MuiListItemIcon-root': {
color: `${pink} !important`,
},
},
active: {
color: `${pink} !important`,
'& .MuiListItemIcon-root': {
color: `${pink} !important`,
},
},
},
RaLink: {
link: {
color: cyan,
},
},
RaButton: {
button: {
margin: '0 5px 0 5px',
},
},
RaPaginationActions: {
currentPageButton: {
border: `2px solid ${purple}`,
},
button: {
backgroundColor: currentLine,
minWidth: 48,
margin: '0 4px',
},
},
},
player: {
theme: 'dark',
stylesheet,
},
}

View File

@@ -9,7 +9,6 @@ import ElectricPurpleTheme from './electricPurple'
import NordTheme from './nord'
import GruvboxDarkTheme from './gruvboxDark'
import CatppuccinMacchiatoTheme from './catppuccinMacchiato'
import DraculaTheme from './dracula'
import NuclearTheme from './nuclear'
import AmusicTheme from './amusic'
import SquiddiesGlassTheme from './SquiddiesGlass'
@@ -23,7 +22,6 @@ export default {
// New themes should be added here, in alphabetic order
AmusicTheme,
CatppuccinMacchiatoTheme,
DraculaTheme,
ElectricPurpleTheme,
ExtraDarkTheme,
GreenTheme,