Merge origin/master into subsonic-folder

Resolved conflict in server/subsonic/helpers.go by keeping both
childFromFolder (from this branch) and albumCreatedAt (from master).

Signed-off-by: Patrik Wallström <pawal@amplitut.de>
This commit is contained in:
Patrik Wallström 2026-04-16 21:43:12 +02:00
commit 6eedaa3dd7
80 changed files with 2045 additions and 2695 deletions

View File

@ -13,17 +13,5 @@ RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/shar
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends ffmpeg
# Install TagLib from cross-taglib releases
ARG CROSS_TAGLIB_VERSION="2.2.0-1"
ARG TARGETARCH
RUN DOWNLOAD_ARCH="linux-${TARGETARCH}" \
&& wget -q "https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/taglib-${DOWNLOAD_ARCH}.tar.gz" -O /tmp/cross-taglib.tar.gz \
&& tar -xzf /tmp/cross-taglib.tar.gz -C /usr --strip-components=1 \
&& mv /usr/include/taglib/* /usr/include/ \
&& rmdir /usr/include/taglib \
&& rm /tmp/cross-taglib.tar.gz /usr/provenance.json
ENV CGO_CFLAGS_ALLOW="--define-prefix"
# [Optional] Uncomment this line to install global node packages.
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1

View File

@ -4,11 +4,10 @@
"dockerfile": "Dockerfile",
"args": {
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
"VARIANT": "1.25",
"VARIANT": "1.26",
// Options
"INSTALL_NODE": "true",
"NODE_VERSION": "v24",
"CROSS_TAGLIB_VERSION": "2.2.0-1"
"NODE_VERSION": "v24"
}
},
"workspaceMount": "",

View File

@ -1,23 +0,0 @@
name: 'Download TagLib'
description: 'Downloads and extracts the TagLib library, adding it to PKG_CONFIG_PATH'
inputs:
version:
description: 'Version of TagLib to download'
required: true
platform:
description: 'Platform to download TagLib for'
default: 'linux-amd64'
runs:
using: 'composite'
steps:
- name: Download TagLib
shell: bash
run: |
mkdir -p /tmp/taglib
cd /tmp
FILE=taglib-${{ inputs.platform }}.tar.gz
wget https://github.com/navidrome/cross-taglib/releases/download/v${{ inputs.version }}/${FILE}
tar -xzf ${FILE} -C taglib
PKG_CONFIG_PREFIX=/tmp/taglib
echo "PKG_CONFIG_PREFIX=${PKG_CONFIG_PREFIX}" >> $GITHUB_ENV
echo "PKG_CONFIG_PATH=${PKG_CONFIG_PATH}:${PKG_CONFIG_PREFIX}/lib/pkgconfig" >> $GITHUB_ENV

View File

@ -14,8 +14,6 @@ concurrency:
cancel-in-progress: true
env:
CROSS_TAGLIB_VERSION: "2.2.0-1"
CGO_CFLAGS_ALLOW: "--define-prefix"
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
jobs:
@ -66,10 +64,9 @@ jobs:
steps:
- uses: actions/checkout@v6
- name: Download TagLib
uses: ./.github/actions/download-taglib
- uses: actions/setup-go@v6
with:
version: ${{ env.CROSS_TAGLIB_VERSION }}
go-version-file: go.mod
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
@ -106,18 +103,15 @@ jobs:
- name: Check out code into the Go module directory
uses: actions/checkout@v6
- name: Download TagLib
uses: ./.github/actions/download-taglib
- uses: actions/setup-go@v6
with:
version: ${{ env.CROSS_TAGLIB_VERSION }}
go-version-file: go.mod
- name: Download dependencies
run: go mod download
- name: Test
run: |
pkg-config --define-prefix --cflags --libs taglib # for debugging
go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v
run: go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v
- name: Test ndpgen
run: |
@ -232,7 +226,6 @@ jobs:
build-args: |
GIT_SHA=${{ env.GIT_SHA }}
GIT_TAG=${{ env.GIT_TAG }}
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
- name: Upload Binaries
uses: actions/upload-artifact@v7
@ -253,7 +246,6 @@ jobs:
build-args: |
GIT_SHA=${{ env.GIT_SHA }}
GIT_TAG=${{ env.GIT_TAG }}
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
outputs: |
type=image,name=${{ steps.docker.outputs.hub_repository }},push-by-digest=true,name-canonical=true,push=${{ steps.docker.outputs.hub_enabled }}
type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true

View File

@ -24,26 +24,6 @@ RUN cd /out && \
FROM scratch AS xx
COPY --from=xx-build /out/ /usr/bin/
########################################################################################################################
### Get TagLib
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS taglib-build
ARG TARGETPLATFORM
ARG CROSS_TAGLIB_VERSION=2.2.0-1
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
# wget in busybox can't follow redirects
RUN <<EOT
apk add --no-cache wget
PLATFORM=$(echo ${TARGETPLATFORM} | tr '/' '-')
FILE=taglib-${PLATFORM}.tar.gz
DOWNLOAD_URL=${CROSS_TAGLIB_RELEASES_URL}${FILE}
wget ${DOWNLOAD_URL}
mkdir /taglib
tar -xzf ${FILE} -C /taglib
EOT
########################################################################################################################
### Build Navidrome UI
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/node:lts-alpine AS ui
@ -62,8 +42,47 @@ FROM scratch AS ui-bundle
COPY --from=ui /build /build
########################################################################################################################
### Build Navidrome binary
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.25-trixie AS base
### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen)
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-alpine AS build-alpine
COPY --from=xx / /
ARG TARGETPLATFORM
RUN apk add --no-cache clang lld file git
RUN xx-apk add --no-cache gcc musl-dev zlib-dev
RUN xx-verify --setup
WORKDIR /workspace
RUN --mount=type=bind,source=. \
--mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/go/pkg/mod \
go mod download
ARG GIT_SHA
ARG GIT_TAG
RUN --mount=type=bind,source=. \
--mount=from=ui,source=/build,target=./ui/build,ro \
--mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/go/pkg/mod <<EOT
set -e
xx-go --wrap
export CGO_ENABLED=1
# -latomic is required on 32-bit arm (arm/v6, arm/v7) so SQLite's 64-bit atomics resolve.
go build -tags=netgo,sqlite_fts5 -ldflags="-w -s \
-linkmode=external -extldflags '-latomic' \
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
-o /out/navidrome .
# Fail the build if the binary is accidentally statically linked: dlopen (and
# therefore native libwebp detection) only works with a dynamic interpreter.
file /out/navidrome | grep -q "dynamically linked" || { echo "ERROR: /out/navidrome is not dynamically linked"; file /out/navidrome; exit 1; }
EOT
########################################################################################################################
### Build Navidrome binary for standalone distribution (static glibc, cross-compiled)
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-trixie AS base
RUN apt-get update && apt-get install -y clang lld
COPY --from=xx / /
WORKDIR /workspace
@ -88,14 +107,11 @@ RUN --mount=type=bind,source=. \
--mount=from=ui,source=/build,target=./ui/build,ro \
--mount=from=osxcross,src=/osxcross/SDK,target=/xx-sdk,ro \
--mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/go/pkg/mod \
--mount=from=taglib-build,target=/taglib,src=/taglib,ro <<EOT
--mount=type=cache,target=/go/pkg/mod <<EOT
# Setup CGO cross-compilation environment
xx-go --wrap
export CGO_ENABLED=1
export CGO_CFLAGS_ALLOW="--define-prefix"
export PKG_CONFIG_PATH=/taglib/lib/pkgconfig
cat $(go env GOENV)
# Only Darwin (macOS) requires clang (default), Windows requires gcc, everything else can use any compiler.
@ -127,17 +143,23 @@ FROM public.ecr.aws/docker/library/alpine:3.20 AS final
LABEL maintainer="deluan@navidrome.org"
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
# Install ffmpeg and mpv
RUN apk add -U --no-cache ffmpeg mpv sqlite
# Install runtime dependencies
# - libwebp + symlinks: enables native WebP encoding via purego/dlopen
RUN apk add -U --no-cache ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \
for lib in libwebp libwebpdemux libwebpmux; do \
target=$(ls /usr/lib/$lib.so.* 2>/dev/null | head -1) && \
[ -n "$target" ] && ln -sf "$target" /usr/lib/$lib.so; \
done
# Copy navidrome binary
COPY --from=build /out/navidrome /app/
# Copy navidrome binary (musl build for Docker, enables native libwebp)
COPY --from=build-alpine /out/navidrome /app/
VOLUME ["/data", "/music"]
ENV ND_MUSICFOLDER=/music
ENV ND_DATAFOLDER=/data
ENV ND_CONFIGFILE=/data/navidrome.toml
ENV ND_PORT=4533
ENV ND_ENABLEWEBPENCODING=true
RUN touch /.nddockerenv
EXPOSE ${ND_PORT}

View File

@ -5,7 +5,6 @@ comma:=,
GO_BUILD_TAGS=netgo,sqlite_fts5$(if $(EXTRA_BUILD_TAGS),$(comma)$(EXTRA_BUILD_TAGS))
# Set global environment variables, required for most targets
export CGO_CFLAGS_ALLOW=--define-prefix
export ND_ENABLEINSIGHTSCOLLECTOR=false
ifneq ("$(wildcard .git/HEAD)","")
@ -21,8 +20,6 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
DOCKER_TAG ?= deluan/navidrome:develop
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
CROSS_TAGLIB_VERSION ?= 2.2.1-1
GOLANGCI_LINT_VERSION ?= v2.11.1
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
@ -179,7 +176,6 @@ docker-build: ##@Cross_Compilation Cross-compile for any supported platform (che
--platform $(PLATFORMS) \
--build-arg GIT_TAG=${GIT_TAG} \
--build-arg GIT_SHA=${GIT_SHA} \
--build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \
--output "./binaries" --target binary .
.PHONY: docker-build
@ -191,7 +187,6 @@ docker-image: ##@Cross_Compilation Build Docker image, tagged as `deluan/navidro
--platform $(IMAGE_PLATFORMS) \
--build-arg GIT_TAG=${GIT_TAG} \
--build-arg GIT_SHA=${GIT_SHA} \
--build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \
--tag $(DOCKER_TAG) .
.PHONY: docker-image

View File

@ -1,274 +0,0 @@
package taglib
import (
"io/fs"
"os"
"time"
"github.com/djherbis/times"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type testFileInfo struct {
fs.FileInfo
}
func (t testFileInfo) BirthTime() time.Time {
if ts := times.Get(t.FileInfo); ts.HasBirthTime() {
return ts.BirthTime()
}
return t.FileInfo.ModTime()
}
var _ = Describe("Extractor", func() {
toP := func(name, sortName, mbid string) model.Participant {
return model.Participant{
Artist: model.Artist{Name: name, SortArtistName: sortName, MbzArtistID: mbid},
}
}
roles := []struct {
model.Role
model.ParticipantList
}{
{model.RoleComposer, model.ParticipantList{
toP("coma a", "a, coma", "bf13b584-f27c-43db-8f42-32898d33d4e2"),
toP("comb", "comb", "924039a2-09c6-4d29-9b4f-50cc54447d36"),
}},
{model.RoleLyricist, model.ParticipantList{
toP("la a", "a, la", "c84f648f-68a6-40a2-a0cb-d135b25da3c2"),
toP("lb", "lb", "0a7c582d-143a-4540-b4e9-77200835af65"),
}},
{model.RoleArranger, model.ParticipantList{
toP("aa", "", "4605a1d4-8d15-42a3-bd00-9c20e42f71e6"),
toP("ab", "", "002f0ff8-77bf-42cc-8216-61a9c43dc145"),
}},
{model.RoleConductor, model.ParticipantList{
toP("cona", "", "af86879b-2141-42af-bad2-389a4dc91489"),
toP("conb", "", "3dfa3c70-d7d3-4b97-b953-c298dd305e12"),
}},
{model.RoleDirector, model.ParticipantList{
toP("dia", "", "f943187f-73de-4794-be47-88c66f0fd0f4"),
toP("dib", "", "bceb75da-1853-4b3d-b399-b27f0cafc389"),
}},
{model.RoleEngineer, model.ParticipantList{
toP("ea", "", "f634bf6d-d66a-425d-888a-28ad39392759"),
toP("eb", "", "243d64ae-d514-44e1-901a-b918d692baee"),
}},
{model.RoleProducer, model.ParticipantList{
toP("pra", "", "d971c8d7-999c-4a5f-ac31-719721ab35d6"),
toP("prb", "", "f0a09070-9324-434f-a599-6d25ded87b69"),
}},
{model.RoleRemixer, model.ParticipantList{
toP("ra", "", "c7dc6095-9534-4c72-87cc-aea0103462cf"),
toP("rb", "", "8ebeef51-c08c-4736-992f-c37870becedd"),
}},
{model.RoleDJMixer, model.ParticipantList{
toP("dja", "", "d063f13b-7589-4efc-ab7f-c60e6db17247"),
toP("djb", "", "3636670c-385f-4212-89c8-0ff51d6bc456"),
}},
{model.RoleMixer, model.ParticipantList{
toP("ma", "", "53fb5a2d-7016-427e-a563-d91819a5f35a"),
toP("mb", "", "64c13e65-f0da-4ab9-a300-71ee53b0376a"),
}},
}
var e *extractor
parseTestFile := func(path string) *model.MediaFile {
mds, err := e.Parse(path)
Expect(err).ToNot(HaveOccurred())
info, ok := mds[path]
Expect(ok).To(BeTrue())
fileInfo, err := os.Stat(path)
Expect(err).ToNot(HaveOccurred())
info.FileInfo = testFileInfo{FileInfo: fileInfo}
metadata := metadata.New(path, info)
mf := metadata.ToMediaFile(1, "folderID")
return &mf
}
BeforeEach(func() {
e = &extractor{}
})
Describe("ReplayGain", func() {
DescribeTable("test replaygain end-to-end", func(file string, trackGain, trackPeak, albumGain, albumPeak *float64) {
mf := parseTestFile("tests/fixtures/" + file)
Expect(mf.RGTrackGain).To(Equal(trackGain))
Expect(mf.RGTrackPeak).To(Equal(trackPeak))
Expect(mf.RGAlbumGain).To(Equal(albumGain))
Expect(mf.RGAlbumPeak).To(Equal(albumPeak))
},
Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil),
Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)),
)
})
Describe("lyrics", func() {
makeLyrics := func(code, secondLine string) model.Lyrics {
return model.Lyrics{
DisplayArtist: "",
DisplayTitle: "",
Lang: code,
Line: []model.Line{
{Start: gg.P(int64(0)), Value: "This is"},
{Start: gg.P(int64(2500)), Value: secondLine},
},
Offset: nil,
Synced: true,
}
}
It("should fetch both synced and unsynced lyrics in mixed flac", func() {
mf := parseTestFile("tests/fixtures/mixed-lyrics.flac")
lyrics, err := mf.StructuredLyrics()
Expect(err).ToNot(HaveOccurred())
Expect(lyrics).To(HaveLen(2))
Expect(lyrics[0].Synced).To(BeTrue())
Expect(lyrics[1].Synced).To(BeFalse())
})
It("should handle mp3 with uslt and sylt", func() {
mf := parseTestFile("tests/fixtures/test.mp3")
lyrics, err := mf.StructuredLyrics()
Expect(err).ToNot(HaveOccurred())
Expect(lyrics).To(HaveLen(4))
engSylt := makeLyrics("eng", "English SYLT")
engUslt := makeLyrics("eng", "English")
unsSylt := makeLyrics("xxx", "unspecified SYLT")
unsUslt := makeLyrics("xxx", "unspecified")
Expect(lyrics).To(ConsistOf(engSylt, engUslt, unsSylt, unsUslt))
})
DescribeTable("format-specific lyrics", func(file string, isId3 bool) {
mf := parseTestFile("tests/fixtures/" + file)
lyrics, err := mf.StructuredLyrics()
Expect(err).To(Not(HaveOccurred()))
Expect(lyrics).To(HaveLen(2))
unspec := makeLyrics("xxx", "unspecified")
eng := makeLyrics("xxx", "English")
if isId3 {
eng.Lang = "eng"
}
Expect(lyrics).To(Or(
Equal(model.LyricList{unspec, eng}),
Equal(model.LyricList{eng, unspec})))
},
Entry("flac", "test.flac", false),
Entry("m4a", "test.m4a", false),
Entry("ogg", "test.ogg", false),
Entry("wma", "test.wma", false),
Entry("wv", "test.wv", false),
Entry("wav", "test.wav", true),
Entry("aiff", "test.aiff", true),
)
})
Describe("Participants", func() {
DescribeTable("test tags consistent across formats", func(format string) {
mf := parseTestFile("tests/fixtures/test." + format)
for _, data := range roles {
role := data.Role
artists := data.ParticipantList
actual := mf.Participants[role]
Expect(actual).To(HaveLen(len(artists)))
for i := range artists {
actualArtist := actual[i]
expectedArtist := artists[i]
Expect(actualArtist.Name).To(Equal(expectedArtist.Name))
Expect(actualArtist.SortArtistName).To(Equal(expectedArtist.SortArtistName))
Expect(actualArtist.MbzArtistID).To(Equal(expectedArtist.MbzArtistID))
}
}
if format != "m4a" {
performers := mf.Participants[model.RolePerformer]
Expect(performers).To(HaveLen(8))
rules := map[string][]string{
"pgaa": {"2fd0b311-9fa8-4ff9-be5d-f6f3d16b835e", "Guitar"},
"pgbb": {"223d030b-bf97-4c2a-ad26-b7f7bbe25c93", "Guitar", ""},
"pvaa": {"cb195f72-448f-41c8-b962-3f3c13d09d38", "Vocals"},
"pvbb": {"60a1f832-8ca2-49f6-8660-84d57f07b520", "Vocals", "Flute"},
"pfaa": {"51fb40c-0305-4bf9-a11b-2ee615277725", "", "Flute"},
}
for name, rule := range rules {
mbid := rule[0]
for i := 1; i < len(rule); i++ {
found := false
for _, mapped := range performers {
if mapped.Name == name && mapped.MbzArtistID == mbid && mapped.SubRole == rule[i] {
found = true
break
}
}
Expect(found).To(BeTrue(), "Could not find matching artist")
}
}
}
},
Entry("FLAC format", "flac"),
Entry("M4a format", "m4a"),
Entry("OGG format", "ogg"),
Entry("WV format", "wv"),
Entry("MP3 format", "mp3"),
Entry("WAV format", "wav"),
Entry("AIFF format", "aiff"),
)
It("should parse wma", func() {
mf := parseTestFile("tests/fixtures/test.wma")
for _, data := range roles {
role := data.Role
artists := data.ParticipantList
actual := mf.Participants[role]
// WMA has no Arranger role
if role == model.RoleArranger {
Expect(actual).To(HaveLen(0))
continue
}
Expect(actual).To(HaveLen(len(artists)), role.String())
// For some bizarre reason, the order is inverted. We also don't get
// sort names or MBIDs
for i := range artists {
idx := len(artists) - 1 - i
actualArtist := actual[i]
expectedArtist := artists[idx]
Expect(actualArtist.Name).To(Equal(expectedArtist.Name))
}
}
})
})
})

View File

@ -1,9 +0,0 @@
//go:build !windows
package taglib
import "C"
func getFilename(s string) *C.char {
return C.CString(s)
}

View File

@ -1,96 +0,0 @@
//go:build windows
package taglib
// From https://github.com/orofarne/gowchar
/*
#include <wchar.h>
const size_t SIZEOF_WCHAR_T = sizeof(wchar_t);
void gowchar_set (wchar_t *arr, int pos, wchar_t val)
{
arr[pos] = val;
}
wchar_t gowchar_get (wchar_t *arr, int pos)
{
return arr[pos];
}
*/
import "C"
import (
"fmt"
"unicode/utf16"
"unicode/utf8"
)
var SIZEOF_WCHAR_T C.size_t = C.size_t(C.SIZEOF_WCHAR_T)
func getFilename(s string) *C.wchar_t {
wstr, _ := StringToWcharT(s)
return wstr
}
func StringToWcharT(s string) (*C.wchar_t, C.size_t) {
switch SIZEOF_WCHAR_T {
case 2:
return stringToWchar2(s) // Windows
case 4:
return stringToWchar4(s) // Unix
default:
panic(fmt.Sprintf("Invalid sizeof(wchar_t) = %v", SIZEOF_WCHAR_T))
}
panic("?!!")
}
// Windows
func stringToWchar2(s string) (*C.wchar_t, C.size_t) {
var slen int
s1 := s
for len(s1) > 0 {
r, size := utf8.DecodeRuneInString(s1)
if er, _ := utf16.EncodeRune(r); er == '\uFFFD' {
slen += 1
} else {
slen += 2
}
s1 = s1[size:]
}
slen++ // \0
res := C.malloc(C.size_t(slen) * SIZEOF_WCHAR_T)
var i int
for len(s) > 0 {
r, size := utf8.DecodeRuneInString(s)
if r1, r2 := utf16.EncodeRune(r); r1 != '\uFFFD' {
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r1))
i++
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r2))
i++
} else {
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r))
i++
}
s = s[size:]
}
C.gowchar_set((*C.wchar_t)(res), C.int(slen-1), C.wchar_t(0)) // \0
return (*C.wchar_t)(res), C.size_t(slen)
}
// Unix
func stringToWchar4(s string) (*C.wchar_t, C.size_t) {
slen := utf8.RuneCountInString(s)
slen++ // \0
res := C.malloc(C.size_t(slen) * SIZEOF_WCHAR_T)
var i int
for len(s) > 0 {
r, size := utf8.DecodeRuneInString(s)
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r))
s = s[size:]
i++
}
C.gowchar_set((*C.wchar_t)(res), C.int(slen-1), C.wchar_t(0)) // \0
return (*C.wchar_t)(res), C.size_t(slen)
}

View File

@ -1,178 +0,0 @@
package taglib
import (
"io/fs"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/storage/local"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/metadata"
)
type extractor struct {
baseDir string
}
func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
results := make(map[string]metadata.Info)
for _, path := range files {
props, err := e.extractMetadata(path)
if err != nil {
continue
}
results[path] = *props
}
return results, nil
}
func (e extractor) Version() string {
return Version()
}
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
fullPath := filepath.Join(e.baseDir, filePath)
tags, err := Read(fullPath)
if err != nil {
log.Warn("extractor: Error reading metadata from file. Skipping", "filePath", fullPath, err)
return nil, err
}
// Parse audio properties
ap := metadata.AudioProperties{}
ap.BitRate = parseProp(tags, "__bitrate")
ap.Channels = parseProp(tags, "__channels")
ap.SampleRate = parseProp(tags, "__samplerate")
ap.BitDepth = parseProp(tags, "__bitspersample")
length := parseProp(tags, "__lengthinmilliseconds")
ap.Duration = (time.Millisecond * time.Duration(length)).Round(time.Millisecond * 10)
// Extract basic tags
parseBasicTag(tags, "__title", "title")
parseBasicTag(tags, "__artist", "artist")
parseBasicTag(tags, "__album", "album")
parseBasicTag(tags, "__comment", "comment")
parseBasicTag(tags, "__genre", "genre")
parseBasicTag(tags, "__year", "year")
parseBasicTag(tags, "__track", "tracknumber")
// Parse track/disc totals
parseTuple := func(prop string) {
tagName := prop + "number"
tagTotal := prop + "total"
if value, ok := tags[tagName]; ok && len(value) > 0 {
parts := strings.Split(value[0], "/")
tags[tagName] = []string{parts[0]}
if len(parts) == 2 {
tags[tagTotal] = []string{parts[1]}
}
}
}
parseTuple("track")
parseTuple("disc")
// Adjust some ID3 tags
parseLyrics(tags)
parseTIPL(tags)
delete(tags, "tmcl") // TMCL is already parsed by TagLib
return &metadata.Info{
Tags: tags,
AudioProperties: ap,
HasPicture: tags["has_picture"] != nil && len(tags["has_picture"]) > 0 && tags["has_picture"][0] == "true",
}, nil
}
// parseLyrics make sure lyrics tags have language
func parseLyrics(tags map[string][]string) {
lyrics := tags["lyrics"]
if len(lyrics) > 0 {
tags["lyrics:xxx"] = lyrics
delete(tags, "lyrics")
}
}
// These are the only roles we support, based on Picard's tag map:
// https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html
var tiplMapping = map[string]string{
"arranger": "arranger",
"engineer": "engineer",
"producer": "producer",
"mix": "mixer",
"DJ-mix": "djmixer",
}
// parseProp parses a property from the tags map and sets it to the target integer.
// It also deletes the property from the tags map after parsing.
func parseProp(tags map[string][]string, prop string) int {
if value, ok := tags[prop]; ok && len(value) > 0 {
v, _ := strconv.Atoi(value[0])
delete(tags, prop)
return v
}
return 0
}
// parseBasicTag checks if a basic tag (like __title, __artist, etc.) exists in the tags map.
// If it does, it moves the value to a more appropriate tag name (like title, artist, etc.),
// and deletes the basic tag from the map. If the target tag already exists, it ignores the basic tag.
func parseBasicTag(tags map[string][]string, basicName string, tagName string) {
basicValue := tags[basicName]
if len(basicValue) == 0 {
return
}
delete(tags, basicName)
if len(tags[tagName]) == 0 {
tags[tagName] = basicValue
}
}
// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format:
//
// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson".
//
// and breaks it down into a map of roles and names, e.g.:
//
// {"arranger": ["Andrew Powell"], "engineer": ["Chris Blair", "Pat Stapley"], "producer": ["Eric Woolfson"]}.
func parseTIPL(tags map[string][]string) {
tipl := tags["tipl"]
if len(tipl) == 0 {
return
}
addRole := func(currentRole string, currentValue []string) {
if currentRole != "" && len(currentValue) > 0 {
role := tiplMapping[currentRole]
tags[role] = append(tags[role], strings.Join(currentValue, " "))
}
}
var currentRole string
var currentValue []string
for _, part := range strings.Split(tipl[0], " ") {
if _, ok := tiplMapping[part]; ok {
addRole(currentRole, currentValue)
currentRole = part
currentValue = nil
continue
}
currentValue = append(currentValue, part)
}
addRole(currentRole, currentValue)
delete(tags, "tipl")
}
var _ local.Extractor = (*extractor)(nil)
func init() {
local.RegisterExtractor("legacy-taglib", func(_ fs.FS, baseDir string) local.Extractor {
// ignores fs, as taglib extractor only works with local files
return &extractor{baseDir}
})
conf.AddHook(func() {
log.Debug("TagLib version", "version", Version())
})
}

View File

@ -1,295 +0,0 @@
package taglib
import (
"io/fs"
"os"
"strings"
"github.com/navidrome/navidrome/utils"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Extractor", func() {
var e *extractor
BeforeEach(func() {
e = &extractor{}
})
Describe("Parse", func() {
It("correctly parses metadata from all files in folder", func() {
mds, err := e.Parse(
"tests/fixtures/test.mp3",
"tests/fixtures/test.ogg",
)
Expect(err).NotTo(HaveOccurred())
Expect(mds).To(HaveLen(2))
// Test MP3
m := mds["tests/fixtures/test.mp3"]
Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Song"}))
Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"}))
Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"}))
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
Expect(m.HasPicture).To(BeTrue())
Expect(m.AudioProperties.Duration.String()).To(Equal("1.02s"))
Expect(m.AudioProperties.BitRate).To(Equal(192))
Expect(m.AudioProperties.Channels).To(Equal(2))
Expect(m.AudioProperties.SampleRate).To(Equal(44100))
Expect(m.Tags).To(Or(
HaveKeyWithValue("compilation", []string{"1"}),
HaveKeyWithValue("tcmp", []string{"1"})),
)
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014-05-21"}))
Expect(m.Tags).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"}))
Expect(m.Tags).To(HaveKeyWithValue("releasedate", []string{"2020-12-31"}))
Expect(m.Tags).To(HaveKeyWithValue("discnumber", []string{"1"}))
Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"}))
Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_gain", []string{"+3.21518 dB"}))
Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"}))
Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_gain", []string{"-1.48 dB"}))
Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_peak", []string{"0.4512"}))
Expect(m.Tags).To(HaveKeyWithValue("tracknumber", []string{"2"}))
Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"}))
Expect(m.Tags).ToNot(HaveKey("lyrics"))
Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:eng", []string{
"[00:00.00]This is\n[00:02.50]English SYLT\n",
"[00:00.00]This is\n[00:02.50]English",
}), HaveKeyWithValue("lyrics:eng", []string{
"[00:00.00]This is\n[00:02.50]English",
"[00:00.00]This is\n[00:02.50]English SYLT\n",
})))
Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified SYLT\n",
"[00:00.00]This is\n[00:02.50]unspecified",
}), HaveKeyWithValue("lyrics:xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified",
"[00:00.00]This is\n[00:02.50]unspecified SYLT\n",
})))
// Test OGG
m = mds["tests/fixtures/test.ogg"]
Expect(err).To(BeNil())
Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
// TagLib 1.12 returns 18, previous versions return 39.
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
Expect(m.AudioProperties.Channels).To(BeElementOf(2))
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
Expect(m.HasPicture).To(BeTrue())
})
DescribeTable("Format-Specific tests",
func(file, duration string, channels, samplerate, bitdepth int, albumGain, albumPeak, trackGain, trackPeak string, id3Lyrics bool, image bool) {
file = "tests/fixtures/" + file
mds, err := e.Parse(file)
Expect(err).NotTo(HaveOccurred())
Expect(mds).To(HaveLen(1))
m := mds[file]
Expect(m.HasPicture).To(Equal(image))
Expect(m.AudioProperties.Duration.String()).To(Equal(duration))
Expect(m.AudioProperties.Channels).To(Equal(channels))
Expect(m.AudioProperties.SampleRate).To(Equal(samplerate))
Expect(m.AudioProperties.BitDepth).To(Equal(bitdepth))
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_album_gain", []string{albumGain}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_gain", []string{albumGain}),
))
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_album_peak", []string{albumPeak}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_peak", []string{albumPeak}),
))
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_track_gain", []string{trackGain}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{trackGain}),
))
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_track_peak", []string{trackPeak}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_peak", []string{trackPeak}),
))
Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Title"}))
Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"}))
Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"}))
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"}))
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
Expect(m.Tags).To(Or(
HaveKeyWithValue("tracknumber", []string{"3"}),
HaveKeyWithValue("tracknumber", []string{"3/10"}),
))
if !strings.HasSuffix(file, "test.wma") {
// TODO Not sure why this is not working for WMA
Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"}))
}
Expect(m.Tags).To(Or(
HaveKeyWithValue("discnumber", []string{"1"}),
HaveKeyWithValue("discnumber", []string{"1/2"}),
))
Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"}))
// WMA does not have a "compilation" tag, but "wm/iscompilation"
Expect(m.Tags).To(Or(
HaveKeyWithValue("compilation", []string{"1"}),
HaveKeyWithValue("wm/iscompilation", []string{"1"})),
)
if id3Lyrics {
Expect(m.Tags).To(HaveKeyWithValue("lyrics:eng", []string{
"[00:00.00]This is\n[00:02.50]English",
}))
Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified",
}))
} else {
Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified",
"[00:00.00]This is\n[00:02.50]English",
}))
}
Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
},
// ffmpeg -f lavfi -i "sine=frequency=1200:duration=1" test.flac
Entry("correctly parses flac tags", "test.flac", "1s", 1, 44100, 16, "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948", false, true),
Entry("correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false, true),
// ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma
// Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order
Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false, true),
// ffmpeg -f lavfi -i "sine=frequency=800:duration=1" test.wv
Entry("correctly parses wv (wavpak) tags", "test.wv", "1s", 1, 44100, 16, "3.43 dB", "0.125061", "3.43 dB", "0.125061", false, true),
// ffmpeg -f lavfi -i "sine=frequency=1000:duration=1" test.wav
Entry("correctly parses wav tags", "test.wav", "1s", 1, 44100, 16, "3.06 dB", "0.125056", "3.06 dB", "0.125056", true, true),
// ffmpeg -f lavfi -i "sine=frequency=1400:duration=1" test.aiff
Entry("correctly parses aiff tags", "test.aiff", "1s", 1, 44100, 16, "2.00 dB", "0.124972", "2.00 dB", "0.124972", true, true),
)
// Skip these tests when running as root
Context("Access Forbidden", func() {
var accessForbiddenFile string
var RegularUserContext = XContext
var isRegularUser = os.Getuid() != 0
if isRegularUser {
RegularUserContext = Context
}
// Only run permission tests if we are not root
RegularUserContext("when run without root privileges", func() {
BeforeEach(func() {
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
Expect(f.Close()).To(Succeed())
Expect(os.Remove(accessForbiddenFile)).To(Succeed())
})
})
It("correctly handle unreadable file due to insufficient read permission", func() {
_, err := e.extractMetadata(accessForbiddenFile)
Expect(err).To(MatchError(os.ErrPermission))
})
It("skips the file if it cannot be read", func() {
files := []string{
"tests/fixtures/test.mp3",
"tests/fixtures/test.ogg",
accessForbiddenFile,
}
mds, err := e.Parse(files...)
Expect(err).NotTo(HaveOccurred())
Expect(mds).To(HaveLen(2))
Expect(mds).ToNot(HaveKey(accessForbiddenFile))
})
})
})
})
Describe("Error Checking", func() {
It("returns a generic ErrPath if file does not exist", func() {
testFilePath := "tests/fixtures/NON_EXISTENT.ogg"
_, err := e.extractMetadata(testFilePath)
Expect(err).To(MatchError(fs.ErrNotExist))
})
It("does not throw a SIGSEGV error when reading a file with an invalid frame", func() {
// File has an empty TDAT frame
md, err := e.extractMetadata("tests/fixtures/invalid-files/test-invalid-frame.mp3")
Expect(err).ToNot(HaveOccurred())
Expect(md.Tags).To(HaveKeyWithValue("albumartist", []string{"Elvis Presley"}))
})
})
Describe("parseTIPL", func() {
var tags map[string][]string
BeforeEach(func() {
tags = make(map[string][]string)
})
Context("when the TIPL string is populated", func() {
It("correctly parses roles and names", func() {
tags["tipl"] = []string{"arranger Andrew Powell DJ-mix François Kevorkian DJ-mix Jane Doe engineer Chris Blair"}
parseTIPL(tags)
Expect(tags["arranger"]).To(ConsistOf("Andrew Powell"))
Expect(tags["engineer"]).To(ConsistOf("Chris Blair"))
Expect(tags["djmixer"]).To(ConsistOf("François Kevorkian", "Jane Doe"))
})
It("handles multiple names for a single role", func() {
tags["tipl"] = []string{"engineer Pat Stapley producer Eric Woolfson engineer Chris Blair"}
parseTIPL(tags)
Expect(tags["producer"]).To(ConsistOf("Eric Woolfson"))
Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair"))
})
It("discards roles without names", func() {
tags["tipl"] = []string{"engineer Pat Stapley producer engineer Chris Blair"}
parseTIPL(tags)
Expect(tags).ToNot(HaveKey("producer"))
Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair"))
})
})
Context("when the TIPL string is empty", func() {
It("does nothing", func() {
tags["tipl"] = []string{""}
parseTIPL(tags)
Expect(tags).To(BeEmpty())
})
})
Context("when the TIPL is not present", func() {
It("does nothing", func() {
parseTIPL(tags)
Expect(tags).To(BeEmpty())
})
})
})
})

View File

@ -1,299 +0,0 @@
#include <stdlib.h>
#include <string.h>
#define TAGLIB_STATIC
#include <apeproperties.h>
#include <apetag.h>
#include <aifffile.h>
#include <asffile.h>
#include <dsffile.h>
#include <fileref.h>
#include <flacfile.h>
#include <id3v2tag.h>
#include <unsynchronizedlyricsframe.h>
#include <synchronizedlyricsframe.h>
#include <mp4file.h>
#include <mpegfile.h>
#include <opusfile.h>
#include <tpropertymap.h>
#include <vorbisfile.h>
#include <wavfile.h>
#include <wavfile.h>
#include <wavpackfile.h>
#include "taglib_wrapper.h"
char has_cover(const TagLib::FileRef f);
static char TAGLIB_VERSION[16];
char* taglib_version() {
snprintf((char *)TAGLIB_VERSION, 16, "%d.%d.%d", TAGLIB_MAJOR_VERSION, TAGLIB_MINOR_VERSION, TAGLIB_PATCH_VERSION);
return (char *)TAGLIB_VERSION;
}
int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
TagLib::FileRef f(filename, true, TagLib::AudioProperties::Fast);
if (f.isNull()) {
return TAGLIB_ERR_PARSE;
}
if (!f.audioProperties()) {
return TAGLIB_ERR_AUDIO_PROPS;
}
// Add audio properties to the tags
const TagLib::AudioProperties *props(f.audioProperties());
goPutInt(id, (char *)"__lengthinmilliseconds", props->lengthInMilliseconds());
goPutInt(id, (char *)"__bitrate", props->bitrate());
goPutInt(id, (char *)"__channels", props->channels());
goPutInt(id, (char *)"__samplerate", props->sampleRate());
// Extract bits per sample for supported formats
int bitsPerSample = 0;
if (const auto* apeProperties{ dynamic_cast<const TagLib::APE::Properties*>(props) })
bitsPerSample = apeProperties->bitsPerSample();
else if (const auto* asfProperties{ dynamic_cast<const TagLib::ASF::Properties*>(props) })
bitsPerSample = asfProperties->bitsPerSample();
else if (const auto* flacProperties{ dynamic_cast<const TagLib::FLAC::Properties*>(props) })
bitsPerSample = flacProperties->bitsPerSample();
else if (const auto* mp4Properties{ dynamic_cast<const TagLib::MP4::Properties*>(props) })
bitsPerSample = mp4Properties->bitsPerSample();
else if (const auto* wavePackProperties{ dynamic_cast<const TagLib::WavPack::Properties*>(props) })
bitsPerSample = wavePackProperties->bitsPerSample();
else if (const auto* aiffProperties{ dynamic_cast<const TagLib::RIFF::AIFF::Properties*>(props) })
bitsPerSample = aiffProperties->bitsPerSample();
else if (const auto* wavProperties{ dynamic_cast<const TagLib::RIFF::WAV::Properties*>(props) })
bitsPerSample = wavProperties->bitsPerSample();
else if (const auto* dsfProperties{ dynamic_cast<const TagLib::DSF::Properties*>(props) })
bitsPerSample = dsfProperties->bitsPerSample();
if (bitsPerSample > 0) {
goPutInt(id, (char *)"__bitspersample", bitsPerSample);
}
// Send all properties to the Go map
TagLib::PropertyMap tags = f.file()->properties();
// Make sure at least the basic properties are extracted
TagLib::Tag *basic = f.file()->tag();
if (!basic->isEmpty()) {
if (!basic->title().isEmpty()) {
tags.insert("__title", basic->title());
}
if (!basic->artist().isEmpty()) {
tags.insert("__artist", basic->artist());
}
if (!basic->album().isEmpty()) {
tags.insert("__album", basic->album());
}
if (!basic->comment().isEmpty()) {
tags.insert("__comment", basic->comment());
}
if (!basic->genre().isEmpty()) {
tags.insert("__genre", basic->genre());
}
if (basic->year() > 0) {
tags.insert("__year", TagLib::String::number(basic->year()));
}
if (basic->track() > 0) {
tags.insert("__track", TagLib::String::number(basic->track()));
}
}
TagLib::ID3v2::Tag *id3Tags = NULL;
// Get some extended/non-standard ID3-only tags (ex: iTunes extended frames)
TagLib::MPEG::File *mp3File(dynamic_cast<TagLib::MPEG::File *>(f.file()));
if (mp3File != NULL) {
id3Tags = mp3File->ID3v2Tag();
}
if (id3Tags == NULL) {
TagLib::RIFF::WAV::File *wavFile(dynamic_cast<TagLib::RIFF::WAV::File *>(f.file()));
if (wavFile != NULL && wavFile->hasID3v2Tag()) {
id3Tags = wavFile->ID3v2Tag();
}
}
if (id3Tags == NULL) {
TagLib::RIFF::AIFF::File *aiffFile(dynamic_cast<TagLib::RIFF::AIFF::File *>(f.file()));
if (aiffFile && aiffFile->hasID3v2Tag()) {
id3Tags = aiffFile->tag();
}
}
// Yes, it is possible to have ID3v2 tags in FLAC. However, that can cause problems
// with many players, so they will not be parsed
if (id3Tags != NULL) {
const auto &frames = id3Tags->frameListMap();
for (const auto &kv: frames) {
if (kv.first == "USLT") {
for (const auto &tag: kv.second) {
TagLib::ID3v2::UnsynchronizedLyricsFrame *frame = dynamic_cast<TagLib::ID3v2::UnsynchronizedLyricsFrame *>(tag);
if (frame == NULL) continue;
tags.erase("LYRICS");
const auto bv = frame->language();
char language[4] = {'x', 'x', 'x', '\0'};
if (bv.size() == 3) {
strncpy(language, bv.data(), 3);
}
char *val = const_cast<char*>(frame->text().toCString(true));
goPutLyrics(id, language, val);
}
} else if (kv.first == "SYLT") {
for (const auto &tag: kv.second) {
TagLib::ID3v2::SynchronizedLyricsFrame *frame = dynamic_cast<TagLib::ID3v2::SynchronizedLyricsFrame *>(tag);
if (frame == NULL) continue;
const auto bv = frame->language();
char language[4] = {'x', 'x', 'x', '\0'};
if (bv.size() == 3) {
strncpy(language, bv.data(), 3);
}
const auto format = frame->timestampFormat();
if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMilliseconds) {
for (const auto &line: frame->synchedText()) {
char *text = const_cast<char*>(line.text.toCString(true));
goPutLyricLine(id, language, text, line.time);
}
} else if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMpegFrames) {
const int sampleRate = props->sampleRate();
if (sampleRate != 0) {
for (const auto &line: frame->synchedText()) {
const int timeInMs = (line.time * 1000) / sampleRate;
char *text = const_cast<char*>(line.text.toCString(true));
goPutLyricLine(id, language, text, timeInMs);
}
}
}
}
} else if (kv.first == "TIPL"){
if (!kv.second.isEmpty()) {
tags.insert(kv.first, kv.second.front()->toString());
}
}
}
}
// M4A may have some iTunes specific tags not captured by the PropertyMap interface
TagLib::MP4::File *m4afile(dynamic_cast<TagLib::MP4::File *>(f.file()));
if (m4afile != NULL) {
const auto itemListMap = m4afile->tag()->itemMap();
for (const auto item: itemListMap) {
char *key = const_cast<char*>(item.first.toCString(true));
for (const auto value: item.second.toStringList()) {
char *val = const_cast<char*>(value.toCString(true));
goPutM4AStr(id, key, val);
}
}
}
// WMA/ASF files may have additional tags not captured by the PropertyMap interface
TagLib::ASF::File *asfFile(dynamic_cast<TagLib::ASF::File *>(f.file()));
if (asfFile != NULL) {
const TagLib::ASF::Tag *asfTags{asfFile->tag()};
const auto itemListMap = asfTags->attributeListMap();
for (const auto item : itemListMap) {
char *key = const_cast<char*>(item.first.toCString(true));
for (auto j = item.second.begin();
j != item.second.end(); ++j) {
char *val = const_cast<char*>(j->toString().toCString(true));
goPutStr(id, key, val);
}
}
}
// Send all collected tags to the Go map
for (TagLib::PropertyMap::ConstIterator i = tags.begin(); i != tags.end();
++i) {
char *key = const_cast<char*>(i->first.toCString(true));
for (TagLib::StringList::ConstIterator j = i->second.begin();
j != i->second.end(); ++j) {
char *val = const_cast<char*>((*j).toCString(true));
goPutStr(id, key, val);
}
}
// Cover art has to be handled separately
if (has_cover(f)) {
goPutStr(id, (char *)"has_picture", (char *)"true");
}
return 0;
}
// Detect if the file has cover art. Returns 1 if the file has cover art, 0 otherwise.
char has_cover(const TagLib::FileRef f) {
char hasCover = 0;
// ----- MP3
if (TagLib::MPEG::File * mp3File{dynamic_cast<TagLib::MPEG::File *>(f.file())}) {
if (mp3File->ID3v2Tag()) {
const auto &frameListMap{mp3File->ID3v2Tag()->frameListMap()};
hasCover = !frameListMap["APIC"].isEmpty();
}
}
// ----- FLAC
else if (TagLib::FLAC::File * flacFile{dynamic_cast<TagLib::FLAC::File *>(f.file())}) {
hasCover = !flacFile->pictureList().isEmpty();
}
// ----- MP4
else if (TagLib::MP4::File * mp4File{dynamic_cast<TagLib::MP4::File *>(f.file())}) {
auto &coverItem{mp4File->tag()->itemMap()["covr"]};
TagLib::MP4::CoverArtList coverArtList{coverItem.toCoverArtList()};
hasCover = !coverArtList.isEmpty();
}
// ----- Ogg
else if (TagLib::Ogg::Vorbis::File * vorbisFile{dynamic_cast<TagLib::Ogg::Vorbis::File *>(f.file())}) {
hasCover = !vorbisFile->tag()->pictureList().isEmpty();
}
// ----- Opus
else if (TagLib::Ogg::Opus::File * opusFile{dynamic_cast<TagLib::Ogg::Opus::File *>(f.file())}) {
hasCover = !opusFile->tag()->pictureList().isEmpty();
}
// ----- WAV
else if (TagLib::RIFF::WAV::File * wavFile{ dynamic_cast<TagLib::RIFF::WAV::File*>(f.file()) }) {
if (wavFile->hasID3v2Tag()) {
const auto& frameListMap{ wavFile->ID3v2Tag()->frameListMap() };
hasCover = !frameListMap["APIC"].isEmpty();
}
}
// ----- AIFF
else if (TagLib::RIFF::AIFF::File * aiffFile{ dynamic_cast<TagLib::RIFF::AIFF::File *>(f.file())}) {
if (aiffFile->hasID3v2Tag()) {
const auto& frameListMap{ aiffFile->tag()->frameListMap() };
hasCover = !frameListMap["APIC"].isEmpty();
}
}
// ----- WMA
else if (TagLib::ASF::File * asfFile{dynamic_cast<TagLib::ASF::File *>(f.file())}) {
const TagLib::ASF::Tag *tag{ asfFile->tag() };
hasCover = tag && tag->attributeListMap().contains("WM/Picture");
}
// ----- DSF
else if (TagLib::DSF::File * dsffile{ dynamic_cast<TagLib::DSF::File *>(f.file())}) {
const TagLib::ID3v2::Tag *tag { dsffile->tag() };
hasCover = tag && !tag->frameListMap()["APIC"].isEmpty();
}
// ----- WAVPAK (APE tag)
else if (TagLib::WavPack::File * wvFile{dynamic_cast<TagLib::WavPack::File *>(f.file())}) {
if (wvFile->hasAPETag()) {
// This is the particular string that Picard uses
hasCover = !wvFile->APETag()->itemListMap()["COVER ART (FRONT)"].isEmpty();
}
}
return hasCover;
}

View File

@ -1,157 +0,0 @@
package taglib
/*
#cgo !windows pkg-config: --define-prefix taglib
#cgo windows pkg-config: taglib
#cgo illumos LDFLAGS: -lstdc++ -lsendfile
#cgo linux darwin CXXFLAGS: -std=c++11
#cgo darwin LDFLAGS: -L/opt/homebrew/opt/taglib/lib
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "taglib_wrapper.h"
*/
import "C"
import (
"encoding/json"
"fmt"
"os"
"runtime/debug"
"strconv"
"strings"
"sync"
"sync/atomic"
"unsafe"
"github.com/navidrome/navidrome/log"
)
const iTunesKeyPrefix = "----:com.apple.itunes:"
func Version() string {
return C.GoString(C.taglib_version())
}
func Read(filename string) (tags map[string][]string, err error) {
// Do not crash on failures in the C code/library
debug.SetPanicOnFault(true)
defer func() {
if r := recover(); r != nil {
log.Error("extractor: recovered from panic when reading tags", "file", filename, "error", r)
err = fmt.Errorf("extractor: recovered from panic: %s", r)
}
}()
fp := getFilename(filename)
defer C.free(unsafe.Pointer(fp))
id, m, release := newMap()
defer release()
log.Trace("extractor: reading tags", "filename", filename, "map_id", id)
res := C.taglib_read(fp, C.ulong(id))
switch res {
case C.TAGLIB_ERR_PARSE:
// Check additional case whether the file is unreadable due to permission
file, fileErr := os.OpenFile(filename, os.O_RDONLY, 0600)
defer file.Close()
if os.IsPermission(fileErr) {
return nil, fmt.Errorf("navidrome does not have permission: %w", fileErr)
} else if fileErr != nil {
return nil, fmt.Errorf("cannot parse file media file: %w", fileErr)
} else {
return nil, fmt.Errorf("cannot parse file media file")
}
case C.TAGLIB_ERR_AUDIO_PROPS:
return nil, fmt.Errorf("can't get audio properties from file")
}
if log.IsGreaterOrEqualTo(log.LevelDebug) {
j, _ := json.Marshal(m)
log.Trace("extractor: read tags", "tags", string(j), "filename", filename, "id", id)
} else {
log.Trace("extractor: read tags", "tags", m, "filename", filename, "id", id)
}
return m, nil
}
type tagMap map[string][]string
var allMaps sync.Map
var mapsNextID atomic.Uint32
func newMap() (uint32, tagMap, func()) {
id := mapsNextID.Add(1)
m := tagMap{}
allMaps.Store(id, m)
return id, m, func() {
allMaps.Delete(id)
}
}
func doPutTag(id C.ulong, key string, val *C.char) {
if key == "" {
return
}
r, _ := allMaps.Load(uint32(id))
m := r.(tagMap)
k := strings.ToLower(key)
v := strings.TrimSpace(C.GoString(val))
m[k] = append(m[k], v)
}
//export goPutM4AStr
func goPutM4AStr(id C.ulong, key *C.char, val *C.char) {
k := C.GoString(key)
// Special for M4A, do not catch keys that have no actual name
k = strings.TrimPrefix(k, iTunesKeyPrefix)
doPutTag(id, k, val)
}
//export goPutStr
func goPutStr(id C.ulong, key *C.char, val *C.char) {
doPutTag(id, C.GoString(key), val)
}
//export goPutInt
func goPutInt(id C.ulong, key *C.char, val C.int) {
valStr := strconv.Itoa(int(val))
vp := C.CString(valStr)
defer C.free(unsafe.Pointer(vp))
goPutStr(id, key, vp)
}
//export goPutLyrics
func goPutLyrics(id C.ulong, lang *C.char, val *C.char) {
doPutTag(id, "lyrics:"+C.GoString(lang), val)
}
//export goPutLyricLine
func goPutLyricLine(id C.ulong, lang *C.char, text *C.char, time C.int) {
language := C.GoString(lang)
line := C.GoString(text)
timeGo := int64(time)
ms := timeGo % 1000
timeGo /= 1000
sec := timeGo % 60
timeGo /= 60
minimum := timeGo % 60
formattedLine := fmt.Sprintf("[%02d:%02d.%02d]%s\n", minimum, sec, ms/10, line)
key := "lyrics:" + language
r, _ := allMaps.Load(uint32(id))
m := r.(tagMap)
k := strings.ToLower(key)
existing, ok := m[k]
if ok {
existing[0] += formattedLine
} else {
m[k] = []string{formattedLine}
}
}

View File

@ -1,24 +0,0 @@
#define TAGLIB_ERR_PARSE -1
#define TAGLIB_ERR_AUDIO_PROPS -2
#ifdef __cplusplus
extern "C" {
#endif
#ifdef WIN32
#define FILENAME_CHAR_T wchar_t
#else
#define FILENAME_CHAR_T char
#endif
extern void goPutM4AStr(unsigned long id, char *key, char *val);
extern void goPutStr(unsigned long id, char *key, char *val);
extern void goPutInt(unsigned long id, char *key, int val);
extern void goPutLyrics(unsigned long id, char *lang, char *val);
extern void goPutLyricLine(unsigned long id, char *lang, char *text, int time);
int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id);
char* taglib_version();
#ifdef __cplusplus
}
#endif

View File

@ -27,7 +27,6 @@ import (
_ "github.com/navidrome/navidrome/adapters/gotaglib"
_ "github.com/navidrome/navidrome/adapters/lastfm"
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
_ "github.com/navidrome/navidrome/adapters/taglib"
)
var (

View File

@ -17,6 +17,7 @@ import (
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
@ -39,7 +40,6 @@ import (
_ "github.com/navidrome/navidrome/adapters/gotaglib"
_ "github.com/navidrome/navidrome/adapters/lastfm"
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
_ "github.com/navidrome/navidrome/adapters/taglib"
)
// Injectors from wire_injectors.go:
@ -72,7 +72,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents)
matcherMatcher := matcher.New(dataStore)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
@ -93,7 +94,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents)
matcherMatcher := matcher.New(dataStore)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
transcodingCache := stream.GetTranscodingCache()
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
@ -121,7 +123,8 @@ func CreatePublicRouter() *public.Router {
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents)
matcherMatcher := matcher.New(dataStore)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
transcodingCache := stream.GetTranscodingCache()
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
@ -168,7 +171,8 @@ func CreateScanner(ctx context.Context) model.Scanner {
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents)
matcherMatcher := matcher.New(dataStore)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
imageUploadService := core.NewImageUploadService()
@ -186,7 +190,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents)
matcherMatcher := matcher.New(dataStore)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
imageUploadService := core.NewImageUploadService()

View File

@ -12,6 +12,7 @@ import (
"time"
"github.com/bmatcuk/doublestar/v4"
"github.com/dustin/go-humanize"
"github.com/go-viper/encoding/ini"
"github.com/kr/pretty"
"github.com/navidrome/navidrome/consts"
@ -80,6 +81,7 @@ type configOptions struct {
EnableStarRating bool
EnableUserEditing bool
EnableArtworkUpload bool
MaxImageUploadSize string
EnableSharing bool
ShareURL string
DefaultShareExpiration time.Duration
@ -361,10 +363,11 @@ func Load(noConfigDump bool) {
validateBackupSchedule,
validatePlaylistsPath,
validatePurgeMissingOption,
validateMaxImageUploadSize,
validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL),
)
if err != nil {
os.Exit(1)
logFatal(err)
}
Server.Search.Backend = normalizeSearchBackend(Server.Search.Backend)
@ -550,8 +553,7 @@ func validatePlaylistsPath() error {
for path := range strings.SplitSeq(Server.PlaylistsPath, string(filepath.ListSeparator)) {
_, err := doublestar.Match(path, "")
if err != nil {
log.Error("Invalid PlaylistsPath", "path", path, err)
return err
return fmt.Errorf("invalid PlaylistsPath %q: %w", path, err)
}
}
return nil
@ -578,13 +580,19 @@ func validatePurgeMissingOption() error {
valid := slices.Contains(allowedValues, Server.Scanner.PurgeMissing)
if !valid {
err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
log.Error(err.Error())
Server.Scanner.PurgeMissing = consts.PurgeMissingNever
return err
}
return nil
}
func validateMaxImageUploadSize() error {
if _, err := humanize.ParseBytes(Server.MaxImageUploadSize); err != nil {
return fmt.Errorf("invalid MaxImageUploadSize %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", Server.MaxImageUploadSize, err)
}
return nil
}
func validateScanSchedule() error {
if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" {
Server.Scanner.Schedule = ""
@ -608,9 +616,9 @@ func validateBackupSchedule() error {
func validateSchedule(schedule, field string) (string, error) {
_, err := scheduler.ParseCrontab(schedule)
if err != nil {
log.Error(fmt.Sprintf("Invalid %s. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", field), "schedule", schedule, err)
return schedule, fmt.Errorf("invalid %s %q (see https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format): %w", field, schedule, err)
}
return schedule, err
return schedule, nil
}
// validateURL checks if the provided URL is valid and has either http or https scheme.
@ -622,19 +630,13 @@ func validateURL(optionName, optionURL string) func() error {
}
u, err := url.Parse(optionURL)
if err != nil {
log.Error(fmt.Sprintf("Invalid %s: it could not be parsed", optionName), "url", optionURL, "err", err)
return err
return fmt.Errorf("invalid %s %q: %w", optionName, optionURL, err)
}
if u.Scheme != "http" && u.Scheme != "https" {
err := fmt.Errorf("invalid scheme for %s: '%s'. Only 'http' and 'https' are allowed", optionName, u.Scheme)
log.Error(err.Error())
return err
return fmt.Errorf("invalid scheme for %s: '%s'. Only 'http' and 'https' are allowed", optionName, u.Scheme)
}
// Require an absolute URL with a non-empty host and no opaque component.
if u.Host == "" || u.Opaque != "" {
err := fmt.Errorf("invalid %s: '%s'. A full http(s) URL with a non-empty host is required", optionName, optionURL)
log.Error(err.Error())
return err
return fmt.Errorf("invalid %s: '%s'. A full http(s) URL with a non-empty host is required", optionName, optionURL)
}
return nil
}
@ -743,6 +745,7 @@ func setViperDefaults() {
viper.SetDefault("enablecoveranimation", true)
viper.SetDefault("enablenowplaying", true)
viper.SetDefault("enableartworkupload", true)
viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize)
viper.SetDefault("enablesharing", false)
viper.SetDefault("shareurl", "")
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)

View File

@ -219,6 +219,37 @@ var _ = Describe("Configuration", func() {
})
Describe("ValidateMaxImageUploadSize", func() {
BeforeEach(func() {
viper.Reset()
conf.SetViperDefaults()
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("loglevel", "error")
conf.ResetConf()
})
DescribeTable("accepts valid size values",
func(input string) {
conf.Server.MaxImageUploadSize = input
Expect(conf.ValidateMaxImageUploadSize()).To(Succeed())
},
Entry("megabytes", "10MB"),
Entry("gigabytes", "1GB"),
Entry("raw bytes", "10485760"),
Entry("mebibytes", "10MiB"),
Entry("lower case", "50mb"),
)
DescribeTable("rejects invalid size values",
func(input string) {
conf.Server.MaxImageUploadSize = input
Expect(conf.ValidateMaxImageUploadSize()).To(MatchError(ContainSubstring("invalid MaxImageUploadSize")))
},
Entry("garbage string", "not-a-size"),
Entry("negative-looking", "-10MB"),
)
})
DescribeTable("should load configuration from",
func(format string) {
filename := filepath.Join("testdata", "cfg."+format)

View File

@ -14,6 +14,8 @@ var NormalizeSearchBackend = normalizeSearchBackend
var ToPascalCase = toPascalCase
var ValidateMaxImageUploadSize = validateMaxImageUploadSize
func SetLogFatal(f func(...any)) func() {
old := logFatal
logFatal = f

View File

@ -85,7 +85,8 @@ const (
)
const (
DefaultUICoverArtSize = 300
DefaultUICoverArtSize = 300
DefaultMaxImageUploadSize = "10MB"
)
// Prometheus options

4
context7.json Normal file
View File

@ -0,0 +1,4 @@
{
"url": "https://context7.com/navidrome/navidrome",
"public_key": "pk_WqzhKScNKWQ84J4n0oG0J"
}

View File

@ -168,47 +168,38 @@ func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle strin
}
}
// extractDiscNumber extracts a disc number from a filename based on a glob pattern.
// It finds the portion of the filename that the wildcard matched and parses leading
// digits as the disc number. Returns (0, false) if the pattern doesn't match or
// no leading digits are found in the wildcard portion.
// globMetaChars holds the substitution metacharacters understood by
// filepath.Match. The '\' escape character is intentionally excluded:
// disc art patterns come from user config and never include escaped
// metachars in practice, and treating '\' as a metachar would misalign
// the literal-prefix extraction in extractDiscNumber.
const globMetaChars = "*?["
// extractDiscNumber parses the disc number from a filename matched by a
// filepath.Match-style glob pattern.
//
// Both pattern and filename must already be lowercased by the caller, which
// is also expected to have verified that filepath.Match(pattern, filename)
// is true before calling this function.
func extractDiscNumber(pattern, filename string) (int, bool) {
filename = strings.ToLower(filename)
pattern = strings.ToLower(pattern)
matched, err := filepath.Match(pattern, filename)
if err != nil || !matched {
metaIdx := strings.IndexAny(pattern, globMetaChars)
if metaIdx < 0 {
return 0, false
}
// Find the prefix before the first '*' in the pattern
starIdx := strings.IndexByte(pattern, '*')
if starIdx < 0 {
return 0, false
}
prefix := pattern[:starIdx]
// Strip the prefix from the filename to get the wildcard-matched portion
prefix := pattern[:metaIdx]
if !strings.HasPrefix(filename, prefix) {
return 0, false
}
remainder := filename[len(prefix):]
// Extract leading ASCII digits from the remainder
var digits []byte
for _, r := range remainder {
if r >= '0' && r <= '9' {
digits = append(digits, byte(r))
} else {
break
}
start := len(prefix)
end := start
for end < len(filename) && filename[end] >= '0' && filename[end] <= '9' {
end++
}
if len(digits) == 0 {
if end == start {
return 0, false
}
num, err := strconv.Atoi(string(digits))
num, err := strconv.Atoi(filename[start:end])
if err != nil {
return 0, false
}
@ -216,20 +207,16 @@ func extractDiscNumber(pattern, filename string) (int, bool) {
}
// fromExternalFile returns a sourceFunc that matches image files against a glob
// pattern with disc-number-aware filtering.
//
// Matching rules:
// - If a disc number can be extracted from the filename, the file matches only if
// the number equals the target disc number.
// - If no number is found and this is a multi-folder album, the file matches if
// it's in a folder containing tracks for this disc.
// - If no number is found and this is a single-folder album, the file is skipped
// (ambiguous).
// pattern. A numbered filename whose number equals the target disc wins over
// any unnumbered candidate; callers must pass a lowercase pattern.
func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string) sourceFunc {
isLiteral := !strings.ContainsAny(pattern, globMetaChars)
return func() (io.ReadCloser, string, error) {
var fallbacks []string
for _, file := range d.imgFiles {
_, name := filepath.Split(file)
match, err := filepath.Match(pattern, strings.ToLower(name))
name = strings.ToLower(name)
match, err := filepath.Match(pattern, name)
if err != nil {
log.Warn(ctx, "Error matching disc art file to pattern", "pattern", pattern, "file", file)
continue
@ -238,24 +225,27 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string
continue
}
// Try to extract disc number from filename
num, hasNum := extractDiscNumber(pattern, name)
if hasNum {
// File has a disc number — must match target disc
if num != d.discNumber {
continue
if !isLiteral {
if num, hasNum := extractDiscNumber(pattern, name); hasNum {
if num != d.discNumber {
continue
}
f, err := os.Open(file)
if err != nil {
log.Warn(ctx, "Could not open disc art file", "file", file, err)
continue
}
return f, file, nil
}
} else if d.isMultiFolder {
// No number, multi-folder: match by folder association
dir := filepath.Dir(file)
if !d.discFolders[dir] {
continue
}
} else {
// No number, single-folder: ambiguous, skip
continue
}
if d.isMultiFolder && !d.discFolders[filepath.Dir(file)] {
continue
}
fallbacks = append(fallbacks, file)
}
for _, file := range fallbacks {
f, err := os.Open(file)
if err != nil {
log.Warn(ctx, "Could not open disc art file", "file", file, err)

View File

@ -42,11 +42,24 @@ var _ = Describe("Disc Artwork Reader", func() {
// Case insensitive (filename already lowered by caller)
Entry("Disc1.jpg lowered", "disc*.*", "disc1.jpg", 1, true),
// Pattern doesn't match
Entry("cover.jpg doesn't match disc*.*", "disc*.*", "cover.jpg", 0, false),
// HasPrefix guard: filename doesn't share the pattern's literal prefix
Entry("cover.jpg with disc*.* (no prefix match)", "disc*.*", "cover.jpg", 0, false),
// Pattern with no wildcard before dot
Entry("front1.jpg with front*.*", "front*.*", "front1.jpg", 1, true),
// '?' single-char wildcard
Entry("disc?.jpg with disc1.jpg", "disc?.jpg", "disc1.jpg", 1, true),
Entry("disc?.jpg with disc2.jpg", "disc?.jpg", "disc2.jpg", 2, true),
Entry("cd??.jpg with cd07.jpg", "cd??.jpg", "cd07.jpg", 7, true),
// '[...]' character class wildcard
Entry("cd[12].jpg with cd1.jpg", "cd[12].jpg", "cd1.jpg", 1, true),
Entry("cd[12].jpg with cd2.jpg", "cd[12].jpg", "cd2.jpg", 2, true),
Entry("disc[0-9].jpg with disc5.jpg", "disc[0-9].jpg", "disc5.jpg", 5, true),
// Literal pattern (no wildcard) returns false
Entry("shellac.png literal", "shellac.png", "shellac.png", 0, false),
)
})
@ -85,19 +98,186 @@ var _ = Describe("Disc Artwork Reader", func() {
Expect(path).To(Equal(f1))
})
It("skips file without number in single-folder album", func() {
f1 := createFile("album/disc.jpg")
It("matches file without number in single-folder album (shared disc art)", func() {
f1 := createFile("album/cover.png")
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1},
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
}
sf := reader.fromExternalFile(ctx, "disc*.*")
r, _, _ := sf()
Expect(r).To(BeNil())
sf := reader.fromExternalFile(ctx, "cover.*")
r, path, err := sf()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
r.Close()
Expect(path).To(Equal(f1))
})
It("returns shared disc art for every disc number in single-folder album", func() {
f1 := createFile("album/shellac.png")
makeReader := func(discNum int) *discArtworkReader {
return &discArtworkReader{
discNumber: discNum,
imgFiles: []string{f1},
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
}
}
for _, disc := range []int{1, 2, 5} {
sf := makeReader(disc).fromExternalFile(ctx, "shellac.png")
r, path, err := sf()
Expect(err).ToNot(HaveOccurred(), "disc %d", disc)
Expect(r).ToNot(BeNil())
r.Close()
Expect(path).To(Equal(f1), "disc %d", disc)
}
})
It("numbered and unnumbered patterns both resolve against the same reader", func() {
f1 := createFile("album/cover.png")
f2 := createFile("album/disc1.jpg")
f3 := createFile("album/disc2.jpg")
reader := &discArtworkReader{
discNumber: 2,
imgFiles: []string{f1, f2, f3},
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
}
sf := reader.fromExternalFile(ctx, "disc*.*")
r, path, err := sf()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
r.Close()
Expect(path).To(Equal(f3))
sf = reader.fromExternalFile(ctx, "cover.*")
r, path, err = sf()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
r.Close()
Expect(path).To(Equal(f1))
})
It("respects DiscArtPriority order when both numbered and unnumbered patterns match", func() {
f1 := createFile("album/cover.png")
f2 := createFile("album/disc1.jpg")
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1, f2},
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
}
ff := reader.fromDiscArtPriority(ctx, nil, "disc*.*, cover.*")
Expect(ff).To(HaveLen(2))
r, path, err := ff[0]()
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal(f2))
r.Close()
ff = reader.fromDiscArtPriority(ctx, nil, "cover.*, disc*.*")
Expect(ff).To(HaveLen(2))
r, path, err = ff[0]()
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal(f1))
r.Close()
})
DescribeTable("numbered match wins over shared fallback within a pattern",
func(discNumber, expectedIdx int) {
files := []string{
createFile("album/disc.jpg"),
createFile("album/disc1.jpg"),
createFile("album/disc2.jpg"),
}
reader := &discArtworkReader{
discNumber: discNumber,
imgFiles: files,
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
}
sf := reader.fromExternalFile(ctx, "disc*.*")
r, path, err := sf()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
r.Close()
Expect(path).To(Equal(files[expectedIdx]))
},
Entry("disc 2 picks disc2.jpg over the shared disc.jpg", 2, 2),
Entry("disc 3 falls back to disc.jpg when no numbered match exists", 3, 0),
)
It("tries the next fallback candidate when the first one cannot be opened", func() {
f1 := createFile("album/cover.jpg")
f2 := createFile("album/cover.png")
// Remove f1 so os.Open will fail on it; f2 should still win.
Expect(os.Remove(f1)).To(Succeed())
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1, f2},
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
}
sf := reader.fromExternalFile(ctx, "cover.*")
r, path, err := sf()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
r.Close()
Expect(path).To(Equal(f2))
})
It("keeps scanning literal-pattern matches so fallback retry still works", func() {
// Guards against an 'early break on first literal match' optimization.
// Multiple imgFiles entries can share a basename (symlinks, case-variant
// duplicates on case-sensitive filesystems). If the loop breaks after
// recording just the first, the fallback retry cannot recover when
// that first file is unreadable.
f1 := createFile("album/stale/cover.png")
f2 := createFile("album/cover.png")
Expect(os.Remove(f1)).To(Succeed())
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1, f2},
discFolders: map[string]bool{
filepath.Join(tmpDir, "album"): true,
filepath.Join(tmpDir, "album/stale"): true,
},
isMultiFolder: true,
}
sf := reader.fromExternalFile(ctx, "cover.png")
r, path, err := sf()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
r.Close()
Expect(path).To(Equal(f2))
})
DescribeTable("filters by disc number for non-'*' wildcard patterns",
func(pattern string, discNumber, expectedIdx int) {
files := []string{
createFile("album/disc1.jpg"),
createFile("album/disc2.jpg"),
}
reader := &discArtworkReader{
discNumber: discNumber,
imgFiles: files,
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
}
sf := reader.fromExternalFile(ctx, pattern)
r, path, err := sf()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
r.Close()
Expect(path).To(Equal(files[expectedIdx]))
},
Entry("disc?.jpg, target disc 1 → disc1.jpg", "disc?.jpg", 1, 0),
Entry("disc?.jpg, target disc 2 → disc2.jpg", "disc?.jpg", 2, 1),
Entry("disc[0-9].jpg, target disc 1 → disc1.jpg", "disc[0-9].jpg", 1, 0),
Entry("disc[0-9].jpg, target disc 2 → disc2.jpg", "disc[0-9].jpg", 2, 1),
)
It("matches file without number in multi-folder album by folder", func() {
f1 := createFile("album/cd1/disc.jpg")
f2 := createFile("album/cd2/disc.jpg")

View File

@ -12,6 +12,7 @@ import (
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
@ -41,6 +42,7 @@ type Provider interface {
type provider struct {
ds model.DataStore
ag Agents
matcher *matcher.Matcher
artistQueue refreshQueue[auxArtist]
albumQueue refreshQueue[auxAlbum]
}
@ -85,8 +87,8 @@ type Agents interface {
agents.SimilarSongsByArtistRetriever
}
func NewProvider(ds model.DataStore, agents Agents) Provider {
e := &provider{ds: ds, ag: agents}
func NewProvider(ds model.DataStore, agents Agents, m *matcher.Matcher) Provider {
e := &provider{ds: ds, ag: agents, matcher: m}
e.artistQueue = newRefreshQueue(context.TODO(), e.populateArtistInfo)
e.albumQueue = newRefreshQueue(context.TODO(), e.populateAlbumInfo)
return e
@ -300,7 +302,7 @@ func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (mode
}
if err == nil && len(songs) > 0 {
return e.matchSongsToLibrary(ctx, songs, count)
return e.matcher.MatchSongsToLibrary(ctx, songs, count)
}
// Fallback to existing similar artists + top songs algorithm
@ -479,7 +481,7 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT
}
}
mfs, err := e.matchSongsToLibrary(ctx, songs, count)
mfs, err := e.matcher.MatchSongsToLibrary(ctx, songs, count)
if err != nil {
return nil, err
}

View File

@ -9,6 +9,7 @@ import (
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
. "github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
@ -43,7 +44,7 @@ var _ = Describe("Provider - AlbumImage", func() {
mockAlbumAgent = newMockAlbumInfoAgent()
agentsCombined := &mockAgents{albumInfoAgent: mockAlbumAgent}
provider = NewProvider(ds, agentsCombined)
provider = NewProvider(ds, agentsCombined, matcher.New(ds))
// Default mocks
// Mocks for GetEntityByID sequence (initial failed lookups)

View File

@ -11,6 +11,7 @@ import (
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
. "github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
@ -51,7 +52,7 @@ var _ = Describe("Provider - ArtistImage", func() {
imageAgent: mockImageAgent,
}
provider = NewProvider(ds, agentsCombined)
provider = NewProvider(ds, agentsCombined, matcher.New(ds))
// Default mocks for successful Get calls
mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Maybe()

View File

@ -1,762 +0,0 @@
package external_test
import (
"context"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
. "github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
)
var _ = Describe("Provider - Song Matching", func() {
var ds model.DataStore
var provider Provider
var agentsCombined *mockAgents
var artistRepo *mockArtistRepo
var mediaFileRepo *mockMediaFileRepo
var albumRepo *mockAlbumRepo
var ctx context.Context
BeforeEach(func() {
ctx = GinkgoT().Context()
artistRepo = newMockArtistRepo()
mediaFileRepo = newMockMediaFileRepo()
albumRepo = newMockAlbumRepo()
ds = &tests.MockDataStore{
MockedArtist: artistRepo,
MockedMediaFile: mediaFileRepo,
MockedAlbum: albumRepo,
}
agentsCombined = &mockAgents{}
provider = NewProvider(ds, agentsCombined)
})
// Shared helper for tests that only need artist track queries (no ID/MBID matching)
setupSimilarSongsExpectations := func(returnedSongs []agents.Song, artistTracks model.MediaFiles) {
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(returnedSongs, nil).Once()
// loadTracksByTitleAndArtist - queries by artist name
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
and, ok := opt.Filters.(squirrel.And)
if !ok || len(and) < 2 {
return false
}
eq, hasEq := and[0].(squirrel.Eq)
if !hasEq {
return false
}
_, hasArtist := eq["order_artist_name"]
return hasArtist
})).Return(artistTracks, nil).Maybe()
}
Describe("matchSongsToLibrary priority matching", func() {
var track model.MediaFile
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
// Disable fuzzy matching for these tests to avoid unexpected GetAll calls
conf.Server.SimilarSongsMatchThreshold = 100
track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist", MbzRecordingID: ""}
// Setup for GetEntityByID to return the track
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
})
setupExpectations := func(returnedSongs []agents.Song, idMatches, mbidMatches, artistTracks model.MediaFiles) {
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(returnedSongs, nil).Once()
// loadTracksByID
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
_, ok := opt.Filters.(squirrel.Eq)
return ok
})).Return(idMatches, nil).Once()
// loadTracksByMBID
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
and, ok := opt.Filters.(squirrel.And)
if !ok || len(and) < 1 {
return false
}
eq, hasEq := and[0].(squirrel.Eq)
if !hasEq {
return false
}
_, hasMBID := eq["mbz_recording_id"]
return hasMBID
})).Return(mbidMatches, nil).Once()
// loadTracksByTitleAndArtist - now queries by artist name
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
and, ok := opt.Filters.(squirrel.And)
if !ok || len(and) < 2 {
return false
}
eq, hasEq := and[0].(squirrel.Eq)
if !hasEq {
return false
}
_, hasArtist := eq["order_artist_name"]
return hasArtist
})).Return(artistTracks, nil).Maybe()
}
Context("when agent returns artist and album metadata", func() {
It("matches by title + artist MBID + album MBID (highest priority)", func() {
// Song in library with all MBIDs
correctMatch := model.MediaFile{
ID: "correct-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Violator",
MbzArtistID: "artist-mbid-123", MbzAlbumID: "album-mbid-456",
}
// Another song with same title but different MBIDs (should NOT match)
wrongMatch := model.MediaFile{
ID: "wrong-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Some Other Album",
MbzArtistID: "artist-mbid-123", MbzAlbumID: "different-album-mbid",
}
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Depeche Mode", ArtistMBID: "artist-mbid-123", Album: "Violator", AlbumMBID: "album-mbid-456"},
}
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("correct-match"))
})
It("matches by title + artist name + album name when MBIDs unavailable", func() {
// Song in library without MBIDs but with matching artist/album names
correctMatch := model.MediaFile{
ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "violator",
}
// Another song with same title but different artist (should NOT match)
wrongMatch := model.MediaFile{
ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album",
}
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Depeche Mode", Album: "Violator"}, // No MBIDs
}
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("correct-match"))
})
It("matches by title + artist only when album info unavailable", func() {
// Song in library with matching artist
correctMatch := model.MediaFile{
ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "Some Album",
}
// Another song with same title but different artist
wrongMatch := model.MediaFile{
ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album",
}
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Depeche Mode"}, // No album info
}
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("correct-match"))
})
It("does not match songs without artist info", func() {
// Songs without artist info cannot be matched since we query by artist
returnedSongs := []agents.Song{
{Name: "Similar Song"}, // No artist/album info at all
}
// No artist to query, so no GetAll calls for title matching
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(BeEmpty())
})
})
Context("when matching multiple songs with the same title but different artists", func() {
It("returns distinct matches for each artist's version (covers scenario)", func() {
// Multiple covers of the same song by different artists
cover1 := model.MediaFile{
ID: "cover-1", Title: "Yesterday", Artist: "The Beatles", Album: "Help!",
}
cover2 := model.MediaFile{
ID: "cover-2", Title: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits",
}
cover3 := model.MediaFile{
ID: "cover-3", Title: "Yesterday", Artist: "Frank Sinatra", Album: "My Way",
}
returnedSongs := []agents.Song{
{Name: "Yesterday", Artist: "The Beatles", Album: "Help!"},
{Name: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"},
{Name: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"},
}
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{cover1, cover2, cover3})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
// All three covers should be returned, not just the first one
Expect(songs).To(HaveLen(3))
// Verify all three different versions are included
ids := []string{songs[0].ID, songs[1].ID, songs[2].ID}
Expect(ids).To(ContainElements("cover-1", "cover-2", "cover-3"))
})
})
Context("when matching multiple songs with different precision levels", func() {
It("prefers more precise matches for each song", func() {
// Library has multiple versions of same song
preciseMatch := model.MediaFile{
ID: "precise", Title: "Song A", Artist: "Artist One", Album: "Album One",
MbzArtistID: "mbid-1", MbzAlbumID: "album-mbid-1",
}
lessAccurateMatch := model.MediaFile{
ID: "less-accurate", Title: "Song A", Artist: "Artist One", Album: "Compilation",
MbzArtistID: "mbid-1",
}
artistTwoMatch := model.MediaFile{
ID: "artist-two", Title: "Song B", Artist: "Artist Two",
}
returnedSongs := []agents.Song{
{Name: "Song A", Artist: "Artist One", ArtistMBID: "mbid-1", Album: "Album One", AlbumMBID: "album-mbid-1"},
{Name: "Song B", Artist: "Artist Two"}, // Different artist
}
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(2))
// First song should be the precise match (has all MBIDs)
Expect(songs[0].ID).To(Equal("precise"))
// Second song matches by title + artist
Expect(songs[1].ID).To(Equal("artist-two"))
})
})
})
Describe("Fuzzy matching fallback", func() {
var track model.MediaFile
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"}
// Setup for GetEntityByID to return the track
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
})
Context("with default threshold (85%)", func() {
It("matches songs with remastered suffix", func() {
conf.Server.SimilarSongsMatchThreshold = 85
// Agent returns "Paranoid Android" but library has "Paranoid Android - Remastered"
returnedSongs := []agents.Song{
{Name: "Paranoid Android", Artist: "Radiohead"},
}
// Artist catalog has the remastered version (fuzzy match will find it)
artistTracks := model.MediaFiles{
{ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
}
setupSimilarSongsExpectations(returnedSongs, artistTracks)
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("remastered"))
})
It("matches songs with live suffix", func() {
conf.Server.SimilarSongsMatchThreshold = 85
returnedSongs := []agents.Song{
{Name: "Bohemian Rhapsody", Artist: "Queen"},
}
artistTracks := model.MediaFiles{
{ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"},
}
setupSimilarSongsExpectations(returnedSongs, artistTracks)
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("live"))
})
It("does not match completely different songs", func() {
conf.Server.SimilarSongsMatchThreshold = 85
returnedSongs := []agents.Song{
{Name: "Yesterday", Artist: "The Beatles"},
}
// Artist catalog has completely different songs
artistTracks := model.MediaFiles{
{ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"},
{ID: "different2", Title: "Here Comes The Sun", Artist: "The Beatles"},
}
setupSimilarSongsExpectations(returnedSongs, artistTracks)
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(BeEmpty())
})
})
Context("with threshold set to 100 (exact match only)", func() {
It("only matches exact titles", func() {
conf.Server.SimilarSongsMatchThreshold = 100
returnedSongs := []agents.Song{
{Name: "Paranoid Android", Artist: "Radiohead"},
}
// Artist catalog has only remastered version - no exact match
artistTracks := model.MediaFiles{
{ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
}
setupSimilarSongsExpectations(returnedSongs, artistTracks)
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(BeEmpty())
})
})
Context("with lower threshold (75%)", func() {
It("matches more aggressively", func() {
conf.Server.SimilarSongsMatchThreshold = 75
returnedSongs := []agents.Song{
{Name: "Song", Artist: "Artist"},
}
artistTracks := model.MediaFiles{
{ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"},
}
setupSimilarSongsExpectations(returnedSongs, artistTracks)
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("extended"))
})
})
Context("with fuzzy album matching", func() {
It("matches album with (Remaster) suffix", func() {
conf.Server.SimilarSongsMatchThreshold = 85
// Agent returns "A Night at the Opera" but library has remastered version
returnedSongs := []agents.Song{
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
}
// Library has same album with remaster suffix
correctMatch := model.MediaFile{
ID: "correct", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera (2011 Remaster)",
}
wrongMatch := model.MediaFile{
ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits",
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
// Should prefer the fuzzy album match (Level 3) over title+artist only (Level 1)
Expect(songs[0].ID).To(Equal("correct"))
})
It("matches album with (Deluxe Edition) suffix", func() {
conf.Server.SimilarSongsMatchThreshold = 85
returnedSongs := []agents.Song{
{Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
}
correctMatch := model.MediaFile{
ID: "correct", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)",
}
wrongMatch := model.MediaFile{
ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101",
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("correct"))
})
It("prefers exact album match over fuzzy album match", func() {
conf.Server.SimilarSongsMatchThreshold = 85
returnedSongs := []agents.Song{
{Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
}
exactMatch := model.MediaFile{
ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator",
}
fuzzyMatch := model.MediaFile{
ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)",
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{fuzzyMatch, exactMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
// Both have same title similarity (1.0), so should prefer exact album match (higher specificity via higher album similarity)
Expect(songs[0].ID).To(Equal("exact"))
})
})
})
Describe("Duration matching", func() {
var track model.MediaFile
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.SimilarSongsMatchThreshold = 100 // Exact title match for predictable tests
track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"}
// Setup for GetEntityByID to return the track
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
})
Context("when agent provides duration", func() {
It("prefers tracks with matching duration", func() {
// Agent returns song with duration 180000ms (180 seconds)
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
}
// Library has two versions: one matching duration, one not
correctMatch := model.MediaFile{
ID: "correct", Title: "Similar Song", Artist: "Test Artist", Duration: 180.0,
}
wrongDuration := model.MediaFile{
ID: "wrong", Title: "Similar Song", Artist: "Test Artist", Duration: 240.0,
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongDuration, correctMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("correct"))
})
It("matches tracks with close duration", func() {
// Agent returns song with duration 180000ms (180 seconds)
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
}
// Library has track with 182.5 seconds (close to target)
closeDuration := model.MediaFile{
ID: "close-duration", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5,
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{closeDuration})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("close-duration"))
})
It("prefers closer duration over farther duration", func() {
// Agent returns song with duration 180000ms (180 seconds)
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
}
// Library has one close, one far
closeDuration := model.MediaFile{
ID: "close", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0,
}
farDuration := model.MediaFile{
ID: "far", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0,
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{farDuration, closeDuration})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("close"))
})
It("still matches when no tracks have matching duration", func() {
// Agent returns song with duration 180000ms
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
}
// Library only has tracks with very different duration
differentDuration := model.MediaFile{
ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0,
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{differentDuration})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
// Duration mismatch doesn't exclude the track; it's just scored lower
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("different"))
})
It("prefers title match over duration match when titles differ", func() {
// Agent returns "Similar Song" with duration 180000ms
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
}
// Library has:
// - differentTitle: matches duration but has different title (won't pass title threshold)
// - correctTitle: doesn't match duration but has correct title (wins on title similarity)
differentTitle := model.MediaFile{
ID: "wrong-title", Title: "Different Song", Artist: "Test Artist", Duration: 180.0,
}
correctTitle := model.MediaFile{
ID: "correct-title", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0,
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{differentTitle, correctTitle})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
// Title similarity is the top priority, so the correct title wins despite duration mismatch
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("correct-title"))
})
})
Context("when agent does not provide duration", func() {
It("matches without duration filtering (duration=0)", func() {
// Agent returns song without duration
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Test Artist", Duration: 0},
}
// Library tracks with various durations should all be candidates
anyTrack := model.MediaFile{
ID: "any", Title: "Similar Song", Artist: "Test Artist", Duration: 999.0,
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{anyTrack})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("any"))
})
})
Context("edge cases", func() {
It("handles very short songs with close duration", func() {
// 30-second song with 1-second difference
returnedSongs := []agents.Song{
{Name: "Short Song", Artist: "Test Artist", Duration: 30000},
}
shortTrack := model.MediaFile{
ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0,
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{shortTrack})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("short"))
})
})
})
Describe("Deduplication of mismatched songs", func() {
var track model.MediaFile
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.SimilarSongsMatchThreshold = 85 // Allow fuzzy matching
track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"}
// Setup for GetEntityByID to return the track
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
})
It("removes duplicates when different input songs match the same library track", func() {
// Agent returns two different versions that will both fuzzy-match to the same library track
returnedSongs := []agents.Song{
{Name: "Bohemian Rhapsody (Live)", Artist: "Queen"},
{Name: "Bohemian Rhapsody (Original Mix)", Artist: "Queen"},
}
// Library only has one version
libraryTrack := model.MediaFile{
ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen",
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
// Should only return one track, not two duplicates
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("br-live"))
})
It("preserves duplicates when identical input songs match the same library track", func() {
// Agent returns the exact same song twice (intentional repetition)
returnedSongs := []agents.Song{
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
}
// Library has matching track
libraryTrack := model.MediaFile{
ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera",
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
// Should return two tracks since input songs were identical
Expect(songs).To(HaveLen(2))
Expect(songs[0].ID).To(Equal("br"))
Expect(songs[1].ID).To(Equal("br"))
})
It("handles mixed scenario with both identical and different input songs", func() {
// Agent returns: Song A, Song B (different from A), Song A again (same as first)
// All three match to the same library track
returnedSongs := []agents.Song{
{Name: "Yesterday", Artist: "The Beatles", Album: "Help!"},
{Name: "Yesterday (Remastered)", Artist: "The Beatles", Album: "1"}, // Different version
{Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, // Same as first
{Name: "Yesterday (Anthology)", Artist: "The Beatles", Album: "Anthology"}, // Another different version
}
// Library only has one version
libraryTrack := model.MediaFile{
ID: "yesterday", Title: "Yesterday", Artist: "The Beatles", Album: "Help!",
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
// Should return 2 tracks:
// 1. First "Yesterday" (original)
// 2. Third "Yesterday" (same as first, so kept)
// Skip: Second "Yesterday (Remastered)" (different input, same library track)
// Skip: Fourth "Yesterday (Anthology)" (different input, same library track)
Expect(songs).To(HaveLen(2))
Expect(songs[0].ID).To(Equal("yesterday"))
Expect(songs[1].ID).To(Equal("yesterday"))
})
It("does not deduplicate songs that match different library tracks", func() {
// Agent returns different songs that match different library tracks
returnedSongs := []agents.Song{
{Name: "Song A", Artist: "Artist"},
{Name: "Song B", Artist: "Artist"},
{Name: "Song C", Artist: "Artist"},
}
// Library has all three songs
trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"}
trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"}
trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist"}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{trackA, trackB, trackC})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
// All three should be returned since they match different library tracks
Expect(songs).To(HaveLen(3))
Expect(songs[0].ID).To(Equal("track-a"))
Expect(songs[1].ID).To(Equal("track-b"))
Expect(songs[2].ID).To(Equal("track-c"))
})
It("respects count limit after deduplication", func() {
// Agent returns 4 songs: 2 unique + 2 that would create duplicates
returnedSongs := []agents.Song{
{Name: "Song A", Artist: "Artist"},
{Name: "Song A (Live)", Artist: "Artist"}, // Different, matches same track
{Name: "Song B", Artist: "Artist"},
{Name: "Song B (Remix)", Artist: "Artist"}, // Different, matches same track
}
trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"}
trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{trackA, trackB})
// Request only 2 songs
songs, err := provider.SimilarSongs(ctx, "track-1", 2)
Expect(err).ToNot(HaveOccurred())
// Should return exactly 2: Song A and Song B (skipping duplicates)
Expect(songs).To(HaveLen(2))
Expect(songs[0].ID).To(Equal("track-a"))
Expect(songs[1].ID).To(Equal("track-b"))
})
})
})

View File

@ -7,6 +7,7 @@ import (
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core/agents"
. "github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
@ -48,7 +49,7 @@ var _ = Describe("Provider - SimilarSongs", func() {
similarAgent: mockSimilarAgent,
}
provider = NewProvider(ds, agentsCombined)
provider = NewProvider(ds, agentsCombined, matcher.New(ds))
})
Describe("dispatch by entity type", func() {

View File

@ -10,6 +10,7 @@ import (
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
. "github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
@ -44,7 +45,7 @@ var _ = Describe("Provider - TopSongs", func() {
ag = new(mockAgents)
p = NewProvider(ds, ag)
p = NewProvider(ds, ag, matcher.New(ds))
})
It("returns top songs for a known artist", func() {

View File

@ -8,6 +8,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
@ -34,7 +35,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
ctx = GinkgoT().Context()
ds = new(tests.MockDataStore)
ag = new(mockAgents)
p = external.NewProvider(ds, ag)
p = external.NewProvider(ds, ag, matcher.New(ds))
mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
conf.Server.DevAlbumInfoTimeToLive = 1 * time.Hour
})

View File

@ -9,6 +9,7 @@ import (
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
@ -37,7 +38,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
ctx = GinkgoT().Context()
ds = new(tests.MockDataStore)
ag = new(mockAgents)
p = external.NewProvider(ds, ag)
p = external.NewProvider(ds, ag, matcher.New(ds))
mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
})

View File

@ -387,18 +387,7 @@ func buildDynamicArgs(opts TranscodeOptions) []string {
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 = injectDynamicAudioFlags(args, opts)
args = append(args, "-v", "0")
@ -412,12 +401,19 @@ func buildDynamicArgs(opts TranscodeOptions) []string {
// buildTemplateArgs handles user-customized command templates, with dynamic injection
// of sample rate, channels, and bit depth when requested by the transcode decision.
// Note: these flags are injected unconditionally when non-zero, even if the template
// already includes them. FFmpeg uses the last occurrence of duplicate flags.
// Values in opts have already been clamped to codec limits upstream (see
// core/stream/codec.go codecMax* helpers), so injecting them unconditionally is safe —
// ffmpeg honors the last occurrence of a duplicate flag.
func buildTemplateArgs(opts TranscodeOptions) []string {
args := createFFmpegCommand(opts.Command, opts.FilePath, opts.BitRate, opts.Offset)
return injectDynamicAudioFlags(args, opts)
}
// Dynamically inject -ar, -ac, and -sample_fmt before the output target
// injectDynamicAudioFlags appends -ar, -ac, and -sample_fmt flags based on opts.
// Only passes -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.
func injectDynamicAudioFlags(args []string, opts TranscodeOptions) []string {
if opts.SampleRate > 0 {
args = injectBeforeOutput(args, "-ar", strconv.Itoa(opts.SampleRate))
}

View File

@ -1,4 +1,4 @@
package external
package matcher
import (
"context"
@ -13,7 +13,17 @@ import (
"github.com/xrash/smetrics"
)
// matchSongsToLibrary matches agent song results to local library tracks using a multi-phase
// Matcher matches agent song results to local library tracks.
type Matcher struct {
ds model.DataStore
}
// New creates a new Matcher with the given DataStore.
func New(ds model.DataStore) *Matcher {
return &Matcher{ds: ds}
}
// MatchSongsToLibrary matches agent song results to local library tracks using a multi-phase
// matching algorithm that prioritizes accuracy over recall.
//
// # Algorithm Overview
@ -95,36 +105,34 @@ import (
//
// Returns up to 'count' MediaFiles from the library that best match the input songs,
// preserving the original order from the agent. Songs that cannot be matched are skipped.
func (e *provider) matchSongsToLibrary(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) {
idMatches, err := e.loadTracksByID(ctx, songs)
func (m *Matcher) MatchSongsToLibrary(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) {
idMatches, err := m.loadTracksByID(ctx, songs)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by ID: %w", err)
}
mbidMatches, err := e.loadTracksByMBID(ctx, songs, idMatches)
mbidMatches, err := m.loadTracksByMBID(ctx, songs, idMatches)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
}
isrcMatches, err := e.loadTracksByISRC(ctx, songs, idMatches, mbidMatches)
isrcMatches, err := m.loadTracksByISRC(ctx, songs, idMatches, mbidMatches)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by ISRC: %w", err)
}
titleMatches, err := e.loadTracksByTitleAndArtist(ctx, songs, idMatches, mbidMatches, isrcMatches)
titleMatches, err := m.loadTracksByTitleAndArtist(ctx, songs, idMatches, mbidMatches, isrcMatches)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by title: %w", err)
}
return e.selectBestMatchingSongs(songs, idMatches, mbidMatches, isrcMatches, titleMatches, count), nil
return m.selectBestMatchingSongs(songs, idMatches, mbidMatches, isrcMatches, titleMatches, count), nil
}
// songMatchedIn checks if a song has already been matched in any of the provided match maps.
// It checks the song's ID, MBID, and ISRC fields against the corresponding map keys.
func songMatchedIn(s agents.Song, priorMatches ...map[string]model.MediaFile) bool {
_, found := lookupByIdentifiers(s, priorMatches...)
return found
}
// lookupByIdentifiers searches for a song's identifiers (ID, MBID, ISRC) in the provided maps.
// Returns the first matching MediaFile found and true, or an empty MediaFile and false if no match.
func lookupByIdentifiers(s agents.Song, maps ...map[string]model.MediaFile) (model.MediaFile, bool) {
keys := []string{s.ID, s.MBID, s.ISRC}
for _, m := range maps {
@ -140,10 +148,7 @@ func lookupByIdentifiers(s agents.Song, maps ...map[string]model.MediaFile) (mod
}
// loadTracksByID fetches MediaFiles from the library using direct ID matching.
// It extracts all non-empty ID fields from the input songs and performs a single
// batch query to the database. Returns a map keyed by MediaFile ID for O(1) lookup.
// Only non-missing files are returned.
func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
func (m *Matcher) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
var ids []string
for _, s := range songs {
if s.ID != "" {
@ -154,7 +159,7 @@ func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map
if len(ids) == 0 {
return matches, nil
}
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"media_file.id": ids},
squirrel.Eq{"missing": false},
@ -172,10 +177,7 @@ func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map
}
// loadTracksByMBID fetches MediaFiles from the library using MusicBrainz Recording IDs.
// It extracts all non-empty MBID fields from the input songs and performs a single
// batch query against the mbz_recording_id column. Returns a map keyed by MBID for
// O(1) lookup. Only non-missing files are returned.
func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
func (m *Matcher) loadTracksByMBID(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
var mbids []string
for _, s := range songs {
if s.MBID != "" && !songMatchedIn(s, priorMatches...) {
@ -186,7 +188,7 @@ func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song, pr
if len(mbids) == 0 {
return matches, nil
}
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"mbz_recording_id": mbids},
squirrel.Eq{"missing": false},
@ -205,11 +207,8 @@ func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song, pr
return matches, nil
}
// loadTracksByISRC fetches MediaFiles from the library using ISRC (International Standard
// Recording Code) matching. It extracts all non-empty ISRC fields from the input songs and
// queries the tags JSON column for matching ISRC values. Returns a map keyed by ISRC for
// O(1) lookup. Only non-missing files are returned.
func (e *provider) loadTracksByISRC(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
// loadTracksByISRC fetches MediaFiles from the library using ISRC matching.
func (m *Matcher) loadTracksByISRC(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
var isrcs []string
for _, s := range songs {
if s.ISRC != "" && !songMatchedIn(s, priorMatches...) {
@ -220,8 +219,9 @@ func (e *provider) loadTracksByISRC(ctx context.Context, songs []agents.Song, pr
if len(isrcs) == 0 {
return matches, nil
}
res, err := e.ds.MediaFile(ctx).GetAllByTags(model.TagISRC, isrcs, model.QueryOptions{
res, err := m.ds.MediaFile(ctx).GetAllByTags(model.TagISRC, isrcs, model.QueryOptions{
Filters: squirrel.Eq{"missing": false},
Sort: "starred desc, rating desc, year asc, compilation asc",
})
if err != nil {
return matches, err
@ -237,27 +237,24 @@ func (e *provider) loadTracksByISRC(ctx context.Context, songs []agents.Song, pr
}
// songQuery represents a normalized query for matching a song to library tracks.
// All string fields are sanitized (lowercased, diacritics removed) for comparison.
// This struct is used internally by loadTracksByTitleAndArtist to group queries by artist.
type songQuery struct {
title string // Sanitized song title
artist string // Sanitized artist name (without articles like "The")
artistMBID string // MusicBrainz Artist ID (optional, for higher specificity matching)
album string // Sanitized album name (optional, for specificity scoring)
albumMBID string // MusicBrainz Album ID (optional, for highest specificity matching)
durationMs uint32 // Duration in milliseconds (0 means unknown, skip duration filtering)
title string
artist string
artistMBID string
album string
albumMBID string
durationMs uint32
}
// matchScore combines title/album similarity with metadata specificity for ranking matches
// matchScore combines title/album similarity with metadata specificity for ranking matches.
type matchScore struct {
titleSimilarity float64 // 0.0-1.0 (Jaro-Winkler)
durationProximity float64 // 0.0-1.0 (closer duration = higher, 1.0 if unknown)
albumSimilarity float64 // 0.0-1.0 (Jaro-Winkler), used as tiebreaker
specificityLevel int // 0-5 (higher = more specific metadata match)
titleSimilarity float64
durationProximity float64
albumSimilarity float64
specificityLevel int
}
// betterThan returns true if this score beats another.
// Comparison order: title similarity > duration proximity > specificity level > album similarity
func (s matchScore) betterThan(other matchScore) bool {
if s.titleSimilarity != other.titleSimilarity {
return s.titleSimilarity > other.titleSimilarity
@ -271,58 +268,62 @@ func (s matchScore) betterThan(other matchScore) bool {
return s.albumSimilarity > other.albumSimilarity
}
// computeSpecificityLevel determines how well query metadata matches a track (0-5).
// Higher values indicate more specific matches (MBIDs > names > title only).
// Uses fuzzy matching for album names with the same threshold as title matching.
func computeSpecificityLevel(q songQuery, mf model.MediaFile, albumThreshold float64) int {
title := str.SanitizeFieldForSorting(mf.Title)
artist := str.SanitizeFieldForSortingNoArticle(mf.Artist)
album := str.SanitizeFieldForSorting(mf.Album)
// sanitizedTrack holds pre-sanitized fields for a media file, avoiding redundant sanitization
// when the same track is scored against multiple queries in the inner loop. The `mf` field
// is a pointer to avoid copying the large MediaFile struct into each entry of the per-artist
// sanitized slice.
type sanitizedTrack struct {
mf *model.MediaFile
title string
artist string
album string
}
// Level 5: Title + Artist MBID + Album MBID (most specific)
func newSanitizedTrack(mf *model.MediaFile) sanitizedTrack {
return sanitizedTrack{
mf: mf,
title: str.SanitizeFieldForSorting(mf.Title),
artist: str.SanitizeFieldForSortingNoArticle(mf.Artist),
album: str.SanitizeFieldForSorting(mf.Album),
}
}
// computeSpecificityLevel determines how well query metadata matches a track (0-5).
// The track's title, artist, and album fields must be pre-sanitized.
func computeSpecificityLevel(q songQuery, t sanitizedTrack, albumThreshold float64) int {
if q.artistMBID != "" && q.albumMBID != "" &&
mf.MbzArtistID == q.artistMBID && mf.MbzAlbumID == q.albumMBID {
t.mf.MbzArtistID == q.artistMBID && t.mf.MbzAlbumID == q.albumMBID {
return 5
}
// Level 4: Title + Artist MBID + Album name (fuzzy)
if q.artistMBID != "" && q.album != "" &&
mf.MbzArtistID == q.artistMBID && similarityRatio(album, q.album) >= albumThreshold {
t.mf.MbzArtistID == q.artistMBID && similarityRatio(t.album, q.album) >= albumThreshold {
return 4
}
// Level 3: Title + Artist name + Album name (fuzzy)
if q.artist != "" && q.album != "" &&
artist == q.artist && similarityRatio(album, q.album) >= albumThreshold {
t.artist == q.artist && similarityRatio(t.album, q.album) >= albumThreshold {
return 3
}
// Level 2: Title + Artist MBID
if q.artistMBID != "" && mf.MbzArtistID == q.artistMBID {
if q.artistMBID != "" && t.mf.MbzArtistID == q.artistMBID {
return 2
}
// Level 1: Title + Artist name
if q.artist != "" && artist == q.artist {
if q.artist != "" && t.artist == q.artist {
return 1
}
// Level 0: Title only match (but for fuzzy, title matched via similarity)
// Check if at least the title matches exactly
if title == q.title {
if t.title == q.title {
return 0
}
return -1 // No exact title match, but could still be a fuzzy match
return -1
}
// loadTracksByTitleAndArtist loads tracks matching by title with optional artist/album filtering.
// Uses a unified scoring approach that combines title similarity (Jaro-Winkler) with
// metadata specificity (MBIDs, album names) for both exact and fuzzy matches.
// Returns a map keyed by "title|artist" for compatibility with selectBestMatchingSongs.
func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
queries := e.buildTitleQueries(songs, priorMatches...)
func (m *Matcher) loadTracksByTitleAndArtist(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
queries := m.buildTitleQueries(songs, priorMatches...)
if len(queries) == 0 {
return map[string]model.MediaFile{}, nil
}
threshold := float64(conf.Server.SimilarSongsMatchThreshold) / 100.0
// Group queries by artist for efficient DB access
byArtist := map[string][]songQuery{}
for _, q := range queries {
if q.artist != "" {
@ -332,8 +333,7 @@ func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agent
matches := map[string]model.MediaFile{}
for artist, artistQueries := range byArtist {
// Single DB query per artist - get all their tracks
tracks, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
tracks, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"order_artist_name": artist},
squirrel.Eq{"missing": false},
@ -344,9 +344,13 @@ func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agent
continue
}
// Find best match for each query using unified scoring
sanitized := make([]sanitizedTrack, len(tracks))
for i := range tracks {
sanitized[i] = newSanitizedTrack(&tracks[i])
}
for _, q := range artistQueries {
if mf, found := e.findBestMatch(q, tracks, threshold); found {
if mf, found := m.findBestMatch(q, sanitized, threshold); found {
key := q.title + "|" + q.artist
if _, exists := matches[key]; !exists {
matches[key] = mf
@ -357,13 +361,11 @@ func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agent
return matches, nil
}
// durationProximity returns a score from 0.0 to 1.0 indicating how close
// the track's duration is to the target. A perfect match returns 1.0, and the
// score decreases as the difference grows (using 1 / (1 + diff)). Returns 1.0
// if durationMs is 0 (unknown), so duration does not influence scoring.
// durationProximity returns a score from 0.0 to 1.0 indicating how close the track's duration
// is to the target. Returns 1.0 if durationMs is 0 (unknown).
func durationProximity(durationMs uint32, mediaFileDurationSec float32) float64 {
if durationMs <= 0 {
return 1.0 // Unknown duration — don't penalise
if durationMs == 0 {
return 1.0
}
durationSec := float64(durationMs) / 1000.0
diff := math.Abs(durationSec - float64(mediaFileDurationSec))
@ -371,41 +373,33 @@ func durationProximity(durationMs uint32, mediaFileDurationSec float32) float64
}
// findBestMatch finds the best matching track using combined title/album similarity and specificity scoring.
// A track must meet the threshold for title similarity, then the best match is chosen by:
// 1. Highest title similarity
// 2. Duration proximity (closer duration = higher score, 1.0 if unknown)
// 3. Highest specificity level
// 4. Highest album similarity (as final tiebreaker)
func (e *provider) findBestMatch(q songQuery, tracks model.MediaFiles, threshold float64) (model.MediaFile, bool) {
func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, threshold float64) (model.MediaFile, bool) {
var bestMatch model.MediaFile
bestScore := matchScore{titleSimilarity: -1}
found := false
for _, mf := range tracks {
trackTitle := str.SanitizeFieldForSorting(mf.Title)
titleSim := similarityRatio(q.title, trackTitle)
for _, t := range sanitizedTracks {
titleSim := similarityRatio(q.title, t.title)
if titleSim < threshold {
continue
}
// Compute album similarity for tiebreaking (0.0 if no album in query)
var albumSim float64
if q.album != "" {
trackAlbum := str.SanitizeFieldForSorting(mf.Album)
albumSim = similarityRatio(q.album, trackAlbum)
albumSim = similarityRatio(q.album, t.album)
}
score := matchScore{
titleSimilarity: titleSim,
durationProximity: durationProximity(q.durationMs, mf.Duration),
durationProximity: durationProximity(q.durationMs, t.mf.Duration),
albumSimilarity: albumSim,
specificityLevel: computeSpecificityLevel(q, mf, threshold),
specificityLevel: computeSpecificityLevel(q, t, threshold),
}
if score.betterThan(bestScore) {
bestScore = score
bestMatch = mf
bestMatch = *t.mf
found = true
}
}
@ -413,9 +407,7 @@ func (e *provider) findBestMatch(q songQuery, tracks model.MediaFiles, threshold
}
// buildTitleQueries converts agent songs into normalized songQuery structs for title+artist matching.
// It skips songs that have already been matched in prior phases (by ID, MBID, or ISRC) and sanitizes
// all string fields for consistent comparison (lowercase, diacritics removed, articles stripped from artist names).
func (e *provider) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery {
func (m *Matcher) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery {
var queries []songQuery
for _, s := range songs {
if songMatchedIn(s, priorMatches...) {
@ -434,18 +426,9 @@ func (e *provider) buildTitleQueries(songs []agents.Song, priorMatches ...map[st
}
// selectBestMatchingSongs assembles the final result by mapping input songs to their best matching
// library tracks. It iterates through the input songs in order and selects the first available match
// using priority order: ID > MBID > ISRC > title+artist.
//
// The function also handles deduplication: when multiple different input songs would match the same
// library track (e.g., "Song (Live)" and "Song (Remastered)" both matching "Song (Live)" in the library),
// only the first match is kept. However, if the same input song appears multiple times (intentional
// repetition), duplicates are preserved in the output.
//
// Returns up to 'count' MediaFiles, preserving the input order. Songs that cannot be matched are skipped.
func (e *provider) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile, count int) model.MediaFiles {
// library tracks using priority order: ID > MBID > ISRC > title+artist.
func (m *Matcher) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile, count int) model.MediaFiles {
mfs := make(model.MediaFiles, 0, len(songs))
// Track MediaFile.ID -> input song that added it, for deduplication
addedBy := make(map[string]agents.Song, len(songs))
for _, t := range songs {
@ -458,11 +441,9 @@ func (e *provider) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, by
continue
}
// Check for duplicate library track
if prevSong, alreadyAdded := addedBy[mf.ID]; alreadyAdded {
// Only add duplicate if input songs are identical
if t != prevSong {
continue // Different input songs → skip mismatch-induced duplicate
continue
}
} else {
addedBy[mf.ID] = t
@ -473,14 +454,11 @@ func (e *provider) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, by
return mfs
}
// findMatchingTrack looks up a song in the match maps using priority order: ID > MBID > ISRC > title+artist.
// Returns the matched MediaFile and true if found, or an empty MediaFile and false if no match exists.
// findMatchingTrack looks up a song in the match maps using priority order.
func findMatchingTrack(t agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile) (model.MediaFile, bool) {
// Try identifier-based matches first (ID, MBID, ISRC)
if mf, found := lookupByIdentifiers(t, byID, byMBID, byISRC); found {
return mf, true
}
// Fall back to title+artist fuzzy match
key := str.SanitizeFieldForSorting(t.Name) + "|" + str.SanitizeFieldForSortingNoArticle(t.Artist)
if mf, ok := byTitleArtist[key]; ok {
return mf, true
@ -489,9 +467,6 @@ func findMatchingTrack(t agents.Song, byID, byMBID, byISRC, byTitleArtist map[st
}
// similarityRatio calculates the similarity between two strings using Jaro-Winkler algorithm.
// Returns a value between 0.0 (completely different) and 1.0 (identical).
// Jaro-Winkler is well-suited for matching song titles because it gives higher scores
// when strings share a common prefix (e.g., "Song Title" vs "Song Title - Remastered").
func similarityRatio(a, b string) float64 {
if a == b {
return 1.0
@ -499,6 +474,5 @@ func similarityRatio(a, b string) float64 {
if len(a) == 0 || len(b) == 0 {
return 0.0
}
// JaroWinkler params: boostThreshold=0.7, prefixSize=4
return smetrics.JaroWinkler(a, b, 0.7, 4)
}

View File

@ -1,4 +1,4 @@
package external
package matcher
import (
. "github.com/onsi/ginkgo/v2"
@ -16,25 +16,21 @@ var _ = Describe("similarityRatio", func() {
})
It("returns high similarity for remastered suffix", func() {
// Jaro-Winkler gives ~0.92 for this case
ratio := similarityRatio("paranoid android", "paranoid android remastered")
Expect(ratio).To(BeNumerically(">=", 0.85))
})
It("returns high similarity for suffix additions like (Live)", func() {
// Jaro-Winkler gives ~0.96 for this case
ratio := similarityRatio("bohemian rhapsody", "bohemian rhapsody live")
Expect(ratio).To(BeNumerically(">=", 0.90))
})
It("returns high similarity for 'yesterday' variants (common prefix)", func() {
// Jaro-Winkler gives ~0.90 because of common prefix
ratio := similarityRatio("yesterday", "yesterday once more")
Expect(ratio).To(BeNumerically(">=", 0.85))
})
It("returns low similarity for same suffix", func() {
// Jaro-Winkler gives ~0.70 for this case
ratio := similarityRatio("postman (live)", "taxman (live)")
Expect(ratio).To(BeNumerically("<", 0.85))
})

View File

@ -1,4 +1,4 @@
package taglib
package matcher_test
import (
"testing"
@ -9,9 +9,9 @@ import (
. "github.com/onsi/gomega"
)
func TestTagLib(t *testing.T) {
tests.Init(t, true)
func TestMatcher(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "TagLib Suite")
RunSpecs(t, "Matcher Suite")
}

View File

@ -0,0 +1,807 @@
package matcher_test
import (
"context"
"errors"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
)
var _ = Describe("Matcher", func() {
var ds model.DataStore
var mediaFileRepo *mockMediaFileRepo
var ctx context.Context
var m *matcher.Matcher
BeforeEach(func() {
ctx = GinkgoT().Context()
DeferCleanup(configtest.SetupConfig())
mediaFileRepo = newMockMediaFileRepo()
DeferCleanup(func() {
mediaFileRepo.AssertExpectations(GinkgoT())
})
ds = &tests.MockDataStore{
MockedMediaFile: mediaFileRepo,
}
m = matcher.New(ds)
})
// Per-phase expectation helpers. Each `expect*Phase` registers a .Once() expectation
// that will fail the suite via AssertExpectations if the phase is NOT called. Tests
// use these to deterministically verify which matching phases fire. Phases that may
// or may not fire should use the `allow*Phase` variants instead, which register
// .Maybe() fallbacks.
expectIDPhase := func(matches model.MediaFiles) {
mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("media_file.id"))).
Return(matches, nil).Once()
}
expectMBIDPhase := func(matches model.MediaFiles) {
mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("mbz_recording_id"))).
Return(matches, nil).Once()
}
expectISRCPhase := func(matches model.MediaFiles) {
mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInEq("missing"))).
Return(matches, nil).Once()
}
// allowOtherPhases installs .Maybe() catch-alls so phases that short-circuit (return
// early without hitting the DB) don't cause test failures for unexpected calls. Call
// this after expect*Phase for the phases the test actually wants to verify.
allowOtherPhases := func() {
mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("media_file.id"))).
Return(model.MediaFiles{}, nil).Maybe()
mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("mbz_recording_id"))).
Return(model.MediaFiles{}, nil).Maybe()
mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInEq("missing"))).
Return(model.MediaFiles{}, nil).Maybe()
mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))).
Return(model.MediaFiles{}, nil).Maybe()
}
// setupTitleOnlyExpectations is a convenience for fuzzy-match tests that only exercise
// the title+artist phase. The title phase uses .Maybe() because it may short-circuit
// when no songs have an artist.
setupTitleOnlyExpectations := func(artistTracks model.MediaFiles) {
mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))).
Return(artistTracks, nil).Maybe()
}
Describe("MatchSongsToLibrary", func() {
Context("matching by direct ID", func() {
It("matches songs with an ID field to MediaFiles by ID", func() {
conf.Server.SimilarSongsMatchThreshold = 100
songs := []agents.Song{
{ID: "track-1", Name: "Some Song", Artist: "Some Artist"},
}
idMatch := model.MediaFile{
ID: "track-1", Title: "Some Song", Artist: "Some Artist",
}
expectIDPhase(model.MediaFiles{idMatch})
allowOtherPhases()
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("track-1"))
})
})
Context("matching by MBID", func() {
It("matches songs with MBID to tracks with matching mbz_recording_id", func() {
conf.Server.SimilarSongsMatchThreshold = 100
songs := []agents.Song{
{Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"},
}
mbidMatch := model.MediaFile{
ID: "track-mbid", Title: "Paranoid Android", Artist: "Radiohead",
MbzRecordingID: "abc-123",
}
expectMBIDPhase(model.MediaFiles{mbidMatch})
allowOtherPhases()
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("track-mbid"))
})
})
Context("matching by ISRC", func() {
It("matches songs with ISRC to tracks with matching ISRC tag", func() {
conf.Server.SimilarSongsMatchThreshold = 100
songs := []agents.Song{
{Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"},
}
isrcMatch := model.MediaFile{
ID: "track-isrc", Title: "Paranoid Android", Artist: "Radiohead",
Tags: model.Tags{model.TagISRC: []string{"GBAYE0000351"}},
}
expectISRCPhase(model.MediaFiles{isrcMatch})
allowOtherPhases()
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("track-isrc"))
})
})
Context("fuzzy title+artist matching", func() {
It("matches songs by title and artist name", func() {
conf.Server.SimilarSongsMatchThreshold = 100
songs := []agents.Song{
{Name: "Enjoy the Silence", Artist: "Depeche Mode"},
}
titleMatch := model.MediaFile{
ID: "track-title", Title: "Enjoy the Silence", Artist: "Depeche Mode",
}
setupTitleOnlyExpectations(model.MediaFiles{titleMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("track-title"))
})
It("matches songs with fuzzy title similarity", func() {
conf.Server.SimilarSongsMatchThreshold = 85
songs := []agents.Song{
{Name: "Bohemian Rhapsody", Artist: "Queen"},
}
fuzzyMatch := model.MediaFile{
ID: "track-fuzzy", Title: "Bohemian Rhapsody (Live)", Artist: "Queen",
}
setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("track-fuzzy"))
})
It("does not match completely different titles", func() {
conf.Server.SimilarSongsMatchThreshold = 85
songs := []agents.Song{
{Name: "Yesterday", Artist: "The Beatles"},
}
differentTracks := model.MediaFiles{
{ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"},
}
setupTitleOnlyExpectations(differentTracks)
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
})
Context("deduplication", func() {
It("removes duplicates when different input songs match the same library track", func() {
conf.Server.SimilarSongsMatchThreshold = 85
songs := []agents.Song{
{Name: "Bohemian Rhapsody (Live)", Artist: "Queen"},
{Name: "Bohemian Rhapsody (Original Mix)", Artist: "Queen"},
}
libraryTrack := model.MediaFile{
ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen",
}
setupTitleOnlyExpectations(model.MediaFiles{libraryTrack})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("br-live"))
})
It("preserves duplicates when identical input songs match the same library track", func() {
conf.Server.SimilarSongsMatchThreshold = 85
songs := []agents.Song{
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
}
libraryTrack := model.MediaFile{
ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera",
}
setupTitleOnlyExpectations(model.MediaFiles{libraryTrack})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
Expect(result[0].ID).To(Equal("br"))
Expect(result[1].ID).To(Equal("br"))
})
})
Context("priority ordering", func() {
It("prefers ID match over MBID match", func() {
conf.Server.SimilarSongsMatchThreshold = 100
// Song has both ID and MBID set. The matcher should resolve via ID
// and short-circuit the MBID phase entirely, so no MBID fetch should
// occur even though an mbz_recording_id exists in the input.
songs := []agents.Song{
{ID: "track-id", Name: "Song", MBID: "mbid-1", Artist: "Artist"},
}
idMatch := model.MediaFile{
ID: "track-id", Title: "Song", Artist: "Artist",
}
expectIDPhase(model.MediaFiles{idMatch})
allowOtherPhases()
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("track-id"))
})
})
Context("count limit", func() {
It("returns at most 'count' results", func() {
conf.Server.SimilarSongsMatchThreshold = 100
songs := []agents.Song{
{Name: "Song A", Artist: "Artist"},
{Name: "Song B", Artist: "Artist"},
{Name: "Song C", Artist: "Artist"},
}
tracks := model.MediaFiles{
{ID: "a", Title: "Song A", Artist: "Artist"},
{ID: "b", Title: "Song B", Artist: "Artist"},
{ID: "c", Title: "Song C", Artist: "Artist"},
}
setupTitleOnlyExpectations(tracks)
result, err := m.MatchSongsToLibrary(ctx, songs, 2)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
})
})
Context("empty input", func() {
It("returns empty results for no songs", func() {
result, err := m.MatchSongsToLibrary(ctx, []agents.Song{}, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
})
})
Describe("specificity level matching", func() {
BeforeEach(func() {
conf.Server.SimilarSongsMatchThreshold = 100
})
It("matches by title + artist MBID + album MBID (highest priority)", func() {
correctMatch := model.MediaFile{
ID: "correct-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Violator",
MbzArtistID: "artist-mbid-123", MbzAlbumID: "album-mbid-456",
}
wrongMatch := model.MediaFile{
ID: "wrong-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Some Other Album",
MbzArtistID: "artist-mbid-123", MbzAlbumID: "different-album-mbid",
}
songs := []agents.Song{
{Name: "Similar Song", Artist: "Depeche Mode", ArtistMBID: "artist-mbid-123", Album: "Violator", AlbumMBID: "album-mbid-456"},
}
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("correct-match"))
})
It("matches by title + artist name + album name when MBIDs unavailable", func() {
correctMatch := model.MediaFile{
ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "violator",
}
wrongMatch := model.MediaFile{
ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album",
}
songs := []agents.Song{
{Name: "Similar Song", Artist: "Depeche Mode", Album: "Violator"},
}
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("correct-match"))
})
It("matches by title + artist only when album info unavailable", func() {
correctMatch := model.MediaFile{
ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "Some Album",
}
wrongMatch := model.MediaFile{
ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album",
}
songs := []agents.Song{
{Name: "Similar Song", Artist: "Depeche Mode"},
}
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("correct-match"))
})
It("does not match songs without artist info", func() {
songs := []agents.Song{
{Name: "Similar Song"},
}
setupTitleOnlyExpectations(model.MediaFiles{})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
It("returns distinct matches for each artist's version (covers scenario)", func() {
cover1 := model.MediaFile{ID: "cover-1", Title: "Yesterday", Artist: "The Beatles", Album: "Help!"}
cover2 := model.MediaFile{ID: "cover-2", Title: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"}
cover3 := model.MediaFile{ID: "cover-3", Title: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"}
songs := []agents.Song{
{Name: "Yesterday", Artist: "The Beatles", Album: "Help!"},
{Name: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"},
{Name: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"},
}
setupTitleOnlyExpectations(model.MediaFiles{cover1, cover2, cover3})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(3))
ids := []string{result[0].ID, result[1].ID, result[2].ID}
Expect(ids).To(ContainElements("cover-1", "cover-2", "cover-3"))
})
It("prefers more precise matches for each song", func() {
preciseMatch := model.MediaFile{
ID: "precise", Title: "Song A", Artist: "Artist One", Album: "Album One",
MbzArtistID: "mbid-1", MbzAlbumID: "album-mbid-1",
}
lessAccurateMatch := model.MediaFile{
ID: "less-accurate", Title: "Song A", Artist: "Artist One", Album: "Compilation",
MbzArtistID: "mbid-1",
}
artistTwoMatch := model.MediaFile{
ID: "artist-two", Title: "Song B", Artist: "Artist Two",
}
songs := []agents.Song{
{Name: "Song A", Artist: "Artist One", ArtistMBID: "mbid-1", Album: "Album One", AlbumMBID: "album-mbid-1"},
{Name: "Song B", Artist: "Artist Two"},
}
setupTitleOnlyExpectations(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
Expect(result[0].ID).To(Equal("precise"))
Expect(result[1].ID).To(Equal("artist-two"))
})
})
Describe("fuzzy matching thresholds", func() {
Context("with default threshold (85%)", func() {
It("matches songs with remastered suffix", func() {
conf.Server.SimilarSongsMatchThreshold = 85
songs := []agents.Song{
{Name: "Paranoid Android", Artist: "Radiohead"},
}
artistTracks := model.MediaFiles{
{ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
}
setupTitleOnlyExpectations(artistTracks)
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("remastered"))
})
It("matches songs with live suffix", func() {
conf.Server.SimilarSongsMatchThreshold = 85
songs := []agents.Song{
{Name: "Bohemian Rhapsody", Artist: "Queen"},
}
artistTracks := model.MediaFiles{
{ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"},
}
setupTitleOnlyExpectations(artistTracks)
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("live"))
})
})
Context("with threshold set to 100 (exact match only)", func() {
It("only matches exact titles", func() {
conf.Server.SimilarSongsMatchThreshold = 100
songs := []agents.Song{
{Name: "Paranoid Android", Artist: "Radiohead"},
}
artistTracks := model.MediaFiles{
{ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
}
setupTitleOnlyExpectations(artistTracks)
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
})
Context("with lower threshold (75%)", func() {
It("matches more aggressively", func() {
conf.Server.SimilarSongsMatchThreshold = 75
songs := []agents.Song{
{Name: "Song", Artist: "Artist"},
}
artistTracks := model.MediaFiles{
{ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"},
}
setupTitleOnlyExpectations(artistTracks)
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("extended"))
})
})
})
Describe("fuzzy album matching", func() {
BeforeEach(func() {
conf.Server.SimilarSongsMatchThreshold = 85
})
It("matches album with (Remaster) suffix", func() {
songs := []agents.Song{
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
}
correctMatch := model.MediaFile{
ID: "correct", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera (2011 Remaster)",
}
wrongMatch := model.MediaFile{
ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits",
}
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("correct"))
})
It("matches album with (Deluxe Edition) suffix", func() {
songs := []agents.Song{
{Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
}
correctMatch := model.MediaFile{
ID: "correct", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)",
}
wrongMatch := model.MediaFile{
ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101",
}
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("correct"))
})
It("prefers exact album match over fuzzy album match", func() {
songs := []agents.Song{
{Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
}
exactMatch := model.MediaFile{
ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator",
}
fuzzyMatch := model.MediaFile{
ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)",
}
setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch, exactMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("exact"))
})
})
Describe("duration matching", func() {
BeforeEach(func() {
conf.Server.SimilarSongsMatchThreshold = 100
})
It("prefers tracks with matching duration", func() {
songs := []agents.Song{
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
}
correctMatch := model.MediaFile{
ID: "correct", Title: "Similar Song", Artist: "Test Artist", Duration: 180.0,
}
wrongDuration := model.MediaFile{
ID: "wrong", Title: "Similar Song", Artist: "Test Artist", Duration: 240.0,
}
setupTitleOnlyExpectations(model.MediaFiles{wrongDuration, correctMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("correct"))
})
It("matches tracks with close duration", func() {
songs := []agents.Song{
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
}
closeDuration := model.MediaFile{
ID: "close-duration", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5,
}
setupTitleOnlyExpectations(model.MediaFiles{closeDuration})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("close-duration"))
})
It("prefers closer duration over farther duration", func() {
songs := []agents.Song{
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
}
closeDuration := model.MediaFile{
ID: "close", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0,
}
farDuration := model.MediaFile{
ID: "far", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0,
}
setupTitleOnlyExpectations(model.MediaFiles{farDuration, closeDuration})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("close"))
})
It("still matches when no tracks have matching duration", func() {
songs := []agents.Song{
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
}
differentDuration := model.MediaFile{
ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0,
}
setupTitleOnlyExpectations(model.MediaFiles{differentDuration})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("different"))
})
It("prefers title match over duration match when titles differ", func() {
songs := []agents.Song{
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
}
differentTitle := model.MediaFile{
ID: "wrong-title", Title: "Different Song", Artist: "Test Artist", Duration: 180.0,
}
correctTitle := model.MediaFile{
ID: "correct-title", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0,
}
setupTitleOnlyExpectations(model.MediaFiles{differentTitle, correctTitle})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("correct-title"))
})
It("matches without duration filtering when agent duration is 0", func() {
songs := []agents.Song{
{Name: "Similar Song", Artist: "Test Artist", Duration: 0},
}
anyTrack := model.MediaFile{
ID: "any", Title: "Similar Song", Artist: "Test Artist", Duration: 999.0,
}
setupTitleOnlyExpectations(model.MediaFiles{anyTrack})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("any"))
})
It("handles very short songs with close duration", func() {
songs := []agents.Song{
{Name: "Short Song", Artist: "Test Artist", Duration: 30000},
}
shortTrack := model.MediaFile{
ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0,
}
setupTitleOnlyExpectations(model.MediaFiles{shortTrack})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("short"))
})
})
Describe("deduplication edge cases", func() {
BeforeEach(func() {
conf.Server.SimilarSongsMatchThreshold = 85
})
It("handles mixed scenario with both identical and different input songs", func() {
songs := []agents.Song{
{Name: "Yesterday", Artist: "The Beatles", Album: "Help!"},
{Name: "Yesterday (Remastered)", Artist: "The Beatles", Album: "1"},
{Name: "Yesterday", Artist: "The Beatles", Album: "Help!"},
{Name: "Yesterday (Anthology)", Artist: "The Beatles", Album: "Anthology"},
}
libraryTrack := model.MediaFile{
ID: "yesterday", Title: "Yesterday", Artist: "The Beatles", Album: "Help!",
}
setupTitleOnlyExpectations(model.MediaFiles{libraryTrack})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
Expect(result[0].ID).To(Equal("yesterday"))
Expect(result[1].ID).To(Equal("yesterday"))
})
It("does not deduplicate songs that match different library tracks", func() {
songs := []agents.Song{
{Name: "Song A", Artist: "Artist"},
{Name: "Song B", Artist: "Artist"},
{Name: "Song C", Artist: "Artist"},
}
trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"}
trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"}
trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist"}
setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB, trackC})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(3))
Expect(result[0].ID).To(Equal("track-a"))
Expect(result[1].ID).To(Equal("track-b"))
Expect(result[2].ID).To(Equal("track-c"))
})
It("respects count limit after deduplication", func() {
songs := []agents.Song{
{Name: "Song A", Artist: "Artist"},
{Name: "Song A (Live)", Artist: "Artist"},
{Name: "Song B", Artist: "Artist"},
{Name: "Song B (Remix)", Artist: "Artist"},
}
trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"}
trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"}
setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB})
result, err := m.MatchSongsToLibrary(ctx, songs, 2)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
Expect(result[0].ID).To(Equal("track-a"))
Expect(result[1].ID).To(Equal("track-b"))
})
})
})
type mockMediaFileRepo struct {
mock.Mock
model.MediaFileRepository
}
func newMockMediaFileRepo() *mockMediaFileRepo {
return &mockMediaFileRepo{}
}
func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
argsSlice := make([]any, len(options))
for i, v := range options {
argsSlice[i] = v
}
args := m.Called(argsSlice...)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(model.MediaFiles), args.Error(1)
}
func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
return m.GetAll(options...)
}
func (m *mockMediaFileRepo) SetError(hasError bool) {
if hasError {
m.On("GetAll", mock.Anything).Return(nil, errors.New("mock repo error"))
}
}
// matchFieldInAnd returns a matcher that checks whether QueryOptions.Filters is a
// squirrel.And whose first element is a squirrel.Eq containing the given field name.
func matchFieldInAnd(fieldName string) func(opt model.QueryOptions) bool {
return func(opt model.QueryOptions) bool {
and, ok := opt.Filters.(squirrel.And)
if !ok || len(and) < 2 {
return false
}
eq, hasEq := and[0].(squirrel.Eq)
if !hasEq {
return false
}
_, hasField := eq[fieldName]
return hasField
}
}
// matchFieldInEq returns a matcher that checks whether QueryOptions.Filters is a
// squirrel.Eq containing the given field name.
func matchFieldInEq(fieldName string) func(opt model.QueryOptions) bool {
return func(opt model.QueryOptions) bool {
eq, ok := opt.Filters.(squirrel.Eq)
if !ok {
return false
}
_, hasField := eq[fieldName]
return hasField
}
}

View File

@ -11,6 +11,7 @@ import (
"github.com/djherbis/times"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/metadata"
@ -28,7 +29,13 @@ type localStorage struct {
func newLocalStorage(u url.URL) storage.Storage {
newExtractor, ok := extractors[conf.Server.Scanner.Extractor]
if !ok || newExtractor == nil {
log.Fatal("Extractor not found", "path", conf.Server.Scanner.Extractor)
if conf.Server.Scanner.Extractor != consts.DefaultScannerExtractor {
log.Warn("Extractor not found, using default", "extractor", conf.Server.Scanner.Extractor, "default", consts.DefaultScannerExtractor)
}
newExtractor = extractors[consts.DefaultScannerExtractor]
if newExtractor == nil {
log.Fatal("Default extractor not registered", "extractor", consts.DefaultScannerExtractor)
}
}
isWindowsPath := filepath.VolumeName(u.Host) != ""
if u.Scheme == storage.LocalSchemaID && isWindowsPath {

View File

@ -10,6 +10,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/model/metadata"
. "github.com/onsi/ginkgo/v2"
@ -135,16 +136,31 @@ var _ = Describe("LocalStorage", func() {
})
})
Context("with invalid extractor", func() {
It("should handle extractor validation correctly", func() {
// Note: The actual implementation uses log.Fatal which exits the process,
// so we test the normal path where extractors exist
Context("when the configured extractor is not registered", func() {
var defaultExtractor *mockTestExtractor
BeforeEach(func() {
defaultExtractor = &mockTestExtractor{results: make(map[string]metadata.Info)}
RegisterExtractor(consts.DefaultScannerExtractor, func(fs.FS, string) Extractor {
return defaultExtractor
})
DeferCleanup(func() {
lock.Lock()
delete(extractors, consts.DefaultScannerExtractor)
lock.Unlock()
})
})
It("falls back to the default extractor instead of crashing", func() {
conf.Server.Scanner.Extractor = "nonexistent-extractor"
u, err := url.Parse("file://" + tempDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(*u)
Expect(storage).ToNot(BeNil())
ls, ok := storage.(*localStorage)
Expect(ok).To(BeTrue())
Expect(ls.extractor).To(BeIdenticalTo(defaultExtractor))
})
})
})

View File

@ -75,3 +75,16 @@ func codecMaxSampleRate(codec string) int {
}
return 0
}
// codecMaxChannels returns the hard maximum number of audio channels a codec
// supports. Returns 0 if the codec has no hard limit (or is unknown), in which
// case the source/profile constraints applied upstream are authoritative.
func codecMaxChannels(codec string) int {
switch strings.ToLower(codec) {
case "mp3":
return 2
case "opus":
return 8
}
return 0
}

View File

@ -66,4 +66,26 @@ var _ = Describe("Codec", func() {
Expect(normalizeProbeCodec("DSD_LSBF_PLANAR")).To(Equal("dsd"))
})
})
Describe("codecMaxChannels", func() {
It("returns 2 for mp3", func() {
Expect(codecMaxChannels("mp3")).To(Equal(2))
})
It("returns 8 for opus", func() {
Expect(codecMaxChannels("opus")).To(Equal(8))
})
It("is case-insensitive", func() {
Expect(codecMaxChannels("MP3")).To(Equal(2))
Expect(codecMaxChannels("Opus")).To(Equal(8))
})
It("returns 0 for codecs with no hard limit", func() {
Expect(codecMaxChannels("aac")).To(Equal(0))
Expect(codecMaxChannels("flac")).To(Equal(0))
Expect(codecMaxChannels("vorbis")).To(Equal(0))
Expect(codecMaxChannels("")).To(Equal(0))
})
})
})

View File

@ -294,14 +294,19 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Detai
if maxRate := codecMaxSampleRate(ts.Codec); maxRate > 0 && ts.SampleRate > maxRate {
ts.SampleRate = maxRate
}
if maxCh := codecMaxChannels(ts.Codec); maxCh > 0 && ts.Channels > maxCh {
ts.Channels = maxCh
}
// Determine target bitrate (all in kbps)
if ok := s.computeBitrate(ctx, src, targetFormat, targetIsLossless, clientInfo, ts); !ok {
return nil, ""
}
// Apply MaxAudioChannels from the transcoding profile
if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels {
// Apply MaxAudioChannels from the transcoding profile. Compare against the
// already-clamped ts.Channels (not src.Channels) so the codec hard limit
// applied above is never raised by a looser profile setting.
if profile.MaxAudioChannels > 0 && ts.Channels > profile.MaxAudioChannels {
ts.Channels = profile.MaxAudioChannels
}

View File

@ -770,6 +770,73 @@ var _ = Describe("Decider", func() {
})
})
Context("Codec channel limits", func() {
It("clamps 6-channel FLAC to 2 channels when transcoding to MP3", func() {
// Regression test for #5336: ffmpeg's mp3 encoder rejects >2 channels.
// The decider must clamp to the codec's hard limit even when no
// transcoding profile MaxAudioChannels is configured.
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TargetFormat).To(Equal("mp3"))
Expect(decision.TranscodeStream.Channels).To(Equal(2))
Expect(decision.TargetChannels).To(Equal(2))
})
It("honors a stricter profile MaxAudioChannels over the codec clamp", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 1},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TranscodeStream.Channels).To(Equal(1))
Expect(decision.TargetChannels).To(Equal(1))
})
It("applies the codec clamp when the profile limit is looser", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 4},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TranscodeStream.Channels).To(Equal(2))
Expect(decision.TargetChannels).To(Equal(2))
})
It("passes channels through unchanged for codecs with no hard limit", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
{Container: "m4a", AudioCodec: "aac", Protocol: ProtocolHTTP},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TargetFormat).To(Equal("aac"))
Expect(decision.TranscodeStream.Channels).To(Equal(6))
Expect(decision.TargetChannels).To(Equal(6))
})
})
Context("Probe-based lossless detection", func() {
It("uses probe codec name for lossless detection", func() {
// WavPack files: ffprobe reports codec as "wavpack", suffix is ".wv"

View File

@ -6,6 +6,7 @@ import (
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
@ -28,6 +29,7 @@ var Set = wire.NewSet(
stream.NewTranscodeDecider,
agents.GetAgents,
external.NewProvider,
matcher.New,
wire.Bind(new(external.Agents), new(*agents.Agents)),
ffmpeg.New,
scrobbler.GetPlayTracker,

View File

@ -81,12 +81,12 @@ func backupOrRestore(ctx context.Context, isBackup bool, path string) error {
// Caution: -1 means that sqlite will hold a read lock until the operation finishes
// This will lock out other writes that could happen at the same time
done, err := backupOp.Step(-1)
if !done {
return fmt.Errorf("backup not done with step -1")
}
if err != nil {
return fmt.Errorf("error during backup step: %w", err)
}
if !done {
return fmt.Errorf("backup not done with step -1")
}
err = backupOp.Finish()
if err != nil {

View File

@ -0,0 +1,22 @@
-- +goose Up
-- Backfill album.created_at for rows poisoned by early scanner versions or
-- propagated via CopyAttributes during metadata-driven ID changes. Prefer the
-- oldest valid birth_time from the album's media files, fall back to updated_at.
UPDATE album
SET created_at = COALESCE(
(SELECT MIN(birth_time)
FROM media_file
WHERE media_file.album_id = album.id
AND birth_time IS NOT NULL
AND birth_time != ''
AND birth_time NOT LIKE '0001-%'),
updated_at
)
WHERE created_at IS NULL
OR created_at = ''
OR created_at LIKE '0001-%';
-- +goose Down
SELECT 1;

26
go.mod
View File

@ -1,6 +1,6 @@
module github.com/navidrome/navidrome
go 1.25.0
go 1.26.0
// Fork to implement raw tags support
replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a
@ -36,7 +36,7 @@ require (
github.com/kardianos/service v1.2.4
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v3 v3.0.13
github.com/mattn/go-sqlite3 v1.14.38
github.com/mattn/go-sqlite3 v1.14.42
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
github.com/onsi/ginkgo/v2 v2.28.1
@ -58,12 +58,12 @@ require (
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
go.senan.xyz/taglib v0.11.1
go.uber.org/goleak v1.3.0
golang.org/x/image v0.38.0
golang.org/x/net v0.52.0
golang.org/x/image v0.39.0
golang.org/x/net v0.53.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.42.0
golang.org/x/term v0.41.0
golang.org/x/text v0.35.0
golang.org/x/sys v0.43.0
golang.org/x/term v0.42.0
golang.org/x/text v0.36.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
)
@ -89,7 +89,7 @@ require (
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect
github.com/google/subcommands v1.2.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
@ -101,7 +101,7 @@ require (
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
github.com/lestrrat-go/dsig v1.0.0 // indirect
github.com/lestrrat-go/dsig v1.3.0 // indirect
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect
@ -134,10 +134,10 @@ require (
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect
golang.org/x/tools v0.43.0 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect
golang.org/x/tools v0.44.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.1 // indirect
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect

48
go.sum
View File

@ -108,8 +108,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg=
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@ -161,8 +161,8 @@ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhR
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38=
github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo=
github.com/lestrrat-go/dsig v1.3.0 h1:phjMOCXvYzhuIgn7Voe2rex8z166vGfxRxmqM25P9/Q=
github.com/lestrrat-go/dsig v1.3.0/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc=
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY=
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
@ -177,8 +177,8 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4=
github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
@ -319,19 +319,19 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@ -343,8 +343,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -369,11 +369,11 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc=
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4=
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@ -382,8 +382,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -394,8 +394,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -405,8 +405,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@ -361,6 +361,9 @@ func older(t1, t2 time.Time) time.Time {
if t1.IsZero() {
return t2
}
if t2.IsZero() {
return t1
}
if t1.After(t2) {
return t2
}

View File

@ -119,6 +119,20 @@ var _ = Describe("MediaFiles", func() {
Expect(a.MinYear).To(Equal(1999))
})
})
Context("CreatedAt aggregation", func() {
It("ignores zero BirthTime values when computing the oldest", func() {
mfs = MediaFiles{
{BirthTime: t("2022-12-19 08:30")},
{BirthTime: time.Time{}},
{BirthTime: t("2022-12-18 10:00")},
}
Expect(mfs.ToAlbum().CreatedAt).To(Equal(t("2022-12-18 10:00")))
})
It("returns zero when all BirthTime values are zero", func() {
mfs = MediaFiles{{BirthTime: time.Time{}}, {BirthTime: time.Time{}}}
Expect(mfs.ToAlbum().CreatedAt).To(BeZero())
})
})
})
When("we have multiple songs with same dates", func() {
BeforeEach(func() {

View File

@ -12,88 +12,85 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/utils/str"
)
type hashFunc = func(...string) string
// createGetPID returns a function that calculates the persistent ID for a given spec, getting the referenced values from the metadata
// The spec is a pipe-separated list of fields, where each field is a comma-separated list of attributes
// Attributes can be either tags or some processed values like folder, albumid, albumartistid, etc.
// For each field, it gets all its attributes values and concatenates them, then hashes the result.
// If a field is empty, it is skipped and the function looks for the next field.
type getPIDFunc = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string
func createGetPID(hash hashFunc) getPIDFunc {
var getPID getPIDFunc
getAttr := func(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string) string {
attr = strings.TrimSpace(strings.ToLower(attr))
switch attr {
case "albumid":
if spec == conf.Server.PID.Album {
log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec)
return ""
// computePID calculates the persistent ID for a given spec. The spec is a
// pipe-separated list of fields, where each field is a comma-separated list of
// attributes. Attributes can be either tags or processed values like folder,
// albumid, albumartistid, etc. For each field, it gets all its attribute values
// and concatenates them, then hashes the result. If a field is empty, it is
// skipped and the function looks for the next field.
//
// Taking hash as a parameter (instead of closing over it in a factory) keeps
// mf on the stack: closing over mf would force the whole ~1KB MediaFile to the
// heap on every call.
func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool, hash hashFunc) string {
switch spec {
case "track_legacy":
return legacyTrackID(mf, prependLibId)
case "album_legacy":
return legacyAlbumID(mf, md, prependLibId)
}
pid := ""
fields := strings.SplitSeq(spec, "|")
for field := range fields {
attributes := strings.Split(field, ",")
values := make([]string, len(attributes))
hasValue := false
for i, attr := range attributes {
v := getPIDAttr(mf, md, attr, prependLibId, spec, hash)
if v != "" {
hasValue = true
}
return getPID(mf, md, conf.Server.PID.Album, prependLibId)
case "folder":
return filepath.Dir(mf.Path)
case "albumartistid":
return hash(str.Clear(strings.ToLower(mf.AlbumArtist)))
case "title":
return mf.Title
case "album":
return str.Clear(strings.ToLower(md.String(model.TagAlbum)))
values[i] = v
}
if hasValue {
pid += strings.Join(values, "\\")
break
}
return md.String(model.TagName(attr))
}
getPID = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
pid := ""
fields := strings.SplitSeq(spec, "|")
for field := range fields {
attributes := strings.Split(field, ",")
hasValue := false
values := slice.Map(attributes, func(attr string) string {
v := getAttr(mf, md, attr, prependLibId, spec)
if v != "" {
hasValue = true
}
return v
})
if hasValue {
pid += strings.Join(values, "\\")
break
}
}
if prependLibId {
pid = fmt.Sprintf("%d\\%s", mf.LibraryID, pid)
}
return hash(pid)
if prependLibId {
pid = fmt.Sprintf("%d\\%s", mf.LibraryID, pid)
}
return hash(pid)
}
return func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
switch spec {
case "track_legacy":
return legacyTrackID(mf, prependLibId)
case "album_legacy":
return legacyAlbumID(mf, md, prependLibId)
func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string, hash hashFunc) string {
attr = strings.TrimSpace(strings.ToLower(attr))
switch attr {
case "albumid":
if spec == conf.Server.PID.Album {
log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec)
return ""
}
return getPID(mf, md, spec, prependLibId)
return computePID(mf, md, conf.Server.PID.Album, prependLibId, hash)
case "folder":
return filepath.Dir(mf.Path)
case "albumartistid":
return hash(str.Clear(strings.ToLower(mf.AlbumArtist)))
case "title":
return mf.Title
case "album":
return str.Clear(strings.ToLower(md.String(model.TagAlbum)))
}
return md.String(model.TagName(attr))
}
func (md Metadata) trackPID(mf model.MediaFile) string {
return createGetPID(id.NewHash)(mf, md, conf.Server.PID.Track, true)
return computePID(mf, md, conf.Server.PID.Track, true, id.NewHash)
}
func (md Metadata) albumID(mf model.MediaFile, pidConf string) string {
return createGetPID(id.NewHash)(mf, md, pidConf, true)
return computePID(mf, md, pidConf, true, id.NewHash)
}
// BFR Must be configurable?
func (md Metadata) artistID(name string) string {
mf := model.MediaFile{AlbumArtist: name}
return createGetPID(id.NewHash)(mf, md, "albumartistid", false)
return computePID(mf, md, "albumartistid", false, id.NewHash)
}
func (md Metadata) mapTrackTitle() string {

View File

@ -12,15 +12,16 @@ import (
var _ = Describe("getPID", func() {
var (
md Metadata
mf model.MediaFile
sum hashFunc
getPID getPIDFunc
md Metadata
mf model.MediaFile
sum hashFunc
)
getPID := func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
return computePID(mf, md, spec, prependLibId, sum)
}
BeforeEach(func() {
sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" }
getPID = createGetPID(sum)
})
Context("attributes are tags", func() {

View File

@ -252,7 +252,17 @@ func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string)
}
to := make(map[string]any)
for _, col := range columns {
to[col] = from[col]
v := from[col]
// created_at is aggregated from song birth_times and must never be
// overwritten with a zero/poisoned value, or it propagates forward on
// every metadata-driven album ID change.
if col == "created_at" && (!v.Valid || v.String == "" || strings.HasPrefix(v.String, "0001-")) {
continue
}
to[col] = v
}
if len(to) == 0 {
return nil
}
_, err = r.executeSQL(Update(r.tableName).SetMap(to).Where(Eq{"id": toID}))
return err

View File

@ -41,6 +41,32 @@ var _ = Describe("AlbumRepository", func() {
})
})
Describe("CopyAttributes", func() {
var srcTime, dstTime time.Time
BeforeEach(func() {
srcTime = time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
dstTime = time.Date(2024, 6, 7, 8, 9, 10, 0, time.UTC)
Expect(albumRepo.Put(&model.Album{ID: "copy-src", Name: "src", LibraryID: 1, CreatedAt: srcTime})).To(Succeed())
Expect(albumRepo.Put(&model.Album{ID: "copy-dst", Name: "dst", LibraryID: 1, CreatedAt: dstTime})).To(Succeed())
Expect(albumRepo.Put(&model.Album{ID: "copy-zero", Name: "zero", LibraryID: 1})).To(Succeed())
DeferCleanup(func() {
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"copy-src", "copy-dst", "copy-zero"}}))
})
})
It("copies a valid created_at from source to destination", func() {
Expect(albumRepo.CopyAttributes("copy-src", "copy-dst", "created_at")).To(Succeed())
got, err := albumRepo.Get("copy-dst")
Expect(err).ToNot(HaveOccurred())
Expect(got.CreatedAt).To(BeTemporally("~", srcTime, time.Second))
})
It("leaves destination untouched when source created_at is zero", func() {
Expect(albumRepo.CopyAttributes("copy-zero", "copy-dst", "created_at")).To(Succeed())
got, err := albumRepo.Get("copy-dst")
Expect(err).ToNot(HaveOccurred())
Expect(got.CreatedAt).To(BeTemporally("~", dstTime, time.Second))
})
})
Describe("GetAll", func() {
var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) {
albums, err := albumRepo.GetAll(opts...)

View File

@ -102,6 +102,11 @@ components:
mbzReleaseTrackId:
type: string
description: MBZReleaseTrackID is the MusicBrainz release track ID.
path:
type: string
description: |-
Path is the full path to the track file, relative to the library root.
Only included if the plugin has library permission with filesystem access for the track's library.
required:
- id
- title

View File

@ -68,6 +68,9 @@ type TrackInfo struct {
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
// MBZReleaseTrackID is the MusicBrainz release track ID.
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
// Path is the full path to the track file, relative to the library root.
// Only included if the plugin has library permission with filesystem access for the track's library.
Path string `json:"path,omitempty"`
}
// NowPlayingRequest is the request for now playing notification.

View File

@ -128,6 +128,11 @@ components:
mbzReleaseTrackId:
type: string
description: MBZReleaseTrackID is the MusicBrainz release track ID.
path:
type: string
description: |-
Path is the full path to the track file, relative to the library root.
Only included if the plugin has library permission with filesystem access for the track's library.
required:
- id
- title

View File

@ -31,7 +31,7 @@ type LyricsPlugin struct {
// using model.ToLyrics.
func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
req := capabilities.GetLyricsRequest{
Track: mediaFileToTrackInfo(mf),
Track: mediaFileToTrackInfo(l.plugin, mf),
}
resp, err := callPluginFunction[capabilities.GetLyricsRequest, capabilities.GetLyricsResponse](
ctx, l.plugin, FuncLyricsGetLyrics, req,

View File

@ -301,7 +301,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
}
// Configure filesystem access for library permission
if pkg.Manifest.Permissions != nil && pkg.Manifest.Permissions.Library != nil && pkg.Manifest.Permissions.Library.Filesystem {
if pkg.Manifest.HasLibraryFilesystemPermission() {
adminCtx := adminContext(ctx)
libraries, err := m.ds.Library(adminCtx).GetAll()
if err != nil {
@ -384,6 +384,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
metrics: m.metrics,
allowedUserIDs: allowedUsers,
allUsers: p.AllUsers,
libraries: newLibraryAccess(allowedLibraries, p.AllLibraries),
}
m.mu.Unlock()

View File

@ -21,6 +21,7 @@ type plugin struct {
metrics PluginMetricsRecorder
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
allUsers bool // If true, plugin can access all users
libraries libraryAccess
}
// instance creates a new plugin instance for the given context.
@ -47,3 +48,30 @@ func (p *plugin) Close() error {
}
return errors.Join(errs...)
}
func (p *plugin) hasLibraryFilesystemAccess(libID int) bool {
return p.manifest.HasLibraryFilesystemPermission() && p.libraries.contains(libID)
}
// libraryAccess captures the set of libraries a plugin is permitted to see,
// precomputed at load time for O(1) lookup.
type libraryAccess struct {
allLibraries bool
libraryIDSet map[int]struct{}
}
func newLibraryAccess(allowedLibraryIDs []int, allLibraries bool) libraryAccess {
set := make(map[int]struct{}, len(allowedLibraryIDs))
for _, id := range allowedLibraryIDs {
set[id] = struct{}{}
}
return libraryAccess{allLibraries: allLibraries, libraryIDSet: set}
}
func (a libraryAccess) contains(libID int) bool {
if a.allLibraries {
return true
}
_, ok := a.libraryIDSet[libID]
return ok
}

View File

@ -0,0 +1,34 @@
package plugins
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("plugin", func() {
Describe("hasLibraryFilesystemAccess", func() {
fsManifest := &Manifest{
Permissions: &Permissions{
Library: &LibraryPermission{Filesystem: true},
},
}
It("returns false when the manifest does not grant filesystem permission", func() {
p := &plugin{manifest: &Manifest{}, libraries: newLibraryAccess(nil, true)}
Expect(p.hasLibraryFilesystemAccess(1)).To(BeFalse())
})
It("returns true for any library when allLibraries is set", func() {
p := &plugin{manifest: fsManifest, libraries: newLibraryAccess(nil, true)}
Expect(p.hasLibraryFilesystemAccess(1)).To(BeTrue())
Expect(p.hasLibraryFilesystemAccess(42)).To(BeTrue())
})
It("returns true only for libraries in the allowed list", func() {
p := &plugin{manifest: fsManifest, libraries: newLibraryAccess([]int{1, 3}, false)}
Expect(p.hasLibraryFilesystemAccess(1)).To(BeTrue())
Expect(p.hasLibraryFilesystemAccess(3)).To(BeTrue())
Expect(p.hasLibraryFilesystemAccess(2)).To(BeFalse())
})
})
})

View File

@ -86,3 +86,10 @@ func ValidateWithCapabilities(m *Manifest, capabilities []Capability) error {
func (m *Manifest) HasExperimentalThreads() bool {
return m.Experimental != nil && m.Experimental.Threads != nil
}
// HasLibraryFilesystemPermission checks if the manifest grants filesystem permission for libraries.
func (m *Manifest) HasLibraryFilesystemPermission() bool {
return m.Permissions != nil &&
m.Permissions.Library != nil &&
m.Permissions.Library.Filesystem
}

View File

@ -68,6 +68,9 @@ type TrackInfo struct {
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
// MBZReleaseTrackID is the MusicBrainz release track ID.
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
// Path is the full path to the track file, relative to the library root.
// Only included if the plugin has library permission with filesystem access for the track's library.
Path string `json:"path,omitempty"`
}
// Lyrics requires all methods to be implemented.

View File

@ -65,6 +65,9 @@ type TrackInfo struct {
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
// MBZReleaseTrackID is the MusicBrainz release track ID.
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
// Path is the full path to the track file, relative to the library root.
// Only included if the plugin has library permission with filesystem access for the track's library.
Path string `json:"path,omitempty"`
}
// Lyrics requires all methods to be implemented.

View File

@ -92,6 +92,9 @@ type TrackInfo struct {
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
// MBZReleaseTrackID is the MusicBrainz release track ID.
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
// Path is the full path to the track file, relative to the library root.
// Only included if the plugin has library permission with filesystem access for the track's library.
Path string `json:"path,omitempty"`
}
// Scrobbler requires all methods to be implemented.

View File

@ -89,6 +89,9 @@ type TrackInfo struct {
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
// MBZReleaseTrackID is the MusicBrainz release track ID.
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
// Path is the full path to the track file, relative to the library root.
// Only included if the plugin has library permission with filesystem access for the track's library.
Path string `json:"path,omitempty"`
}
// Scrobbler requires all methods to be implemented.

View File

@ -102,6 +102,10 @@ pub struct TrackInfo {
/// MBZReleaseTrackID is the MusicBrainz release track ID.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub mbz_release_track_id: String,
/// Path is the full path to the track file, relative to the library root.
/// Only included if the plugin has library permission with filesystem access for the track's library.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub path: String,
}
/// Error represents an error from a capability method.

View File

@ -122,6 +122,10 @@ pub struct TrackInfo {
/// MBZReleaseTrackID is the MusicBrainz release track ID.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub mbz_release_track_id: String,
/// Path is the full path to the track file, relative to the library root.
/// Only included if the plugin has library permission with filesystem access for the track's library.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub path: String,
}
/// Error represents an error from a capability method.

View File

@ -80,7 +80,7 @@ func (s *ScrobblerPlugin) NowPlaying(ctx context.Context, userId string, track *
username := getUsernameFromContext(ctx)
input := capabilities.NowPlayingRequest{
Username: username,
Track: mediaFileToTrackInfo(track),
Track: mediaFileToTrackInfo(s.plugin, track),
Position: int32(position),
}
@ -93,7 +93,7 @@ func (s *ScrobblerPlugin) Scrobble(ctx context.Context, userId string, sc scrobb
username := getUsernameFromContext(ctx)
input := capabilities.ScrobbleRequest{
Username: username,
Track: mediaFileToTrackInfo(&sc.MediaFile),
Track: mediaFileToTrackInfo(s.plugin, &sc.MediaFile),
Timestamp: sc.TimeStamp.Unix(),
}
@ -109,9 +109,11 @@ func getUsernameFromContext(ctx context.Context) string {
return ""
}
// mediaFileToTrackInfo converts a model.MediaFile to capabilities.TrackInfo
func mediaFileToTrackInfo(mf *model.MediaFile) capabilities.TrackInfo {
return capabilities.TrackInfo{
// mediaFileToTrackInfo converts a model.MediaFile to capabilities.TrackInfo.
// Path is populated only when the plugin is allowed filesystem access to the
// track's library.
func mediaFileToTrackInfo(p *plugin, mf *model.MediaFile) capabilities.TrackInfo {
ti := capabilities.TrackInfo{
ID: mf.ID,
Title: mf.Title,
Album: mf.Album,
@ -127,6 +129,10 @@ func mediaFileToTrackInfo(mf *model.MediaFile) capabilities.TrackInfo {
MBZReleaseGroupID: mf.MbzReleaseGroupID,
MBZReleaseTrackID: mf.MbzReleaseTrackID,
}
if p.hasLibraryFilesystemAccess(mf.LibraryID) {
ti.Path = mf.Path
}
return ti
}
// participantsToArtistRefs converts a ParticipantList to a slice of ArtistRef

View File

@ -240,6 +240,40 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() {
Expect(names).ToNot(ContainElement("test-metadata-agent"))
})
})
Describe("mediaFileToTrackInfo", func() {
var track *model.MediaFile
BeforeEach(func() {
track = &model.MediaFile{
ID: "track-1",
Title: "Test Song",
Path: "/music/test.flac",
LibraryID: 1,
}
})
fsManifest := &Manifest{
Permissions: &Permissions{
Library: &LibraryPermission{Filesystem: true},
},
}
It("includes Path when the plugin has filesystem access to the track's library", func() {
p := &plugin{manifest: fsManifest, libraries: newLibraryAccess([]int{1}, false)}
Expect(mediaFileToTrackInfo(p, track).Path).To(Equal("/music/test.flac"))
})
It("omits Path when the plugin lacks filesystem permission", func() {
p := &plugin{manifest: &Manifest{}, libraries: newLibraryAccess([]int{1}, false)}
Expect(mediaFileToTrackInfo(p, track).Path).To(BeEmpty())
})
It("omits Path when the track's library is not in the allowed set", func() {
p := &plugin{manifest: fsManifest, libraries: newLibraryAccess([]int{2}, false)}
Expect(mediaFileToTrackInfo(p, track).Path).To(BeEmpty())
})
})
})
var _ = Describe("mapScrobblerError", func() {

View File

@ -35,6 +35,8 @@
"rawTags": "Tags originais",
"bitDepth": "Profundidade de bits",
"sampleRate": "Taxa de amostragem",
"albumGain": "Ganho do álbum",
"trackGain": "Ganho da faixa",
"missing": "Ausente",
"libraryName": "Biblioteca",
"composer": "Compositor",

View File

@ -1,5 +1,5 @@
{
"languageName": "Pусский",
"languageName": "Русский",
"resources": {
"song": {
"name": "Трек |||| Треки |||| Треков",
@ -7,19 +7,19 @@
"albumArtist": "Исполнитель альбома",
"duration": "Длительность",
"trackNumber": "#",
"playCount": "Проигрывания",
"playCount": "Прослушивания",
"title": "Название трека",
"artist": "Исполнитель",
"artist": "Артист",
"album": "Альбом",
"path": "Путь",
"genre": "Жанр",
"compilation": "Сборник",
"year": "Год",
"size": "Размер",
"updatedAt": "Обновлен",
"updatedAt": "Обновлено",
"bitRate": "Битрейт",
"discSubtitle": "Название диска",
"starred": "Избранные",
"starred": "Избранное",
"comment": "Комментарий",
"rating": "Рейтинг",
"quality": "Качество",
@ -35,10 +35,12 @@
"rawTags": "Исходные теги",
"bitDepth": "Битовая глубина (Bit)",
"sampleRate": "Частота дискретизации (Hz)",
"albumGain": "Усиление альбома",
"trackGain": "Усиление трека",
"missing": "Поле отсутствует",
"libraryName": "Библиотека",
"composer": "Композитор",
"disc": ""
"disc": "Диск %{discNumber}"
},
"actions": {
"addToQueue": "В очередь",
@ -53,18 +55,18 @@
}
},
"album": {
"name": "Альбом |||| Альбомы",
"name": "Альбом |||| Альбомы |||| Альбомов",
"fields": {
"albumArtist": "Исполнитель альбома",
"artist": "Исполнитель",
"artist": "Артист",
"duration": "Длительность",
"songCount": "Треков",
"playCount": "Проигрывания",
"songCount": "Трек |||| Треки |||| Треков",
"playCount": "Прослушивания",
"name": "Название альбома",
"genre": "Жанр",
"compilation": "Сборник",
"year": "Год",
"updatedAt": "Обновлен",
"updatedAt": "Обновлено",
"comment": "Комментарий",
"rating": "Рейтинг",
"createdAt": "Дата добавления",
@ -99,17 +101,17 @@
"recentlyAdded": "Новые",
"recentlyPlayed": "Проигранные",
"mostPlayed": "Популярные",
"starred": "Избранные",
"starred": "Избранное",
"topRated": "Лучшие"
}
},
"artist": {
"name": "Исполнитель |||| Исполнители",
"name": "Артист |||| Артисты |||| Артистов",
"fields": {
"name": "Название исполнителя",
"albumCount": "Количество альбомов",
"songCount": "Количество треков",
"playCount": "Проигрывания",
"playCount": "Прослушивания",
"rating": "Рейтинг",
"genre": "Жанр",
"size": "Размер",
@ -117,29 +119,29 @@
"missing": "Поле отсутствует"
},
"roles": {
"albumartist": "Исполнитель альбома |||| Исполнители альбома",
"artist": "Исполнитель |||| Исполнители",
"composer": "Композитор |||| Композиторы",
"conductor": "Дирижёр |||| Дирижёры",
"lyricist": "Автор текста |||| Авторы текста",
"arranger": "Аранжировщик |||| Аранжировщики",
"producer": "Продюсер |||| Продюсеры",
"director": "Режиссёр |||| Режиссёры",
"engineer": "Инженер |||| Инженеры",
"mixer": "Звукоинженер |||| Звукоинженеры",
"remixer": "Ремиксер |||| Ремиксеры",
"djmixer": "DJ-миксер |||| DJ-миксеры",
"performer": "Исполнитель |||| Исполнители",
"maincredit": "Исполнитель альбома или Исполнитель |||| Исполнители альбома или Исполнители"
"albumartist": "Исполнитель альбома |||| Исполнители альбома |||| Исполнителей альбома",
"artist": "Артист |||| Артисты |||| Артистов",
"composer": "Композитор |||| Композиторы |||| Композиторов",
"conductor": "Дирижёр |||| Дирижёры |||| Дирижёров",
"lyricist": "Автор текста |||| Авторы текста |||| Авторов текста",
"arranger": "Аранжировщик |||| Аранжировщики |||| Аранжировщиков",
"producer": "Продюсер |||| Продюсеры |||| Продюсеров",
"director": "Режиссёр |||| Режиссёры |||| Режиссёров",
"engineer": "Инженер |||| Инженеры |||| Инженеров",
"mixer": "Звукоинженер |||| Звукоинженеры |||| Звукоинженеров",
"remixer": "Ремиксер |||| Ремиксеры |||| Ремиксеров",
"djmixer": "DJ-миксер |||| DJ-миксеры |||| DJ-миксеров",
"performer": "Исполнитель |||| Исполнители |||| Исполнителей",
"maincredit": "Исполнитель альбома или артист |||| Исполнители альбома или артисты |||| Исполнителей альбома или артистов"
},
"actions": {
"shuffle": "Смешать",
"shuffle": "Перемешать",
"radio": "Радио",
"topSongs": "Топовые треки"
}
},
"user": {
"name": "Пользователь |||| Пользователи",
"name": "Пользователь |||| Пользователи |||| Пользователей",
"fields": {
"userName": "Имя пользователя",
"isAdmin": "Администратор",
@ -175,9 +177,9 @@
}
},
"player": {
"name": "Плеер |||| Плееры",
"name": "Плеер |||| Плееры |||| Плееров",
"fields": {
"name": "Имя",
"name": "Название",
"transcodingId": "Транскодирование",
"maxBitRate": "Макс. битрейт",
"client": "Клиент",
@ -188,7 +190,7 @@
}
},
"transcoding": {
"name": "Транскодирование |||| Транскодирование",
"name": "Транскодирование |||| Транскодирование |||| Транскодирований",
"fields": {
"name": "Название",
"targetFormat": "Целевой формат",
@ -197,15 +199,15 @@
}
},
"playlist": {
"name": "Плейлист |||| Плейлисты",
"name": "Плейлист |||| Плейлисты |||| Плейлистов",
"fields": {
"name": "Название трека",
"name": "Название",
"duration": "Длительность",
"ownerName": "Владелец",
"public": "Публичный",
"updatedAt": "Обновлен",
"updatedAt": "Обновлено",
"createdAt": "Создан",
"songCount": "Треков",
"songCount": "Трек |||| Трека |||| Треков",
"comment": "Комментарий",
"sync": "Автоимпорт",
"path": "Импортировать из"
@ -218,7 +220,7 @@
"makePrivate": "Сделать личным",
"saveQueue": "Сохранить очередь в плейлист",
"searchOrCreate": "Поиск плейлистов или введите текст для создания новых...",
"pressEnterToCreate": "Нажмите Enter, чтобы создать новый список воспроизведения",
"pressEnterToCreate": "Нажмите Enter, чтобы создать новый плейлист",
"removeFromSelection": "Удалить из списка выделенных"
},
"message": {
@ -229,9 +231,9 @@
}
},
"radio": {
"name": "Радио |||| Радио",
"name": "Радио |||| Радио |||| Радио",
"fields": {
"name": "Имя",
"name": "Название",
"streamUrl": "Ссылка на поток",
"homePageUrl": "Домашняя страница",
"updatedAt": "Обновлено",
@ -242,7 +244,7 @@
}
},
"share": {
"name": "Общий доступ |||| Общий доступ",
"name": "Общий доступ |||| Общий доступ |||| Общий доступ",
"fields": {
"username": "Поделился",
"url": "Ссылка",
@ -253,15 +255,15 @@
"visitCount": "Количество посещений",
"format": "Формат",
"maxBitRate": "Макс. битрейт",
"updatedAt": "Обновлено в",
"updatedAt": "Обновлено",
"createdAt": "Создано",
"downloadable": "Разрешить загрузку?"
}
},
"missing": {
"name": "Файл отсутствует |||| Файлы отсутствуют",
"name": "Отсутствующий файл |||| Отсутствующие файлы |||| Отсутствующих файлов",
"fields": {
"path": "Место расположения",
"path": "Путь",
"size": "Размер",
"updatedAt": "Исчез",
"libraryName": "Библиотека"
@ -276,21 +278,21 @@
"empty": "Нет отсутствующих файлов"
},
"library": {
"name": "Библиотека |||| Библиотеки",
"name": "Библиотека |||| Библиотеки |||| Библиотек",
"fields": {
"name": "Имя",
"name": "Название",
"path": "Путь",
"remotePath": "Удаленный путь",
"lastScanAt": "Последнее сканирование",
"songCount": "Треки",
"albumCount": "Альбомы",
"artistCount": "Исполнители",
"artistCount": "Артисты",
"totalSongs": "Треки",
"totalAlbums": "Альбомы",
"totalArtists": "Исполнители",
"totalArtists": "Артисты",
"totalFolders": "Папки",
"totalFiles": "Файлов",
"totalMissingFiles": "Пропавших файлов",
"totalFiles": "Файлы",
"totalMissingFiles": "Отсутствующие файлы",
"totalSize": "Общий размер",
"totalDuration": "Длительность",
"defaultNewUsers": "По умолчанию для новых пользователей",
@ -319,7 +321,7 @@
"scanError": "Ошибка при запуске сканирования. Проверьте логи"
},
"validation": {
"nameRequired": "Имя библиотеки обязательно",
"nameRequired": "Название библиотеки обязательно",
"pathRequired": "Путь к библиотеке обязателен",
"pathNotDirectory": "Путь к библиотеке должен быть директорией",
"pathNotFound": "Путь к библиотеке не найден",
@ -333,14 +335,14 @@
}
},
"plugin": {
"name": "Плагин |||| Плагины",
"name": "Плагин |||| Плагины |||| Плагинов",
"fields": {
"id": "ID",
"name": "Имя",
"name": "Название",
"description": "Описание",
"version": "Версия",
"author": "Автор",
"website": "Вебсайт",
"website": "Веб-сайт",
"permissions": "Разрешения",
"enabled": "Включено",
"status": "Статус",
@ -348,26 +350,26 @@
"lastError": "Ошибка",
"hasError": "Ошибка",
"updatedAt": "Обновлено",
"createdAt": "Установленный",
"createdAt": "Дата установки",
"configKey": "Ключ",
"configValue": "Значение",
"allUsers": "Разрешить всем пользователям",
"selectedUsers": "Выбранные пользователи",
"allLibraries": "Разрешить доступ ко всем библиотекам",
"selectedLibraries": "Избранные библиотеки",
"allowWriteAccess": ""
"selectedLibraries": "Выбранные библиотеки",
"allowWriteAccess": "Разрешить запись"
},
"sections": {
"status": "Статус",
"info": "Информация о плагине",
"configuration": "Конфигурация",
"manifest": "Манифест",
"usersPermission": "Разрешение пользователей",
"libraryPermission": "Разрешение на использование библиотеки"
"usersPermission": "Права доступа пользователей",
"libraryPermission": "Права доступа к библиотекам"
},
"status": {
"enabled": "Включено",
"disabled": "Отключить"
"disabled": "Отключено"
},
"actions": {
"enable": "Включить",
@ -401,7 +403,7 @@
"requiredHosts": "Необходимые хосты",
"configValidationError": "Проверка конфигурации завершилась неудачей:",
"schemaRenderError": "Не удалось отобразить форму конфигурации. Возможно, схема плагина недействительна.",
"allowWriteAccessHelp": ""
"allowWriteAccessHelp": "Разрешить плагину изменять файлы в вашей библиотеке"
},
"placeholders": {
"configKey": "ключ",
@ -412,9 +414,9 @@
"ra": {
"auth": {
"welcome1": "Спасибо за установку Navidrome!",
"welcome2": "Для начала, создайте аккаунт Администратора",
"confirmPassword": "Подтвердить Пароль",
"buttonCreateAdmin": "Создать аккаунт Администратора",
"welcome2": "Для начала создайте аккаунт администратора",
"confirmPassword": "Подтвердите пароль",
"buttonCreateAdmin": "Создать аккаунт администратора",
"auth_check_error": "Пожалуйста, авторизуйтесь для продолжения работы",
"user_menu": "Профиль",
"username": "Имя пользователя",
@ -428,14 +430,14 @@
"invalidChars": "Пожалуйста, используйте только буквы и цифры",
"passwordDoesNotMatch": "Пароли не совпадают",
"required": "Обязательно для заполнения",
"minLength": "Минимальное кол-во символов %{min}",
"maxLength": "Максимальное кол-во символов %{max}",
"minValue": "Минимальное значение %{min}",
"maxValue": "Значение может быть %{max} или меньше",
"number": "Должно быть цифрой",
"minLength": "Минимальное количество символов: %{min}",
"maxLength": "Максимальное количество символов: %{max}",
"minValue": "Минимальное значение: %{min}",
"maxValue": "Максимальное значение: %{max}",
"number": "Должно быть числом",
"email": "Некорректный Email",
"oneOf": "Должно быть одним из: %{options}",
"regex": "Должно быть в формате (regexp): %{pattern}",
"regex": "Должно соответствовать формату: %{pattern}",
"unique": "Должно быть уникальным",
"url": "Должен быть действительный URL"
},
@ -443,7 +445,7 @@
"add_filter": "Фильтр",
"add": "Добавить",
"back": "Назад",
"bulk_actions": "1 выбран |||| %{smart_count} выбрано |||| %{smart_count} выбрано",
"bulk_actions": "1 выбран |||| %{smart_count} выбраны |||| %{smart_count} выбрано",
"cancel": "Отмена",
"clear_input_value": "Очистить",
"clone": "Дублировать",
@ -461,13 +463,13 @@
"show": "Просмотр",
"sort": "Сортировать",
"undo": "Отменить",
"expand": "Расширить",
"expand": "Развернуть",
"close": "Закрыть",
"open_menu": "Открыть меню",
"close_menu": "Закрыть меню",
"unselect": "Отменить выделение",
"unselect": "Снять выделение",
"skip": "Пропустить",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"bulk_actions_mobile": "1 |||| %{smart_count} |||| %{smart_count}",
"share": "Поделиться",
"download": "Скачать"
},
@ -481,7 +483,7 @@
"edit": "%{name} #%{id}",
"error": "Что-то пошло не так",
"list": "%{name}",
"loading": "Загрузка",
"loading": "Загрузка...",
"not_found": "Не найдено",
"show": "%{name} #%{id}",
"empty": "Нет %{name}.",
@ -493,13 +495,13 @@
"upload_single": "Перетащите файл для загрузки или щёлкните для выбора."
},
"image": {
"upload_several": "Перетащите картинки для загрузки или щёлкните для выбора.",
"upload_single": "Перетащите картинку для загрузки или щёлкните для выбора."
"upload_several": "Перетащите изображения для загрузки или щёлкните для выбора.",
"upload_single": "Перетащите изображение для загрузки или щёлкните для выбора."
},
"references": {
"all_missing": "Связанных данных не найдено.",
"many_missing": "Некоторые из связанных данных не доступны",
"single_missing": "Связанный объект не доступен"
"many_missing": "Некоторые из связанных данных недоступны",
"single_missing": "Связанный объект недоступен"
},
"password": {
"toggle_visible": "Скрыть пароль",
@ -507,45 +509,45 @@
}
},
"message": {
"about": "Справка",
"about": "О программе",
"are_you_sure": "Вы уверены?",
"bulk_delete_content": "Вы уверены, что хотите удалить %{name}? |||| Вы уверены, что хотите удалить объекты, кол-вом %{smart_count} ? |||| Вы уверены, что хотите удалить объекты, кол-вом %{smart_count} ?",
"bulk_delete_title": "Удалить %{name} |||| Удалить %{smart_count} %{name} |||| Удалить %{smart_count} %{name}",
"delete_content": "Вы уверены что хотите удалить этот объект",
"bulk_delete_content": "Вы уверены, что хотите удалить %{name}? |||| Удалить %{smart_count} объекта? |||| Удалить %{smart_count} объектов?",
"bulk_delete_title": "Удалить %{name} |||| Удалить %{smart_count} объекта |||| Удалить %{smart_count} объектов",
"delete_content": "Вы уверены, что хотите удалить этот объект?",
"delete_title": "Удалить %{name} #%{id}",
"details": "Описание",
"error": "При выполнении запроса возникла ошибка, и он не может быть завершен",
"invalid_form": "Форма заполнена неверно, проверьте, пожалуйста, ошибки",
"loading": "Идет загрузка, пожалуйста, немного подождите",
"details": "Подробности",
"error": "При выполнении запроса возникла ошибка",
"invalid_form": "Форма заполнена неверно, проверьте ошибки",
"loading": "Загрузка, пожалуйста, подождите...",
"no": "Нет",
"not_found": "Либо вы ввели неправильный URL, либо перешли по некорректной ссылке.",
"not_found": "Страница не найдена. Возможно, вы ввели неправильный URL.",
"yes": "Да",
"unsaved_changes": "Некоторые из ваших изменений не сохранены. Продолжить без сохранения?"
"unsaved_changes": "Есть несохраненные изменения. Продолжить без сохранения?"
},
"navigation": {
"no_results": "Результатов не найдено",
"no_more_results": "Страница %{page} выходит за пределы нумерации, попробуйте предыдущую",
"page_out_of_boundaries": "Страница %{page} выходит за пределы нумерации",
"no_more_results": "Страница %{page} выходит за пределы, попробуйте предыдущую",
"page_out_of_boundaries": "Страница %{page} выходит за пределы",
"page_out_from_end": "Невозможно переместиться дальше последней страницы",
"page_out_from_begin": "Номер страницы не может быть меньше 1",
"page_range_info": "%{offsetBegin}-%{offsetEnd} из %{total}",
"page_rows_per_page": "Строк на странице:",
"next": "Следующая",
"prev": "Предыдущая",
"skip_nav": "Перейти к содержанию"
"next": "Вперед",
"prev": "Назад",
"skip_nav": "Перейти к основному контенту"
},
"notification": {
"updated": "Элемент обновлен |||| %{smart_count} обновлено |||| %{smart_count} обновлено",
"updated": "Элемент обновлен |||| %{smart_count} элемента обновлены |||| %{smart_count} элементов обновлено",
"created": "Элемент создан",
"deleted": "Элемент удален |||| %{smart_count} удалено |||| %{smart_count} удалено",
"bad_item": "Неправильный элемент",
"deleted": "Элемент удален |||| %{smart_count} элемента удалены |||| %{smart_count} элементов удалено",
"bad_item": "Некорректный элемент",
"item_doesnt_exist": "Элемент не существует",
"http_error": "Ошибка сервера",
"data_provider_error": "Ошибка dataProvider, проверьте консоль",
"i18n_error": "Не удалось загрузить перевод для указанного языка",
"data_provider_error": "Ошибка поставщика данных, проверьте консоль",
"i18n_error": "Не удалось загрузить перевод",
"canceled": "Операция отменена",
"logged_out": "Ваша сессия завершена, попробуйте переподключиться/войти снова",
"new_version": "Доступна новая версия! Пожалуйста, обновите это окно."
"logged_out": "Сессия завершена, пожалуйста, войдите снова",
"new_version": "Доступна новая версия! Пожалуйста, обновите страницу."
},
"toggleFieldsMenu": {
"columnsToDisplay": "Отображение столбцов",
@ -556,42 +558,42 @@
},
"message": {
"note": "ПРИМЕЧАНИЕ",
"transcodingDisabled": "Изменение настроек транскодирования через веб интерфейс, отключено по соображениям безопасности. Если вы хотите изменить или добавить опции транскодирования, перезапустите сервер с опцией конфигурации %{config}.",
"transcodingEnabled": "Navidrome работает с настройками %{config}, позволяющими запускать команды с настройками транскодирования через веб интерфейс. В целях безопасности, мы рекомендуем отключить эту возможность.",
"songsAddedToPlaylist": "Один трек добавлен в плейлист |||| %{smart_count} треков добавлено в плейлист",
"transcodingDisabled": "Изменение настроек транскодирования через веб-интерфейс отключено по соображениям безопасности. Если вы хотите изменить или добавить опции транскодирования, перезапустите сервер с опцией конфигурации %{config}.",
"transcodingEnabled": "Navidrome работает с настройками %{config}, позволяющими запускать команды транскодирования через веб-интерфейс. В целях безопасности мы рекомендуем отключить эту возможность.",
"songsAddedToPlaylist": "Добавлен 1 трек |||| Добавлены %{smart_count} трека |||| Добавлено %{smart_count} треков",
"noPlaylistsAvailable": "Недоступно",
"delete_user_title": "Удалить пользователя '%{name}'",
"delete_user_content": "Вы уверены, что вы хотите удалить пользователя и все его данные (включая плейлисты и настройки)?",
"delete_user_content": "Вы уверены, что хотите удалить пользователя и все его данные (включая плейлисты и настройки)?",
"notifications_blocked": "Вы заблокировали уведомления для этой страницы в настройках вашего браузера",
"notifications_not_available": "Ваш браузер не поддерживает всплывающие уведомления",
"lastfmLinkSuccess": "Соединение с Last.fm установлено, скробблинг включен",
"lastfmLinkFailure": "Last.fm не может быть подключен",
"lastfmUnlinkSuccess": "Соединение с Last.fm удалено, скробблинг отключен",
"lastfmUnlinkFailure": "Соединение с Last.fm не может быть удалено",
"lastfmLinkFailure": "Не удалось подключиться к Last.fm",
"lastfmUnlinkSuccess": "Соединение с Last.fm разорвано, скробблинг отключен",
"lastfmUnlinkFailure": "Не удалось разорвать соединение с Last.fm",
"openIn": {
"lastfm": "Показать на Last.fm",
"musicbrainz": "Показать на MusicBrainz"
},
"lastfmLink": "Подробнее...",
"listenBrainzLinkSuccess": "ListenBrainz скробблинг успешно подключен для пользователя: %{user}",
"listenBrainzLinkFailure": "ListenBrainz не может быть связан:",
"listenBrainzLinkFailure": "Не удалось подключить ListenBrainz:",
"listenBrainzUnlinkSuccess": "ListenBrainz скробблинг отключен",
"listenBrainzUnlinkFailure": "ListenBrainz не удалось отключить",
"listenBrainzUnlinkFailure": "Не удалось отключить ListenBrainz",
"downloadOriginalFormat": "Скачать в оригинальном формате",
"shareOriginalFormat": "Поделиться в оригинальном формате",
"shareDialogTitle": "Поделиться %{resource} '%{name}'",
"shareBatchDialogTitle": "Поделиться 1 %{resource} |||| Поделиться %{smart_count} %{resource}",
"shareBatchDialogTitle": "Поделиться 1 %{resource} |||| Поделиться %{smart_count} %{resource} |||| Поделиться %{smart_count} %{resource}",
"shareSuccess": "URL скопирован в буфер обмена: %{url}",
"shareFailure": "Ошибка копирования URL-адреса %{url} в буфер обмена",
"shareFailure": "Ошибка копирования URL %{url} в буфер обмена",
"downloadDialogTitle": "Скачать %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Копировать в буфер обмена: Ctrl+C, Enter",
"remove_missing_title": "Удалить отсутствующие файлы?",
"remove_missing_content": "Вы уверены, что хотите удалить выбранные отсутствующие файлы из базы данных? Это навсегда удалит все ссылки на них, включая данные о прослушиваниях и рейтингах.",
"remove_all_missing_title": "Удалите все отсутствующие файлы",
"remove_all_missing_content": "Вы уверены, что хотите удалить все отсутствующие файлы из базы данных? Это навсегда удалит все упоминания о них, включая количество игр и рейтинг.",
"remove_all_missing_title": "Удалить все отсутствующие файлы",
"remove_all_missing_content": "Вы уверены, что хотите удалить все отсутствующие файлы из базы данных? Это навсегда удалит все упоминания о них, включая количество прослушиваний и рейтинг.",
"noSimilarSongsFound": "Похожих треков не найдено",
"noTopSongsFound": "Лучших треков не найдено",
"startingInstantMix": "Загрузка быстрого микса"
"startingInstantMix": "Загрузка быстрого микса..."
},
"menu": {
"library": "Библиотека",
@ -599,7 +601,7 @@
"version": "Версия",
"theme": "Тема",
"personal": {
"name": "Личные",
"name": "Личное",
"options": {
"theme": "Тема",
"language": "Язык",
@ -607,10 +609,10 @@
"desktop_notifications": "Уведомления на рабочем столе",
"lastfmScrobbling": "Скробблинг Last.fm",
"listenBrainzScrobbling": "Скробблинг ListenBrainz",
"replaygain": "ReplayGain режим",
"preAmp": "ReplayGain предусилитель (dB)",
"replaygain": "Режим ReplayGain",
"preAmp": "Предусилитель ReplayGain (дБ)",
"gain": {
"none": "Отключить",
"none": "Отключено",
"album": "Использовать усиление альбома",
"track": "Использовать усиление трека"
},
@ -620,16 +622,16 @@
"albumList": "Альбомы",
"about": "О программе",
"playlists": "Плейлисты",
"sharedPlaylists": "Поделиться плейлистом",
"sharedPlaylists": "Общие плейлисты",
"librarySelector": {
"allLibraries": "Все библиотеки (%{count})",
"multipleLibraries": "%{selected} из %{total} Библиотеки",
"multipleLibraries": "%{selected} из %{total} библиотек |||| %{selected} из %{total} библиотек |||| %{selected} из %{total} библиотек",
"selectLibraries": "Выбор библиотек",
"none": "Отсутствует"
}
},
"player": {
"playListsText": "Очередь Воспроизведения",
"playListsText": "Очередь воспроизведения",
"openText": "Открыть",
"closeText": "Закрыть",
"notContentText": "Нет музыки",
@ -643,19 +645,19 @@
"toggleMiniModeText": "Свернуть",
"destroyText": "Выключить",
"downloadText": "Скачать",
"removeAudioListsText": "Удалить список воспроизведения",
"removeAudioListsText": "Очистить очередь",
"clickToDeleteText": "Нажмите для удаления %{name}",
"emptyLyricText": "Без текста",
"emptyLyricText": "Текст песни отсутствует",
"playModeText": {
"order": "По порядку",
"orderLoop": "Повторять",
"singleLoop": "Повторить один раз",
"singleLoop": "Повторять один трек",
"shufflePlay": "Перемешать"
}
},
"about": {
"links": {
"homepage": "Главная",
"homepage": "Сайт проекта",
"source": "Исходный код",
"featureRequests": "Предложения",
"lastInsightsCollection": "Последний сбор данных",
@ -665,51 +667,51 @@
}
},
"tabs": {
"about": "О нас",
"about": "О программе",
"config": "Конфигурация"
},
"config": {
"configName": "Имя конфигурации",
"environmentVariable": "Переменная среды",
"configName": "Параметр",
"environmentVariable": "Переменная окружения",
"currentValue": "Текущее значение",
"configurationFile": "Файл конфигурации",
"exportToml": "Экспорт конфигурации (TOML)",
"exportSuccess": "Конфигурация экспортирована в буфер обмена в формате TOML",
"exportToml": "Экспорт в TOML",
"exportSuccess": "Конфигурация скопирована в буфер обмена в формате TOML",
"exportFailed": "Не удалось скопировать конфигурацию",
"devFlagsHeader": "Флаги разработки (могут быть изменены/удалены)",
"devFlagsComment": "Это экспериментальные настройки, которые могут быть удалены в будущих версиях.",
"downloadToml": "Скачать конфигурацию (TOML)"
"downloadToml": "Скачать TOML"
}
},
"activity": {
"title": "Действия",
"title": "Активность",
"totalScanned": "Всего просканировано папок",
"quickScan": "Быстрое сканирование",
"fullScan": "Полное сканирование",
"serverUptime": "Время работы сервера",
"serverDown": "Оффлайн",
"serverDown": "Офлайн",
"scanType": "Тип",
"status": "Ошибка сканирования",
"status": "Статус",
"elapsedTime": "Прошедшее время",
"selectiveScan": "Избирательный"
"selectiveScan": "Избирательное"
},
"help": {
"title": "Горячие клавиши Navidrome",
"hotkeys": {
"show_help": "Показать справку",
"toggle_menu": "Показать / скрыть боковое меню",
"toggle_play": "Играть / Пауза",
"toggle_menu": "Показать/скрыть боковое меню",
"toggle_play": "Играть/Пауза",
"prev_song": "Предыдущий трек",
"next_song": "Следующий трек",
"vol_up": "Увеличить громкость",
"vol_down": "Уменьшить громкость",
"toggle_love": "Добавить / удалить песню из избранного",
"toggle_love": "Добавить/удалить из избранного",
"current_song": "Перейти к текущему треку"
}
},
"nowPlaying": {
"title": "Сейчас играет",
"empty": "Ничего не играет",
"minutesAgo": "%{smart_count} минут назад |||| %{smart_count} минут назад"
"minutesAgo": "%{smart_count} минуту назад |||| %{smart_count} минуты назад |||| %{smart_count} минут назад"
}
}

View File

@ -172,6 +172,10 @@ func buildTestFS() storagetest.FakeFS {
"title": "TC MKA Opus", "track": 6, "suffix": "mka", "codec": "opus",
"bitrate": 128, "samplerate": 48000, "bitdepth": 0, "channels": 2, "duration": int64(220),
}),
"Test/Transcode Formats/07 - TC FLAC Multichannel.flac": file(tcBase, _t{
"title": "TC FLAC Multichannel", "track": 7, "suffix": "flac",
"bitrate": 4500, "samplerate": 48000, "bitdepth": 24, "channels": 6, "duration": int64(180),
}),
// _empty folder (directory with no audio)
"_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()},

View File

@ -117,7 +117,7 @@ var _ = Describe("Search Endpoints", func() {
Expect(resp.SearchResult3).ToNot(BeNil())
Expect(resp.SearchResult3.Artist).To(HaveLen(6))
Expect(resp.SearchResult3.Album).To(HaveLen(7))
Expect(resp.SearchResult3.Song).To(HaveLen(13))
Expect(resp.SearchResult3.Song).To(HaveLen(14))
})
It("finds across all entity types simultaneously", func() {

View File

@ -13,8 +13,9 @@ import (
var _ = Describe("stream.view (legacy streaming)", Ordered, func() {
var (
mp3TrackID string // Come Together (mp3, 320kbps)
flacTrackID string // TC FLAC Standard (flac, 900kbps)
mp3TrackID string // Come Together (mp3, 320kbps)
flacTrackID string // TC FLAC Standard (flac, 900kbps)
flacMultichTrackID string // TC FLAC Multichannel (flac, 6ch)
)
BeforeAll(func() {
@ -30,6 +31,8 @@ var _ = Describe("stream.view (legacy streaming)", Ordered, func() {
Expect(mp3TrackID).ToNot(BeEmpty())
flacTrackID = byTitle["TC FLAC Standard"]
Expect(flacTrackID).ToNot(BeEmpty())
flacMultichTrackID = byTitle["TC FLAC Multichannel"]
Expect(flacMultichTrackID).ToNot(BeEmpty())
})
Describe("raw / direct play", func() {
@ -101,6 +104,13 @@ var _ = Describe("stream.view (legacy streaming)", Ordered, func() {
Expect(streamerSpy.LastRequest.Format).To(Equal("mp3"))
Expect(streamerSpy.LastRequest.BitRate).To(Equal(128))
})
It("clamps multichannel FLAC to 2 channels when transcoding to mp3 (#5336)", func() {
w := doRawReq("stream", "id", flacMultichTrackID, "format", "mp3", "maxBitRate", "256")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(streamerSpy.LastRequest.Format).To(Equal("mp3"))
Expect(streamerSpy.LastRequest.Channels).To(Equal(2))
})
})
Describe("downsampling with maxBitRate only", func() {

View File

@ -114,13 +114,14 @@ const (
var _ = Describe("Transcode Endpoints", Ordered, func() {
// Track IDs resolved in BeforeAll
var (
mp3TrackID string // Come Together (mp3, 320kbps)
flacTrackID string // TC FLAC Standard (flac, 900kbps)
flacHiResTrackID string // TC FLAC HiRes (flac, 3000kbps)
alacTrackID string // TC ALAC Track (m4a, alac)
dsdTrackID string // TC DSD Track (dsf, dsd)
opusTrackID string // TC Opus Track (opus, 128kbps)
mkaOpusTrackID string // TC MKA Opus (mka, opus via codec tag)
mp3TrackID string // Come Together (mp3, 320kbps)
flacTrackID string // TC FLAC Standard (flac, 900kbps)
flacHiResTrackID string // TC FLAC HiRes (flac, 3000kbps)
flacMultichTrackID string // TC FLAC Multichannel (flac, 6ch)
alacTrackID string // TC ALAC Track (m4a, alac)
dsdTrackID string // TC DSD Track (dsf, dsd)
opusTrackID string // TC Opus Track (opus, 128kbps)
mkaOpusTrackID string // TC MKA Opus (mka, opus via codec tag)
)
BeforeAll(func() {
@ -140,6 +141,7 @@ var _ = Describe("Transcode Endpoints", Ordered, func() {
mp3TrackID = ensureGetTrackID("Come Together")
flacTrackID = ensureGetTrackID("TC FLAC Standard")
flacHiResTrackID = ensureGetTrackID("TC FLAC HiRes")
flacMultichTrackID = ensureGetTrackID("TC FLAC Multichannel")
alacTrackID = ensureGetTrackID("TC ALAC Track")
dsdTrackID = ensureGetTrackID("TC DSD Track")
opusTrackID = ensureGetTrackID("TC Opus Track")
@ -353,6 +355,19 @@ var _ = Describe("Transcode Endpoints", Ordered, func() {
// maxTranscodingAudioBitrate is 192000 bps = 192 kbps → response in bps
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000)))
})
It("clamps multichannel FLAC to 2 channels when transcoding to MP3 (#5336)", func() {
// mp3OnlyClient has no MaxAudioChannels set, so this exercises the
// codec-intrinsic clamp in core/stream/codec.go (codecMaxChannels).
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacMultichTrackID, "mediaType", "song")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TranscodeDecision).ToNot(BeNil())
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
Expect(resp.TranscodeDecision.SourceStream.AudioChannels).To(Equal(int32(6)))
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("mp3"))
Expect(resp.TranscodeDecision.TranscodeStream.AudioChannels).To(Equal(int32(2)))
})
})
Describe("response structure", func() {

View File

@ -13,14 +13,22 @@ import (
"path/filepath"
"strings"
"github.com/dustin/go-humanize"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
_ "golang.org/x/image/webp"
)
const maxImageSize = 10 << 20 // 10MB
func maxImageUploadSize() int64 {
if size, err := humanize.ParseBytes(conf.Server.MaxImageUploadSize); err == nil && size > 0 {
return int64(size)
}
size, _ := humanize.ParseBytes(consts.DefaultMaxImageUploadSize)
return int64(size)
}
func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool {
user, _ := request.UserFrom(r.Context())
@ -32,13 +40,14 @@ func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool {
}
func handleImageUpload(saveFn func(ctx context.Context, reader io.Reader, ext string) error) http.HandlerFunc {
maxImageSize := maxImageUploadSize()
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !checkImageUploadPermission(w, r) {
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxImageSize)
if err := r.ParseMultipartForm(maxImageSize / 2); err != nil {
if err := r.ParseMultipartForm(min(maxImageSize, 10<<20)); err != nil {
log.Error(ctx, "Error parsing multipart form", err)
http.Error(w, "file too large or invalid form", http.StatusBadRequest)
return

View File

@ -0,0 +1,34 @@
package nativeapi
import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("maxImageUploadSize", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
It("returns the configured size when valid", func() {
conf.Server.MaxImageUploadSize = "20MB"
Expect(maxImageUploadSize()).To(Equal(int64(20_000_000)))
})
It("returns the default size when config is empty", func() {
conf.Server.MaxImageUploadSize = ""
Expect(maxImageUploadSize()).To(Equal(int64(10_000_000)))
})
It("returns the default size when config is invalid", func() {
conf.Server.MaxImageUploadSize = "not-a-size"
Expect(maxImageUploadSize()).To(Equal(int64(10_000_000)))
})
It("parses raw byte values", func() {
conf.Server.MaxImageUploadSize = "52428800"
Expect(maxImageUploadSize()).To(Equal(int64(52_428_800)))
})
})

View File

@ -10,6 +10,7 @@ import (
"slices"
"sort"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
@ -17,6 +18,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/number"
"github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/utils/slice"
@ -215,7 +217,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child
child.Path = fakePath(mf)
}
child.DiscNumber = int32(mf.DiscNumber)
child.Created = &mf.BirthTime
child.Created = P(mf.BirthTime)
child.AlbumId = mf.AlbumID
child.ArtistId = mf.ArtistID
child.Type = "music"
@ -328,6 +330,20 @@ func childFromFolder(_ context.Context, folder model.Folder) responses.Child {
return child
}
// albumCreatedAt returns a best-effort timestamp for the album's `created`
// field, which is required by the OpenSubsonic spec but may be zero on legacy
// DB rows. Falls back to UpdatedAt → ImportedAt; can still return zero if all
// three are unset.
func albumCreatedAt(al model.Album) time.Time {
if !al.CreatedAt.IsZero() {
return al.CreatedAt
}
if !al.UpdatedAt.IsZero() {
return al.UpdatedAt
}
return al.ImportedAt
}
func childFromAlbum(ctx context.Context, al model.Album) responses.Child {
child := responses.Child{}
child.Id = al.ID
@ -340,7 +356,7 @@ func childFromAlbum(ctx context.Context, al model.Album) responses.Child {
child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear))
child.Genre = al.Genre
child.CoverArt = al.CoverArtID().String()
child.Created = &al.CreatedAt
child.Created = P(albumCreatedAt(al))
child.Parent = al.AlbumArtistID
child.ArtistId = al.AlbumArtistID
child.Duration = int32(al.Duration)
@ -402,9 +418,12 @@ func buildDiscSubtitles(a model.Album) []responses.DiscTitle {
return nil
}
var discTitles []responses.DiscTitle
// Hoist UpdatedAt to a single stack-local so &updatedAt doesn't force the
// whole model.Album parameter onto the heap.
updatedAt := a.UpdatedAt
for num, title := range a.Discs {
artID := model.NewArtworkID(model.KindDiscArtwork,
model.DiscArtworkID(a.ID, num), &a.UpdatedAt)
model.DiscArtworkID(a.ID, num), &updatedAt)
discTitles = append(discTitles, responses.DiscTitle{
Disc: int32(num),
Title: title,
@ -432,9 +451,7 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 {
dir.PlayCount = album.PlayCount
dir.Year = int32(cmp.Or(album.MaxOriginalYear, album.MaxYear))
dir.Genre = album.Genre
if !album.CreatedAt.IsZero() {
dir.Created = &album.CreatedAt
}
dir.Created = P(albumCreatedAt(album))
if album.Starred {
dir.Starred = album.StarredAt
}

View File

@ -625,6 +625,38 @@ var _ = Describe("helpers", func() {
})
})
Describe("buildAlbumID3 Created field", func() {
It("uses CreatedAt when set", func() {
t := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
al := model.Album{ID: "a1", Name: "A", CreatedAt: t}
dir := buildAlbumID3(ctx, al)
Expect(dir.Created).ToNot(BeNil())
Expect(*dir.Created).To(Equal(t))
})
It("falls back to UpdatedAt when CreatedAt is zero", func() {
updated := time.Date(2019, 5, 6, 7, 8, 9, 0, time.UTC)
al := model.Album{ID: "a2", Name: "A", UpdatedAt: updated}
dir := buildAlbumID3(ctx, al)
Expect(dir.Created).ToNot(BeNil())
Expect(*dir.Created).To(Equal(updated))
})
It("falls back to ImportedAt when CreatedAt and UpdatedAt are zero", func() {
imported := time.Date(2021, 8, 9, 10, 11, 12, 0, time.UTC)
al := model.Album{ID: "a3", Name: "A", ImportedAt: imported}
dir := buildAlbumID3(ctx, al)
Expect(dir.Created).ToNot(BeNil())
Expect(*dir.Created).To(Equal(imported))
})
It("never leaves Created nil even when all timestamps are zero", func() {
al := model.Album{ID: "a4", Name: "A"}
dir := buildAlbumID3(ctx, al)
Expect(dir.Created).ToNot(BeNil())
})
})
Describe("EnableAverageRating config", func() {
It("excludes averageRating when disabled", func() {
conf.Server.Subsonic.EnableAverageRating = false

View File

@ -22,6 +22,8 @@
"bitRate": "Bit rate",
"bitDepth": "Bit depth",
"sampleRate": "Sample rate",
"albumGain": "Album gain",
"trackGain": "Track gain",
"channels": "Channels",
"disc": "Disc %{discNumber}",
"discSubtitle": "Disc Subtitle",