mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-23 10:27:51 -05:00
Compare commits
21 Commits
feat/fts5-
...
transcodin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b33d831a1d | ||
|
|
3b1bd2c265 | ||
|
|
0c55c7ce89 | ||
|
|
fab2acfe36 | ||
|
|
22dba77509 | ||
|
|
5107492059 | ||
|
|
01b1fc90a9 | ||
|
|
4a50142dd6 | ||
|
|
e843b918b2 | ||
|
|
39e341e863 | ||
|
|
7ca0eade80 | ||
|
|
2e02e92cc4 | ||
|
|
216d0c6c6c | ||
|
|
c26cc0f5b9 | ||
|
|
4bb6802922 | ||
|
|
2e00479a8b | ||
|
|
07e2f699da | ||
|
|
ff57efa170 | ||
|
|
0658e1f824 | ||
|
|
a88ab9f16c | ||
|
|
b5621b9784 |
@@ -14,7 +14,7 @@ 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 CROSS_TAGLIB_VERSION="2.1.1-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 \
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// Options
|
||||
"INSTALL_NODE": "true",
|
||||
"NODE_VERSION": "v24",
|
||||
"CROSS_TAGLIB_VERSION": "2.2.0-1"
|
||||
"CROSS_TAGLIB_VERSION": "2.1.1-1"
|
||||
}
|
||||
},
|
||||
"workspaceMount": "",
|
||||
|
||||
4
.github/workflows/pipeline.yml
vendored
4
.github/workflows/pipeline.yml
vendored
@@ -14,7 +14,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CROSS_TAGLIB_VERSION: "2.2.0-1"
|
||||
CROSS_TAGLIB_VERSION: "2.1.1-2"
|
||||
CGO_CFLAGS_ALLOW: "--define-prefix"
|
||||
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
|
||||
|
||||
@@ -117,7 +117,7 @@ jobs:
|
||||
- name: Test
|
||||
run: |
|
||||
pkg-config --define-prefix --cflags --libs taglib # for debugging
|
||||
go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v
|
||||
go test -shuffle=on -tags netgo -race ./... -v
|
||||
|
||||
- name: Test ndpgen
|
||||
run: |
|
||||
|
||||
138
.github/workflows/push-translations.sh
vendored
138
.github/workflows/push-translations.sh
vendored
@@ -1,138 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
I18N_DIR=resources/i18n
|
||||
|
||||
# Normalize JSON for deterministic comparison:
|
||||
# remove empty/null attributes, sort keys alphabetically
|
||||
process_json() {
|
||||
jq 'walk(if type == "object" then with_entries(select(.value != null and .value != "" and .value != [] and .value != {})) | to_entries | sort_by(.key) | from_entries else . end)' "$1"
|
||||
}
|
||||
|
||||
# Get list of all languages configured in the POEditor project
|
||||
get_language_list() {
|
||||
curl -s -X POST https://api.poeditor.com/v2/languages/list \
|
||||
-d api_token="${POEDITOR_APIKEY}" \
|
||||
-d id="${POEDITOR_PROJECTID}"
|
||||
}
|
||||
|
||||
# Extract language name from the language list JSON given a language code
|
||||
get_language_name() {
|
||||
lang_code="$1"
|
||||
lang_list="$2"
|
||||
echo "$lang_list" | jq -r ".result.languages[] | select(.code == \"$lang_code\") | .name"
|
||||
}
|
||||
|
||||
# Extract language code from a file path (e.g., "resources/i18n/fr.json" -> "fr")
|
||||
get_lang_code() {
|
||||
filepath="$1"
|
||||
filename=$(basename "$filepath")
|
||||
echo "${filename%.*}"
|
||||
}
|
||||
|
||||
# Export the current translation for a language from POEditor (v2 API)
|
||||
export_language() {
|
||||
lang_code="$1"
|
||||
response=$(curl -s -X POST https://api.poeditor.com/v2/projects/export \
|
||||
-d api_token="${POEDITOR_APIKEY}" \
|
||||
-d id="${POEDITOR_PROJECTID}" \
|
||||
-d language="$lang_code" \
|
||||
-d type="key_value_json")
|
||||
|
||||
url=$(echo "$response" | jq -r '.result.url')
|
||||
if [ -z "$url" ] || [ "$url" = "null" ]; then
|
||||
echo "Failed to export $lang_code: $response" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "$url"
|
||||
}
|
||||
|
||||
# Flatten nested JSON to POEditor languages/update format.
|
||||
# POEditor uses term + context pairs, where:
|
||||
# term = the leaf key name
|
||||
# context = the parent path as "key1"."key2"."key3" (empty for root keys)
|
||||
flatten_to_poeditor() {
|
||||
jq -c '[paths(scalars) as $p |
|
||||
{
|
||||
"term": ($p | last | tostring),
|
||||
"context": (if ($p | length) > 1 then ($p[:-1] | map("\"" + tostring + "\"") | join(".")) else "" end),
|
||||
"translation": {"content": getpath($p)}
|
||||
}
|
||||
]' "$1"
|
||||
}
|
||||
|
||||
# Update translations for a language in POEditor via languages/update API
|
||||
update_language() {
|
||||
lang_code="$1"
|
||||
file="$2"
|
||||
|
||||
flatten_to_poeditor "$file" > /tmp/poeditor_data.json
|
||||
response=$(curl -s -X POST https://api.poeditor.com/v2/languages/update \
|
||||
-d api_token="${POEDITOR_APIKEY}" \
|
||||
-d id="${POEDITOR_PROJECTID}" \
|
||||
-d language="$lang_code" \
|
||||
--data-urlencode data@/tmp/poeditor_data.json)
|
||||
rm -f /tmp/poeditor_data.json
|
||||
|
||||
status=$(echo "$response" | jq -r '.response.status')
|
||||
if [ "$status" != "success" ]; then
|
||||
echo "Failed to update $lang_code: $response" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
parsed=$(echo "$response" | jq -r '.result.translations.parsed')
|
||||
added=$(echo "$response" | jq -r '.result.translations.added')
|
||||
updated=$(echo "$response" | jq -r '.result.translations.updated')
|
||||
echo " Translations - parsed: $parsed, added: $added, updated: $updated"
|
||||
}
|
||||
|
||||
# --- Main ---
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: $0 <file1> [file2] ..."
|
||||
echo "No files specified. Nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
lang_list=$(get_language_list)
|
||||
upload_count=0
|
||||
|
||||
for file in "$@"; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "Warning: File not found: $file, skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
lang_code=$(get_lang_code "$file")
|
||||
lang_name=$(get_language_name "$lang_code" "$lang_list")
|
||||
|
||||
if [ -z "$lang_name" ]; then
|
||||
echo "Warning: Language code '$lang_code' not found in POEditor, skipping $file"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Processing $lang_name ($lang_code)..."
|
||||
|
||||
# Export current state from POEditor
|
||||
url=$(export_language "$lang_code")
|
||||
curl -sSL "$url" -o poeditor_export.json
|
||||
|
||||
# Normalize both files for comparison
|
||||
process_json "$file" > local_normalized.json
|
||||
process_json poeditor_export.json > remote_normalized.json
|
||||
|
||||
# Compare normalized versions
|
||||
if diff -q local_normalized.json remote_normalized.json > /dev/null 2>&1; then
|
||||
echo " No differences, skipping"
|
||||
else
|
||||
echo " Differences found, updating POEditor..."
|
||||
update_language "$lang_code" "$file"
|
||||
upload_count=$((upload_count + 1))
|
||||
fi
|
||||
|
||||
rm -f poeditor_export.json local_normalized.json remote_normalized.json
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Done. Updated $upload_count translation(s) in POEditor."
|
||||
32
.github/workflows/push-translations.yml
vendored
32
.github/workflows/push-translations.yml
vendored
@@ -1,32 +0,0 @@
|
||||
name: POEditor export
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- 'resources/i18n/*.json'
|
||||
|
||||
jobs:
|
||||
push-translations:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'navidrome' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Detect changed translation files
|
||||
id: changed
|
||||
run: |
|
||||
CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD -- 'resources/i18n/*.json' | tr '\n' ' ')
|
||||
echo "files=$CHANGED_FILES" >> $GITHUB_OUTPUT
|
||||
echo "Changed translation files: $CHANGED_FILES"
|
||||
|
||||
- name: Push translations to POEditor
|
||||
if: ${{ steps.changed.outputs.files != '' }}
|
||||
env:
|
||||
POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }}
|
||||
POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
|
||||
run: |
|
||||
.github/workflows/push-translations.sh ${{ steps.changed.outputs.files }}
|
||||
@@ -2,7 +2,6 @@ version: "2"
|
||||
run:
|
||||
build-tags:
|
||||
- netgo
|
||||
- sqlite_fts5
|
||||
linters:
|
||||
enable:
|
||||
- asasalint
|
||||
|
||||
@@ -38,7 +38,7 @@ Before submitting a pull request, ensure that you go through the following:
|
||||
### Commit Conventions
|
||||
Each commit message must adhere to the following format:
|
||||
```
|
||||
<type>(scope): <description>
|
||||
<type>(scope): <description> - <issue number>
|
||||
|
||||
[optional body]
|
||||
```
|
||||
|
||||
@@ -28,7 +28,7 @@ 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
|
||||
ARG CROSS_TAGLIB_VERSION=2.1.1-2
|
||||
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
|
||||
|
||||
# wget in busybox can't follow redirects
|
||||
@@ -109,7 +109,7 @@ RUN --mount=type=bind,source=. \
|
||||
export EXT=".exe"
|
||||
fi
|
||||
|
||||
go build -tags=netgo,sqlite_fts5 -ldflags="${LD_EXTRA} -w -s \
|
||||
go build -tags=netgo -ldflags="${LD_EXTRA} -w -s \
|
||||
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
|
||||
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
|
||||
-o /out/navidrome${EXT} .
|
||||
|
||||
23
Makefile
23
Makefile
@@ -1,6 +1,5 @@
|
||||
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
|
||||
NODE_VERSION=$(shell cat .nvmrc)
|
||||
GO_BUILD_TAGS=netgo,sqlite_fts5
|
||||
|
||||
# Set global environment variables, required for most targets
|
||||
export CGO_CFLAGS_ALLOW=--define-prefix
|
||||
@@ -20,8 +19,8 @@ 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.0-1
|
||||
GOLANGCI_LINT_VERSION ?= v2.10.0
|
||||
CROSS_TAGLIB_VERSION ?= 2.1.1-2
|
||||
GOLANGCI_LINT_VERSION ?= v2.8.0
|
||||
|
||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||
|
||||
@@ -47,12 +46,12 @@ stop: ##@Development Stop development servers (UI and backend)
|
||||
.PHONY: stop
|
||||
|
||||
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
|
||||
go tool ginkgo watch -tags=$(GO_BUILD_TAGS) -notify ./...
|
||||
go tool ginkgo watch -tags=netgo -notify ./...
|
||||
.PHONY: watch
|
||||
|
||||
PKG ?= ./...
|
||||
test: ##@Development Run Go tests. Use PKG variable to specify packages to test, e.g. make test PKG=./server
|
||||
go test -tags $(GO_BUILD_TAGS) $(PKG)
|
||||
go test -tags netgo $(PKG)
|
||||
.PHONY: test
|
||||
|
||||
test-ndpgen: ##@Development Run tests for ndpgen plugin
|
||||
@@ -63,7 +62,7 @@ testall: test test-ndpgen test-i18n test-js ##@Development Run Go and JS tests
|
||||
.PHONY: testall
|
||||
|
||||
test-race: ##@Development Run Go tests with race detector
|
||||
go test -tags $(GO_BUILD_TAGS) -race -shuffle=on $(PKG)
|
||||
go test -tags netgo -race -shuffle=on $(PKG)
|
||||
.PHONY: test-race
|
||||
|
||||
test-js: ##@Development Run JS tests
|
||||
@@ -109,7 +108,7 @@ format: ##@Development Format code
|
||||
.PHONY: format
|
||||
|
||||
wire: check_go_env ##@Development Update Dependency Injection
|
||||
go tool wire gen -tags=$(GO_BUILD_TAGS) ./...
|
||||
go tool wire gen -tags=netgo ./...
|
||||
.PHONY: wire
|
||||
|
||||
gen: check_go_env ##@Development Run go generate for code generation
|
||||
@@ -145,14 +144,14 @@ setup-git: ##@Development Setup Git hooks (pre-commit and pre-push)
|
||||
.PHONY: setup-git
|
||||
|
||||
build: check_go_env buildjs ##@Build Build the project
|
||||
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=$(GO_BUILD_TAGS)
|
||||
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo
|
||||
.PHONY: build
|
||||
|
||||
buildall: deprecated build
|
||||
.PHONY: buildall
|
||||
|
||||
debug-build: check_go_env buildjs ##@Build Build the project (with remote debug on)
|
||||
go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=$(GO_BUILD_TAGS)
|
||||
go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo
|
||||
.PHONY: debug-build
|
||||
|
||||
buildjs: check_node_env ui/build/index.html ##@Build Build only frontend
|
||||
@@ -202,8 +201,8 @@ docker-msi: ##@Cross_Compilation Build MSI installer for Windows
|
||||
@du -h binaries/msi/*.msi
|
||||
.PHONY: docker-msi
|
||||
|
||||
docker-run: ##@Development Run a Navidrome Docker image. Usage: make docker-run tag=<tag>
|
||||
@if [ -z "$(tag)" ]; then echo "Usage: make docker-run tag=<tag>"; exit 1; fi
|
||||
run-docker: ##@Development Run a Navidrome Docker image. Usage: make run-docker tag=<tag>
|
||||
@if [ -z "$(tag)" ]; then echo "Usage: make run-docker tag=<tag>"; exit 1; fi
|
||||
@TAG_DIR="tmp/$$(echo '$(tag)' | tr '/:' '_')"; mkdir -p "$$TAG_DIR"; \
|
||||
VOLUMES="-v $(PWD)/$$TAG_DIR:/data"; \
|
||||
if [ -f navidrome.toml ]; then \
|
||||
@@ -214,7 +213,7 @@ docker-run: ##@Development Run a Navidrome Docker image. Usage: make docker-run
|
||||
fi; \
|
||||
fi; \
|
||||
echo "Running: docker run --rm -p 4533:4533 $$VOLUMES $(tag)"; docker run --rm -p 4533:4533 $$VOLUMES $(tag)
|
||||
.PHONY: docker-run
|
||||
.PHONY: run-docker
|
||||
|
||||
package: docker-build ##@Cross_Compilation Create binaries and packages for ALL supported platforms
|
||||
@if [ -z `which goreleaser` ]; then echo "Please install goreleaser first: https://goreleaser.com/install/"; exit 1; fi
|
||||
|
||||
@@ -65,7 +65,7 @@ func (c *client) getJWT(ctx context.Context) (string, error) {
|
||||
}
|
||||
|
||||
type authResponse struct {
|
||||
JWT string `json:"jwt"` //nolint:gosec
|
||||
JWT string `json:"jwt"`
|
||||
}
|
||||
|
||||
var result authResponse
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"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"
|
||||
@@ -44,7 +43,7 @@ func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
|
||||
}
|
||||
|
||||
func (e extractor) Version() string {
|
||||
return "2.2 WASM"
|
||||
return "go-taglib (TagLib 2.1.1 WASM)"
|
||||
}
|
||||
|
||||
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
|
||||
@@ -66,6 +65,7 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
|
||||
Channels: int(props.Channels),
|
||||
SampleRate: int(props.SampleRate),
|
||||
BitDepth: int(props.BitsPerSample),
|
||||
Codec: props.Codec,
|
||||
}
|
||||
|
||||
// Convert normalized tags to lowercase keys (go-taglib returns UPPERCASE keys)
|
||||
@@ -280,7 +280,4 @@ func init() {
|
||||
local.RegisterExtractor("taglib", func(fsys fs.FS, baseDir string) local.Extractor {
|
||||
return &extractor{fsys}
|
||||
})
|
||||
conf.AddHook(func() {
|
||||
log.Debug("go-taglib version", "version", extractor{}.Version())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("An error occurred while authorizing with Last.fm. \n\nRequest ID: " + middleware.GetReqID(ctx))) //nolint:gosec
|
||||
_, _ = w.Write([]byte("An error occurred while authorizing with Last.fm. \n\nRequest ID: " + middleware.GetReqID(ctx)))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ type listenBrainzResponse struct {
|
||||
}
|
||||
|
||||
type listenBrainzRequest struct {
|
||||
ApiKey string //nolint:gosec
|
||||
ApiKey string
|
||||
Body listenBrainzRequestBody
|
||||
}
|
||||
|
||||
|
||||
@@ -196,8 +196,7 @@ func runInitialScan(ctx context.Context) func() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup
|
||||
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
|
||||
scanNeeded := conf.Server.Scanner.ScanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
|
||||
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
|
||||
if scanNeeded {
|
||||
s := CreateScanner(ctx)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -74,7 +74,7 @@ func runScanner(ctx context.Context) {
|
||||
sqlDB := db.Db()
|
||||
defer db.Db().Close()
|
||||
ds := persistence.New(sqlDB)
|
||||
pls := playlists.NewPlaylists(ds)
|
||||
pls := core.NewPlaylists(ds)
|
||||
|
||||
// Parse targets from command line or file
|
||||
var scanTargets []model.ScanTarget
|
||||
|
||||
@@ -18,8 +18,8 @@ import (
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/transcode"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
@@ -62,7 +62,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
share := core.NewShare(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
insights := metrics.GetInstance(dataStore)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
@@ -73,12 +73,12 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
|
||||
user := core.NewUser(dataStore, manager)
|
||||
maintenance := core.NewMaintenance(dataStore)
|
||||
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager)
|
||||
router := nativeapi.New(dataStore, share, playlists, insights, library, user, maintenance, manager)
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -99,11 +99,12 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
players := core.NewPlayers(dataStore)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics)
|
||||
decider := transcode.NewDecider(dataStore)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics, decider)
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -166,8 +167,8 @@ func CreateScanner(ctx context.Context) model.Scanner {
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
return modelScanner
|
||||
}
|
||||
|
||||
@@ -183,8 +184,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||
return watcher
|
||||
}
|
||||
|
||||
4
conf/buildtags/buildtags.go
Normal file
4
conf/buildtags/buildtags.go
Normal file
@@ -0,0 +1,4 @@
|
||||
package buildtags
|
||||
|
||||
// This file is left intentionally empty. It is used to make sure the package is not empty, in the case all
|
||||
// required build tags are disabled.
|
||||
@@ -1,6 +0,0 @@
|
||||
// Package buildtags provides compile-time enforcement of required build tags.
|
||||
//
|
||||
// Each file in this package is guarded by a build constraint and exports a variable
|
||||
// that main.go references. If a required tag is missing during compilation, the build
|
||||
// fails with an "undefined" error, directing the developer to use `make build`.
|
||||
package buildtags
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
package buildtags
|
||||
|
||||
// The `netgo` tag is required when compiling the project. See https://github.com/navidrome/navidrome/issues/700
|
||||
// NOTICE: This file was created to force the inclusion of the `netgo` tag when compiling the project.
|
||||
// If the tag is not included, the compilation will fail because this variable won't be defined, and the `main.go`
|
||||
// file requires it.
|
||||
|
||||
// Why this tag is required? See https://github.com/navidrome/navidrome/issues/700
|
||||
|
||||
var NETGO = true
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
//go:build sqlite_fts5
|
||||
|
||||
package buildtags
|
||||
|
||||
// FTS5 is required for full-text search. Without this tag, the SQLite driver
|
||||
// won't include FTS5 support, causing runtime failures on migrations and search queries.
|
||||
|
||||
var SQLITE_FTS5 = true
|
||||
@@ -58,7 +58,7 @@ type configOptions struct {
|
||||
SmartPlaylistRefreshDelay time.Duration
|
||||
AutoTranscodeDownload bool
|
||||
DefaultDownsamplingFormat string
|
||||
Search searchOptions `json:",omitzero"`
|
||||
SearchFullString bool
|
||||
SimilarSongsMatchThreshold int
|
||||
RecentlyAddedByModTime bool
|
||||
PreferSortTags bool
|
||||
@@ -82,7 +82,6 @@ type configOptions struct {
|
||||
DefaultTheme string
|
||||
DefaultLanguage string
|
||||
DefaultUIVolume int
|
||||
UISearchDebounceMs int
|
||||
EnableReplayGain bool
|
||||
EnableCoverAnimation bool
|
||||
EnableNowPlaying bool
|
||||
@@ -173,8 +172,8 @@ type TagConf struct {
|
||||
|
||||
type lastfmOptions struct {
|
||||
Enabled bool
|
||||
ApiKey string //nolint:gosec
|
||||
Secret string //nolint:gosec
|
||||
ApiKey string
|
||||
Secret string
|
||||
Language string
|
||||
ScrobbleFirstArtistOnly bool
|
||||
|
||||
@@ -184,7 +183,7 @@ type lastfmOptions struct {
|
||||
|
||||
type spotifyOptions struct {
|
||||
ID string
|
||||
Secret string //nolint:gosec
|
||||
Secret string
|
||||
}
|
||||
|
||||
type deezerOptions struct {
|
||||
@@ -209,7 +208,7 @@ type httpHeaderOptions struct {
|
||||
type prometheusOptions struct {
|
||||
Enabled bool
|
||||
MetricsPath string
|
||||
Password string //nolint:gosec
|
||||
Password string
|
||||
}
|
||||
|
||||
type AudioDeviceDefinition []string
|
||||
@@ -252,11 +251,6 @@ type extAuthOptions struct {
|
||||
UserHeader string
|
||||
}
|
||||
|
||||
type searchOptions struct {
|
||||
Backend string
|
||||
FullString bool
|
||||
}
|
||||
|
||||
var (
|
||||
Server = &configOptions{}
|
||||
hooks []func()
|
||||
@@ -350,8 +344,6 @@ func Load(noConfigDump bool) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
Server.Search.Backend = normalizeSearchBackend(Server.Search.Backend)
|
||||
|
||||
if Server.BaseURL != "" {
|
||||
u, err := url.Parse(Server.BaseURL)
|
||||
if err != nil {
|
||||
@@ -400,7 +392,6 @@ func Load(noConfigDump bool) {
|
||||
logDeprecatedOptions("Scanner.GenreSeparators", "")
|
||||
logDeprecatedOptions("Scanner.GroupAlbumReleases", "")
|
||||
logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
|
||||
logDeprecatedOptions("SearchFullString", "Search.FullString")
|
||||
logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
||||
logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
||||
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||
@@ -548,17 +539,6 @@ func validateSchedule(schedule, field string) (string, error) {
|
||||
return schedule, err
|
||||
}
|
||||
|
||||
func normalizeSearchBackend(value string) string {
|
||||
v := strings.ToLower(strings.TrimSpace(value))
|
||||
switch v {
|
||||
case "fts", "legacy":
|
||||
return v
|
||||
default:
|
||||
log.Error("Invalid Search.Backend value, falling back to 'fts'", "value", value)
|
||||
return "fts"
|
||||
}
|
||||
}
|
||||
|
||||
// AddHook is used to register initialization code that should run as soon as the config is loaded
|
||||
func AddHook(hook func()) {
|
||||
hooks = append(hooks, hook)
|
||||
@@ -605,8 +585,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("enablemediafilecoverart", true)
|
||||
viper.SetDefault("autotranscodedownload", false)
|
||||
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
|
||||
viper.SetDefault("search.fullstring", false)
|
||||
viper.SetDefault("search.backend", "fts")
|
||||
viper.SetDefault("searchfullstring", false)
|
||||
viper.SetDefault("similarsongsmatchthreshold", 85)
|
||||
viper.SetDefault("recentlyaddedbymodtime", false)
|
||||
viper.SetDefault("prefersorttags", false)
|
||||
@@ -625,7 +604,6 @@ func setViperDefaults() {
|
||||
viper.SetDefault("defaulttheme", "Dark")
|
||||
viper.SetDefault("defaultlanguage", "")
|
||||
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
|
||||
viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs)
|
||||
viper.SetDefault("enablereplaygain", true)
|
||||
viper.SetDefault("enablecoveranimation", true)
|
||||
viper.SetDefault("enablenowplaying", true)
|
||||
@@ -770,7 +748,7 @@ func getConfigFile(cfgFile string) string {
|
||||
}
|
||||
cfgFile = os.Getenv("ND_CONFIGFILE")
|
||||
if cfgFile != "" {
|
||||
if _, err := os.Stat(cfgFile); err == nil { //nolint:gosec
|
||||
if _, err := os.Stat(cfgFile); err == nil {
|
||||
return cfgFile
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,20 +52,6 @@ var _ = Describe("Configuration", func() {
|
||||
})
|
||||
})
|
||||
|
||||
DescribeTable("NormalizeSearchBackend",
|
||||
func(input, expected string) {
|
||||
Expect(conf.NormalizeSearchBackend(input)).To(Equal(expected))
|
||||
},
|
||||
Entry("accepts 'fts'", "fts", "fts"),
|
||||
Entry("accepts 'legacy'", "legacy", "legacy"),
|
||||
Entry("normalizes 'FTS' to lowercase", "FTS", "fts"),
|
||||
Entry("normalizes 'Legacy' to lowercase", "Legacy", "legacy"),
|
||||
Entry("trims whitespace", " fts ", "fts"),
|
||||
Entry("falls back to 'fts' for 'fts5'", "fts5", "fts"),
|
||||
Entry("falls back to 'fts' for unrecognized values", "invalid", "fts"),
|
||||
Entry("falls back to 'fts' for empty string", "", "fts"),
|
||||
)
|
||||
|
||||
DescribeTable("should load configuration from",
|
||||
func(format string) {
|
||||
filename := filepath.Join("testdata", "cfg."+format)
|
||||
|
||||
@@ -7,5 +7,3 @@ func ResetConf() {
|
||||
var SetViperDefaults = setViperDefaults
|
||||
|
||||
var ParseLanguages = parseLanguages
|
||||
|
||||
var NormalizeSearchBackend = normalizeSearchBackend
|
||||
|
||||
@@ -66,12 +66,11 @@ const (
|
||||
I18nFolder = "i18n"
|
||||
ScanIgnoreFile = ".ndignore"
|
||||
|
||||
PlaceholderArtistArt = "artist-placeholder.webp"
|
||||
PlaceholderAlbumArt = "album-placeholder.webp"
|
||||
PlaceholderAvatar = "logo-192x192.png"
|
||||
UICoverArtSize = 300
|
||||
DefaultUIVolume = 100
|
||||
DefaultUISearchDebounceMs = 200
|
||||
PlaceholderArtistArt = "artist-placeholder.webp"
|
||||
PlaceholderAlbumArt = "album-placeholder.webp"
|
||||
PlaceholderAvatar = "logo-192x192.png"
|
||||
UICoverArtSize = 300
|
||||
DefaultUIVolume = 100
|
||||
|
||||
DefaultHttpClientTimeOut = 10 * time.Second
|
||||
|
||||
@@ -152,7 +151,13 @@ var (
|
||||
Name: "aac audio",
|
||||
TargetFormat: "aac",
|
||||
DefaultBitRate: 256,
|
||||
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
|
||||
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -",
|
||||
},
|
||||
{
|
||||
Name: "flac audio",
|
||||
TargetFormat: "flac",
|
||||
DefaultBitRate: 0,
|
||||
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -176,7 +176,7 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
||||
|
||||
var r io.ReadCloser
|
||||
if format != "raw" && format != "" {
|
||||
r, err = a.ms.DoStream(ctx, &mf, format, bitrate, 0)
|
||||
r, err = a.ms.DoStream(ctx, &mf, StreamRequest{Format: format, BitRate: bitrate})
|
||||
} else {
|
||||
r, err = os.Open(path)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ var _ = Describe("Archiver", func() {
|
||||
}}).Return(mfs, nil)
|
||||
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, core.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
|
||||
@@ -73,7 +73,7 @@ var _ = Describe("Archiver", func() {
|
||||
}}).Return(mfs, nil)
|
||||
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, core.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out)
|
||||
@@ -104,7 +104,7 @@ var _ = Describe("Archiver", func() {
|
||||
}
|
||||
|
||||
sh.On("Load", mock.Anything, "1").Return(share, nil)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, core.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipShare(context.Background(), "1", out)
|
||||
@@ -136,7 +136,7 @@ var _ = Describe("Archiver", func() {
|
||||
plRepo := &mockPlaylistRepository{}
|
||||
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
|
||||
ds.On("Playlist", mock.Anything).Return(plRepo)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, core.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)
|
||||
@@ -217,8 +217,8 @@ type mockMediaStreamer struct {
|
||||
core.MediaStreamer
|
||||
}
|
||||
|
||||
func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*core.Stream, error) {
|
||||
args := m.Called(ctx, mf, reqFormat, reqBitRate, reqOffset)
|
||||
func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, req core.StreamRequest) (*core.Stream, error) {
|
||||
args := m.Called(ctx, mf, req)
|
||||
if args.Error(1) != nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, err
|
||||
hc := http.Client{Timeout: 5 * time.Second}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil)
|
||||
req.Header.Set("User-Agent", consts.HTTPUserAgent)
|
||||
resp, err := hc.Do(req) //nolint:gosec
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
@@ -12,11 +12,24 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
// TranscodeOptions contains all parameters for a transcoding operation.
|
||||
type TranscodeOptions struct {
|
||||
Command string // DB command template (used to detect custom vs default)
|
||||
Format string // Target format (mp3, opus, aac, flac)
|
||||
FilePath string
|
||||
BitRate int // kbps, 0 = codec default
|
||||
SampleRate int // 0 = no constraint
|
||||
Channels int // 0 = no constraint
|
||||
BitDepth int // 0 = no constraint; valid values: 16, 24, 32
|
||||
Offset int // seconds
|
||||
}
|
||||
|
||||
type FFmpeg interface {
|
||||
Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error)
|
||||
Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error)
|
||||
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
|
||||
Probe(ctx context.Context, files []string) (string, error)
|
||||
CmdPath() (string, error)
|
||||
@@ -35,15 +48,19 @@ const (
|
||||
|
||||
type ffmpeg struct{}
|
||||
|
||||
func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error) {
|
||||
func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error) {
|
||||
if _, err := ffmpegCmd(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// First make sure the file exists
|
||||
if err := fileExists(path); err != nil {
|
||||
if err := fileExists(opts.FilePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := createFFmpegCommand(command, path, maxBitRate, offset)
|
||||
var args []string
|
||||
if isDefaultCommand(opts.Format, opts.Command) {
|
||||
args = buildDynamicArgs(opts)
|
||||
} else {
|
||||
args = buildTemplateArgs(opts)
|
||||
}
|
||||
return e.start(ctx, args)
|
||||
}
|
||||
|
||||
@@ -51,7 +68,6 @@ func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser,
|
||||
if _, err := ffmpegCmd(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// First make sure the file exists
|
||||
if err := fileExists(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -156,6 +172,139 @@ func (j *ffCmd) wait() {
|
||||
_ = j.out.Close()
|
||||
}
|
||||
|
||||
// formatCodecMap maps target format to ffmpeg codec flag.
|
||||
var formatCodecMap = map[string]string{
|
||||
"mp3": "libmp3lame",
|
||||
"opus": "libopus",
|
||||
"aac": "aac",
|
||||
"flac": "flac",
|
||||
}
|
||||
|
||||
// formatOutputMap maps target format to ffmpeg output format flag (-f).
|
||||
var formatOutputMap = map[string]string{
|
||||
"mp3": "mp3",
|
||||
"opus": "opus",
|
||||
"aac": "ipod",
|
||||
"flac": "flac",
|
||||
}
|
||||
|
||||
// defaultCommands is used to detect whether a user has customized their transcoding command.
|
||||
var defaultCommands = func() map[string]string {
|
||||
m := make(map[string]string, len(consts.DefaultTranscodings))
|
||||
for _, t := range consts.DefaultTranscodings {
|
||||
m[t.TargetFormat] = t.Command
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// isDefaultCommand returns true if the command matches the known default for this format.
|
||||
func isDefaultCommand(format, command string) bool {
|
||||
return defaultCommands[format] == command
|
||||
}
|
||||
|
||||
// buildDynamicArgs programmatically constructs ffmpeg arguments for known formats,
|
||||
// including all transcoding parameters (bitrate, sample rate, channels).
|
||||
func buildDynamicArgs(opts TranscodeOptions) []string {
|
||||
cmdPath, _ := ffmpegCmd()
|
||||
args := []string{cmdPath, "-i", opts.FilePath}
|
||||
|
||||
if opts.Offset > 0 {
|
||||
args = append(args, "-ss", strconv.Itoa(opts.Offset))
|
||||
}
|
||||
|
||||
args = append(args, "-map", "0:a:0")
|
||||
|
||||
if codec, ok := formatCodecMap[opts.Format]; ok {
|
||||
args = append(args, "-c:a", codec)
|
||||
}
|
||||
|
||||
if opts.BitRate > 0 {
|
||||
args = append(args, "-b:a", strconv.Itoa(opts.BitRate)+"k")
|
||||
}
|
||||
if opts.SampleRate > 0 {
|
||||
args = append(args, "-ar", strconv.Itoa(opts.SampleRate))
|
||||
}
|
||||
if opts.Channels > 0 {
|
||||
args = append(args, "-ac", strconv.Itoa(opts.Channels))
|
||||
}
|
||||
// Only pass -sample_fmt for lossless output formats where bit depth matters.
|
||||
// Lossy codecs (mp3, aac, opus) handle sample format conversion internally,
|
||||
// and passing interleaved formats like "s16" causes silent failures.
|
||||
if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) {
|
||||
args = append(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth))
|
||||
}
|
||||
|
||||
args = append(args, "-v", "0")
|
||||
|
||||
if outputFmt, ok := formatOutputMap[opts.Format]; ok {
|
||||
args = append(args, "-f", outputFmt)
|
||||
}
|
||||
|
||||
// For AAC in MP4 container, enable fragmented MP4 for pipe-safe streaming
|
||||
if opts.Format == "aac" {
|
||||
args = append(args, "-movflags", "frag_keyframe+empty_moov")
|
||||
}
|
||||
|
||||
args = append(args, "-")
|
||||
return args
|
||||
}
|
||||
|
||||
// buildTemplateArgs handles user-customized command templates, with dynamic injection
|
||||
// of sample rate and channels when the template doesn't already include them.
|
||||
func buildTemplateArgs(opts TranscodeOptions) []string {
|
||||
args := createFFmpegCommand(opts.Command, opts.FilePath, opts.BitRate, opts.Offset)
|
||||
|
||||
// Dynamically inject -ar, -ac, and -sample_fmt for custom templates that don't include them
|
||||
if opts.SampleRate > 0 {
|
||||
args = injectBeforeOutput(args, "-ar", strconv.Itoa(opts.SampleRate))
|
||||
}
|
||||
if opts.Channels > 0 {
|
||||
args = injectBeforeOutput(args, "-ac", strconv.Itoa(opts.Channels))
|
||||
}
|
||||
if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) {
|
||||
args = injectBeforeOutput(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth))
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// injectBeforeOutput inserts a flag and value before the trailing "-" (stdout output).
|
||||
func injectBeforeOutput(args []string, flag, value string) []string {
|
||||
if len(args) > 0 && args[len(args)-1] == "-" {
|
||||
result := make([]string, 0, len(args)+2)
|
||||
result = append(result, args[:len(args)-1]...)
|
||||
result = append(result, flag, value, "-")
|
||||
return result
|
||||
}
|
||||
return append(args, flag, value)
|
||||
}
|
||||
|
||||
// isLosslessOutputFormat returns true if the format is a lossless audio format
|
||||
// where preserving bit depth via -sample_fmt is meaningful.
|
||||
// Note: this covers only formats ffmpeg can produce as output. For the full set of
|
||||
// lossless formats used in transcoding decisions, see core/transcode/codec.go:isLosslessFormat.
|
||||
func isLosslessOutputFormat(format string) bool {
|
||||
switch strings.ToLower(format) {
|
||||
case "flac", "alac", "wav", "aiff":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// bitDepthToSampleFmt converts a bit depth value to the ffmpeg sample_fmt string.
|
||||
// FLAC only supports s16 and s32; for 24-bit sources, s32 is the correct format
|
||||
// (ffmpeg packs 24-bit samples into 32-bit containers).
|
||||
func bitDepthToSampleFmt(bitDepth int) string {
|
||||
switch bitDepth {
|
||||
case 16:
|
||||
return "s16"
|
||||
case 32:
|
||||
return "s32"
|
||||
default:
|
||||
// 24-bit and other depths: use s32 (the next valid container size)
|
||||
return "s32"
|
||||
}
|
||||
}
|
||||
|
||||
// Path will always be an absolute path
|
||||
func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
|
||||
var args []string
|
||||
|
||||
@@ -2,19 +2,27 @@ package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
sync "sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestFFmpeg(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
// Inline test init to avoid import cycle with tests package
|
||||
//nolint:dogsled
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
appPath, _ := filepath.Abs(filepath.Join(filepath.Dir(file), "..", ".."))
|
||||
confPath := filepath.Join(appPath, "tests", "navidrome-test.toml")
|
||||
_ = os.Chdir(appPath)
|
||||
conf.LoadFromFile(confPath)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "FFmpeg Suite")
|
||||
@@ -70,6 +78,286 @@ var _ = Describe("ffmpeg", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("isDefaultCommand", func() {
|
||||
It("returns true for known default mp3 command", func() {
|
||||
Expect(isDefaultCommand("mp3", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue())
|
||||
})
|
||||
It("returns true for known default opus command", func() {
|
||||
Expect(isDefaultCommand("opus", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue())
|
||||
})
|
||||
It("returns true for known default aac command", func() {
|
||||
Expect(isDefaultCommand("aac", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -")).To(BeTrue())
|
||||
})
|
||||
It("returns true for known default flac command", func() {
|
||||
Expect(isDefaultCommand("flac", "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue())
|
||||
})
|
||||
It("returns false for a custom command", func() {
|
||||
Expect(isDefaultCommand("mp3", "ffmpeg -i %s -b:a %bk -custom-flag -f mp3 -")).To(BeFalse())
|
||||
})
|
||||
It("returns false for unknown format", func() {
|
||||
Expect(isDefaultCommand("wav", "ffmpeg -i %s -f wav -")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("buildDynamicArgs", func() {
|
||||
It("builds mp3 args with bitrate, samplerate, and channels", func() {
|
||||
args := buildDynamicArgs(TranscodeOptions{
|
||||
Format: "mp3",
|
||||
FilePath: "/music/file.flac",
|
||||
BitRate: 256,
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
})
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.flac",
|
||||
"-map", "0:a:0",
|
||||
"-c:a", "libmp3lame",
|
||||
"-b:a", "256k",
|
||||
"-ar", "48000",
|
||||
"-ac", "2",
|
||||
"-v", "0",
|
||||
"-f", "mp3",
|
||||
"-",
|
||||
}))
|
||||
})
|
||||
|
||||
It("builds flac args without bitrate", func() {
|
||||
args := buildDynamicArgs(TranscodeOptions{
|
||||
Format: "flac",
|
||||
FilePath: "/music/file.dsf",
|
||||
SampleRate: 48000,
|
||||
})
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.dsf",
|
||||
"-map", "0:a:0",
|
||||
"-c:a", "flac",
|
||||
"-ar", "48000",
|
||||
"-v", "0",
|
||||
"-f", "flac",
|
||||
"-",
|
||||
}))
|
||||
})
|
||||
|
||||
It("builds opus args with bitrate only", func() {
|
||||
args := buildDynamicArgs(TranscodeOptions{
|
||||
Format: "opus",
|
||||
FilePath: "/music/file.flac",
|
||||
BitRate: 128,
|
||||
})
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.flac",
|
||||
"-map", "0:a:0",
|
||||
"-c:a", "libopus",
|
||||
"-b:a", "128k",
|
||||
"-v", "0",
|
||||
"-f", "opus",
|
||||
"-",
|
||||
}))
|
||||
})
|
||||
|
||||
It("includes offset when specified", func() {
|
||||
args := buildDynamicArgs(TranscodeOptions{
|
||||
Format: "mp3",
|
||||
FilePath: "/music/file.mp3",
|
||||
BitRate: 192,
|
||||
Offset: 30,
|
||||
})
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.mp3",
|
||||
"-ss", "30",
|
||||
"-map", "0:a:0",
|
||||
"-c:a", "libmp3lame",
|
||||
"-b:a", "192k",
|
||||
"-v", "0",
|
||||
"-f", "mp3",
|
||||
"-",
|
||||
}))
|
||||
})
|
||||
|
||||
It("builds aac args with fragmented MP4 container", func() {
|
||||
args := buildDynamicArgs(TranscodeOptions{
|
||||
Format: "aac",
|
||||
FilePath: "/music/file.flac",
|
||||
BitRate: 256,
|
||||
})
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.flac",
|
||||
"-map", "0:a:0",
|
||||
"-c:a", "aac",
|
||||
"-b:a", "256k",
|
||||
"-v", "0",
|
||||
"-f", "ipod",
|
||||
"-movflags", "frag_keyframe+empty_moov",
|
||||
"-",
|
||||
}))
|
||||
})
|
||||
|
||||
It("builds flac args with bit depth", func() {
|
||||
args := buildDynamicArgs(TranscodeOptions{
|
||||
Format: "flac",
|
||||
FilePath: "/music/file.dsf",
|
||||
BitDepth: 24,
|
||||
})
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.dsf",
|
||||
"-map", "0:a:0",
|
||||
"-c:a", "flac",
|
||||
"-sample_fmt", "s32",
|
||||
"-v", "0",
|
||||
"-f", "flac",
|
||||
"-",
|
||||
}))
|
||||
})
|
||||
|
||||
It("omits -sample_fmt when bit depth is 0", func() {
|
||||
args := buildDynamicArgs(TranscodeOptions{
|
||||
Format: "flac",
|
||||
FilePath: "/music/file.flac",
|
||||
BitDepth: 0,
|
||||
})
|
||||
Expect(args).ToNot(ContainElement("-sample_fmt"))
|
||||
})
|
||||
|
||||
It("omits -sample_fmt when bit depth is too low (DSD)", func() {
|
||||
args := buildDynamicArgs(TranscodeOptions{
|
||||
Format: "flac",
|
||||
FilePath: "/music/file.dsf",
|
||||
BitDepth: 1,
|
||||
})
|
||||
Expect(args).ToNot(ContainElement("-sample_fmt"))
|
||||
})
|
||||
|
||||
It("omits -sample_fmt for mp3 even when bit depth >= 16", func() {
|
||||
args := buildDynamicArgs(TranscodeOptions{
|
||||
Format: "mp3",
|
||||
FilePath: "/music/file.flac",
|
||||
BitRate: 256,
|
||||
BitDepth: 16,
|
||||
})
|
||||
Expect(args).ToNot(ContainElement("-sample_fmt"))
|
||||
})
|
||||
|
||||
It("omits -sample_fmt for aac even when bit depth >= 16", func() {
|
||||
args := buildDynamicArgs(TranscodeOptions{
|
||||
Format: "aac",
|
||||
FilePath: "/music/file.flac",
|
||||
BitRate: 256,
|
||||
BitDepth: 16,
|
||||
})
|
||||
Expect(args).ToNot(ContainElement("-sample_fmt"))
|
||||
})
|
||||
|
||||
It("omits -sample_fmt for opus even when bit depth >= 16", func() {
|
||||
args := buildDynamicArgs(TranscodeOptions{
|
||||
Format: "opus",
|
||||
FilePath: "/music/file.flac",
|
||||
BitRate: 128,
|
||||
BitDepth: 16,
|
||||
})
|
||||
Expect(args).ToNot(ContainElement("-sample_fmt"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("bitDepthToSampleFmt", func() {
|
||||
It("converts 16-bit", func() {
|
||||
Expect(bitDepthToSampleFmt(16)).To(Equal("s16"))
|
||||
})
|
||||
It("converts 24-bit to s32 (FLAC only supports s16/s32)", func() {
|
||||
Expect(bitDepthToSampleFmt(24)).To(Equal("s32"))
|
||||
})
|
||||
It("converts 32-bit", func() {
|
||||
Expect(bitDepthToSampleFmt(32)).To(Equal("s32"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("buildTemplateArgs", func() {
|
||||
It("injects -ar and -ac into custom template", func() {
|
||||
args := buildTemplateArgs(TranscodeOptions{
|
||||
Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
|
||||
FilePath: "/music/file.flac",
|
||||
BitRate: 192,
|
||||
SampleRate: 44100,
|
||||
Channels: 2,
|
||||
})
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.flac",
|
||||
"-b:a", "192k", "-v", "0", "-f", "mp3",
|
||||
"-ar", "44100", "-ac", "2",
|
||||
"-",
|
||||
}))
|
||||
})
|
||||
|
||||
It("injects only -ar when channels is 0", func() {
|
||||
args := buildTemplateArgs(TranscodeOptions{
|
||||
Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
|
||||
FilePath: "/music/file.flac",
|
||||
BitRate: 192,
|
||||
SampleRate: 48000,
|
||||
})
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.flac",
|
||||
"-b:a", "192k", "-v", "0", "-f", "mp3",
|
||||
"-ar", "48000",
|
||||
"-",
|
||||
}))
|
||||
})
|
||||
|
||||
It("does not inject anything when sample rate and channels are 0", func() {
|
||||
args := buildTemplateArgs(TranscodeOptions{
|
||||
Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
|
||||
FilePath: "/music/file.flac",
|
||||
BitRate: 192,
|
||||
})
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.flac",
|
||||
"-b:a", "192k", "-v", "0", "-f", "mp3",
|
||||
"-",
|
||||
}))
|
||||
})
|
||||
|
||||
It("injects -sample_fmt for lossless output format with bit depth", func() {
|
||||
args := buildTemplateArgs(TranscodeOptions{
|
||||
Command: "ffmpeg -i %s -v 0 -c:a flac -f flac -",
|
||||
Format: "flac",
|
||||
FilePath: "/music/file.dsf",
|
||||
BitDepth: 24,
|
||||
})
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.dsf",
|
||||
"-v", "0", "-c:a", "flac", "-f", "flac",
|
||||
"-sample_fmt", "s32",
|
||||
"-",
|
||||
}))
|
||||
})
|
||||
|
||||
It("does not inject -sample_fmt for lossy output format even with bit depth", func() {
|
||||
args := buildTemplateArgs(TranscodeOptions{
|
||||
Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
|
||||
Format: "mp3",
|
||||
FilePath: "/music/file.flac",
|
||||
BitRate: 192,
|
||||
BitDepth: 16,
|
||||
})
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.flac",
|
||||
"-b:a", "192k", "-v", "0", "-f", "mp3",
|
||||
"-",
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("injectBeforeOutput", func() {
|
||||
It("inserts flag before trailing dash", func() {
|
||||
args := injectBeforeOutput([]string{"ffmpeg", "-i", "file.mp3", "-f", "mp3", "-"}, "-ar", "48000")
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "file.mp3", "-f", "mp3", "-ar", "48000", "-"}))
|
||||
})
|
||||
|
||||
It("appends when no trailing dash", func() {
|
||||
args := injectBeforeOutput([]string{"ffmpeg", "-i", "file.mp3"}, "-ar", "48000")
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "file.mp3", "-ar", "48000"}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("FFmpeg", func() {
|
||||
Context("when FFmpeg is available", func() {
|
||||
var ff FFmpeg
|
||||
@@ -93,7 +381,12 @@ var _ = Describe("ffmpeg", func() {
|
||||
command := "ffmpeg -f lavfi -i sine=frequency=1000:duration=0 -f mp3 -"
|
||||
|
||||
// The input file is not used here, but we need to provide a valid path to the Transcode function
|
||||
stream, err := ff.Transcode(ctx, command, "tests/fixtures/test.mp3", 128, 0)
|
||||
stream, err := ff.Transcode(ctx, TranscodeOptions{
|
||||
Command: command,
|
||||
Format: "mp3",
|
||||
FilePath: "tests/fixtures/test.mp3",
|
||||
BitRate: 128,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer stream.Close()
|
||||
|
||||
@@ -115,7 +408,12 @@ var _ = Describe("ffmpeg", func() {
|
||||
cancel() // Cancel immediately
|
||||
|
||||
// This should fail immediately
|
||||
_, err := ff.Transcode(ctx, "ffmpeg -i %s -f mp3 -", "tests/fixtures/test.mp3", 128, 0)
|
||||
_, err := ff.Transcode(ctx, TranscodeOptions{
|
||||
Command: "ffmpeg -i %s -f mp3 -",
|
||||
Format: "mp3",
|
||||
FilePath: "tests/fixtures/test.mp3",
|
||||
BitRate: 128,
|
||||
})
|
||||
Expect(err).To(MatchError(context.Canceled))
|
||||
})
|
||||
})
|
||||
@@ -142,7 +440,10 @@ var _ = Describe("ffmpeg", func() {
|
||||
defer cancel()
|
||||
|
||||
// Start a process that will run for a while
|
||||
stream, err := ff.Transcode(ctx, longRunningCmd, "tests/fixtures/test.mp3", 0, 0)
|
||||
stream, err := ff.Transcode(ctx, TranscodeOptions{
|
||||
Command: longRunningCmd,
|
||||
FilePath: "tests/fixtures/test.mp3",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer stream.Close()
|
||||
|
||||
|
||||
@@ -18,9 +18,20 @@ import (
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
)
|
||||
|
||||
// StreamRequest contains all parameters for creating a media stream.
|
||||
type StreamRequest struct {
|
||||
ID string
|
||||
Format string
|
||||
BitRate int // kbps
|
||||
SampleRate int
|
||||
BitDepth int
|
||||
Channels int
|
||||
Offset int // seconds
|
||||
}
|
||||
|
||||
type MediaStreamer interface {
|
||||
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, offset int) (*Stream, error)
|
||||
DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error)
|
||||
NewStream(ctx context.Context, req StreamRequest) (*Stream, error)
|
||||
DoStream(ctx context.Context, mf *model.MediaFile, req StreamRequest) (*Stream, error)
|
||||
}
|
||||
|
||||
type TranscodingCache cache.FileCache
|
||||
@@ -36,44 +47,48 @@ type mediaStreamer struct {
|
||||
}
|
||||
|
||||
type streamJob struct {
|
||||
ms *mediaStreamer
|
||||
mf *model.MediaFile
|
||||
filePath string
|
||||
format string
|
||||
bitRate int
|
||||
offset int
|
||||
ms *mediaStreamer
|
||||
mf *model.MediaFile
|
||||
filePath string
|
||||
format string
|
||||
bitRate int
|
||||
sampleRate int
|
||||
bitDepth int
|
||||
channels int
|
||||
offset int
|
||||
}
|
||||
|
||||
func (j *streamJob) Key() string {
|
||||
return fmt.Sprintf("%s.%s.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format, j.offset)
|
||||
return fmt.Sprintf("%s.%s.%d.%d.%d.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.sampleRate, j.bitDepth, j.channels, j.format, j.offset)
|
||||
}
|
||||
|
||||
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
|
||||
mf, err := ms.ds.MediaFile(ctx).Get(id)
|
||||
func (ms *mediaStreamer) NewStream(ctx context.Context, req StreamRequest) (*Stream, error) {
|
||||
mf, err := ms.ds.MediaFile(ctx).Get(req.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ms.DoStream(ctx, mf, reqFormat, reqBitRate, reqOffset)
|
||||
return ms.DoStream(ctx, mf, req)
|
||||
}
|
||||
|
||||
func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
|
||||
func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, req StreamRequest) (*Stream, error) {
|
||||
var format string
|
||||
var bitRate int
|
||||
var cached bool
|
||||
defer func() {
|
||||
log.Info(ctx, "Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format, "cached", cached,
|
||||
"bitRate", bitRate, "user", userName(ctx), "transcoding", format != "raw",
|
||||
"bitRate", bitRate, "sampleRate", req.SampleRate, "bitDepth", req.BitDepth, "channels", req.Channels,
|
||||
"user", userName(ctx), "transcoding", format != "raw",
|
||||
"originalFormat", mf.Suffix, "originalBitRate", mf.BitRate)
|
||||
}()
|
||||
|
||||
format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate)
|
||||
format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, req.Format, req.BitRate, req.SampleRate)
|
||||
s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate}
|
||||
filePath := mf.AbsolutePath()
|
||||
|
||||
if format == "raw" {
|
||||
log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", filePath,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
|
||||
"requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
|
||||
"selectedBitrate", bitRate, "selectedFormat", format)
|
||||
f, err := os.Open(filePath)
|
||||
@@ -87,12 +102,15 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF
|
||||
}
|
||||
|
||||
job := &streamJob{
|
||||
ms: ms,
|
||||
mf: mf,
|
||||
filePath: filePath,
|
||||
format: format,
|
||||
bitRate: bitRate,
|
||||
offset: reqOffset,
|
||||
ms: ms,
|
||||
mf: mf,
|
||||
filePath: filePath,
|
||||
format: format,
|
||||
bitRate: bitRate,
|
||||
sampleRate: req.SampleRate,
|
||||
bitDepth: req.BitDepth,
|
||||
channels: req.Channels,
|
||||
offset: req.Offset,
|
||||
}
|
||||
r, err := ms.cache.Get(ctx, job)
|
||||
if err != nil {
|
||||
@@ -105,7 +123,7 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF
|
||||
s.Seeker = r.Seeker
|
||||
|
||||
log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", filePath,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
|
||||
"requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
|
||||
"selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable())
|
||||
|
||||
@@ -131,12 +149,13 @@ func (s *Stream) EstimatedContentLength() int {
|
||||
}
|
||||
|
||||
// TODO This function deserves some love (refactoring)
|
||||
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) {
|
||||
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int, reqSampleRate int) (format string, bitRate int) {
|
||||
format = "raw"
|
||||
if reqFormat == "raw" {
|
||||
return format, 0
|
||||
}
|
||||
if reqFormat == mf.Suffix && reqBitRate == 0 {
|
||||
needsResample := reqSampleRate > 0 && reqSampleRate < mf.SampleRate
|
||||
if reqFormat == mf.Suffix && reqBitRate == 0 && !needsResample {
|
||||
bitRate = mf.BitRate
|
||||
return format, bitRate
|
||||
}
|
||||
@@ -175,7 +194,7 @@ func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model
|
||||
bitRate = t.DefaultBitRate
|
||||
}
|
||||
}
|
||||
if format == mf.Suffix && bitRate >= mf.BitRate {
|
||||
if format == mf.Suffix && bitRate >= mf.BitRate && !needsResample {
|
||||
format = "raw"
|
||||
bitRate = 0
|
||||
}
|
||||
@@ -217,7 +236,16 @@ func NewTranscodingCache() TranscodingCache {
|
||||
transcodingCtx = request.AddValues(context.Background(), ctx)
|
||||
}
|
||||
|
||||
out, err := job.ms.transcoder.Transcode(transcodingCtx, t.Command, job.filePath, job.bitRate, job.offset)
|
||||
out, err := job.ms.transcoder.Transcode(transcodingCtx, ffmpeg.TranscodeOptions{
|
||||
Command: t.Command,
|
||||
Format: job.format,
|
||||
FilePath: job.filePath,
|
||||
BitRate: job.bitRate,
|
||||
SampleRate: job.sampleRate,
|
||||
BitDepth: job.bitDepth,
|
||||
Channels: job.channels,
|
||||
Offset: job.offset,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
|
||||
return nil, os.ErrInvalid
|
||||
|
||||
@@ -26,42 +26,64 @@ var _ = Describe("MediaStreamer", func() {
|
||||
It("returns raw if raw is requested", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0, 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns raw if a transcoder does not exists", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 0)
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 0, 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns the requested format if a transcoder exists", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0, 0)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
||||
})
|
||||
It("returns raw if requested format is the same as the original and it is not necessary to downsample", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 112
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128)
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128, 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns the requested format if requested BitRate is lower than original", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 320
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192, 0)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(192))
|
||||
})
|
||||
It("returns raw if requested format is the same as the original, but requested BitRate is 0", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 320
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0, 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
Expect(bitRate).To(Equal(320))
|
||||
})
|
||||
It("returns the format when same format is requested but with a lower sample rate", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 2118
|
||||
mf.SampleRate = 96000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "flac", 0, 48000)
|
||||
Expect(format).To(Equal("flac"))
|
||||
Expect(bitRate).To(Equal(0))
|
||||
})
|
||||
It("returns raw when same format is requested with same sample rate", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
mf.SampleRate = 48000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "flac", 0, 48000)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns raw when same format is requested with no sample rate constraint", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
mf.SampleRate = 96000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "flac", 0, 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
Context("Downsampling", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DefaultDownsamplingFormat = "opus"
|
||||
@@ -69,13 +91,13 @@ var _ = Describe("MediaStreamer", func() {
|
||||
mf.BitRate = 960
|
||||
})
|
||||
It("returns the DefaultDownsamplingFormat if a maxBitrate is requested but not the format", func() {
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 128)
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 128, 0)
|
||||
Expect(format).To(Equal("opus"))
|
||||
Expect(bitRate).To(Equal(128))
|
||||
})
|
||||
It("returns raw if maxBitrate is equal or greater than original", func() {
|
||||
// This happens with DSub (and maybe other clients?). See https://github.com/navidrome/navidrome/issues/2066
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 960)
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 960, 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
Expect(bitRate).To(Equal(0))
|
||||
})
|
||||
@@ -90,34 +112,34 @@ var _ = Describe("MediaStreamer", func() {
|
||||
It("returns raw if raw is requested", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0, 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns configured format/bitrate as default", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0, 0)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(96))
|
||||
})
|
||||
It("returns requested format", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0, 0)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
||||
})
|
||||
It("returns requested bitrate", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80)
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80, 0)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(80))
|
||||
})
|
||||
It("returns raw if selected bitrate and format is the same as original", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 192
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192, 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
Expect(bitRate).To(Equal(0))
|
||||
})
|
||||
@@ -133,27 +155,27 @@ var _ = Describe("MediaStreamer", func() {
|
||||
It("returns raw if raw is requested", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0, 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns configured format/bitrate as default", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0, 0)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(192))
|
||||
})
|
||||
It("returns requested format", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0, 0)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
||||
})
|
||||
It("returns requested bitrate", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 160)
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 160, 0)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(160))
|
||||
})
|
||||
|
||||
@@ -39,34 +39,34 @@ var _ = Describe("MediaStreamer", func() {
|
||||
|
||||
Context("NewStream", func() {
|
||||
It("returns a seekable stream if format is 'raw'", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "raw", 0, 0)
|
||||
s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "raw"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
It("returns a seekable stream if maxBitRate is 0", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 0, 0)
|
||||
s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
It("returns a seekable stream if maxBitRate is higher than file bitRate", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 320, 0)
|
||||
s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 320})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
It("returns a NON seekable stream if transcode is required", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 64, 0)
|
||||
s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 64})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s.Seekable()).To(BeFalse())
|
||||
Expect(s.Duration()).To(Equal(float32(257.0)))
|
||||
})
|
||||
It("returns a seekable stream if the file is complete in the cache", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 32, 0)
|
||||
s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 32})
|
||||
Expect(err).To(BeNil())
|
||||
_, _ = io.ReadAll(s)
|
||||
_ = s.Close()
|
||||
Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue())
|
||||
|
||||
s, err = streamer.NewStream(ctx, "123", "mp3", 32, 0)
|
||||
s, err = streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 32})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
|
||||
@@ -108,7 +108,7 @@ func (c *insightsCollector) sendInsights(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := hc.Do(req) //nolint:gosec
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Could not send Insights data", err)
|
||||
return
|
||||
@@ -208,8 +208,7 @@ var staticData = sync.OnceValue(func() insights.Data {
|
||||
data.Config.TranscodingCacheSize = conf.Server.TranscodingCacheSize
|
||||
data.Config.ImageCacheSize = conf.Server.ImageCacheSize
|
||||
data.Config.SessionTimeout = uint64(math.Trunc(conf.Server.SessionTimeout.Seconds()))
|
||||
data.Config.SearchFullString = conf.Server.Search.FullString
|
||||
data.Config.SearchBackend = conf.Server.Search.Backend
|
||||
data.Config.SearchFullString = conf.Server.SearchFullString
|
||||
data.Config.RecentlyAddedByModTime = conf.Server.RecentlyAddedByModTime
|
||||
data.Config.PreferSortTags = conf.Server.PreferSortTags
|
||||
data.Config.BackupSchedule = conf.Server.Backup.Schedule
|
||||
@@ -221,7 +220,7 @@ var staticData = sync.OnceValue(func() insights.Data {
|
||||
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
|
||||
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
|
||||
data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != ""
|
||||
data.Config.HasCustomPID = conf.Server.PID.Track != consts.DefaultTrackPID || conf.Server.PID.Album != consts.DefaultAlbumPID
|
||||
data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != ""
|
||||
data.Config.HasCustomTags = len(conf.Server.Tags) > 0
|
||||
|
||||
return data
|
||||
|
||||
@@ -68,7 +68,6 @@ type Data struct {
|
||||
EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
|
||||
SessionTimeout uint64 `json:"sessionTimeout,omitempty"`
|
||||
SearchFullString bool `json:"searchFullString,omitempty"`
|
||||
SearchBackend string `json:"searchBackend,omitempty"`
|
||||
RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"`
|
||||
PreferSortTags bool `json:"preferSortTags,omitempty"`
|
||||
BackupSchedule string `json:"backupSchedule,omitempty"`
|
||||
|
||||
@@ -1,28 +1,183 @@
|
||||
package playlists
|
||||
package core
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/RaveNoX/go-jsoncommentstrip"
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/ioutils"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
type Playlists interface {
|
||||
ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error)
|
||||
Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
|
||||
ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
|
||||
}
|
||||
|
||||
type playlists struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func NewPlaylists(ds model.DataStore) Playlists {
|
||||
return &playlists{ds: ds}
|
||||
}
|
||||
|
||||
func InPlaylistsPath(folder model.Folder) bool {
|
||||
if conf.Server.PlaylistsPath == "" {
|
||||
return true
|
||||
}
|
||||
rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath())
|
||||
for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
||||
if match, _ := doublestar.Match(path, rel); match {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *playlists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
|
||||
pls, err := s.parsePlaylist(ctx, filename, folder)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
|
||||
return nil, err
|
||||
}
|
||||
log.Debug("Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks))
|
||||
err = s.updatePlaylist(ctx, pls)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
|
||||
}
|
||||
return pls, err
|
||||
}
|
||||
|
||||
func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) {
|
||||
owner, _ := request.UserFrom(ctx)
|
||||
pls := &model.Playlist{
|
||||
OwnerID: owner.ID,
|
||||
Public: false,
|
||||
Sync: false,
|
||||
}
|
||||
err := s.parseM3U(ctx, pls, nil, reader)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error parsing playlist", err)
|
||||
return nil, err
|
||||
}
|
||||
err = s.ds.Playlist(ctx).Put(pls)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error saving playlist", err)
|
||||
return nil, err
|
||||
}
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, folder *model.Folder) (*model.Playlist, error) {
|
||||
pls, err := s.newSyncedPlaylist(folder.AbsolutePath(), playlistFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err := os.Open(pls.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := ioutils.UTF8Reader(file)
|
||||
extension := strings.ToLower(filepath.Ext(playlistFile))
|
||||
switch extension {
|
||||
case ".nsp":
|
||||
err = s.parseNSP(ctx, pls, reader)
|
||||
default:
|
||||
err = s.parseM3U(ctx, pls, folder, reader)
|
||||
}
|
||||
return pls, err
|
||||
}
|
||||
|
||||
func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) {
|
||||
playlistPath := filepath.Join(baseDir, playlistFile)
|
||||
info, err := os.Stat(playlistPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var extension = filepath.Ext(playlistFile)
|
||||
var name = playlistFile[0 : len(playlistFile)-len(extension)]
|
||||
|
||||
pls := &model.Playlist{
|
||||
Name: name,
|
||||
Comment: fmt.Sprintf("Auto-imported from '%s'", playlistFile),
|
||||
Public: false,
|
||||
Path: playlistPath,
|
||||
Sync: true,
|
||||
UpdatedAt: info.ModTime(),
|
||||
}
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
func getPositionFromOffset(data []byte, offset int64) (line, column int) {
|
||||
line = 1
|
||||
for _, b := range data[:offset] {
|
||||
if b == '\n' {
|
||||
line++
|
||||
column = 1
|
||||
} else {
|
||||
column++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.Reader) error {
|
||||
nsp := &nspFile{}
|
||||
reader = io.LimitReader(reader, 100*1024) // Limit to 100KB
|
||||
reader = jsoncommentstrip.NewReader(reader)
|
||||
input, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading SmartPlaylist: %w", err)
|
||||
}
|
||||
err = json.Unmarshal(input, nsp)
|
||||
if err != nil {
|
||||
var syntaxErr *json.SyntaxError
|
||||
if errors.As(err, &syntaxErr) {
|
||||
line, col := getPositionFromOffset(input, syntaxErr.Offset)
|
||||
return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err)
|
||||
}
|
||||
return fmt.Errorf("JSON parsing error in SmartPlaylist: %w", err)
|
||||
}
|
||||
pls.Rules = &nsp.Criteria
|
||||
if nsp.Name != "" {
|
||||
pls.Name = nsp.Name
|
||||
}
|
||||
if nsp.Comment != "" {
|
||||
pls.Comment = nsp.Comment
|
||||
}
|
||||
if nsp.Public != nil {
|
||||
pls.Public = *nsp.Public
|
||||
} else {
|
||||
pls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *model.Folder, reader io.Reader) error {
|
||||
mediaFileRepository := s.ds.MediaFile(ctx)
|
||||
resolver, err := newPathResolver(ctx, s.ds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var mfs model.MediaFiles
|
||||
// Chunk size of 100 lines, as each line can generate up to 4 lookup candidates
|
||||
// (NFC/NFD × raw/lowercase), and SQLite has a max expression tree depth of 1000.
|
||||
@@ -47,7 +202,7 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
||||
}
|
||||
filteredLines = append(filteredLines, line)
|
||||
}
|
||||
resolvedPaths, err := resolver.resolvePaths(ctx, folder, filteredLines)
|
||||
resolvedPaths, err := s.resolvePaths(ctx, folder, filteredLines)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err)
|
||||
continue
|
||||
@@ -103,9 +258,7 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
||||
existing[key] = idx
|
||||
}
|
||||
|
||||
// Find media files in the order of the resolved paths, to keep playlist order.
|
||||
// Both `existing` keys and `resolvedPaths` use the library-qualified format "libraryID:relativePath",
|
||||
// so normalizing the full string produces matching keys (digits and ':' are ASCII-invariant).
|
||||
// Find media files in the order of the resolved paths, to keep playlist order
|
||||
for _, path := range resolvedPaths {
|
||||
key := strings.ToLower(norm.NFC.String(path))
|
||||
idx, ok := existing[key]
|
||||
@@ -245,10 +398,15 @@ func (r *pathResolver) findInLibraries(absolutePath string) pathResolution {
|
||||
// resolvePaths converts playlist file paths to library-qualified paths (format: "libraryID:relativePath").
|
||||
// For relative paths, it resolves them to absolute paths first, then determines which
|
||||
// library they belong to. This allows playlists to reference files across library boundaries.
|
||||
func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
|
||||
func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
|
||||
resolver, err := newPathResolver(ctx, s.ds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := make([]string, 0, len(lines))
|
||||
for idx, line := range lines {
|
||||
resolution := r.resolvePath(line, folder)
|
||||
resolution := resolver.resolvePath(line, folder)
|
||||
|
||||
if !resolution.valid {
|
||||
log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
|
||||
@@ -267,3 +425,123 @@ func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, l
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
|
||||
owner, _ := request.UserFrom(ctx)
|
||||
|
||||
// Try to find existing playlist by path. Since filesystem normalization differs across
|
||||
// platforms (macOS uses NFD, Linux/Windows use NFC), we try both forms to match
|
||||
// playlists that may have been imported on a different platform.
|
||||
pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
// Try alternate normalization form
|
||||
altPath := norm.NFD.String(newPls.Path)
|
||||
if altPath == newPls.Path {
|
||||
altPath = norm.NFC.String(newPls.Path)
|
||||
}
|
||||
if altPath != newPls.Path {
|
||||
pls, err = s.ds.Playlist(ctx).FindByPath(altPath)
|
||||
}
|
||||
}
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
if err == nil && !pls.Sync {
|
||||
log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path)
|
||||
newPls.ID = pls.ID
|
||||
newPls.Name = pls.Name
|
||||
newPls.Comment = pls.Comment
|
||||
newPls.OwnerID = pls.OwnerID
|
||||
newPls.Public = pls.Public
|
||||
newPls.EvaluatedAt = &time.Time{}
|
||||
} else {
|
||||
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
||||
newPls.OwnerID = owner.ID
|
||||
// For NSP files, Public may already be set from the file; for M3U, use server default
|
||||
if !newPls.IsSmartPlaylist() {
|
||||
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
}
|
||||
}
|
||||
return s.ds.Playlist(ctx).Put(newPls)
|
||||
}
|
||||
|
||||
func (s *playlists) Update(ctx context.Context, playlistID string,
|
||||
name *string, comment *string, public *bool,
|
||||
idsToAdd []string, idxToRemove []int) error {
|
||||
needsInfoUpdate := name != nil || comment != nil || public != nil
|
||||
needsTrackRefresh := len(idxToRemove) > 0
|
||||
|
||||
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
|
||||
var pls *model.Playlist
|
||||
var err error
|
||||
repo := tx.Playlist(ctx)
|
||||
tracks := repo.Tracks(playlistID, true)
|
||||
if tracks == nil {
|
||||
return fmt.Errorf("%w: playlist '%s'", model.ErrNotFound, playlistID)
|
||||
}
|
||||
if needsTrackRefresh {
|
||||
pls, err = repo.GetWithTracks(playlistID, true, false)
|
||||
pls.RemoveTracks(idxToRemove)
|
||||
pls.AddMediaFilesByID(idsToAdd)
|
||||
} else {
|
||||
if len(idsToAdd) > 0 {
|
||||
_, err = tracks.Add(idsToAdd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if needsInfoUpdate {
|
||||
pls, err = repo.Get(playlistID)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !needsTrackRefresh && !needsInfoUpdate {
|
||||
return nil
|
||||
}
|
||||
|
||||
if name != nil {
|
||||
pls.Name = *name
|
||||
}
|
||||
if comment != nil {
|
||||
pls.Comment = *comment
|
||||
}
|
||||
if public != nil {
|
||||
pls.Public = *public
|
||||
}
|
||||
// Special case: The playlist is now empty
|
||||
if len(idxToRemove) > 0 && len(pls.Tracks) == 0 {
|
||||
if err = tracks.DeleteAll(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return repo.Put(pls)
|
||||
})
|
||||
}
|
||||
|
||||
type nspFile struct {
|
||||
criteria.Criteria
|
||||
Name string `json:"name"`
|
||||
Comment string `json:"comment"`
|
||||
Public *bool `json:"public"`
|
||||
}
|
||||
|
||||
func (i *nspFile) UnmarshalJSON(data []byte) error {
|
||||
m := map[string]any{}
|
||||
err := json.Unmarshal(data, &m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i.Name, _ = m["name"].(string)
|
||||
i.Comment, _ = m["comment"].(string)
|
||||
if public, ok := m["public"].(bool); ok {
|
||||
i.Public = &public
|
||||
}
|
||||
return json.Unmarshal(data, &i.Criteria)
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/ioutils"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
func (s *playlists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
|
||||
pls, err := s.parsePlaylist(ctx, filename, folder)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
|
||||
return nil, err
|
||||
}
|
||||
log.Debug(ctx, "Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks))
|
||||
err = s.updatePlaylist(ctx, pls)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
|
||||
}
|
||||
return pls, err
|
||||
}
|
||||
|
||||
func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) {
|
||||
owner, _ := request.UserFrom(ctx)
|
||||
pls := &model.Playlist{
|
||||
OwnerID: owner.ID,
|
||||
Public: false,
|
||||
Sync: false,
|
||||
}
|
||||
err := s.parseM3U(ctx, pls, nil, reader)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error parsing playlist", err)
|
||||
return nil, err
|
||||
}
|
||||
err = s.ds.Playlist(ctx).Put(pls)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error saving playlist", err)
|
||||
return nil, err
|
||||
}
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, folder *model.Folder) (*model.Playlist, error) {
|
||||
pls, err := s.newSyncedPlaylist(folder.AbsolutePath(), playlistFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err := os.Open(pls.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := ioutils.UTF8Reader(file)
|
||||
extension := strings.ToLower(filepath.Ext(playlistFile))
|
||||
switch extension {
|
||||
case ".nsp":
|
||||
err = s.parseNSP(ctx, pls, reader)
|
||||
default:
|
||||
err = s.parseM3U(ctx, pls, folder, reader)
|
||||
}
|
||||
return pls, err
|
||||
}
|
||||
|
||||
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
|
||||
owner, _ := request.UserFrom(ctx)
|
||||
|
||||
// Try to find existing playlist by path. Since filesystem normalization differs across
|
||||
// platforms (macOS uses NFD, Linux/Windows use NFC), we try both forms to match
|
||||
// playlists that may have been imported on a different platform.
|
||||
pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
// Try alternate normalization form
|
||||
altPath := norm.NFD.String(newPls.Path)
|
||||
if altPath == newPls.Path {
|
||||
altPath = norm.NFC.String(newPls.Path)
|
||||
}
|
||||
if altPath != newPls.Path {
|
||||
pls, err = s.ds.Playlist(ctx).FindByPath(altPath)
|
||||
}
|
||||
}
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
if err == nil && !pls.Sync {
|
||||
log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path)
|
||||
newPls.ID = pls.ID
|
||||
newPls.Name = pls.Name
|
||||
newPls.Comment = pls.Comment
|
||||
newPls.OwnerID = pls.OwnerID
|
||||
newPls.Public = pls.Public
|
||||
newPls.EvaluatedAt = &time.Time{}
|
||||
} else {
|
||||
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
||||
newPls.OwnerID = owner.ID
|
||||
// For NSP files, Public may already be set from the file; for M3U, use server default
|
||||
if !newPls.IsSmartPlaylist() {
|
||||
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
}
|
||||
}
|
||||
return s.ds.Playlist(ctx).Put(newPls)
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/RaveNoX/go-jsoncommentstrip"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
)
|
||||
|
||||
func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) {
|
||||
playlistPath := filepath.Join(baseDir, playlistFile)
|
||||
info, err := os.Stat(playlistPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var extension = filepath.Ext(playlistFile)
|
||||
var name = playlistFile[0 : len(playlistFile)-len(extension)]
|
||||
|
||||
pls := &model.Playlist{
|
||||
Name: name,
|
||||
Comment: fmt.Sprintf("Auto-imported from '%s'", playlistFile),
|
||||
Public: false,
|
||||
Path: playlistPath,
|
||||
Sync: true,
|
||||
UpdatedAt: info.ModTime(),
|
||||
}
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
func getPositionFromOffset(data []byte, offset int64) (line, column int) {
|
||||
line = 1
|
||||
for _, b := range data[:offset] {
|
||||
if b == '\n' {
|
||||
line++
|
||||
column = 1
|
||||
} else {
|
||||
column++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.Reader) error {
|
||||
nsp := &nspFile{}
|
||||
reader = io.LimitReader(reader, 100*1024) // Limit to 100KB
|
||||
reader = jsoncommentstrip.NewReader(reader)
|
||||
input, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading SmartPlaylist: %w", err)
|
||||
}
|
||||
err = json.Unmarshal(input, nsp)
|
||||
if err != nil {
|
||||
var syntaxErr *json.SyntaxError
|
||||
if errors.As(err, &syntaxErr) {
|
||||
line, col := getPositionFromOffset(input, syntaxErr.Offset)
|
||||
return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err)
|
||||
}
|
||||
return fmt.Errorf("JSON parsing error in SmartPlaylist: %w", err)
|
||||
}
|
||||
pls.Rules = &nsp.Criteria
|
||||
if nsp.Name != "" {
|
||||
pls.Name = nsp.Name
|
||||
}
|
||||
if nsp.Comment != "" {
|
||||
pls.Comment = nsp.Comment
|
||||
}
|
||||
if nsp.Public != nil {
|
||||
pls.Public = *nsp.Public
|
||||
} else {
|
||||
pls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type nspFile struct {
|
||||
criteria.Criteria
|
||||
Name string `json:"name"`
|
||||
Comment string `json:"comment"`
|
||||
Public *bool `json:"public"`
|
||||
}
|
||||
|
||||
func (i *nspFile) UnmarshalJSON(data []byte) error {
|
||||
m := map[string]any{}
|
||||
err := json.Unmarshal(data, &m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i.Name, _ = m["name"].(string)
|
||||
i.Comment, _ = m["comment"].(string)
|
||||
if public, ok := m["public"].(bool); ok {
|
||||
i.Public = &public
|
||||
}
|
||||
return json.Unmarshal(data, &i.Criteria)
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("parseNSP", func() {
|
||||
var s *playlists
|
||||
ctx := context.Background()
|
||||
|
||||
BeforeEach(func() {
|
||||
s = &playlists{}
|
||||
})
|
||||
|
||||
It("parses a well-formed NSP with all fields", func() {
|
||||
nsp := `{
|
||||
"name": "My Smart Playlist",
|
||||
"comment": "A test playlist",
|
||||
"public": true,
|
||||
"all": [{"is": {"loved": true}}],
|
||||
"sort": "title",
|
||||
"order": "asc",
|
||||
"limit": 50
|
||||
}`
|
||||
pls := &model.Playlist{Name: "default-name"}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("My Smart Playlist"))
|
||||
Expect(pls.Comment).To(Equal("A test playlist"))
|
||||
Expect(pls.Public).To(BeTrue())
|
||||
Expect(pls.Rules).ToNot(BeNil())
|
||||
Expect(pls.Rules.Sort).To(Equal("title"))
|
||||
Expect(pls.Rules.Order).To(Equal("asc"))
|
||||
Expect(pls.Rules.Limit).To(Equal(50))
|
||||
Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{}))
|
||||
})
|
||||
|
||||
It("keeps existing name when NSP has no name field", func() {
|
||||
nsp := `{"all": [{"is": {"loved": true}}]}`
|
||||
pls := &model.Playlist{Name: "Original Name"}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("Original Name"))
|
||||
})
|
||||
|
||||
It("keeps existing comment when NSP has no comment field", func() {
|
||||
nsp := `{"all": [{"is": {"loved": true}}]}`
|
||||
pls := &model.Playlist{Comment: "Original Comment"}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Comment).To(Equal("Original Comment"))
|
||||
})
|
||||
|
||||
It("strips JSON comments before parsing", func() {
|
||||
nsp := `{
|
||||
// Line comment
|
||||
"name": "Commented Playlist",
|
||||
/* Block comment */
|
||||
"all": [{"is": {"loved": true}}]
|
||||
}`
|
||||
pls := &model.Playlist{}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("Commented Playlist"))
|
||||
})
|
||||
|
||||
It("uses server default when public field is absent", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultPlaylistPublicVisibility = true
|
||||
|
||||
nsp := `{"all": [{"is": {"loved": true}}]}`
|
||||
pls := &model.Playlist{}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Public).To(BeTrue())
|
||||
})
|
||||
|
||||
It("honors explicit public: false over server default", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultPlaylistPublicVisibility = true
|
||||
|
||||
nsp := `{"public": false, "all": [{"is": {"loved": true}}]}`
|
||||
pls := &model.Playlist{}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Public).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns a syntax error with line and column info", func() {
|
||||
nsp := "{\n \"name\": \"Bad\",\n \"all\": [INVALID]\n}"
|
||||
pls := &model.Playlist{}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("JSON syntax error in SmartPlaylist"))
|
||||
Expect(err.Error()).To(MatchRegexp(`line \d+, column \d+`))
|
||||
})
|
||||
|
||||
It("returns a parsing error for completely invalid JSON", func() {
|
||||
nsp := `not json at all`
|
||||
pls := &model.Playlist{}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("SmartPlaylist"))
|
||||
})
|
||||
|
||||
It("gracefully handles non-string name field", func() {
|
||||
nsp := `{"name": 123, "all": [{"is": {"loved": true}}]}`
|
||||
pls := &model.Playlist{Name: "Original"}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Type assertion in UnmarshalJSON fails silently; name stays as original
|
||||
Expect(pls.Name).To(Equal("Original"))
|
||||
})
|
||||
|
||||
It("parses criteria with multiple rules", func() {
|
||||
nsp := `{
|
||||
"all": [
|
||||
{"is": {"loved": true}},
|
||||
{"contains": {"title": "rock"}}
|
||||
],
|
||||
"sort": "lastPlayed",
|
||||
"order": "desc",
|
||||
"limit": 100
|
||||
}`
|
||||
pls := &model.Playlist{}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Rules).ToNot(BeNil())
|
||||
Expect(pls.Rules.Sort).To(Equal("lastPlayed"))
|
||||
Expect(pls.Rules.Order).To(Equal("desc"))
|
||||
Expect(pls.Rules.Limit).To(Equal(100))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("getPositionFromOffset", func() {
|
||||
It("returns correct position on first line", func() {
|
||||
data := []byte("hello world")
|
||||
line, col := getPositionFromOffset(data, 5)
|
||||
Expect(line).To(Equal(1))
|
||||
Expect(col).To(Equal(5))
|
||||
})
|
||||
|
||||
It("returns correct position after newlines", func() {
|
||||
data := []byte("line1\nline2\nline3")
|
||||
// Offsets: l(0) i(1) n(2) e(3) 1(4) \n(5) l(6) i(7) n(8)
|
||||
line, col := getPositionFromOffset(data, 8)
|
||||
Expect(line).To(Equal(2))
|
||||
Expect(col).To(Equal(3))
|
||||
})
|
||||
|
||||
It("returns correct position at start of new line", func() {
|
||||
data := []byte("line1\nline2")
|
||||
// After \n at offset 5, col resets to 1; offset 6 is 'l' -> col=1
|
||||
line, col := getPositionFromOffset(data, 6)
|
||||
Expect(line).To(Equal(2))
|
||||
Expect(col).To(Equal(1))
|
||||
})
|
||||
|
||||
It("handles multiple newlines", func() {
|
||||
data := []byte("a\nb\nc\nd")
|
||||
// a(0) \n(1) b(2) \n(3) c(4) \n(5) d(6)
|
||||
line, col := getPositionFromOffset(data, 6)
|
||||
Expect(line).To(Equal(4))
|
||||
Expect(col).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("newSyncedPlaylist", func() {
|
||||
var s *playlists
|
||||
|
||||
BeforeEach(func() {
|
||||
s = &playlists{}
|
||||
})
|
||||
|
||||
It("creates a synced playlist with correct attributes", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
Expect(os.WriteFile(filepath.Join(tmpDir, "test.m3u"), []byte("content"), 0600)).To(Succeed())
|
||||
|
||||
pls, err := s.newSyncedPlaylist(tmpDir, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("test"))
|
||||
Expect(pls.Comment).To(Equal("Auto-imported from 'test.m3u'"))
|
||||
Expect(pls.Public).To(BeFalse())
|
||||
Expect(pls.Path).To(Equal(filepath.Join(tmpDir, "test.m3u")))
|
||||
Expect(pls.Sync).To(BeTrue())
|
||||
Expect(pls.UpdatedAt).ToNot(BeZero())
|
||||
})
|
||||
|
||||
It("strips extension from filename to derive name", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
Expect(os.WriteFile(filepath.Join(tmpDir, "My Favorites.nsp"), []byte("{}"), 0600)).To(Succeed())
|
||||
|
||||
pls, err := s.newSyncedPlaylist(tmpDir, "My Favorites.nsp")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("My Favorites"))
|
||||
})
|
||||
|
||||
It("returns error for non-existent file", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
_, err := s.newSyncedPlaylist(tmpDir, "nonexistent.m3u")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
@@ -1,265 +0,0 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
)
|
||||
|
||||
type Playlists interface {
|
||||
// Reads
|
||||
GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error)
|
||||
Get(ctx context.Context, id string) (*model.Playlist, error)
|
||||
GetWithTracks(ctx context.Context, id string) (*model.Playlist, error)
|
||||
GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error)
|
||||
|
||||
// Mutations
|
||||
Create(ctx context.Context, playlistId string, name string, ids []string) (string, error)
|
||||
Delete(ctx context.Context, id string) error
|
||||
Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
|
||||
|
||||
// Track management
|
||||
AddTracks(ctx context.Context, playlistID string, ids []string) (int, error)
|
||||
AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error)
|
||||
AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error)
|
||||
AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error)
|
||||
RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error
|
||||
ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error
|
||||
|
||||
// Import
|
||||
ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error)
|
||||
ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
|
||||
|
||||
// REST adapters (follows Share/Library pattern)
|
||||
NewRepository(ctx context.Context) rest.Repository
|
||||
TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository
|
||||
}
|
||||
|
||||
type playlists struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func NewPlaylists(ds model.DataStore) Playlists {
|
||||
return &playlists{ds: ds}
|
||||
}
|
||||
|
||||
func InPath(folder model.Folder) bool {
|
||||
if conf.Server.PlaylistsPath == "" {
|
||||
return true
|
||||
}
|
||||
rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath())
|
||||
for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
||||
if match, _ := doublestar.Match(path, rel); match {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- Read operations ---
|
||||
|
||||
func (s *playlists) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error) {
|
||||
return s.ds.Playlist(ctx).GetAll(options...)
|
||||
}
|
||||
|
||||
func (s *playlists) Get(ctx context.Context, id string) (*model.Playlist, error) {
|
||||
return s.ds.Playlist(ctx).Get(id)
|
||||
}
|
||||
|
||||
func (s *playlists) GetWithTracks(ctx context.Context, id string) (*model.Playlist, error) {
|
||||
return s.ds.Playlist(ctx).GetWithTracks(id, true, false)
|
||||
}
|
||||
|
||||
func (s *playlists) GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error) {
|
||||
return s.ds.Playlist(ctx).GetPlaylists(mediaFileId)
|
||||
}
|
||||
|
||||
// --- Mutation operations ---
|
||||
|
||||
// Create creates a new playlist (when name is provided) or replaces tracks on an existing
|
||||
// playlist (when playlistId is provided). This matches the Subsonic createPlaylist semantics.
|
||||
func (s *playlists) Create(ctx context.Context, playlistId string, name string, ids []string) (string, error) {
|
||||
usr, _ := request.UserFrom(ctx)
|
||||
err := s.ds.WithTxImmediate(func(tx model.DataStore) error {
|
||||
var pls *model.Playlist
|
||||
var err error
|
||||
|
||||
if playlistId != "" {
|
||||
pls, err = tx.Playlist(ctx).Get(playlistId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pls.IsSmartPlaylist() {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
if !usr.IsAdmin && pls.OwnerID != usr.ID {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
} else {
|
||||
pls = &model.Playlist{Name: name}
|
||||
pls.OwnerID = usr.ID
|
||||
}
|
||||
pls.Tracks = nil
|
||||
pls.AddMediaFilesByID(ids)
|
||||
|
||||
err = tx.Playlist(ctx).Put(pls)
|
||||
playlistId = pls.ID
|
||||
return err
|
||||
})
|
||||
return playlistId, err
|
||||
}
|
||||
|
||||
func (s *playlists) Delete(ctx context.Context, id string) error {
|
||||
if _, err := s.checkWritable(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.ds.Playlist(ctx).Delete(id)
|
||||
}
|
||||
|
||||
func (s *playlists) Update(ctx context.Context, playlistID string,
|
||||
name *string, comment *string, public *bool,
|
||||
idsToAdd []string, idxToRemove []int) error {
|
||||
var pls *model.Playlist
|
||||
var err error
|
||||
hasTrackChanges := len(idsToAdd) > 0 || len(idxToRemove) > 0
|
||||
if hasTrackChanges {
|
||||
pls, err = s.checkTracksEditable(ctx, playlistID)
|
||||
} else {
|
||||
pls, err = s.checkWritable(ctx, playlistID)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
|
||||
repo := tx.Playlist(ctx)
|
||||
|
||||
if len(idxToRemove) > 0 {
|
||||
tracksRepo := repo.Tracks(playlistID, false)
|
||||
// Convert 0-based indices to 1-based position IDs and delete them directly,
|
||||
// avoiding the need to load all tracks into memory.
|
||||
positions := make([]string, len(idxToRemove))
|
||||
for i, idx := range idxToRemove {
|
||||
positions[i] = strconv.Itoa(idx + 1)
|
||||
}
|
||||
if err := tracksRepo.Delete(positions...); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(idsToAdd) > 0 {
|
||||
if _, err := tracksRepo.Add(idsToAdd); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.updateMetadata(ctx, tx, pls, name, comment, public)
|
||||
}
|
||||
|
||||
if len(idsToAdd) > 0 {
|
||||
if _, err := repo.Tracks(playlistID, false).Add(idsToAdd); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if name == nil && comment == nil && public == nil {
|
||||
return nil
|
||||
}
|
||||
// Reuse the playlist from checkWritable (no tracks loaded, so Put only refreshes counters)
|
||||
return s.updateMetadata(ctx, tx, pls, name, comment, public)
|
||||
})
|
||||
}
|
||||
|
||||
// --- Permission helpers ---
|
||||
|
||||
// checkWritable fetches the playlist and verifies the current user can modify it.
|
||||
func (s *playlists) checkWritable(ctx context.Context, id string) (*model.Playlist, error) {
|
||||
pls, err := s.ds.Playlist(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
usr, _ := request.UserFrom(ctx)
|
||||
if !usr.IsAdmin && pls.OwnerID != usr.ID {
|
||||
return nil, model.ErrNotAuthorized
|
||||
}
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
// checkTracksEditable verifies the user can modify tracks (ownership + not smart playlist).
|
||||
func (s *playlists) checkTracksEditable(ctx context.Context, playlistID string) (*model.Playlist, error) {
|
||||
pls, err := s.checkWritable(ctx, playlistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pls.IsSmartPlaylist() {
|
||||
return nil, model.ErrNotAuthorized
|
||||
}
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
// updateMetadata applies optional metadata changes to a playlist and persists it.
|
||||
// Accepts a DataStore parameter so it can be used inside transactions.
|
||||
// The caller is responsible for permission checks.
|
||||
func (s *playlists) updateMetadata(ctx context.Context, ds model.DataStore, pls *model.Playlist, name *string, comment *string, public *bool) error {
|
||||
if name != nil {
|
||||
pls.Name = *name
|
||||
}
|
||||
if comment != nil {
|
||||
pls.Comment = *comment
|
||||
}
|
||||
if public != nil {
|
||||
pls.Public = *public
|
||||
}
|
||||
return ds.Playlist(ctx).Put(pls)
|
||||
}
|
||||
|
||||
// --- Track management operations ---
|
||||
|
||||
func (s *playlists) AddTracks(ctx context.Context, playlistID string, ids []string) (int, error) {
|
||||
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.ds.Playlist(ctx).Tracks(playlistID, false).Add(ids)
|
||||
}
|
||||
|
||||
func (s *playlists) AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error) {
|
||||
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddAlbums(albumIds)
|
||||
}
|
||||
|
||||
func (s *playlists) AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error) {
|
||||
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddArtists(artistIds)
|
||||
}
|
||||
|
||||
func (s *playlists) AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error) {
|
||||
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddDiscs(discs)
|
||||
}
|
||||
|
||||
func (s *playlists) RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error {
|
||||
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.ds.WithTx(func(tx model.DataStore) error {
|
||||
return tx.Playlist(ctx).Tracks(playlistID, false).Delete(trackIds...)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error {
|
||||
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.ds.WithTx(func(tx model.DataStore) error {
|
||||
return tx.Playlist(ctx).Tracks(playlistID, false).Reorder(pos, newPos)
|
||||
})
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
package playlists_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Playlists", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var ps playlists.Playlists
|
||||
var mockPlsRepo *tests.MockPlaylistRepo
|
||||
ctx := context.Background()
|
||||
|
||||
BeforeEach(func() {
|
||||
mockPlsRepo = tests.CreateMockPlaylistRepo()
|
||||
ds = &tests.MockDataStore{
|
||||
MockedPlaylist: mockPlsRepo,
|
||||
MockedLibrary: &tests.MockLibraryRepo{},
|
||||
}
|
||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||
})
|
||||
|
||||
Describe("Delete", func() {
|
||||
var mockTracks *tests.MockPlaylistTrackRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 3}
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
It("allows owner to delete their playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.Delete(ctx, "pls-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
|
||||
})
|
||||
|
||||
It("allows admin to delete any playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
||||
err := ps.Delete(ctx, "pls-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
|
||||
})
|
||||
|
||||
It("denies non-owner, non-admin from deleting", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
err := ps.Delete(ctx, "pls-1")
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
Expect(mockPlsRepo.Deleted).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns error when playlist not found", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.Delete(ctx, "nonexistent")
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Create", func() {
|
||||
BeforeEach(func() {
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "Existing", OwnerID: "user-1"},
|
||||
"pls-2": {ID: "pls-2", Name: "Other's", OwnerID: "other-user"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
It("creates a new playlist with owner set from context", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
id, err := ps.Create(ctx, "", "New Playlist", []string{"song-1", "song-2"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(id).ToNot(BeEmpty())
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("New Playlist"))
|
||||
Expect(mockPlsRepo.Last.OwnerID).To(Equal("user-1"))
|
||||
})
|
||||
|
||||
It("replaces tracks on existing playlist when owner matches", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
id, err := ps.Create(ctx, "pls-1", "", []string{"song-3"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(id).To(Equal("pls-1"))
|
||||
Expect(mockPlsRepo.Last.Tracks).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("allows admin to replace tracks on any playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
||||
id, err := ps.Create(ctx, "pls-2", "", []string{"song-3"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(id).To(Equal("pls-2"))
|
||||
})
|
||||
|
||||
It("denies non-owner, non-admin from replacing tracks on existing playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.Create(ctx, "pls-2", "", []string{"song-3"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("returns error when existing playlistId not found", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.Create(ctx, "nonexistent", "", []string{"song-1"})
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("denies replacing tracks on a smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.Create(ctx, "pls-smart", "", []string{"song-1"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Update", func() {
|
||||
var mockTracks *tests.MockPlaylistTrackRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2}
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
It("allows owner to update their playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
newName := "Updated Name"
|
||||
err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("allows admin to update any playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
||||
newName := "Updated Name"
|
||||
err := ps.Update(ctx, "pls-other", &newName, nil, nil, nil, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("denies non-owner, non-admin from updating", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
newName := "Updated Name"
|
||||
err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil)
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("returns error when playlist not found", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
newName := "Updated Name"
|
||||
err := ps.Update(ctx, "nonexistent", &newName, nil, nil, nil, nil)
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("denies adding tracks to a smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.Update(ctx, "pls-smart", nil, nil, nil, []string{"song-1"}, nil)
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("denies removing tracks from a smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.Update(ctx, "pls-smart", nil, nil, nil, nil, []int{0})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("allows metadata updates on a smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
newName := "Updated Smart"
|
||||
err := ps.Update(ctx, "pls-smart", &newName, nil, nil, nil, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("AddTracks", func() {
|
||||
var mockTracks *tests.MockPlaylistTrackRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2}
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
It("allows owner to add tracks", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
count, err := ps.AddTracks(ctx, "pls-1", []string{"song-1", "song-2"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(2))
|
||||
Expect(mockTracks.AddedIds).To(ConsistOf("song-1", "song-2"))
|
||||
})
|
||||
|
||||
It("allows admin to add tracks to any playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
||||
count, err := ps.AddTracks(ctx, "pls-other", []string{"song-1"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(2))
|
||||
})
|
||||
|
||||
It("denies non-owner, non-admin", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
_, err := ps.AddTracks(ctx, "pls-1", []string{"song-1"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("denies editing smart playlists", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.AddTracks(ctx, "pls-smart", []string{"song-1"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("returns error when playlist not found", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.AddTracks(ctx, "nonexistent", []string{"song-1"})
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("RemoveTracks", func() {
|
||||
var mockTracks *tests.MockPlaylistTrackRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
mockTracks = &tests.MockPlaylistTrackRepo{}
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
It("allows owner to remove tracks", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1", "track-2"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockTracks.DeletedIds).To(ConsistOf("track-1", "track-2"))
|
||||
})
|
||||
|
||||
It("denies on smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.RemoveTracks(ctx, "pls-smart", []string{"track-1"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("denies non-owner", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ReorderTrack", func() {
|
||||
var mockTracks *tests.MockPlaylistTrackRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
mockTracks = &tests.MockPlaylistTrackRepo{}
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
It("allows owner to reorder", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.ReorderTrack(ctx, "pls-1", 1, 3)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockTracks.Reordered).To(BeTrue())
|
||||
})
|
||||
|
||||
It("denies on smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.ReorderTrack(ctx, "pls-smart", 1, 3)
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,95 +0,0 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
)
|
||||
|
||||
// --- REST adapter (follows Share/Library pattern) ---
|
||||
|
||||
func (s *playlists) NewRepository(ctx context.Context) rest.Repository {
|
||||
return &playlistRepositoryWrapper{
|
||||
ctx: ctx,
|
||||
PlaylistRepository: s.ds.Playlist(ctx),
|
||||
service: s,
|
||||
}
|
||||
}
|
||||
|
||||
// playlistRepositoryWrapper wraps the playlist repository as a thin REST-to-service adapter.
|
||||
// It satisfies rest.Repository through the embedded PlaylistRepository (via ResourceRepository),
|
||||
// and rest.Persistable by delegating to service methods for all mutations.
|
||||
type playlistRepositoryWrapper struct {
|
||||
model.PlaylistRepository
|
||||
ctx context.Context
|
||||
service *playlists
|
||||
}
|
||||
|
||||
func (r *playlistRepositoryWrapper) Save(entity any) (string, error) {
|
||||
return r.service.savePlaylist(r.ctx, entity.(*model.Playlist))
|
||||
}
|
||||
|
||||
func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string) error {
|
||||
return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist), cols...)
|
||||
}
|
||||
|
||||
func (r *playlistRepositoryWrapper) Delete(id string) error {
|
||||
err := r.service.Delete(r.ctx, id)
|
||||
switch {
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
return rest.ErrNotFound
|
||||
case errors.Is(err, model.ErrNotAuthorized):
|
||||
return rest.ErrPermissionDenied
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (s *playlists) TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository {
|
||||
repo := s.ds.Playlist(ctx)
|
||||
tracks := repo.Tracks(playlistId, refreshSmartPlaylist)
|
||||
if tracks == nil {
|
||||
return nil
|
||||
}
|
||||
return tracks.(rest.Repository)
|
||||
}
|
||||
|
||||
// savePlaylist creates a new playlist, assigning the owner from context.
|
||||
func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (string, error) {
|
||||
usr, _ := request.UserFrom(ctx)
|
||||
pls.OwnerID = usr.ID
|
||||
pls.ID = "" // Force new creation
|
||||
err := s.ds.Playlist(ctx).Put(pls)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return pls.ID, nil
|
||||
}
|
||||
|
||||
// updatePlaylistEntity updates playlist metadata with permission checks.
|
||||
// Used by the REST API wrapper.
|
||||
func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error {
|
||||
current, err := s.checkWritable(ctx, id)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
return rest.ErrNotFound
|
||||
case errors.Is(err, model.ErrNotAuthorized):
|
||||
return rest.ErrPermissionDenied
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
usr, _ := request.UserFrom(ctx)
|
||||
if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
// Apply ownership change (admin only)
|
||||
if entity.OwnerID != "" {
|
||||
current.OwnerID = entity.OwnerID
|
||||
}
|
||||
return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public)
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package playlists_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("REST Adapter", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var ps playlists.Playlists
|
||||
var mockPlsRepo *tests.MockPlaylistRepo
|
||||
ctx := context.Background()
|
||||
|
||||
BeforeEach(func() {
|
||||
mockPlsRepo = tests.CreateMockPlaylistRepo()
|
||||
ds = &tests.MockDataStore{
|
||||
MockedPlaylist: mockPlsRepo,
|
||||
MockedLibrary: &tests.MockLibraryRepo{},
|
||||
}
|
||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||
})
|
||||
|
||||
Describe("NewRepository", func() {
|
||||
var repo rest.Persistable
|
||||
|
||||
BeforeEach(func() {
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
Describe("Save", func() {
|
||||
It("sets the owner from the context user", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "New Playlist"}
|
||||
id, err := repo.Save(pls)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(id).ToNot(BeEmpty())
|
||||
Expect(pls.OwnerID).To(Equal("user-1"))
|
||||
})
|
||||
|
||||
It("forces a new creation by clearing ID", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{ID: "should-be-cleared", Name: "New"}
|
||||
_, err := repo.Save(pls)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ID).ToNot(Equal("should-be-cleared"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Update", func() {
|
||||
It("allows owner to update their playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "Updated"}
|
||||
err := repo.Update("pls-1", pls)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("allows admin to update any playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "Updated"}
|
||||
err := repo.Update("pls-1", pls)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("denies non-owner, non-admin", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "Updated"}
|
||||
err := repo.Update("pls-1", pls)
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
|
||||
It("denies regular user from changing ownership", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "Updated", OwnerID: "other-user"}
|
||||
err := repo.Update("pls-1", pls)
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
|
||||
It("returns rest.ErrNotFound when playlist doesn't exist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "Updated"}
|
||||
err := repo.Update("nonexistent", pls)
|
||||
Expect(err).To(Equal(rest.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Delete", func() {
|
||||
It("delegates to service Delete with permission checks", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Delete("pls-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
|
||||
})
|
||||
|
||||
It("denies non-owner", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Delete("pls-1")
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
package playlists
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -214,38 +214,38 @@ var _ = Describe("pathResolver", func() {
|
||||
})
|
||||
|
||||
Describe("resolvePath", func() {
|
||||
Context("basic", func() {
|
||||
It("resolves absolute paths", func() {
|
||||
resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
|
||||
It("resolves absolute paths", func() {
|
||||
resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
|
||||
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(1))
|
||||
Expect(resolution.libraryPath).To(Equal("/music"))
|
||||
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
|
||||
})
|
||||
|
||||
It("resolves relative paths when folder is provided", func() {
|
||||
folder := &model.Folder{
|
||||
Path: "playlists",
|
||||
LibraryPath: "/music",
|
||||
LibraryID: 1,
|
||||
}
|
||||
|
||||
resolution := resolver.resolvePath("../artist/album/track.mp3", folder)
|
||||
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(1))
|
||||
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
|
||||
})
|
||||
|
||||
It("returns invalid resolution for paths outside any library", func() {
|
||||
resolution := resolver.resolvePath("/outside/library/track.mp3", nil)
|
||||
|
||||
Expect(resolution.valid).To(BeFalse())
|
||||
})
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(1))
|
||||
Expect(resolution.libraryPath).To(Equal("/music"))
|
||||
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
|
||||
})
|
||||
|
||||
Context("cross-library", func() {
|
||||
It("resolves relative paths when folder is provided", func() {
|
||||
folder := &model.Folder{
|
||||
Path: "playlists",
|
||||
LibraryPath: "/music",
|
||||
LibraryID: 1,
|
||||
}
|
||||
|
||||
resolution := resolver.resolvePath("../artist/album/track.mp3", folder)
|
||||
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(1))
|
||||
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
|
||||
})
|
||||
|
||||
It("returns invalid resolution for paths outside any library", func() {
|
||||
resolution := resolver.resolvePath("/outside/library/track.mp3", nil)
|
||||
|
||||
Expect(resolution.valid).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("resolvePath", func() {
|
||||
Context("With absolute paths", func() {
|
||||
It("resolves path within a library", func() {
|
||||
resolution := resolver.resolvePath("/music/track.mp3", nil)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package playlists_test
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -19,18 +19,18 @@ import (
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
var _ = Describe("Playlists - Import", func() {
|
||||
var _ = Describe("Playlists", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var ps playlists.Playlists
|
||||
var mockPlsRepo *tests.MockPlaylistRepo
|
||||
var ps core.Playlists
|
||||
var mockPlsRepo mockedPlaylistRepo
|
||||
var mockLibRepo *tests.MockLibraryRepo
|
||||
ctx := context.Background()
|
||||
|
||||
BeforeEach(func() {
|
||||
mockPlsRepo = tests.CreateMockPlaylistRepo()
|
||||
mockPlsRepo = mockedPlaylistRepo{}
|
||||
mockLibRepo = &tests.MockLibraryRepo{}
|
||||
ds = &tests.MockDataStore{
|
||||
MockedPlaylist: mockPlsRepo,
|
||||
MockedPlaylist: &mockPlsRepo,
|
||||
MockedLibrary: mockLibRepo,
|
||||
}
|
||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||
@@ -39,7 +39,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
Describe("ImportFile", func() {
|
||||
var folder *model.Folder
|
||||
BeforeEach(func() {
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = core.NewPlaylists(ds)
|
||||
ds.MockedMediaFile = &mockedMediaFileRepo{}
|
||||
libPath, _ := os.Getwd()
|
||||
// Set up library with the actual library path that matches the folder
|
||||
@@ -61,7 +61,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
Expect(pls.Tracks).To(HaveLen(2))
|
||||
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
|
||||
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/playlists/test.ogg"))
|
||||
Expect(mockPlsRepo.Last).To(Equal(pls))
|
||||
Expect(mockPlsRepo.last).To(Equal(pls))
|
||||
})
|
||||
|
||||
It("parses playlists using LF ending", func() {
|
||||
@@ -99,7 +99,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
It("parses well-formed playlists", func() {
|
||||
pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last).To(Equal(pls))
|
||||
Expect(mockPlsRepo.last).To(Equal(pls))
|
||||
Expect(pls.OwnerID).To(Equal("123"))
|
||||
Expect(pls.Name).To(Equal("Recently Played"))
|
||||
Expect(pls.Comment).To(Equal("Recently played tracks"))
|
||||
@@ -149,7 +149,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = core.NewPlaylists(ds)
|
||||
|
||||
// Create the playlist file on disk with the filesystem's normalization form
|
||||
plsFile := tmpDir + "/" + filesystemName + ".m3u"
|
||||
@@ -163,7 +163,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
Path: storedPath,
|
||||
Sync: true,
|
||||
}
|
||||
mockPlsRepo.PathMap = map[string]*model.Playlist{storedPath: existingPls}
|
||||
mockPlsRepo.data = map[string]*model.Playlist{storedPath: existingPls}
|
||||
|
||||
// Import using the filesystem's normalization form
|
||||
plsFolder := &model.Folder{
|
||||
@@ -209,7 +209,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
"def.mp3", // This is playlists/def.mp3 relative to plsDir
|
||||
},
|
||||
}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = core.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
It("handles relative paths that reference files in other libraries", func() {
|
||||
@@ -365,7 +365,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
},
|
||||
}
|
||||
// Recreate playlists service to pick up new mock
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = core.NewPlaylists(ds)
|
||||
|
||||
// Create playlist in music library that references both tracks
|
||||
plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3"
|
||||
@@ -408,7 +408,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
BeforeEach(func() {
|
||||
repo = &mockedMediaFileFromListRepo{}
|
||||
ds.MockedMediaFile = repo
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = core.NewPlaylists(ds)
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
|
||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||
})
|
||||
@@ -439,7 +439,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
Expect(pls.Tracks[1].Path).To(Equal("tests/test.ogg"))
|
||||
Expect(pls.Tracks[2].Path).To(Equal("downloads/newfile.flac"))
|
||||
Expect(pls.Tracks[3].Path).To(Equal("tests/01 Invisible (RED) Edit Version.mp3"))
|
||||
Expect(mockPlsRepo.Last).To(Equal(pls))
|
||||
Expect(mockPlsRepo.last).To(Equal(pls))
|
||||
})
|
||||
|
||||
It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() {
|
||||
@@ -460,7 +460,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
Expect(pls.Tracks).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("returns only tracks that exist in the database and in the same order as the m3u", func() {
|
||||
It("returns only tracks that exist in the database and in the same other as the m3u", func() {
|
||||
repo.data = []string{
|
||||
"album1/test1.mp3",
|
||||
"album2/test2.mp3",
|
||||
@@ -570,7 +570,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
|
||||
})
|
||||
|
||||
Describe("InPath", func() {
|
||||
Describe("InPlaylistsPath", func() {
|
||||
var folder model.Folder
|
||||
|
||||
BeforeEach(func() {
|
||||
@@ -584,27 +584,27 @@ var _ = Describe("Playlists - Import", func() {
|
||||
|
||||
It("returns true if PlaylistsPath is empty", func() {
|
||||
conf.Server.PlaylistsPath = ""
|
||||
Expect(playlists.InPath(folder)).To(BeTrue())
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns true if PlaylistsPath is any (**/**)", func() {
|
||||
conf.Server.PlaylistsPath = "**/**"
|
||||
Expect(playlists.InPath(folder)).To(BeTrue())
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns true if folder is in PlaylistsPath", func() {
|
||||
conf.Server.PlaylistsPath = "other/**:playlists/**"
|
||||
Expect(playlists.InPath(folder)).To(BeTrue())
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false if folder is not in PlaylistsPath", func() {
|
||||
conf.Server.PlaylistsPath = "other"
|
||||
Expect(playlists.InPath(folder)).To(BeFalse())
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() {
|
||||
conf.Server.PlaylistsPath = "."
|
||||
Expect(playlists.InPath(folder)).To(BeFalse())
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
|
||||
|
||||
folder2 := model.Folder{
|
||||
LibraryPath: "/music",
|
||||
@@ -612,7 +612,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
Name: ".",
|
||||
}
|
||||
|
||||
Expect(playlists.InPath(folder2)).To(BeTrue())
|
||||
Expect(core.InPlaylistsPath(folder2)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -693,3 +693,23 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi
|
||||
}
|
||||
return mfs, nil
|
||||
}
|
||||
|
||||
type mockedPlaylistRepo struct {
|
||||
last *model.Playlist
|
||||
data map[string]*model.Playlist // keyed by path
|
||||
model.PlaylistRepository
|
||||
}
|
||||
|
||||
func (r *mockedPlaylistRepo) FindByPath(path string) (*model.Playlist, error) {
|
||||
if r.data != nil {
|
||||
if pls, ok := r.data[path]; ok {
|
||||
return pls, nil
|
||||
}
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (r *mockedPlaylistRepo) Put(pls *model.Playlist) error {
|
||||
r.last = pls
|
||||
return nil
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func newLocalStorage(u url.URL) storage.Storage {
|
||||
|
||||
func (s *localStorage) FS() (storage.MusicFS, error) {
|
||||
path := s.u.Path
|
||||
if _, err := os.Stat(path); err != nil { //nolint:gosec
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", err, path)
|
||||
}
|
||||
return &localFS{FS: os.DirFS(path), extractor: s.extractor}, nil
|
||||
|
||||
87
core/transcode/aliases.go
Normal file
87
core/transcode/aliases.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package transcode
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// containerAliasGroups maps each container alias to a canonical group name.
|
||||
var containerAliasGroups = func() map[string]string {
|
||||
groups := [][]string{
|
||||
{"aac", "adts", "m4a", "mp4", "m4b", "m4p"},
|
||||
{"mpeg", "mp3", "mp2"},
|
||||
{"ogg", "oga"},
|
||||
{"aif", "aiff"},
|
||||
{"asf", "wma"},
|
||||
{"mpc", "mpp"},
|
||||
{"wv"},
|
||||
}
|
||||
m := make(map[string]string)
|
||||
for _, g := range groups {
|
||||
canonical := g[0]
|
||||
for _, name := range g {
|
||||
m[name] = canonical
|
||||
}
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// codecAliasGroups maps each codec alias to a canonical group name.
|
||||
// Codecs within the same group are considered equivalent.
|
||||
var codecAliasGroups = func() map[string]string {
|
||||
groups := [][]string{
|
||||
{"aac", "adts"},
|
||||
{"ac3", "ac-3"},
|
||||
{"eac3", "e-ac3", "e-ac-3", "eac-3"},
|
||||
{"mpc7", "musepack7"},
|
||||
{"mpc8", "musepack8"},
|
||||
{"wma1", "wmav1"},
|
||||
{"wma2", "wmav2"},
|
||||
{"wmalossless", "wma9lossless"},
|
||||
{"wmapro", "wma9pro"},
|
||||
{"shn", "shorten"},
|
||||
{"mp4als", "als"},
|
||||
}
|
||||
m := make(map[string]string)
|
||||
for _, g := range groups {
|
||||
for _, name := range g {
|
||||
m[name] = g[0] // canonical = first entry
|
||||
}
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// matchesWithAliases checks if a value matches any entry in candidates,
|
||||
// consulting the alias map for equivalent names.
|
||||
func matchesWithAliases(value string, candidates []string, aliases map[string]string) bool {
|
||||
value = strings.ToLower(value)
|
||||
canonical := aliases[value]
|
||||
for _, c := range candidates {
|
||||
c = strings.ToLower(c)
|
||||
if c == value {
|
||||
return true
|
||||
}
|
||||
if canonical != "" && aliases[c] == canonical {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesContainer checks if a file suffix matches any of the container names,
|
||||
// including common aliases.
|
||||
func matchesContainer(suffix string, containers []string) bool {
|
||||
return matchesWithAliases(suffix, containers, containerAliasGroups)
|
||||
}
|
||||
|
||||
// matchesCodec checks if a codec matches any of the codec names,
|
||||
// including common aliases.
|
||||
func matchesCodec(codec string, codecs []string) bool {
|
||||
return matchesWithAliases(codec, codecs, codecAliasGroups)
|
||||
}
|
||||
|
||||
func containsIgnoreCase(slice []string, s string) bool {
|
||||
return slices.ContainsFunc(slice, func(item string) bool {
|
||||
return strings.EqualFold(item, s)
|
||||
})
|
||||
}
|
||||
59
core/transcode/codec.go
Normal file
59
core/transcode/codec.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package transcode
|
||||
|
||||
import "strings"
|
||||
|
||||
// isLosslessFormat returns true if the format is a lossless audio codec/format.
|
||||
// Note: core/ffmpeg has a separate isLosslessOutputFormat that covers only formats
|
||||
// ffmpeg can produce as output (a smaller set). This function covers all known lossless formats
|
||||
// for transcoding decision purposes.
|
||||
func isLosslessFormat(format string) bool {
|
||||
switch strings.ToLower(format) {
|
||||
case "flac", "alac", "wav", "aiff", "ape", "wv", "tta", "tak", "shn", "dsd", "pcm":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeSourceSampleRate adjusts the source sample rate for codecs that store
|
||||
// it differently than PCM. Currently handles DSD (÷8):
|
||||
// DSD64=2822400→352800, DSD128=5644800→705600, etc.
|
||||
// For other codecs, returns the rate unchanged.
|
||||
func normalizeSourceSampleRate(sampleRate int, codec string) int {
|
||||
if strings.EqualFold(codec, "dsd") && sampleRate > 0 {
|
||||
return sampleRate / 8
|
||||
}
|
||||
return sampleRate
|
||||
}
|
||||
|
||||
// normalizeSourceBitDepth adjusts the source bit depth for codecs that use
|
||||
// non-standard bit depths. Currently handles DSD (1-bit → 24-bit PCM, which is
|
||||
// what ffmpeg produces). For other codecs, returns the depth unchanged.
|
||||
func normalizeSourceBitDepth(bitDepth int, codec string) int {
|
||||
if strings.EqualFold(codec, "dsd") && bitDepth == 1 {
|
||||
return 24
|
||||
}
|
||||
return bitDepth
|
||||
}
|
||||
|
||||
// codecFixedOutputSampleRate returns the mandatory output sample rate for codecs
|
||||
// that always resample regardless of input (e.g., Opus always outputs 48000Hz).
|
||||
// Returns 0 if the codec has no fixed output rate.
|
||||
func codecFixedOutputSampleRate(codec string) int {
|
||||
switch strings.ToLower(codec) {
|
||||
case "opus":
|
||||
return 48000
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// codecMaxSampleRate returns the hard maximum output sample rate for a codec.
|
||||
// Returns 0 if the codec has no hard limit.
|
||||
func codecMaxSampleRate(codec string) int {
|
||||
switch strings.ToLower(codec) {
|
||||
case "mp3":
|
||||
return 48000
|
||||
case "aac":
|
||||
return 96000
|
||||
}
|
||||
return 0
|
||||
}
|
||||
206
core/transcode/limitations.go
Normal file
206
core/transcode/limitations.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package transcode
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// adjustResult represents the outcome of applying a limitation to a transcoded stream value
|
||||
type adjustResult int
|
||||
|
||||
const (
|
||||
adjustNone adjustResult = iota // Value already satisfies the limitation
|
||||
adjustAdjusted // Value was changed to fit the limitation
|
||||
adjustCannotFit // Cannot satisfy the limitation (reject this profile)
|
||||
)
|
||||
|
||||
// checkLimitations checks codec profile limitations against source media.
|
||||
// Returns "" if all limitations pass, or a typed reason string for the first failure.
|
||||
func checkLimitations(mf *model.MediaFile, sourceBitrate int, limitations []Limitation) string {
|
||||
for _, lim := range limitations {
|
||||
var ok bool
|
||||
var reason string
|
||||
|
||||
switch lim.Name {
|
||||
case LimitationAudioChannels:
|
||||
ok = checkIntLimitation(mf.Channels, lim.Comparison, lim.Values)
|
||||
reason = "audio channels not supported"
|
||||
case LimitationAudioSamplerate:
|
||||
ok = checkIntLimitation(mf.SampleRate, lim.Comparison, lim.Values)
|
||||
reason = "audio samplerate not supported"
|
||||
case LimitationAudioBitrate:
|
||||
ok = checkIntLimitation(sourceBitrate, lim.Comparison, lim.Values)
|
||||
reason = "audio bitrate not supported"
|
||||
case LimitationAudioBitdepth:
|
||||
ok = checkIntLimitation(mf.BitDepth, lim.Comparison, lim.Values)
|
||||
reason = "audio bitdepth not supported"
|
||||
case LimitationAudioProfile:
|
||||
// TODO: populate source profile when MediaFile has audio profile info
|
||||
ok = checkStringLimitation("", lim.Comparison, lim.Values)
|
||||
reason = "audio profile not supported"
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
if !ok && lim.Required {
|
||||
return reason
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// applyLimitation adjusts a transcoded stream parameter to satisfy the limitation.
|
||||
// Returns the adjustment result.
|
||||
func applyLimitation(sourceBitrate int, lim *Limitation, ts *StreamDetails) adjustResult {
|
||||
switch lim.Name {
|
||||
case LimitationAudioChannels:
|
||||
return applyIntLimitation(lim.Comparison, lim.Values, ts.Channels, func(v int) { ts.Channels = v })
|
||||
case LimitationAudioBitrate:
|
||||
current := ts.Bitrate
|
||||
if current == 0 {
|
||||
current = sourceBitrate
|
||||
}
|
||||
return applyIntLimitation(lim.Comparison, lim.Values, current, func(v int) { ts.Bitrate = v })
|
||||
case LimitationAudioSamplerate:
|
||||
return applyIntLimitation(lim.Comparison, lim.Values, ts.SampleRate, func(v int) { ts.SampleRate = v })
|
||||
case LimitationAudioBitdepth:
|
||||
if ts.BitDepth > 0 {
|
||||
return applyIntLimitation(lim.Comparison, lim.Values, ts.BitDepth, func(v int) { ts.BitDepth = v })
|
||||
}
|
||||
case LimitationAudioProfile:
|
||||
// TODO: implement when audio profile data is available
|
||||
}
|
||||
return adjustNone
|
||||
}
|
||||
|
||||
// applyIntLimitation applies a limitation comparison to a value.
|
||||
// If the value needs adjusting, calls the setter and returns the result.
|
||||
func applyIntLimitation(comparison string, values []string, current int, setter func(int)) adjustResult {
|
||||
if len(values) == 0 {
|
||||
return adjustNone
|
||||
}
|
||||
|
||||
switch comparison {
|
||||
case ComparisonLessThanEqual:
|
||||
limit, ok := parseInt(values[0])
|
||||
if !ok {
|
||||
return adjustNone
|
||||
}
|
||||
if current <= limit {
|
||||
return adjustNone
|
||||
}
|
||||
setter(limit)
|
||||
return adjustAdjusted
|
||||
case ComparisonGreaterThanEqual:
|
||||
limit, ok := parseInt(values[0])
|
||||
if !ok {
|
||||
return adjustNone
|
||||
}
|
||||
if current >= limit {
|
||||
return adjustNone
|
||||
}
|
||||
// Cannot upscale
|
||||
return adjustCannotFit
|
||||
case ComparisonEquals:
|
||||
// Check if current value matches any allowed value
|
||||
for _, v := range values {
|
||||
if limit, ok := parseInt(v); ok && current == limit {
|
||||
return adjustNone
|
||||
}
|
||||
}
|
||||
// Find the closest allowed value below current (don't upscale)
|
||||
var closest int
|
||||
found := false
|
||||
for _, v := range values {
|
||||
if limit, ok := parseInt(v); ok && limit < current {
|
||||
if !found || limit > closest {
|
||||
closest = limit
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if found {
|
||||
setter(closest)
|
||||
return adjustAdjusted
|
||||
}
|
||||
return adjustCannotFit
|
||||
case ComparisonNotEquals:
|
||||
for _, v := range values {
|
||||
if limit, ok := parseInt(v); ok && current == limit {
|
||||
return adjustCannotFit
|
||||
}
|
||||
}
|
||||
return adjustNone
|
||||
}
|
||||
|
||||
return adjustNone
|
||||
}
|
||||
|
||||
func checkIntLimitation(value int, comparison string, values []string) bool {
|
||||
if len(values) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
switch comparison {
|
||||
case ComparisonLessThanEqual:
|
||||
limit, ok := parseInt(values[0])
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return value <= limit
|
||||
case ComparisonGreaterThanEqual:
|
||||
limit, ok := parseInt(values[0])
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return value >= limit
|
||||
case ComparisonEquals:
|
||||
for _, v := range values {
|
||||
if limit, ok := parseInt(v); ok && value == limit {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case ComparisonNotEquals:
|
||||
for _, v := range values {
|
||||
if limit, ok := parseInt(v); ok && value == limit {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// checkStringLimitation checks a string value against a limitation.
|
||||
// Only Equals and NotEquals comparisons are meaningful for strings.
|
||||
// LessThanEqual/GreaterThanEqual are not applicable and always pass.
|
||||
func checkStringLimitation(value string, comparison string, values []string) bool {
|
||||
switch comparison {
|
||||
case ComparisonEquals:
|
||||
for _, v := range values {
|
||||
if strings.EqualFold(value, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case ComparisonNotEquals:
|
||||
for _, v := range values {
|
||||
if strings.EqualFold(value, v) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseInt(s string) (int, bool) {
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil || v < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
400
core/transcode/transcode.go
Normal file
400
core/transcode/transcode.go
Normal file
@@ -0,0 +1,400 @@
|
||||
package transcode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
const (
|
||||
tokenTTL = 12 * time.Hour
|
||||
defaultBitrate = 256 // kbps
|
||||
|
||||
// JWT claim keys for transcode params tokens
|
||||
claimMediaID = "mid" // Media file ID
|
||||
claimDirectPlay = "dp" // Direct play flag (bool)
|
||||
claimUpdatedAt = "ua" // Source file updated-at (Unix seconds)
|
||||
claimFormat = "fmt" // Target transcoding format
|
||||
claimBitrate = "br" // Target bitrate (kbps)
|
||||
claimChannels = "ch" // Target channels
|
||||
claimSampleRate = "sr" // Target sample rate (Hz)
|
||||
claimBitDepth = "bd" // Target bit depth
|
||||
)
|
||||
|
||||
func NewDecider(ds model.DataStore) Decider {
|
||||
return &deciderService{
|
||||
ds: ds,
|
||||
}
|
||||
}
|
||||
|
||||
type deciderService struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo) (*Decision, error) {
|
||||
decision := &Decision{
|
||||
MediaID: mf.ID,
|
||||
SourceUpdatedAt: mf.UpdatedAt,
|
||||
}
|
||||
|
||||
sourceBitrate := mf.BitRate // kbps
|
||||
|
||||
log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", mf.Suffix,
|
||||
"codec", mf.AudioCodec(), "bitrate", sourceBitrate, "channels", mf.Channels,
|
||||
"sampleRate", mf.SampleRate, "lossless", mf.IsLossless(), "client", clientInfo.Name)
|
||||
|
||||
// Build source stream details
|
||||
decision.SourceStream = buildSourceStream(mf)
|
||||
|
||||
// Check global bitrate constraint first.
|
||||
if clientInfo.MaxAudioBitrate > 0 && sourceBitrate > clientInfo.MaxAudioBitrate {
|
||||
log.Trace(ctx, "Global bitrate constraint exceeded, skipping direct play",
|
||||
"sourceBitrate", sourceBitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate)
|
||||
decision.TranscodeReasons = append(decision.TranscodeReasons, "audio bitrate not supported")
|
||||
// Skip direct play profiles entirely — global constraint fails
|
||||
} else {
|
||||
// Try direct play profiles, collecting reasons for each failure
|
||||
for _, profile := range clientInfo.DirectPlayProfiles {
|
||||
if reason := s.checkDirectPlayProfile(mf, sourceBitrate, &profile, clientInfo); reason == "" {
|
||||
decision.CanDirectPlay = true
|
||||
decision.TranscodeReasons = nil // Clear any previously collected reasons
|
||||
break
|
||||
} else {
|
||||
decision.TranscodeReasons = append(decision.TranscodeReasons, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If direct play is possible, we're done
|
||||
if decision.CanDirectPlay {
|
||||
log.Debug(ctx, "Transcode decision: direct play", "mediaID", mf.ID, "container", mf.Suffix, "codec", mf.AudioCodec())
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
// Try transcoding profiles (in order of preference)
|
||||
for _, profile := range clientInfo.TranscodingProfiles {
|
||||
if ts, transcodeFormat := s.computeTranscodedStream(ctx, mf, sourceBitrate, &profile, clientInfo); ts != nil {
|
||||
decision.CanTranscode = true
|
||||
decision.TargetFormat = transcodeFormat
|
||||
decision.TargetBitrate = ts.Bitrate
|
||||
decision.TargetChannels = ts.Channels
|
||||
decision.TargetSampleRate = ts.SampleRate
|
||||
decision.TargetBitDepth = ts.BitDepth
|
||||
decision.TranscodeStream = ts
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if decision.CanTranscode {
|
||||
log.Debug(ctx, "Transcode decision: transcode", "mediaID", mf.ID,
|
||||
"targetFormat", decision.TargetFormat, "targetBitrate", decision.TargetBitrate,
|
||||
"targetChannels", decision.TargetChannels, "reasons", decision.TranscodeReasons)
|
||||
}
|
||||
|
||||
// If neither direct play nor transcode is possible
|
||||
if !decision.CanDirectPlay && !decision.CanTranscode {
|
||||
decision.ErrorReason = "no compatible playback profile found"
|
||||
log.Warn(ctx, "Transcode decision: no compatible profile", "mediaID", mf.ID,
|
||||
"container", mf.Suffix, "codec", mf.AudioCodec(), "reasons", decision.TranscodeReasons)
|
||||
}
|
||||
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func buildSourceStream(mf *model.MediaFile) StreamDetails {
|
||||
return StreamDetails{
|
||||
Container: mf.Suffix,
|
||||
Codec: mf.AudioCodec(),
|
||||
Bitrate: mf.BitRate,
|
||||
SampleRate: mf.SampleRate,
|
||||
BitDepth: mf.BitDepth,
|
||||
Channels: mf.Channels,
|
||||
Duration: mf.Duration,
|
||||
Size: mf.Size,
|
||||
IsLossless: mf.IsLossless(),
|
||||
}
|
||||
}
|
||||
|
||||
// checkDirectPlayProfile returns "" if the profile matches (direct play OK),
|
||||
// or a typed reason string if it doesn't match.
|
||||
func (s *deciderService) checkDirectPlayProfile(mf *model.MediaFile, sourceBitrate int, profile *DirectPlayProfile, clientInfo *ClientInfo) string {
|
||||
// Check protocol (only http for now)
|
||||
if len(profile.Protocols) > 0 && !containsIgnoreCase(profile.Protocols, ProtocolHTTP) {
|
||||
return "protocol not supported"
|
||||
}
|
||||
|
||||
// Check container
|
||||
if len(profile.Containers) > 0 && !matchesContainer(mf.Suffix, profile.Containers) {
|
||||
return "container not supported"
|
||||
}
|
||||
|
||||
// Check codec
|
||||
if len(profile.AudioCodecs) > 0 && !matchesCodec(mf.AudioCodec(), profile.AudioCodecs) {
|
||||
return "audio codec not supported"
|
||||
}
|
||||
|
||||
// Check channels
|
||||
if profile.MaxAudioChannels > 0 && mf.Channels > profile.MaxAudioChannels {
|
||||
return "audio channels not supported"
|
||||
}
|
||||
|
||||
// Check codec-specific limitations
|
||||
for _, codecProfile := range clientInfo.CodecProfiles {
|
||||
if strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) && matchesCodec(mf.AudioCodec(), []string{codecProfile.Name}) {
|
||||
if reason := checkLimitations(mf, sourceBitrate, codecProfile.Limitations); reason != "" {
|
||||
return reason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// computeTranscodedStream attempts to build a valid transcoded stream for the given profile.
|
||||
// Returns the stream details and the internal transcoding format (which may differ from the
|
||||
// response container when a codec fallback occurs, e.g., "mp4"→"aac").
|
||||
// Returns nil, "" if the profile cannot produce a valid output.
|
||||
func (s *deciderService) computeTranscodedStream(ctx context.Context, mf *model.MediaFile, sourceBitrate int, profile *Profile, clientInfo *ClientInfo) (*StreamDetails, string) {
|
||||
// Check protocol (only http for now)
|
||||
if profile.Protocol != "" && !strings.EqualFold(profile.Protocol, ProtocolHTTP) {
|
||||
log.Trace(ctx, "Skipping transcoding profile: unsupported protocol", "protocol", profile.Protocol)
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
responseContainer, targetFormat := s.resolveTargetFormat(ctx, profile)
|
||||
if targetFormat == "" {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
targetIsLossless := isLosslessFormat(targetFormat)
|
||||
|
||||
// Reject lossy to lossless conversion
|
||||
if !mf.IsLossless() && targetIsLossless {
|
||||
log.Trace(ctx, "Skipping transcoding profile: lossy to lossless not allowed", "targetFormat", targetFormat)
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
ts := &StreamDetails{
|
||||
Container: responseContainer,
|
||||
Codec: strings.ToLower(profile.AudioCodec),
|
||||
SampleRate: normalizeSourceSampleRate(mf.SampleRate, mf.AudioCodec()),
|
||||
Channels: mf.Channels,
|
||||
BitDepth: normalizeSourceBitDepth(mf.BitDepth, mf.AudioCodec()),
|
||||
IsLossless: targetIsLossless,
|
||||
}
|
||||
if ts.Codec == "" {
|
||||
ts.Codec = targetFormat
|
||||
}
|
||||
|
||||
// Apply codec-intrinsic sample rate adjustments before codec profile limitations
|
||||
if fixedRate := codecFixedOutputSampleRate(ts.Codec); fixedRate > 0 {
|
||||
ts.SampleRate = fixedRate
|
||||
}
|
||||
if maxRate := codecMaxSampleRate(ts.Codec); maxRate > 0 && ts.SampleRate > maxRate {
|
||||
ts.SampleRate = maxRate
|
||||
}
|
||||
|
||||
// Determine target bitrate (all in kbps)
|
||||
if ok := s.computeBitrate(ctx, mf, sourceBitrate, targetFormat, targetIsLossless, clientInfo, ts); !ok {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// Apply MaxAudioChannels from the transcoding profile
|
||||
if profile.MaxAudioChannels > 0 && mf.Channels > profile.MaxAudioChannels {
|
||||
ts.Channels = profile.MaxAudioChannels
|
||||
}
|
||||
|
||||
// Apply codec profile limitations to the TARGET codec
|
||||
if ok := s.applyCodecLimitations(ctx, sourceBitrate, targetFormat, targetIsLossless, clientInfo, ts); !ok {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
return ts, targetFormat
|
||||
}
|
||||
|
||||
// resolveTargetFormat determines the response container and internal target format
|
||||
// by looking up transcoding configs. Returns ("", "") if no config found.
|
||||
func (s *deciderService) resolveTargetFormat(ctx context.Context, profile *Profile) (responseContainer, targetFormat string) {
|
||||
responseContainer = strings.ToLower(profile.Container)
|
||||
targetFormat = responseContainer
|
||||
if targetFormat == "" {
|
||||
targetFormat = strings.ToLower(profile.AudioCodec)
|
||||
responseContainer = targetFormat
|
||||
}
|
||||
|
||||
// Try the container first, then fall back to the audioCodec (e.g. "ogg" → "opus", "mp4" → "aac").
|
||||
_, err := s.ds.Transcoding(ctx).FindByFormat(targetFormat)
|
||||
if errors.Is(err, model.ErrNotFound) && profile.AudioCodec != "" && !strings.EqualFold(targetFormat, profile.AudioCodec) {
|
||||
codec := strings.ToLower(profile.AudioCodec)
|
||||
log.Trace(ctx, "No transcoding config for container, trying audioCodec", "container", targetFormat, "audioCodec", codec)
|
||||
_, err = s.ds.Transcoding(ctx).FindByFormat(codec)
|
||||
if err == nil {
|
||||
targetFormat = codec
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if !errors.Is(err, model.ErrNotFound) {
|
||||
log.Error(ctx, "Error looking up transcoding config", "format", targetFormat, err)
|
||||
} else {
|
||||
log.Trace(ctx, "Skipping transcoding profile: no transcoding config", "targetFormat", targetFormat)
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
return responseContainer, targetFormat
|
||||
}
|
||||
|
||||
// computeBitrate determines the target bitrate for the transcoded stream.
|
||||
// Returns false if the profile should be rejected.
|
||||
func (s *deciderService) computeBitrate(ctx context.Context, mf *model.MediaFile, sourceBitrate int, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *StreamDetails) bool {
|
||||
if mf.IsLossless() {
|
||||
if !targetIsLossless {
|
||||
if clientInfo.MaxTranscodingAudioBitrate > 0 {
|
||||
ts.Bitrate = clientInfo.MaxTranscodingAudioBitrate
|
||||
} else {
|
||||
ts.Bitrate = defaultBitrate
|
||||
}
|
||||
} else {
|
||||
if clientInfo.MaxAudioBitrate > 0 && sourceBitrate > clientInfo.MaxAudioBitrate {
|
||||
log.Trace(ctx, "Skipping transcoding profile: lossless target exceeds bitrate limit",
|
||||
"targetFormat", targetFormat, "sourceBitrate", sourceBitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate)
|
||||
return false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ts.Bitrate = sourceBitrate
|
||||
}
|
||||
|
||||
// Apply maxAudioBitrate as final cap
|
||||
if clientInfo.MaxAudioBitrate > 0 && ts.Bitrate > 0 && ts.Bitrate > clientInfo.MaxAudioBitrate {
|
||||
ts.Bitrate = clientInfo.MaxAudioBitrate
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// applyCodecLimitations applies codec profile limitations to the transcoded stream.
|
||||
// Returns false if the profile should be rejected.
|
||||
func (s *deciderService) applyCodecLimitations(ctx context.Context, sourceBitrate int, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *StreamDetails) bool {
|
||||
targetCodec := ts.Codec
|
||||
for _, codecProfile := range clientInfo.CodecProfiles {
|
||||
if !strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) {
|
||||
continue
|
||||
}
|
||||
if !matchesCodec(targetCodec, []string{codecProfile.Name}) {
|
||||
continue
|
||||
}
|
||||
for _, lim := range codecProfile.Limitations {
|
||||
result := applyLimitation(sourceBitrate, &lim, ts)
|
||||
if strings.EqualFold(lim.Name, LimitationAudioBitrate) && targetIsLossless && result == adjustAdjusted {
|
||||
log.Trace(ctx, "Skipping transcoding profile: cannot adjust bitrate for lossless target",
|
||||
"targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name)
|
||||
return false
|
||||
}
|
||||
if result == adjustCannotFit {
|
||||
log.Trace(ctx, "Skipping transcoding profile: codec limitation cannot be satisfied",
|
||||
"targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name,
|
||||
"comparison", lim.Comparison, "values", lim.Values)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *deciderService) CreateTranscodeParams(decision *Decision) (string, error) {
|
||||
exp := time.Now().Add(tokenTTL)
|
||||
claims := map[string]any{
|
||||
claimMediaID: decision.MediaID,
|
||||
claimDirectPlay: decision.CanDirectPlay,
|
||||
claimUpdatedAt: decision.SourceUpdatedAt.Truncate(time.Second).Unix(),
|
||||
}
|
||||
if decision.CanTranscode && decision.TargetFormat != "" {
|
||||
claims[claimFormat] = decision.TargetFormat
|
||||
claims[claimBitrate] = decision.TargetBitrate
|
||||
if decision.TargetChannels > 0 {
|
||||
claims[claimChannels] = decision.TargetChannels
|
||||
}
|
||||
if decision.TargetSampleRate > 0 {
|
||||
claims[claimSampleRate] = decision.TargetSampleRate
|
||||
}
|
||||
if decision.TargetBitDepth > 0 {
|
||||
claims[claimBitDepth] = decision.TargetBitDepth
|
||||
}
|
||||
}
|
||||
return auth.CreateExpiringPublicToken(exp, claims)
|
||||
}
|
||||
|
||||
func (s *deciderService) ParseTranscodeParams(token string) (*Params, error) {
|
||||
claims, err := auth.Validate(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := &Params{}
|
||||
|
||||
// Required claims
|
||||
mid, ok := claims[claimMediaID].(string)
|
||||
if !ok || mid == "" {
|
||||
return nil, fmt.Errorf("%w: invalid transcode token: missing media ID", ErrTokenInvalid)
|
||||
}
|
||||
params.MediaID = mid
|
||||
|
||||
dp, ok := claims[claimDirectPlay].(bool)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: invalid transcode token: missing direct play flag", ErrTokenInvalid)
|
||||
}
|
||||
params.DirectPlay = dp
|
||||
|
||||
// Optional claims (legitimately absent for direct-play tokens)
|
||||
if f, ok := claims[claimFormat].(string); ok {
|
||||
params.TargetFormat = f
|
||||
}
|
||||
if br, ok := claims[claimBitrate].(float64); ok {
|
||||
params.TargetBitrate = int(br)
|
||||
}
|
||||
if ch, ok := claims[claimChannels].(float64); ok {
|
||||
params.TargetChannels = int(ch)
|
||||
}
|
||||
if sr, ok := claims[claimSampleRate].(float64); ok {
|
||||
params.TargetSampleRate = int(sr)
|
||||
}
|
||||
if bd, ok := claims[claimBitDepth].(float64); ok {
|
||||
params.TargetBitDepth = int(bd)
|
||||
}
|
||||
|
||||
ua, ok := claims[claimUpdatedAt].(float64)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: invalid transcode token: missing source timestamp", ErrTokenInvalid)
|
||||
}
|
||||
params.SourceUpdatedAt = time.Unix(int64(ua), 0)
|
||||
|
||||
return params, nil
|
||||
}
|
||||
|
||||
func (s *deciderService) ValidateTranscodeParams(ctx context.Context, token string, mediaID string) (*Params, *model.MediaFile, error) {
|
||||
params, err := s.ParseTranscodeParams(token)
|
||||
if err != nil {
|
||||
return nil, nil, errors.Join(ErrTokenInvalid, err)
|
||||
}
|
||||
if params.MediaID != mediaID {
|
||||
return nil, nil, fmt.Errorf("%w: token mediaID %q does not match %q", ErrTokenInvalid, params.MediaID, mediaID)
|
||||
}
|
||||
mf, err := s.ds.MediaFile(ctx).Get(mediaID)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, nil, ErrMediaNotFound
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
if !mf.UpdatedAt.Truncate(time.Second).Equal(params.SourceUpdatedAt) {
|
||||
log.Info(ctx, "Transcode token is stale", "mediaID", mediaID,
|
||||
"tokenUpdatedAt", params.SourceUpdatedAt, "fileUpdatedAt", mf.UpdatedAt)
|
||||
return nil, nil, ErrTokenStale
|
||||
}
|
||||
return params, mf, nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package playlists_test
|
||||
package transcode
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestPlaylists(t *testing.T) {
|
||||
func TestTranscode(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Playlists Suite")
|
||||
RunSpecs(t, "Transcode Suite")
|
||||
}
|
||||
1073
core/transcode/transcode_test.go
Normal file
1073
core/transcode/transcode_test.go
Normal file
File diff suppressed because it is too large
Load Diff
140
core/transcode/types.go
Normal file
140
core/transcode/types.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package transcode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTokenInvalid = errors.New("invalid or expired transcode token")
|
||||
ErrMediaNotFound = errors.New("media file not found")
|
||||
ErrTokenStale = errors.New("transcode token is stale: media file has changed")
|
||||
)
|
||||
|
||||
// Decider is the core service interface for making transcoding decisions
|
||||
type Decider interface {
|
||||
MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo) (*Decision, error)
|
||||
CreateTranscodeParams(decision *Decision) (string, error)
|
||||
ParseTranscodeParams(token string) (*Params, error)
|
||||
ValidateTranscodeParams(ctx context.Context, token string, mediaID string) (*Params, *model.MediaFile, error)
|
||||
}
|
||||
|
||||
// ClientInfo represents client playback capabilities.
|
||||
// All bitrate values are in kilobits per second (kbps)
|
||||
type ClientInfo struct {
|
||||
Name string
|
||||
Platform string
|
||||
MaxAudioBitrate int
|
||||
MaxTranscodingAudioBitrate int
|
||||
DirectPlayProfiles []DirectPlayProfile
|
||||
TranscodingProfiles []Profile
|
||||
CodecProfiles []CodecProfile
|
||||
}
|
||||
|
||||
// DirectPlayProfile describes a format the client can play directly
|
||||
type DirectPlayProfile struct {
|
||||
Containers []string
|
||||
AudioCodecs []string
|
||||
Protocols []string
|
||||
MaxAudioChannels int
|
||||
}
|
||||
|
||||
// Profile describes a transcoding target the client supports
|
||||
type Profile struct {
|
||||
Container string
|
||||
AudioCodec string
|
||||
Protocol string
|
||||
MaxAudioChannels int
|
||||
}
|
||||
|
||||
// CodecProfile describes codec-specific limitations
|
||||
type CodecProfile struct {
|
||||
Type string
|
||||
Name string
|
||||
Limitations []Limitation
|
||||
}
|
||||
|
||||
// Limitation describes a specific codec limitation
|
||||
type Limitation struct {
|
||||
Name string
|
||||
Comparison string
|
||||
Values []string
|
||||
Required bool
|
||||
}
|
||||
|
||||
// Protocol values (OpenSubsonic spec enum)
|
||||
const (
|
||||
ProtocolHTTP = "http"
|
||||
ProtocolHLS = "hls"
|
||||
)
|
||||
|
||||
// Comparison operators (OpenSubsonic spec enum)
|
||||
const (
|
||||
ComparisonEquals = "Equals"
|
||||
ComparisonNotEquals = "NotEquals"
|
||||
ComparisonLessThanEqual = "LessThanEqual"
|
||||
ComparisonGreaterThanEqual = "GreaterThanEqual"
|
||||
)
|
||||
|
||||
// Limitation names (OpenSubsonic spec enum)
|
||||
const (
|
||||
LimitationAudioChannels = "audioChannels"
|
||||
LimitationAudioBitrate = "audioBitrate"
|
||||
LimitationAudioProfile = "audioProfile"
|
||||
LimitationAudioSamplerate = "audioSamplerate"
|
||||
LimitationAudioBitdepth = "audioBitdepth"
|
||||
)
|
||||
|
||||
// Codec profile types (OpenSubsonic spec enum)
|
||||
const (
|
||||
CodecProfileTypeAudio = "AudioCodec"
|
||||
)
|
||||
|
||||
// Decision represents the internal decision result.
|
||||
// All bitrate values are in kilobits per second (kbps).
|
||||
type Decision struct {
|
||||
MediaID string
|
||||
CanDirectPlay bool
|
||||
CanTranscode bool
|
||||
TranscodeReasons []string
|
||||
ErrorReason string
|
||||
TargetFormat string
|
||||
TargetBitrate int
|
||||
TargetChannels int
|
||||
TargetSampleRate int
|
||||
TargetBitDepth int
|
||||
SourceStream StreamDetails
|
||||
SourceUpdatedAt time.Time
|
||||
TranscodeStream *StreamDetails
|
||||
}
|
||||
|
||||
// StreamDetails describes audio stream properties.
|
||||
// Bitrate is in kilobits per second (kbps).
|
||||
type StreamDetails struct {
|
||||
Container string
|
||||
Codec string
|
||||
Profile string // Audio profile (e.g., "LC", "HE-AAC"). Empty until scanner support is added.
|
||||
Bitrate int
|
||||
SampleRate int
|
||||
BitDepth int
|
||||
Channels int
|
||||
Duration float32
|
||||
Size int64
|
||||
IsLossless bool
|
||||
}
|
||||
|
||||
// Params contains the parameters extracted from a transcode token.
|
||||
// TargetBitrate is in kilobits per second (kbps).
|
||||
type Params struct {
|
||||
MediaID string
|
||||
DirectPlay bool
|
||||
TargetFormat string
|
||||
TargetBitrate int
|
||||
TargetChannels int
|
||||
TargetSampleRate int
|
||||
TargetBitDepth int
|
||||
SourceUpdatedAt time.Time
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/transcode"
|
||||
)
|
||||
|
||||
var Set = wire.NewSet(
|
||||
@@ -17,10 +17,11 @@ var Set = wire.NewSet(
|
||||
NewArchiver,
|
||||
NewPlayers,
|
||||
NewShare,
|
||||
playlists.NewPlaylists,
|
||||
NewPlaylists,
|
||||
NewLibrary,
|
||||
NewUser,
|
||||
NewMaintenance,
|
||||
transcode.NewDecider,
|
||||
agents.GetAgents,
|
||||
external.NewProvider,
|
||||
wire.Bind(new(external.Agents), new(*agents.Agents)),
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigrationContext(upAddCodecAndUpdateTranscodings, downAddCodecAndUpdateTranscodings)
|
||||
}
|
||||
|
||||
func upAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error {
|
||||
// Add codec column to media_file.
|
||||
_, err := tx.Exec(`ALTER TABLE media_file ADD COLUMN codec VARCHAR(255) DEFAULT '' NOT NULL`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`CREATE INDEX IF NOT EXISTS media_file_codec ON media_file(codec)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update old AAC default (adts) to new default (ipod with fragmented MP4).
|
||||
// Only affects users who still have the unmodified old default command.
|
||||
_, err = tx.Exec(
|
||||
`UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND command = ?`,
|
||||
"ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -",
|
||||
"ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add FLAC transcoding for existing installations that were seeded before FLAC was added.
|
||||
var count int
|
||||
err = tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = 'flac'").Scan(&count)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
_, err = tx.Exec(
|
||||
"INSERT INTO transcoding (id, name, target_format, default_bit_rate, command) VALUES (?, ?, ?, ?, ?)",
|
||||
id.NewRandom(), "flac audio", "flac", 0,
|
||||
"ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -",
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func downAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`DROP INDEX IF EXISTS media_file_codec`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`ALTER TABLE media_file DROP COLUMN codec`)
|
||||
return err
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigrationContext(upAddFts5Search, downAddFts5Search)
|
||||
}
|
||||
|
||||
// stripPunct generates a SQL expression that strips common punctuation from a column or expression.
|
||||
// Used during migration to approximate the Go normalizeForFTS function for bulk-populating search_normalized.
|
||||
func stripPunct(col string) string {
|
||||
return fmt.Sprintf(
|
||||
`REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(%s, '.', ''), '/', ''), '-', ''), '''', ''), '&', ''), ',', '')`,
|
||||
col,
|
||||
)
|
||||
}
|
||||
|
||||
func upAddFts5Search(ctx context.Context, tx *sql.Tx) error {
|
||||
notice(tx, "Adding FTS5 full-text search indexes. This may take a moment on large libraries.")
|
||||
|
||||
// Step 1: Add search_participants and search_normalized columns to media_file, album, and artist
|
||||
_, err := tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN search_participants TEXT NOT NULL DEFAULT ''`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("adding search_participants to media_file: %w", err)
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN search_normalized TEXT NOT NULL DEFAULT ''`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("adding search_normalized to media_file: %w", err)
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `ALTER TABLE album ADD COLUMN search_participants TEXT NOT NULL DEFAULT ''`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("adding search_participants to album: %w", err)
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `ALTER TABLE album ADD COLUMN search_normalized TEXT NOT NULL DEFAULT ''`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("adding search_normalized to album: %w", err)
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `ALTER TABLE artist ADD COLUMN search_normalized TEXT NOT NULL DEFAULT ''`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("adding search_normalized to artist: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Populate search_participants from participants JSON.
|
||||
// Extract all "name" values from the participants JSON structure.
|
||||
// participants is a JSON object like: {"artist":[{"name":"...","id":"..."}],"albumartist":[...]}
|
||||
// We use json_each + json_extract to flatten all names into a space-separated string.
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
UPDATE media_file SET search_participants = COALESCE(
|
||||
(SELECT group_concat(json_extract(je2.value, '$.name'), ' ')
|
||||
FROM json_each(media_file.participants) AS je1,
|
||||
json_each(je1.value) AS je2
|
||||
WHERE json_extract(je2.value, '$.name') IS NOT NULL),
|
||||
''
|
||||
)
|
||||
WHERE participants IS NOT NULL AND participants != '' AND participants != '{}'
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("populating media_file search_participants: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
UPDATE album SET search_participants = COALESCE(
|
||||
(SELECT group_concat(json_extract(je2.value, '$.name'), ' ')
|
||||
FROM json_each(album.participants) AS je1,
|
||||
json_each(je1.value) AS je2
|
||||
WHERE json_extract(je2.value, '$.name') IS NOT NULL),
|
||||
''
|
||||
)
|
||||
WHERE participants IS NOT NULL AND participants != '' AND participants != '{}'
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("populating album search_participants: %w", err)
|
||||
}
|
||||
|
||||
// Step 2b: Populate search_normalized using SQL REPLACE chains for common punctuation.
|
||||
// The Go code will compute the precise value on next scan; this is a best-effort approximation.
|
||||
_, err = tx.ExecContext(ctx, fmt.Sprintf(`
|
||||
UPDATE artist SET search_normalized = %s
|
||||
WHERE name != %s`,
|
||||
stripPunct("name"), stripPunct("name")))
|
||||
if err != nil {
|
||||
return fmt.Errorf("populating artist search_normalized: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, fmt.Sprintf(`
|
||||
UPDATE album SET search_normalized = TRIM(%s || ' ' || %s)
|
||||
WHERE name != %s OR COALESCE(album_artist, '') != %s`,
|
||||
stripPunct("name"), stripPunct("COALESCE(album_artist, '')"),
|
||||
stripPunct("name"), stripPunct("COALESCE(album_artist, '')")))
|
||||
if err != nil {
|
||||
return fmt.Errorf("populating album search_normalized: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, fmt.Sprintf(`
|
||||
UPDATE media_file SET search_normalized =
|
||||
TRIM(%s || ' ' || %s || ' ' || %s || ' ' || %s)
|
||||
WHERE title != %s
|
||||
OR COALESCE(album, '') != %s
|
||||
OR COALESCE(artist, '') != %s
|
||||
OR COALESCE(album_artist, '') != %s`,
|
||||
stripPunct("title"), stripPunct("COALESCE(album, '')"),
|
||||
stripPunct("COALESCE(artist, '')"), stripPunct("COALESCE(album_artist, '')"),
|
||||
stripPunct("title"), stripPunct("COALESCE(album, '')"),
|
||||
stripPunct("COALESCE(artist, '')"), stripPunct("COALESCE(album_artist, '')")))
|
||||
if err != nil {
|
||||
return fmt.Errorf("populating media_file search_normalized: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: Create FTS5 virtual tables
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS media_file_fts USING fts5(
|
||||
title, album, artist, album_artist,
|
||||
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
|
||||
disc_subtitle, search_participants, search_normalized,
|
||||
content='', content_rowid='rowid',
|
||||
tokenize='unicode61 remove_diacritics 2'
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating media_file_fts: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS album_fts USING fts5(
|
||||
name, sort_album_name, album_artist,
|
||||
search_participants, discs, catalog_num, album_version, search_normalized,
|
||||
content='', content_rowid='rowid',
|
||||
tokenize='unicode61 remove_diacritics 2'
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating album_fts: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS artist_fts USING fts5(
|
||||
name, sort_artist_name, search_normalized,
|
||||
content='', content_rowid='rowid',
|
||||
tokenize='unicode61 remove_diacritics 2'
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating artist_fts: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Bulk-populate FTS5 indexes from existing data
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO media_file_fts(rowid, title, album, artist, album_artist,
|
||||
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
|
||||
disc_subtitle, search_participants, search_normalized)
|
||||
SELECT rowid, title, album, artist, album_artist,
|
||||
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
|
||||
COALESCE(disc_subtitle, ''), COALESCE(search_participants, ''),
|
||||
COALESCE(search_normalized, '')
|
||||
FROM media_file
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("populating media_file_fts: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO album_fts(rowid, name, sort_album_name, album_artist,
|
||||
search_participants, discs, catalog_num, album_version, search_normalized)
|
||||
SELECT rowid, name, COALESCE(sort_album_name, ''), COALESCE(album_artist, ''),
|
||||
COALESCE(search_participants, ''), COALESCE(discs, ''),
|
||||
COALESCE(catalog_num, ''),
|
||||
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
|
||||
FROM json_each(album.tags, '$.albumversion') AS je), ''),
|
||||
COALESCE(search_normalized, '')
|
||||
FROM album
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("populating album_fts: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized)
|
||||
SELECT rowid, name, COALESCE(sort_artist_name, ''), COALESCE(search_normalized, '')
|
||||
FROM artist
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("populating artist_fts: %w", err)
|
||||
}
|
||||
|
||||
// Step 5: Create triggers for media_file
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
CREATE TRIGGER media_file_fts_ai AFTER INSERT ON media_file BEGIN
|
||||
INSERT INTO media_file_fts(rowid, title, album, artist, album_artist,
|
||||
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
|
||||
disc_subtitle, search_participants, search_normalized)
|
||||
VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist,
|
||||
NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name,
|
||||
COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''),
|
||||
COALESCE(NEW.search_normalized, ''));
|
||||
END
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating media_file_fts insert trigger: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
CREATE TRIGGER media_file_fts_ad AFTER DELETE ON media_file BEGIN
|
||||
INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist,
|
||||
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
|
||||
disc_subtitle, search_participants, search_normalized)
|
||||
VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist,
|
||||
OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name,
|
||||
COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''),
|
||||
COALESCE(OLD.search_normalized, ''));
|
||||
END
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating media_file_fts delete trigger: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
CREATE TRIGGER media_file_fts_au AFTER UPDATE ON media_file
|
||||
WHEN
|
||||
OLD.title IS NOT NEW.title OR
|
||||
OLD.album IS NOT NEW.album OR
|
||||
OLD.artist IS NOT NEW.artist OR
|
||||
OLD.album_artist IS NOT NEW.album_artist OR
|
||||
OLD.sort_title IS NOT NEW.sort_title OR
|
||||
OLD.sort_album_name IS NOT NEW.sort_album_name OR
|
||||
OLD.sort_artist_name IS NOT NEW.sort_artist_name OR
|
||||
OLD.sort_album_artist_name IS NOT NEW.sort_album_artist_name OR
|
||||
OLD.disc_subtitle IS NOT NEW.disc_subtitle OR
|
||||
OLD.search_participants IS NOT NEW.search_participants OR
|
||||
OLD.search_normalized IS NOT NEW.search_normalized
|
||||
BEGIN
|
||||
INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist,
|
||||
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
|
||||
disc_subtitle, search_participants, search_normalized)
|
||||
VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist,
|
||||
OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name,
|
||||
COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''),
|
||||
COALESCE(OLD.search_normalized, ''));
|
||||
INSERT INTO media_file_fts(rowid, title, album, artist, album_artist,
|
||||
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
|
||||
disc_subtitle, search_participants, search_normalized)
|
||||
VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist,
|
||||
NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name,
|
||||
COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''),
|
||||
COALESCE(NEW.search_normalized, ''));
|
||||
END
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating media_file_fts update trigger: %w", err)
|
||||
}
|
||||
|
||||
// Step 6: Create triggers for album
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
CREATE TRIGGER album_fts_ai AFTER INSERT ON album BEGIN
|
||||
INSERT INTO album_fts(rowid, name, sort_album_name, album_artist,
|
||||
search_participants, discs, catalog_num, album_version, search_normalized)
|
||||
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''),
|
||||
COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''),
|
||||
COALESCE(NEW.catalog_num, ''),
|
||||
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
|
||||
FROM json_each(NEW.tags, '$.albumversion') AS je), ''),
|
||||
COALESCE(NEW.search_normalized, ''));
|
||||
END
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating album_fts insert trigger: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
CREATE TRIGGER album_fts_ad AFTER DELETE ON album BEGIN
|
||||
INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist,
|
||||
search_participants, discs, catalog_num, album_version, search_normalized)
|
||||
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''),
|
||||
COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''),
|
||||
COALESCE(OLD.catalog_num, ''),
|
||||
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
|
||||
FROM json_each(OLD.tags, '$.albumversion') AS je), ''),
|
||||
COALESCE(OLD.search_normalized, ''));
|
||||
END
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating album_fts delete trigger: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
CREATE TRIGGER album_fts_au AFTER UPDATE ON album
|
||||
WHEN
|
||||
OLD.name IS NOT NEW.name OR
|
||||
OLD.sort_album_name IS NOT NEW.sort_album_name OR
|
||||
OLD.album_artist IS NOT NEW.album_artist OR
|
||||
OLD.search_participants IS NOT NEW.search_participants OR
|
||||
OLD.discs IS NOT NEW.discs OR
|
||||
OLD.catalog_num IS NOT NEW.catalog_num OR
|
||||
OLD.tags IS NOT NEW.tags OR
|
||||
OLD.search_normalized IS NOT NEW.search_normalized
|
||||
BEGIN
|
||||
INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist,
|
||||
search_participants, discs, catalog_num, album_version, search_normalized)
|
||||
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''),
|
||||
COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''),
|
||||
COALESCE(OLD.catalog_num, ''),
|
||||
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
|
||||
FROM json_each(OLD.tags, '$.albumversion') AS je), ''),
|
||||
COALESCE(OLD.search_normalized, ''));
|
||||
INSERT INTO album_fts(rowid, name, sort_album_name, album_artist,
|
||||
search_participants, discs, catalog_num, album_version, search_normalized)
|
||||
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''),
|
||||
COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''),
|
||||
COALESCE(NEW.catalog_num, ''),
|
||||
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
|
||||
FROM json_each(NEW.tags, '$.albumversion') AS je), ''),
|
||||
COALESCE(NEW.search_normalized, ''));
|
||||
END
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating album_fts update trigger: %w", err)
|
||||
}
|
||||
|
||||
// Step 7: Create triggers for artist
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
CREATE TRIGGER artist_fts_ai AFTER INSERT ON artist BEGIN
|
||||
INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized)
|
||||
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''),
|
||||
COALESCE(NEW.search_normalized, ''));
|
||||
END
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating artist_fts insert trigger: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
CREATE TRIGGER artist_fts_ad AFTER DELETE ON artist BEGIN
|
||||
INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized)
|
||||
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''),
|
||||
COALESCE(OLD.search_normalized, ''));
|
||||
END
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating artist_fts delete trigger: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
CREATE TRIGGER artist_fts_au AFTER UPDATE ON artist
|
||||
WHEN
|
||||
OLD.name IS NOT NEW.name OR
|
||||
OLD.sort_artist_name IS NOT NEW.sort_artist_name OR
|
||||
OLD.search_normalized IS NOT NEW.search_normalized
|
||||
BEGIN
|
||||
INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized)
|
||||
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''),
|
||||
COALESCE(OLD.search_normalized, ''));
|
||||
INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized)
|
||||
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''),
|
||||
COALESCE(NEW.search_normalized, ''));
|
||||
END
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating artist_fts update trigger: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func downAddFts5Search(ctx context.Context, tx *sql.Tx) error {
|
||||
for _, trigger := range []string{
|
||||
"media_file_fts_ai", "media_file_fts_ad", "media_file_fts_au",
|
||||
"album_fts_ai", "album_fts_ad", "album_fts_au",
|
||||
"artist_fts_ai", "artist_fts_ad", "artist_fts_au",
|
||||
} {
|
||||
_, err := tx.ExecContext(ctx, "DROP TRIGGER IF EXISTS "+trigger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dropping trigger %s: %w", trigger, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, table := range []string{"media_file_fts", "album_fts", "artist_fts"} {
|
||||
_, err := tx.ExecContext(ctx, "DROP TABLE IF EXISTS "+table)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dropping table %s: %w", table, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: We don't drop search_participants columns because SQLite doesn't support DROP COLUMN
|
||||
// on older versions, and the column is harmless if left in place.
|
||||
return nil
|
||||
}
|
||||
6
go.mod
6
go.mod
@@ -7,7 +7,7 @@ replace (
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
||||
|
||||
// Fork to implement raw tags support
|
||||
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260221220301-2fab4903f48e
|
||||
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260209170351-c057626454d0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -46,13 +46,13 @@ require (
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6
|
||||
github.com/maruel/natural v1.3.0
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||
github.com/mattn/go-sqlite3 v1.14.34
|
||||
github.com/mattn/go-sqlite3 v1.14.33
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/onsi/ginkgo/v2 v2.28.1
|
||||
github.com/onsi/gomega v1.39.1
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
github.com/pocketbase/dbx v1.12.0
|
||||
github.com/pocketbase/dbx v1.11.0
|
||||
github.com/pressly/goose/v3 v3.26.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/rjeczalik/notify v0.9.3
|
||||
|
||||
12
go.sum
12
go.sum
@@ -36,8 +36,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/deluan/go-taglib v0.0.0-20260221220301-2fab4903f48e h1:yQF3eOcI2dMMtxqdKXm3cgfYZlDcq9SUDDv90bsMj2I=
|
||||
github.com/deluan/go-taglib v0.0.0-20260221220301-2fab4903f48e/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
|
||||
github.com/deluan/go-taglib v0.0.0-20260209170351-c057626454d0 h1:R8fMzz++cqdQ3DVjzrmAKmZFr2PT8vT8pQEfRzxms00=
|
||||
github.com/deluan/go-taglib v0.0.0-20260209170351-c057626454d0/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
|
||||
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
|
||||
@@ -179,8 +179,8 @@ github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
|
||||
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.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
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=
|
||||
@@ -210,8 +210,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
|
||||
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
|
||||
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
|
||||
5
main.go
5
main.go
@@ -9,12 +9,11 @@ import (
|
||||
|
||||
//goland:noinspection GoBoolExpressions
|
||||
func main() {
|
||||
// These references force the inclusion of build tags when compiling the project.
|
||||
// This import is used to force the inclusion of the `netgo` tag when compiling the project.
|
||||
// If you get compilation errors like "undefined: buildtags.NETGO", this means you forgot to specify
|
||||
// the required build tags when compiling the project.
|
||||
// the `netgo` build tag when compiling the project.
|
||||
// To avoid these kind of errors, you should use `make build` to compile the project.
|
||||
_ = buildtags.NETGO
|
||||
_ = buildtags.SQLITE_FTS5
|
||||
|
||||
cmd.Execute()
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ var fieldMap = map[string]*mappedField{
|
||||
"releasedate": {field: "media_file.release_date"},
|
||||
"size": {field: "media_file.size"},
|
||||
"compilation": {field: "media_file.compilation"},
|
||||
"explicitstatus": {field: "media_file.explicit_status"},
|
||||
"dateadded": {field: "media_file.created_at"},
|
||||
"datemodified": {field: "media_file.updated_at"},
|
||||
"discsubtitle": {field: "media_file.disc_subtitle"},
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/gohugoio/hashstructure"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
confmime "github.com/navidrome/navidrome/conf/mime"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
@@ -38,7 +39,7 @@ type MediaFile struct {
|
||||
AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated: Use Participants instead
|
||||
// AlbumArtist is the display name used for the album artist.
|
||||
AlbumArtist string `structs:"album_artist" json:"albumArtist"`
|
||||
AlbumID string `structs:"album_id" json:"albumId" hash:"ignore"`
|
||||
AlbumID string `structs:"album_id" json:"albumId"`
|
||||
HasCoverArt bool `structs:"has_cover_art" json:"hasCoverArt"`
|
||||
TrackNumber int `structs:"track_number" json:"trackNumber"`
|
||||
DiscNumber int `structs:"disc_number" json:"discNumber"`
|
||||
@@ -56,6 +57,7 @@ type MediaFile struct {
|
||||
SampleRate int `structs:"sample_rate" json:"sampleRate"`
|
||||
BitDepth int `structs:"bit_depth" json:"bitDepth"`
|
||||
Channels int `structs:"channels" json:"channels"`
|
||||
Codec string `structs:"codec" json:"codec"`
|
||||
Genre string `structs:"genre" json:"genre"`
|
||||
Genres Genres `structs:"-" json:"genres,omitempty"`
|
||||
SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"`
|
||||
@@ -161,6 +163,81 @@ func (mf MediaFile) AbsolutePath() string {
|
||||
return filepath.Join(mf.LibraryPath, mf.Path)
|
||||
}
|
||||
|
||||
// AudioCodec returns the audio codec for this file.
|
||||
// Uses the stored Codec field if available, otherwise infers from Suffix and audio properties.
|
||||
func (mf MediaFile) AudioCodec() string {
|
||||
// If we have a stored codec from scanning, normalize and return it
|
||||
if mf.Codec != "" {
|
||||
return strings.ToLower(mf.Codec)
|
||||
}
|
||||
// Fallback: infer from Suffix + BitDepth
|
||||
return mf.inferCodecFromSuffix()
|
||||
}
|
||||
|
||||
// inferCodecFromSuffix infers the codec from the file extension when Codec field is empty.
|
||||
func (mf MediaFile) inferCodecFromSuffix() string {
|
||||
switch strings.ToLower(mf.Suffix) {
|
||||
case "mp3", "mpga":
|
||||
return "mp3"
|
||||
case "mp2":
|
||||
return "mp2"
|
||||
case "ogg", "oga":
|
||||
return "vorbis"
|
||||
case "opus":
|
||||
return "opus"
|
||||
case "mpc":
|
||||
return "mpc"
|
||||
case "wma":
|
||||
return "wma"
|
||||
case "flac":
|
||||
return "flac"
|
||||
case "wav":
|
||||
return "pcm"
|
||||
case "aif", "aiff", "aifc":
|
||||
return "pcm"
|
||||
case "ape":
|
||||
return "ape"
|
||||
case "wv", "wvp":
|
||||
return "wv"
|
||||
case "tta":
|
||||
return "tta"
|
||||
case "tak":
|
||||
return "tak"
|
||||
case "shn":
|
||||
return "shn"
|
||||
case "dsf", "dff":
|
||||
return "dsd"
|
||||
case "m4a":
|
||||
// AAC if BitDepth==0, ALAC if BitDepth>0
|
||||
if mf.BitDepth > 0 {
|
||||
return "alac"
|
||||
}
|
||||
return "aac"
|
||||
case "m4b", "m4p", "m4r":
|
||||
return "aac"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// IsLossless returns true if this file uses a lossless codec.
|
||||
func (mf MediaFile) IsLossless() bool {
|
||||
codec := mf.AudioCodec()
|
||||
// Primary: codec-based check (most accurate for containers like M4A)
|
||||
switch codec {
|
||||
case "flac", "alac", "pcm", "ape", "wv", "tta", "tak", "shn", "dsd":
|
||||
return true
|
||||
}
|
||||
// Secondary: suffix-based check using configurable list from YAML
|
||||
if slices.Contains(confmime.LosslessFormats, mf.Suffix) {
|
||||
return true
|
||||
}
|
||||
// Fallback heuristic: if BitDepth is set, it's likely lossless.
|
||||
// This may produce false positives for lossy formats that report bit depth,
|
||||
// but it becomes irrelevant once the Codec column is populated after a full rescan.
|
||||
return mf.BitDepth > 0
|
||||
}
|
||||
|
||||
type MediaFiles []MediaFile
|
||||
|
||||
// ToAlbum creates an Album object based on the attributes of this MediaFiles collection.
|
||||
|
||||
@@ -475,7 +475,7 @@ var _ = Describe("MediaFile", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.EnableMediaFileCoverArt = true
|
||||
})
|
||||
Describe(".CoverArtId()", func() {
|
||||
Describe("CoverArtId", func() {
|
||||
It("returns its own id if it HasCoverArt", func() {
|
||||
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
|
||||
id := mf.CoverArtID()
|
||||
@@ -496,6 +496,94 @@ var _ = Describe("MediaFile", func() {
|
||||
Expect(id.ID).To(Equal(mf.AlbumID))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("AudioCodec", func() {
|
||||
It("returns normalized stored codec when available", func() {
|
||||
mf := MediaFile{Codec: "AAC", Suffix: "m4a"}
|
||||
Expect(mf.AudioCodec()).To(Equal("aac"))
|
||||
})
|
||||
|
||||
It("returns stored codec lowercased", func() {
|
||||
mf := MediaFile{Codec: "ALAC", Suffix: "m4a"}
|
||||
Expect(mf.AudioCodec()).To(Equal("alac"))
|
||||
})
|
||||
|
||||
DescribeTable("infers codec from suffix when Codec field is empty",
|
||||
func(suffix string, bitDepth int, expected string) {
|
||||
mf := MediaFile{Suffix: suffix, BitDepth: bitDepth}
|
||||
Expect(mf.AudioCodec()).To(Equal(expected))
|
||||
},
|
||||
Entry("mp3", "mp3", 0, "mp3"),
|
||||
Entry("mpga", "mpga", 0, "mp3"),
|
||||
Entry("mp2", "mp2", 0, "mp2"),
|
||||
Entry("ogg", "ogg", 0, "vorbis"),
|
||||
Entry("oga", "oga", 0, "vorbis"),
|
||||
Entry("opus", "opus", 0, "opus"),
|
||||
Entry("mpc", "mpc", 0, "mpc"),
|
||||
Entry("wma", "wma", 0, "wma"),
|
||||
Entry("flac", "flac", 0, "flac"),
|
||||
Entry("wav", "wav", 0, "pcm"),
|
||||
Entry("aif", "aif", 0, "pcm"),
|
||||
Entry("aiff", "aiff", 0, "pcm"),
|
||||
Entry("aifc", "aifc", 0, "pcm"),
|
||||
Entry("ape", "ape", 0, "ape"),
|
||||
Entry("wv", "wv", 0, "wv"),
|
||||
Entry("wvp", "wvp", 0, "wv"),
|
||||
Entry("tta", "tta", 0, "tta"),
|
||||
Entry("tak", "tak", 0, "tak"),
|
||||
Entry("shn", "shn", 0, "shn"),
|
||||
Entry("dsf", "dsf", 0, "dsd"),
|
||||
Entry("dff", "dff", 0, "dsd"),
|
||||
Entry("m4a with BitDepth=0 (AAC)", "m4a", 0, "aac"),
|
||||
Entry("m4a with BitDepth>0 (ALAC)", "m4a", 16, "alac"),
|
||||
Entry("m4b", "m4b", 0, "aac"),
|
||||
Entry("m4p", "m4p", 0, "aac"),
|
||||
Entry("m4r", "m4r", 0, "aac"),
|
||||
Entry("unknown suffix", "xyz", 0, ""),
|
||||
)
|
||||
|
||||
It("prefers stored codec over suffix inference", func() {
|
||||
mf := MediaFile{Codec: "ALAC", Suffix: "m4a", BitDepth: 0}
|
||||
Expect(mf.AudioCodec()).To(Equal("alac"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("IsLossless", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
})
|
||||
|
||||
DescribeTable("detects lossless codecs",
|
||||
func(codec string, suffix string, bitDepth int, expected bool) {
|
||||
mf := MediaFile{Codec: codec, Suffix: suffix, BitDepth: bitDepth}
|
||||
Expect(mf.IsLossless()).To(Equal(expected))
|
||||
},
|
||||
Entry("flac", "FLAC", "flac", 16, true),
|
||||
Entry("alac", "ALAC", "m4a", 24, true),
|
||||
Entry("pcm via wav", "", "wav", 16, true),
|
||||
Entry("pcm via aiff", "", "aiff", 24, true),
|
||||
Entry("ape", "", "ape", 16, true),
|
||||
Entry("wv", "", "wv", 0, true),
|
||||
Entry("tta", "", "tta", 0, true),
|
||||
Entry("tak", "", "tak", 0, true),
|
||||
Entry("shn", "", "shn", 0, true),
|
||||
Entry("dsd", "", "dsf", 0, true),
|
||||
Entry("mp3 is lossy", "MP3", "mp3", 0, false),
|
||||
Entry("aac is lossy", "AAC", "m4a", 0, false),
|
||||
Entry("vorbis is lossy", "", "ogg", 0, false),
|
||||
Entry("opus is lossy", "", "opus", 0, false),
|
||||
)
|
||||
|
||||
It("detects lossless via BitDepth fallback when codec is unknown", func() {
|
||||
mf := MediaFile{Suffix: "xyz", BitDepth: 24}
|
||||
Expect(mf.IsLossless()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false for unknown with no BitDepth", func() {
|
||||
mf := MediaFile{Suffix: "xyz", BitDepth: 0}
|
||||
Expect(mf.IsLossless()).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func t(v string) time.Time {
|
||||
|
||||
@@ -65,6 +65,7 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
|
||||
mf.SampleRate = md.AudioProperties().SampleRate
|
||||
mf.BitDepth = md.AudioProperties().BitDepth
|
||||
mf.Channels = md.AudioProperties().Channels
|
||||
mf.Codec = md.AudioProperties().Codec
|
||||
mf.Path = md.FilePath()
|
||||
mf.Suffix = md.Suffix()
|
||||
mf.Size = md.Size()
|
||||
|
||||
@@ -35,6 +35,7 @@ type AudioProperties struct {
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
Channels int
|
||||
Codec string
|
||||
}
|
||||
|
||||
type Date string
|
||||
|
||||
@@ -22,7 +22,7 @@ type User struct {
|
||||
Password string `structs:"-" json:"-"`
|
||||
// This is used to set or change a password when calling Put. If it is empty, the password is not changed.
|
||||
// It is received from the UI with the name "password"
|
||||
NewPassword string `structs:"password,omitempty" json:"password,omitempty"` //nolint:gosec
|
||||
NewPassword string `structs:"password,omitempty" json:"password,omitempty"`
|
||||
// If changing the password, this is also required
|
||||
CurrentPassword string `structs:"current_password,omitempty" json:"currentPassword,omitempty"`
|
||||
}
|
||||
|
||||
@@ -62,14 +62,11 @@ func (a *dbAlbum) PostScan() error {
|
||||
|
||||
func (a *dbAlbum) PostMapArgs(args map[string]any) error {
|
||||
fullText := []string{a.Name, a.SortAlbumName, a.AlbumArtist}
|
||||
participantNames := a.Album.Participants.AllNames()
|
||||
fullText = append(fullText, participantNames...)
|
||||
fullText = append(fullText, a.Album.Participants.AllNames()...)
|
||||
fullText = append(fullText, slices.Collect(maps.Values(a.Album.Discs))...)
|
||||
fullText = append(fullText, a.Album.Tags[model.TagAlbumVersion]...)
|
||||
fullText = append(fullText, a.Album.Tags[model.TagCatalogNumber]...)
|
||||
args["full_text"] = formatFullText(fullText...)
|
||||
args["search_participants"] = strings.Join(participantNames, " ")
|
||||
args["search_normalized"] = normalizeForFTS(a.Name, a.AlbumArtist)
|
||||
|
||||
args["tags"] = marshalTags(a.Album.Tags)
|
||||
args["participants"] = marshalParticipants(a.Album.Participants)
|
||||
|
||||
@@ -56,23 +56,17 @@ var _ = Describe("AlbumRepository", func() {
|
||||
It("returns all records sorted", func() {
|
||||
Expect(GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{
|
||||
albumAbbeyRoad,
|
||||
albumWithVersion,
|
||||
albumCJK,
|
||||
albumMultiDisc,
|
||||
albumRadioactivity,
|
||||
albumSgtPeppers,
|
||||
albumPunctuation,
|
||||
}))
|
||||
})
|
||||
|
||||
It("returns all records sorted desc", func() {
|
||||
Expect(GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{
|
||||
albumPunctuation,
|
||||
albumSgtPeppers,
|
||||
albumRadioactivity,
|
||||
albumMultiDisc,
|
||||
albumCJK,
|
||||
albumWithVersion,
|
||||
albumAbbeyRoad,
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -102,7 +102,6 @@ func (a *dbArtist) PostMapArgs(m map[string]any) error {
|
||||
similarArtists, _ := json.Marshal(sa)
|
||||
m["similar_artists"] = string(similarArtists)
|
||||
m["full_text"] = formatFullText(a.Name, a.SortArtistName)
|
||||
m["search_normalized"] = normalizeForFTS(a.Name)
|
||||
|
||||
// Do not override the sort_artist_name and mbz_artist_id fields if they are empty
|
||||
// TODO: Better way to handle this?
|
||||
@@ -139,7 +138,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
||||
"missing": booleanFilter,
|
||||
"library_id": artistLibraryIdFilter,
|
||||
})
|
||||
r.setSortMappings(map[string]string{ //nolint:gosec
|
||||
r.setSortMappings(map[string]string{
|
||||
"name": "order_artist_name",
|
||||
"starred_at": "starred, starred_at",
|
||||
"rated_at": "rating, rated_at",
|
||||
|
||||
@@ -193,7 +193,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
Describe("Basic Operations", func() {
|
||||
Describe("Count", func() {
|
||||
It("returns the number of artists in the DB", func() {
|
||||
Expect(repo.CountAll()).To(Equal(int64(4)))
|
||||
Expect(repo.CountAll()).To(Equal(int64(2)))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -228,19 +228,13 @@ var _ = Describe("ArtistRepository", func() {
|
||||
|
||||
idx, err := repo.GetIndex(false, []int{1})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(4))
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("F"))
|
||||
Expect(idx[0].Artists).To(HaveLen(1))
|
||||
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
|
||||
Expect(idx[1].ID).To(Equal("K"))
|
||||
Expect(idx[1].Artists).To(HaveLen(1))
|
||||
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
|
||||
Expect(idx[2].ID).To(Equal("R"))
|
||||
Expect(idx[2].Artists).To(HaveLen(1))
|
||||
Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
|
||||
Expect(idx[3].ID).To(Equal("S"))
|
||||
Expect(idx[3].Artists).To(HaveLen(1))
|
||||
Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))
|
||||
|
||||
// Restore the original value
|
||||
artistBeatles.SortArtistName = ""
|
||||
@@ -252,19 +246,13 @@ var _ = Describe("ArtistRepository", func() {
|
||||
XIt("returns the index when PreferSortTags is true and SortArtistName is empty", func() {
|
||||
idx, err := repo.GetIndex(false, []int{1})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(4))
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("B"))
|
||||
Expect(idx[0].Artists).To(HaveLen(1))
|
||||
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
|
||||
Expect(idx[1].ID).To(Equal("K"))
|
||||
Expect(idx[1].Artists).To(HaveLen(1))
|
||||
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
|
||||
Expect(idx[2].ID).To(Equal("R"))
|
||||
Expect(idx[2].Artists).To(HaveLen(1))
|
||||
Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
|
||||
Expect(idx[3].ID).To(Equal("S"))
|
||||
Expect(idx[3].Artists).To(HaveLen(1))
|
||||
Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -280,19 +268,13 @@ var _ = Describe("ArtistRepository", func() {
|
||||
|
||||
idx, err := repo.GetIndex(false, []int{1})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(4))
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("B"))
|
||||
Expect(idx[0].Artists).To(HaveLen(1))
|
||||
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
|
||||
Expect(idx[1].ID).To(Equal("K"))
|
||||
Expect(idx[1].Artists).To(HaveLen(1))
|
||||
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
|
||||
Expect(idx[2].ID).To(Equal("R"))
|
||||
Expect(idx[2].Artists).To(HaveLen(1))
|
||||
Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
|
||||
Expect(idx[3].ID).To(Equal("S"))
|
||||
Expect(idx[3].Artists).To(HaveLen(1))
|
||||
Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))
|
||||
|
||||
// Restore the original value
|
||||
artistBeatles.SortArtistName = ""
|
||||
@@ -303,19 +285,13 @@ var _ = Describe("ArtistRepository", func() {
|
||||
It("returns the index when SortArtistName is empty", func() {
|
||||
idx, err := repo.GetIndex(false, []int{1})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(4))
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("B"))
|
||||
Expect(idx[0].Artists).To(HaveLen(1))
|
||||
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
|
||||
Expect(idx[1].ID).To(Equal("K"))
|
||||
Expect(idx[1].Artists).To(HaveLen(1))
|
||||
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
|
||||
Expect(idx[2].ID).To(Equal("R"))
|
||||
Expect(idx[2].Artists).To(HaveLen(1))
|
||||
Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name))
|
||||
Expect(idx[3].ID).To(Equal("S"))
|
||||
Expect(idx[3].Artists).To(HaveLen(1))
|
||||
Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -401,7 +377,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
// Admin users can see all content when valid library IDs are provided
|
||||
idx, err := repo.GetIndex(false, []int{1})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(4))
|
||||
Expect(idx).To(HaveLen(2))
|
||||
|
||||
// With non-existent library ID, admin users see no content because no artists are associated with that library
|
||||
idx, err = repo.GetIndex(false, []int{999})
|
||||
@@ -649,11 +625,11 @@ var _ = Describe("ArtistRepository", func() {
|
||||
It("sees all artists regardless of library permissions", func() {
|
||||
count, err := repo.CountAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(int64(4)))
|
||||
Expect(count).To(Equal(int64(2)))
|
||||
|
||||
artists, err := repo.GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(artists).To(HaveLen(4))
|
||||
Expect(artists).To(HaveLen(2))
|
||||
|
||||
exists, err := repo.Exists(artistBeatles.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@@ -685,7 +661,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
// Should see missing artist in GetAll by default for admin users
|
||||
artists, err := repo.GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(artists).To(HaveLen(5)) // Including the missing artist
|
||||
Expect(artists).To(HaveLen(3)) // Including the missing artist
|
||||
|
||||
// Search never returns missing artists (hardcoded behavior)
|
||||
results, err := repo.Search("Missing Artist", 0, 10)
|
||||
@@ -791,19 +767,19 @@ var _ = Describe("ArtistRepository", func() {
|
||||
It("CountAll returns correct count after gaining access", func() {
|
||||
count, err := restrictedRepo.CountAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(int64(4))) // Beatles, Kraftwerk, Seatbelts, and The Roots
|
||||
Expect(count).To(Equal(int64(2))) // Beatles and Kraftwerk
|
||||
})
|
||||
|
||||
It("GetAll returns artists after gaining access", func() {
|
||||
artists, err := restrictedRepo.GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(artists).To(HaveLen(4))
|
||||
Expect(artists).To(HaveLen(2))
|
||||
|
||||
var names []string
|
||||
for _, artist := range artists {
|
||||
names = append(names, artist.Name)
|
||||
}
|
||||
Expect(names).To(ContainElements("The Beatles", "Kraftwerk", "シートベルツ", "The Roots"))
|
||||
Expect(names).To(ContainElements("The Beatles", "Kraftwerk"))
|
||||
})
|
||||
|
||||
It("Exists returns true for accessible artists", func() {
|
||||
@@ -820,7 +796,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
// With valid library access, should see artists
|
||||
idx, err := restrictedRepo.GetIndex(false, []int{1})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(4))
|
||||
Expect(idx).To(HaveLen(2))
|
||||
|
||||
// With non-existent library ID, should see nothing (non-admin user)
|
||||
idx, err = restrictedRepo.GetIndex(false, []int{999})
|
||||
|
||||
@@ -58,11 +58,8 @@ func (m *dbMediaFile) PostScan() error {
|
||||
func (m *dbMediaFile) PostMapArgs(args map[string]any) error {
|
||||
fullText := []string{m.FullTitle(), m.Album, m.Artist, m.AlbumArtist,
|
||||
m.SortTitle, m.SortAlbumName, m.SortArtistName, m.SortAlbumArtistName, m.DiscSubtitle}
|
||||
participantNames := m.MediaFile.Participants.AllNames()
|
||||
fullText = append(fullText, participantNames...)
|
||||
fullText = append(fullText, m.MediaFile.Participants.AllNames()...)
|
||||
args["full_text"] = formatFullText(fullText...)
|
||||
args["search_participants"] = strings.Join(participantNames, " ")
|
||||
args["search_normalized"] = normalizeForFTS(m.FullTitle(), m.Album, m.Artist, m.AlbumArtist)
|
||||
args["tags"] = marshalTags(m.MediaFile.Tags)
|
||||
args["participants"] = marshalParticipants(m.MediaFile.Participants)
|
||||
return nil
|
||||
@@ -151,9 +148,7 @@ func (r *mediaFileRepository) Exists(id string) (bool, error) {
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Put(m *model.MediaFile) error {
|
||||
if m.CreatedAt.IsZero() {
|
||||
m.CreatedAt = time.Now()
|
||||
}
|
||||
m.CreatedAt = time.Now()
|
||||
id, err := r.putByMatch(Eq{"path": m.Path, "library_id": m.LibraryID}, m.ID, &dbMediaFile{MediaFile: m})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -39,7 +39,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
})
|
||||
|
||||
It("counts the number of mediafiles in the DB", func() {
|
||||
Expect(mr.CountAll()).To(Equal(int64(13)))
|
||||
Expect(mr.CountAll()).To(Equal(int64(10)))
|
||||
})
|
||||
|
||||
Describe("CountBySuffix", func() {
|
||||
@@ -104,68 +104,6 @@ var _ = Describe("MediaRepository", func() {
|
||||
}
|
||||
})
|
||||
|
||||
Describe("Put CreatedAt behavior (#5050)", func() {
|
||||
It("sets CreatedAt to now when inserting a new file with zero CreatedAt", func() {
|
||||
before := time.Now().Add(-time.Second)
|
||||
newFile := model.MediaFile{ID: id.NewRandom(), LibraryID: 1, Path: "/test/created-at-zero.mp3"}
|
||||
Expect(mr.Put(&newFile)).To(Succeed())
|
||||
|
||||
retrieved, err := mr.Get(newFile.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(retrieved.CreatedAt).To(BeTemporally(">", before))
|
||||
|
||||
_ = mr.Delete(newFile.ID)
|
||||
})
|
||||
|
||||
It("preserves CreatedAt when inserting a new file with non-zero CreatedAt", func() {
|
||||
originalTime := time.Date(2020, 3, 15, 10, 30, 0, 0, time.UTC)
|
||||
newFile := model.MediaFile{
|
||||
ID: id.NewRandom(),
|
||||
LibraryID: 1,
|
||||
Path: "/test/created-at-preserved.mp3",
|
||||
CreatedAt: originalTime,
|
||||
}
|
||||
Expect(mr.Put(&newFile)).To(Succeed())
|
||||
|
||||
retrieved, err := mr.Get(newFile.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second))
|
||||
|
||||
_ = mr.Delete(newFile.ID)
|
||||
})
|
||||
|
||||
It("does not reset CreatedAt when updating an existing file", func() {
|
||||
originalTime := time.Date(2019, 6, 1, 12, 0, 0, 0, time.UTC)
|
||||
fileID := id.NewRandom()
|
||||
newFile := model.MediaFile{
|
||||
ID: fileID,
|
||||
LibraryID: 1,
|
||||
Path: "/test/created-at-update.mp3",
|
||||
Title: "Original Title",
|
||||
CreatedAt: originalTime,
|
||||
}
|
||||
Expect(mr.Put(&newFile)).To(Succeed())
|
||||
|
||||
// Update the file with a new title but zero CreatedAt
|
||||
updatedFile := model.MediaFile{
|
||||
ID: fileID,
|
||||
LibraryID: 1,
|
||||
Path: "/test/created-at-update.mp3",
|
||||
Title: "Updated Title",
|
||||
// CreatedAt is zero - should NOT overwrite the stored value
|
||||
}
|
||||
Expect(mr.Put(&updatedFile)).To(Succeed())
|
||||
|
||||
retrieved, err := mr.Get(fileID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(retrieved.Title).To(Equal("Updated Title"))
|
||||
// CreatedAt should still be the original time (not reset)
|
||||
Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second))
|
||||
|
||||
_ = mr.Delete(fileID)
|
||||
})
|
||||
})
|
||||
|
||||
It("checks existence of mediafiles in the DB", func() {
|
||||
Expect(mr.Exists(songAntenna.ID)).To(BeTrue())
|
||||
Expect(mr.Exists("666")).To(BeFalse())
|
||||
|
||||
@@ -56,22 +56,12 @@ func al(al model.Album) model.Album {
|
||||
return al
|
||||
}
|
||||
|
||||
func alWithTags(a model.Album, tags model.Tags) model.Album {
|
||||
a = al(a)
|
||||
a.Tags = tags
|
||||
return a
|
||||
}
|
||||
|
||||
var (
|
||||
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk"}
|
||||
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles"}
|
||||
artistCJK = model.Artist{ID: "4", Name: "シートベルツ", SortArtistName: "Seatbelts", OrderArtistName: "seatbelts"}
|
||||
artistPunctuation = model.Artist{ID: "5", Name: "The Roots", OrderArtistName: "roots"}
|
||||
testArtists = model.Artists{
|
||||
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk"}
|
||||
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles"}
|
||||
testArtists = model.Artists{
|
||||
artistKraftwerk,
|
||||
artistBeatles,
|
||||
artistCJK,
|
||||
artistPunctuation,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -80,18 +70,11 @@ var (
|
||||
albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969})
|
||||
albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("/kraft/radio/radio.mp3"), SongCount: 2})
|
||||
albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("/test/multi/disc1/track1.mp3"), SongCount: 4})
|
||||
albumCJK = al(model.Album{ID: "105", Name: "COWBOY BEBOP", AlbumArtist: "シートベルツ", OrderAlbumName: "cowboy bebop", AlbumArtistID: "4", EmbedArtPath: p("/seatbelts/cowboy-bebop/track1.mp3"), SongCount: 1})
|
||||
albumWithVersion = alWithTags(model.Album{ID: "106", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/2/come together.mp3"), SongCount: 1, MaxYear: 2019},
|
||||
model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}})
|
||||
albumPunctuation = al(model.Album{ID: "107", Name: "Things Fall Apart", AlbumArtist: "The Roots", OrderAlbumName: "things fall apart", AlbumArtistID: "5", EmbedArtPath: p("/roots/things/track1.mp3"), SongCount: 1})
|
||||
testAlbums = model.Albums{
|
||||
testAlbums = model.Albums{
|
||||
albumSgtPeppers,
|
||||
albumAbbeyRoad,
|
||||
albumRadioactivity,
|
||||
albumMultiDisc,
|
||||
albumCJK,
|
||||
albumWithVersion,
|
||||
albumPunctuation,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -118,9 +101,6 @@ var (
|
||||
songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("/test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
||||
songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("/test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
||||
songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("/test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
||||
songCJK = mf(model.MediaFile{ID: "3001", Title: "プラチナ・ジェット", ArtistID: "4", Artist: "シートベルツ", AlbumID: "105", Album: "COWBOY BEBOP", Path: p("/seatbelts/cowboy-bebop/track1.mp3")})
|
||||
songVersioned = mf(model.MediaFile{ID: "3002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "106", Album: "Abbey Road", Path: p("/beatles/2/come together.mp3")})
|
||||
songPunctuation = mf(model.MediaFile{ID: "3003", Title: "!!!!!!!", ArtistID: "5", Artist: "The Roots", AlbumID: "107", Album: "Things Fall Apart", Path: p("/roots/things/track1.mp3")})
|
||||
testSongs = model.MediaFiles{
|
||||
songDayInALife,
|
||||
songComeTogether,
|
||||
@@ -132,9 +112,6 @@ var (
|
||||
songDisc1Track01,
|
||||
songDisc2Track01,
|
||||
songDisc1Track02,
|
||||
songCJK,
|
||||
songVersioned,
|
||||
songPunctuation,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -96,6 +96,16 @@ func (r *playlistRepository) Exists(id string) (bool, error) {
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Delete(id string) error {
|
||||
usr := loggedUser(r.ctx)
|
||||
if !usr.IsAdmin {
|
||||
pls, err := r.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pls.OwnerID != usr.ID {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
}
|
||||
return r.delete(And{Eq{"id": id}, r.userFilter()})
|
||||
}
|
||||
|
||||
@@ -103,6 +113,14 @@ func (r *playlistRepository) Put(p *model.Playlist) error {
|
||||
pls := dbPlaylist{Playlist: *p}
|
||||
if pls.ID == "" {
|
||||
pls.CreatedAt = time.Now()
|
||||
} else {
|
||||
ok, err := r.Exists(pls.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
}
|
||||
pls.UpdatedAt = time.Now()
|
||||
|
||||
@@ -114,6 +132,7 @@ func (r *playlistRepository) Put(p *model.Playlist) error {
|
||||
|
||||
if p.IsSmartPlaylist() {
|
||||
// Do not update tracks at this point, as it may take a long time and lock the DB, breaking the scan process
|
||||
//r.refreshSmartPlaylist(p)
|
||||
return nil
|
||||
}
|
||||
// Only update tracks if they were specified
|
||||
@@ -301,6 +320,10 @@ func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) er
|
||||
}
|
||||
|
||||
func (r *playlistRepository) updatePlaylist(playlistId string, mediaFileIds []string) error {
|
||||
if !r.isWritable(playlistId) {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Remove old tracks
|
||||
del := Delete("playlist_tracks").Where(Eq{"playlist_id": playlistId})
|
||||
_, err := r.executeSQL(del)
|
||||
@@ -416,7 +439,8 @@ func (r *playlistRepository) NewInstance() any {
|
||||
|
||||
func (r *playlistRepository) Save(entity any) (string, error) {
|
||||
pls := entity.(*model.Playlist)
|
||||
pls.ID = "" // Force new creation
|
||||
pls.OwnerID = loggedUser(r.ctx).ID
|
||||
pls.ID = "" // Make sure we don't override an existing playlist
|
||||
err := r.Put(pls)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -426,9 +450,24 @@ func (r *playlistRepository) Save(entity any) (string, error) {
|
||||
|
||||
func (r *playlistRepository) Update(id string, entity any, cols ...string) error {
|
||||
pls := dbPlaylist{Playlist: *entity.(*model.Playlist)}
|
||||
current, err := r.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
usr := loggedUser(r.ctx)
|
||||
if !usr.IsAdmin {
|
||||
// Only the owner can update the playlist
|
||||
if current.OwnerID != usr.ID {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
// Regular users can't change the ownership of a playlist
|
||||
if pls.OwnerID != "" && pls.OwnerID != usr.ID {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
}
|
||||
pls.ID = id
|
||||
pls.UpdatedAt = time.Now()
|
||||
_, err := r.put(id, pls, append(cols, "updatedAt")...)
|
||||
_, err = r.put(id, pls, append(cols, "updatedAt")...)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
@@ -468,31 +507,23 @@ func (r *playlistRepository) removeOrphans() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// renumber updates the position of all tracks in the playlist to be sequential starting from 1, ordered by their
|
||||
// current position. This is needed after removing orphan tracks, to ensure there are no gaps in the track numbering.
|
||||
// The two-step approach (negate then reassign via CTE) avoids UNIQUE constraint violations on (playlist_id, id).
|
||||
func (r *playlistRepository) renumber(id string) error {
|
||||
// Step 1: Negate all IDs to clear the positive ID space
|
||||
_, err := r.executeSQL(Expr(
|
||||
`UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id > 0`, id))
|
||||
var ids []string
|
||||
sq := Select("media_file_id").From("playlist_tracks").Where(Eq{"playlist_id": id}).OrderBy("id")
|
||||
err := r.queryAllSlice(sq, &ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Step 2: Assign new sequential positive IDs using UPDATE...FROM with a CTE.
|
||||
// The CTE is fully materialized before the UPDATE begins, avoiding self-referencing issues.
|
||||
// ORDER BY id DESC restores original order since IDs are now negative.
|
||||
_, err = r.executeSQL(Expr(
|
||||
`WITH new_ids AS (
|
||||
SELECT rowid as rid, ROW_NUMBER() OVER (ORDER BY id DESC) as new_id
|
||||
FROM playlist_tracks WHERE playlist_id = ?
|
||||
)
|
||||
UPDATE playlist_tracks SET id = new_ids.new_id
|
||||
FROM new_ids
|
||||
WHERE playlist_tracks.rowid = new_ids.rid AND playlist_tracks.playlist_id = ?`, id, id))
|
||||
if err != nil {
|
||||
return err
|
||||
return r.updatePlaylist(id, ids)
|
||||
}
|
||||
|
||||
func (r *playlistRepository) isWritable(playlistId string) bool {
|
||||
usr := loggedUser(r.ctx)
|
||||
if usr.IsAdmin {
|
||||
return true
|
||||
}
|
||||
return r.refreshCounters(&model.Playlist{ID: id})
|
||||
pls, err := r.Get(playlistId)
|
||||
return err == nil && pls.OwnerID == usr.ID
|
||||
}
|
||||
|
||||
var _ model.PlaylistRepository = (*playlistRepository)(nil)
|
||||
|
||||
@@ -401,79 +401,6 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Track Deletion and Renumbering", func() {
|
||||
var testPlaylistID string
|
||||
|
||||
AfterEach(func() {
|
||||
if testPlaylistID != "" {
|
||||
Expect(repo.Delete(testPlaylistID)).To(BeNil())
|
||||
testPlaylistID = ""
|
||||
}
|
||||
})
|
||||
|
||||
// helper to get track positions and media file IDs
|
||||
getTrackInfo := func(playlistID string) (ids []string, mediaFileIDs []string) {
|
||||
pls, err := repo.GetWithTracks(playlistID, false, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
for _, t := range pls.Tracks {
|
||||
ids = append(ids, t.ID)
|
||||
mediaFileIDs = append(mediaFileIDs, t.MediaFileID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
It("renumbers correctly after deleting a track from the middle", func() {
|
||||
By("creating a playlist with 4 tracks")
|
||||
newPls := model.Playlist{Name: "Renumber Test Middle", OwnerID: "userid"}
|
||||
newPls.AddMediaFilesByID([]string{"1001", "1002", "1003", "1004"})
|
||||
Expect(repo.Put(&newPls)).To(Succeed())
|
||||
testPlaylistID = newPls.ID
|
||||
|
||||
By("deleting the second track (position 2)")
|
||||
tracksRepo := repo.Tracks(newPls.ID, false)
|
||||
Expect(tracksRepo.Delete("2")).To(Succeed())
|
||||
|
||||
By("verifying remaining tracks are renumbered sequentially")
|
||||
ids, mediaFileIDs := getTrackInfo(newPls.ID)
|
||||
Expect(ids).To(Equal([]string{"1", "2", "3"}))
|
||||
Expect(mediaFileIDs).To(Equal([]string{"1001", "1003", "1004"}))
|
||||
})
|
||||
|
||||
It("renumbers correctly after deleting the first track", func() {
|
||||
By("creating a playlist with 3 tracks")
|
||||
newPls := model.Playlist{Name: "Renumber Test First", OwnerID: "userid"}
|
||||
newPls.AddMediaFilesByID([]string{"1001", "1002", "1003"})
|
||||
Expect(repo.Put(&newPls)).To(Succeed())
|
||||
testPlaylistID = newPls.ID
|
||||
|
||||
By("deleting the first track (position 1)")
|
||||
tracksRepo := repo.Tracks(newPls.ID, false)
|
||||
Expect(tracksRepo.Delete("1")).To(Succeed())
|
||||
|
||||
By("verifying remaining tracks are renumbered sequentially")
|
||||
ids, mediaFileIDs := getTrackInfo(newPls.ID)
|
||||
Expect(ids).To(Equal([]string{"1", "2"}))
|
||||
Expect(mediaFileIDs).To(Equal([]string{"1002", "1003"}))
|
||||
})
|
||||
|
||||
It("renumbers correctly after deleting the last track", func() {
|
||||
By("creating a playlist with 3 tracks")
|
||||
newPls := model.Playlist{Name: "Renumber Test Last", OwnerID: "userid"}
|
||||
newPls.AddMediaFilesByID([]string{"1001", "1002", "1003"})
|
||||
Expect(repo.Put(&newPls)).To(Succeed())
|
||||
testPlaylistID = newPls.ID
|
||||
|
||||
By("deleting the last track (position 3)")
|
||||
tracksRepo := repo.Tracks(newPls.ID, false)
|
||||
Expect(tracksRepo.Delete("3")).To(Succeed())
|
||||
|
||||
By("verifying remaining tracks are renumbered sequentially")
|
||||
ids, mediaFileIDs := getTrackInfo(newPls.ID)
|
||||
Expect(ids).To(Equal([]string{"1", "2"}))
|
||||
Expect(mediaFileIDs).To(Equal([]string{"1001", "1002"}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Smart Playlists Library Filtering", func() {
|
||||
var mfRepo model.MediaFileRepository
|
||||
var testPlaylistID string
|
||||
|
||||
@@ -140,7 +140,15 @@ func (r *playlistTrackRepository) NewInstance() any {
|
||||
return &model.PlaylistTrack{}
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) isTracksEditable() bool {
|
||||
return r.playlistRepo.isWritable(r.playlistId) && !r.playlist.IsSmartPlaylist()
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) {
|
||||
if !r.isTracksEditable() {
|
||||
return 0, rest.ErrPermissionDenied
|
||||
}
|
||||
|
||||
if len(mediaFileIds) > 0 {
|
||||
log.Debug(r.ctx, "Adding songs to playlist", "playlistId", r.playlistId, "mediaFileIds", mediaFileIds)
|
||||
} else {
|
||||
@@ -188,7 +196,22 @@ func (r *playlistTrackRepository) AddDiscs(discs []model.DiscID) (int, error) {
|
||||
return r.addMediaFileIds(clauses)
|
||||
}
|
||||
|
||||
// Get ids from all current tracks
|
||||
func (r *playlistTrackRepository) getTracks() ([]string, error) {
|
||||
all := r.newSelect().Columns("media_file_id").Where(Eq{"playlist_id": r.playlistId}).OrderBy("id")
|
||||
var ids []string
|
||||
err := r.queryAllSlice(all, &ids)
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Error querying current tracks from playlist", "playlistId", r.playlistId, err)
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) Delete(ids ...string) error {
|
||||
if !r.isTracksEditable() {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
err := r.delete(And{Eq{"playlist_id": r.playlistId}, Eq{"id": ids}})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -198,6 +221,9 @@ func (r *playlistTrackRepository) Delete(ids ...string) error {
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) DeleteAll() error {
|
||||
if !r.isTracksEditable() {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
err := r.delete(Eq{"playlist_id": r.playlistId})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -206,45 +232,16 @@ func (r *playlistTrackRepository) DeleteAll() error {
|
||||
return r.playlistRepo.renumber(r.playlistId)
|
||||
}
|
||||
|
||||
// Reorder moves a track from pos to newPos, shifting other tracks accordingly.
|
||||
func (r *playlistTrackRepository) Reorder(pos int, newPos int) error {
|
||||
if pos == newPos {
|
||||
return nil
|
||||
if !r.isTracksEditable() {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
pid := r.playlistId
|
||||
|
||||
// Step 1: Move the source track out of the way (temporary sentinel value)
|
||||
_, err := r.executeSQL(Expr(
|
||||
`UPDATE playlist_tracks SET id = -999999 WHERE playlist_id = ? AND id = ?`, pid, pos))
|
||||
ids, err := r.getTracks()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 2: Shift the affected range using negative values to avoid unique constraint violations
|
||||
if pos < newPos {
|
||||
_, err = r.executeSQL(Expr(
|
||||
`UPDATE playlist_tracks SET id = -(id - 1) WHERE playlist_id = ? AND id > ? AND id <= ?`,
|
||||
pid, pos, newPos))
|
||||
} else {
|
||||
_, err = r.executeSQL(Expr(
|
||||
`UPDATE playlist_tracks SET id = -(id + 1) WHERE playlist_id = ? AND id >= ? AND id < ?`,
|
||||
pid, newPos, pos))
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: Flip the shifted range back to positive
|
||||
_, err = r.executeSQL(Expr(
|
||||
`UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id < 0 AND id != -999999`, pid))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 4: Place the source track at its new position
|
||||
_, err = r.executeSQL(Expr(
|
||||
`UPDATE playlist_tracks SET id = ? WHERE playlist_id = ? AND id = -999999`, newPos, pid))
|
||||
return err
|
||||
newOrder := slice.Move(ids, pos-1, newPos-1)
|
||||
return r.playlistRepo.updatePlaylist(r.playlistId, newOrder)
|
||||
}
|
||||
|
||||
var _ model.PlaylistTrackRepository = (*playlistTrackRepository)(nil)
|
||||
|
||||
@@ -109,10 +109,11 @@ func booleanFilter(field string, value any) Sqlizer {
|
||||
func fullTextFilter(tableName string, mbidFields ...string) func(string, any) Sqlizer {
|
||||
return func(field string, value any) Sqlizer {
|
||||
v := strings.ToLower(value.(string))
|
||||
return cmp.Or(
|
||||
cond := cmp.Or(
|
||||
mbidExpr(tableName, v, mbidFields...),
|
||||
getSearchFilter(tableName, v),
|
||||
fullTextExpr(tableName, v),
|
||||
)
|
||||
return cond
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,9 +26,7 @@ var _ = Describe("sqlRestful", func() {
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(BeNil())
|
||||
})
|
||||
|
||||
It(`returns nil if tries a filter with legacySearchExpr("'")`, func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Search.Backend = "legacy"
|
||||
It(`returns nil if tries a filter with fullTextExpr("'")`, func() {
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"name": fullTextFilter("table"),
|
||||
}
|
||||
@@ -79,7 +77,6 @@ var _ = Describe("sqlRestful", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Search.Backend = "legacy"
|
||||
tableName = "test_table"
|
||||
mbidFields = []string{"mbid", "artist_mbid"}
|
||||
filter = fullTextFilter(tableName, mbidFields...)
|
||||
@@ -139,7 +136,7 @@ var _ = Describe("sqlRestful", func() {
|
||||
|
||||
Context("when SearchFullString config changes behavior", func() {
|
||||
It("uses different separator with SearchFullString=false", func() {
|
||||
conf.Server.Search.FullString = false
|
||||
conf.Server.SearchFullString = false
|
||||
result := filter("search", "test query")
|
||||
|
||||
andCondition, ok := result.(squirrel.And)
|
||||
@@ -152,7 +149,7 @@ var _ = Describe("sqlRestful", func() {
|
||||
})
|
||||
|
||||
It("uses no separator with SearchFullString=true", func() {
|
||||
conf.Server.Search.FullString = true
|
||||
conf.Server.SearchFullString = true
|
||||
result := filter("search", "test query")
|
||||
|
||||
andCondition, ok := result.(squirrel.And)
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
@@ -16,51 +15,6 @@ func formatFullText(text ...string) string {
|
||||
return " " + fullText
|
||||
}
|
||||
|
||||
// searchFilter is the internal result from search expression builders.
|
||||
// It is never exposed to callers — they use getSearchFilter or applySearchFilter instead.
|
||||
type searchFilter struct {
|
||||
where Sqlizer // WHERE clause (LIKE/legacy/FTS5 rowid IN)
|
||||
rankOrder string // ORDER BY expression for relevance (correlated bm25 subquery)
|
||||
rankArgs []any // Args for the rank ORDER BY expression
|
||||
}
|
||||
|
||||
// buildSearchFilter returns the search filter for the given table and query,
|
||||
// selecting the appropriate backend based on config (FTS5, legacy LIKE, CJK LIKE).
|
||||
func buildSearchFilter(tableName, query string) *searchFilter {
|
||||
if conf.Server.Search.Backend == "legacy" || conf.Server.Search.FullString {
|
||||
return legacySearchExpr(tableName, query)
|
||||
}
|
||||
if containsCJK(query) {
|
||||
return likeSearchExpr(tableName, query)
|
||||
}
|
||||
return ftsSearchExpr(tableName, query)
|
||||
}
|
||||
|
||||
// getSearchFilter returns a Sqlizer for WHERE-only filtering.
|
||||
// Used by fullTextFilter where only filtering is needed, not ranking.
|
||||
func getSearchFilter(tableName, query string) Sqlizer {
|
||||
filter := buildSearchFilter(tableName, query)
|
||||
if filter == nil {
|
||||
return nil
|
||||
}
|
||||
return filter.where
|
||||
}
|
||||
|
||||
// applySearchFilter applies search filtering and ordering to a query builder.
|
||||
// When a filter matches, it adds the WHERE clause, optional BM25 ranking, and orderBys as tiebreakers.
|
||||
// When no filter matches (empty query), it falls back to naturalOrder.
|
||||
func applySearchFilter(sq SelectBuilder, tableName, query, naturalOrder string, orderBys ...string) SelectBuilder {
|
||||
filter := buildSearchFilter(tableName, query)
|
||||
if filter == nil {
|
||||
return sq.OrderBy(naturalOrder)
|
||||
}
|
||||
sq = sq.Where(filter.where)
|
||||
if filter.rankOrder != "" {
|
||||
sq = sq.OrderByClause(filter.rankOrder, filter.rankArgs...)
|
||||
}
|
||||
return sq.OrderBy(orderBys...)
|
||||
}
|
||||
|
||||
// doSearch performs a full-text search with the specified parameters.
|
||||
// The naturalOrder is used to sort results when no full-text filter is applied. It is useful for cases like
|
||||
// OpenSubsonic, where an empty search query should return all results in a natural order. Normally the parameter
|
||||
@@ -72,7 +26,15 @@ func (r sqlRepository) doSearch(sq SelectBuilder, q string, offset, size int, re
|
||||
return nil
|
||||
}
|
||||
|
||||
sq = applySearchFilter(sq, r.tableName, q, naturalOrder, orderBys...)
|
||||
filter := fullTextExpr(r.tableName, q)
|
||||
if filter != nil {
|
||||
sq = sq.Where(filter)
|
||||
sq = sq.OrderBy(orderBys...)
|
||||
} else {
|
||||
// This is to speed up the results of `search3?query=""`, for OpenSubsonic
|
||||
// If the filter is empty, we sort by the specified natural order.
|
||||
sq = sq.OrderBy(naturalOrder)
|
||||
}
|
||||
sq = sq.Where(Eq{r.tableName + ".missing": false})
|
||||
sq = sq.Limit(uint64(size)).Offset(uint64(offset))
|
||||
return r.queryAll(sq, results, model.QueryOptions{Offset: offset})
|
||||
@@ -97,16 +59,13 @@ func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer {
|
||||
return Or(cond)
|
||||
}
|
||||
|
||||
// legacySearchExpr generates LIKE-based search filters against the full_text column.
|
||||
// This is the original search implementation, used when Search.Backend="legacy".
|
||||
func legacySearchExpr(tableName string, s string) *searchFilter {
|
||||
func fullTextExpr(tableName string, s string) Sqlizer {
|
||||
q := str.SanitizeStrings(s)
|
||||
if q == "" {
|
||||
log.Trace("Search using legacy backend, query is empty", "table", tableName)
|
||||
return nil
|
||||
}
|
||||
var sep string
|
||||
if !conf.Server.Search.FullString {
|
||||
if !conf.Server.SearchFullString {
|
||||
sep = " "
|
||||
}
|
||||
parts := strings.Split(q, " ")
|
||||
@@ -114,6 +73,5 @@ func legacySearchExpr(tableName string, s string) *searchFilter {
|
||||
for _, part := range parts {
|
||||
filters = append(filters, Like{tableName + ".full_text": "%" + sep + part + "%"})
|
||||
}
|
||||
log.Trace("Search using legacy backend", "query", filters, "table", tableName)
|
||||
return &searchFilter{where: filters}
|
||||
return filters
|
||||
}
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
// containsCJK returns true if the string contains any CJK (Chinese/Japanese/Korean) characters.
|
||||
// CJK text doesn't use spaces between words, so FTS5's unicode61 tokenizer treats entire
|
||||
// CJK phrases as single tokens, making token-based search ineffective for CJK content.
|
||||
func containsCJK(s string) bool {
|
||||
for _, r := range s {
|
||||
if unicode.Is(unicode.Han, r) ||
|
||||
unicode.Is(unicode.Hiragana, r) ||
|
||||
unicode.Is(unicode.Katakana, r) ||
|
||||
unicode.Is(unicode.Hangul, r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fts5SpecialChars matches characters that should be stripped from user input.
|
||||
// We keep only Unicode letters, numbers, whitespace, * (prefix wildcard), " (phrase quotes),
|
||||
// and \x00 (internal placeholder marker). All punctuation is removed because the unicode61
|
||||
// tokenizer treats it as token separators, and characters like ' can cause FTS5 parse errors
|
||||
// as unbalanced string delimiters.
|
||||
var fts5SpecialChars = regexp.MustCompile(`[^\p{L}\p{N}\s*"\x00]`)
|
||||
|
||||
// fts5PunctStrip strips everything except letters and numbers (no whitespace, wildcards, or quotes).
|
||||
// Used for normalizing words at index time to create concatenated forms (e.g., "R.E.M." → "REM").
|
||||
var fts5PunctStrip = regexp.MustCompile(`[^\p{L}\p{N}]`)
|
||||
|
||||
// fts5Operators matches FTS5 boolean operators as whole words (case-insensitive).
|
||||
var fts5Operators = regexp.MustCompile(`(?i)\b(AND|OR|NOT|NEAR)\b`)
|
||||
|
||||
// fts5LeadingStar matches a * at the start of a token. FTS5 only supports * at the end (prefix queries).
|
||||
var fts5LeadingStar = regexp.MustCompile(`(^|[\s])\*+`)
|
||||
|
||||
// normalizeForFTS takes multiple strings, strips non-letter/non-number characters from each word,
|
||||
// and returns a space-separated string of words that changed after stripping (deduplicated).
|
||||
// This is used at index time to create concatenated forms: "R.E.M." → "REM", "AC/DC" → "ACDC".
|
||||
func normalizeForFTS(values ...string) string {
|
||||
seen := make(map[string]struct{})
|
||||
var result []string
|
||||
for _, v := range values {
|
||||
for _, word := range strings.Fields(v) {
|
||||
stripped := fts5PunctStrip.ReplaceAllString(word, "")
|
||||
if stripped == "" || stripped == word {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(stripped)
|
||||
if _, ok := seen[lower]; ok {
|
||||
continue
|
||||
}
|
||||
seen[lower] = struct{}{}
|
||||
result = append(result, stripped)
|
||||
}
|
||||
}
|
||||
return strings.Join(result, " ")
|
||||
}
|
||||
|
||||
// isSingleUnicodeLetter returns true if token is exactly one Unicode letter.
|
||||
func isSingleUnicodeLetter(token string) bool {
|
||||
r, size := utf8.DecodeRuneInString(token)
|
||||
return size == len(token) && size > 0 && unicode.IsLetter(r)
|
||||
}
|
||||
|
||||
// namePunctuation is the set of characters commonly used as separators in artist/album
|
||||
// names (hyphens, slashes, dots, apostrophes). Only words containing these are candidates
|
||||
// for punctuated-word processing; other special characters (^, :, &) are just stripped.
|
||||
const namePunctuation = `-/.''`
|
||||
|
||||
// processPunctuatedWords handles words with embedded name punctuation before the general
|
||||
// special-character stripping. For each punctuated word it produces either:
|
||||
// - A quoted phrase for dotted abbreviations: R.E.M. → "R E M"
|
||||
// - A phrase+concat OR for other patterns: a-ha → ("a ha" OR aha*)
|
||||
func processPunctuatedWords(input string, phrases []string) (string, []string) {
|
||||
words := strings.Fields(input)
|
||||
var result []string
|
||||
for _, w := range words {
|
||||
if strings.HasPrefix(w, "\x00") || strings.ContainsAny(w, `*"`) || !strings.ContainsAny(w, namePunctuation) {
|
||||
result = append(result, w)
|
||||
continue
|
||||
}
|
||||
concat := fts5PunctStrip.ReplaceAllString(w, "")
|
||||
if concat == "" || concat == w {
|
||||
result = append(result, w)
|
||||
continue
|
||||
}
|
||||
subTokens := strings.Fields(fts5SpecialChars.ReplaceAllString(w, " "))
|
||||
if len(subTokens) < 2 {
|
||||
// Single sub-token after splitting (e.g., N' → N): just use the stripped form
|
||||
result = append(result, concat)
|
||||
continue
|
||||
}
|
||||
// Dotted abbreviations (R.E.M., U.K.) — all single letters separated by dots only
|
||||
if isDottedAbbreviation(w, subTokens) {
|
||||
phrases = append(phrases, fmt.Sprintf(`"%s"`, strings.Join(subTokens, " ")))
|
||||
} else {
|
||||
// Punctuated names (a-ha, AC/DC, Jay-Z) — phrase for adjacency + concat for search_normalized
|
||||
phrases = append(phrases, fmt.Sprintf(`("%s" OR %s*)`, strings.Join(subTokens, " "), concat))
|
||||
}
|
||||
result = append(result, fmt.Sprintf("\x00PHRASE%d\x00", len(phrases)-1))
|
||||
}
|
||||
return strings.Join(result, " "), phrases
|
||||
}
|
||||
|
||||
// isDottedAbbreviation returns true if w uses only dots as punctuation and all sub-tokens
|
||||
// are single letters (e.g., "R.E.M.", "U.K." but not "a-ha" or "AC/DC").
|
||||
func isDottedAbbreviation(w string, subTokens []string) bool {
|
||||
for _, r := range w {
|
||||
if !unicode.IsLetter(r) && !unicode.IsNumber(r) && r != '.' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, st := range subTokens {
|
||||
if !isSingleUnicodeLetter(st) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// buildFTS5Query preprocesses user input into a safe FTS5 MATCH expression.
|
||||
// It preserves quoted phrases and * prefix wildcards, neutralizes FTS5 operators
|
||||
// (by lowercasing them, since FTS5 operators are case-sensitive) and strips
|
||||
// special characters to prevent query injection.
|
||||
func buildFTS5Query(userInput string) string {
|
||||
q := strings.TrimSpace(userInput)
|
||||
if q == "" || q == `""` {
|
||||
return ""
|
||||
}
|
||||
|
||||
var phrases []string
|
||||
result := q
|
||||
for {
|
||||
start := strings.Index(result, `"`)
|
||||
if start == -1 {
|
||||
break
|
||||
}
|
||||
end := strings.Index(result[start+1:], `"`)
|
||||
if end == -1 {
|
||||
// Unmatched quote — remove it
|
||||
result = result[:start] + result[start+1:]
|
||||
break
|
||||
}
|
||||
end += start + 1
|
||||
phrase := result[start : end+1] // includes quotes
|
||||
phrases = append(phrases, phrase)
|
||||
result = result[:start] + fmt.Sprintf("\x00PHRASE%d\x00", len(phrases)-1) + result[end+1:]
|
||||
}
|
||||
|
||||
// Neutralize FTS5 operators by lowercasing them (FTS5 operators are case-sensitive:
|
||||
// AND, OR, NOT, NEAR are operators, but and, or, not, near are plain tokens)
|
||||
result = fts5Operators.ReplaceAllStringFunc(result, strings.ToLower)
|
||||
|
||||
// Handle words with embedded punctuation (a-ha, AC/DC, R.E.M.) before stripping
|
||||
result, phrases = processPunctuatedWords(result, phrases)
|
||||
|
||||
result = fts5SpecialChars.ReplaceAllString(result, " ")
|
||||
result = fts5LeadingStar.ReplaceAllString(result, "$1")
|
||||
tokens := strings.Fields(result)
|
||||
|
||||
// Append * to plain tokens for prefix matching (e.g., "love" → "love*").
|
||||
// Skip tokens that are already wildcarded or are quoted phrase placeholders.
|
||||
for i, t := range tokens {
|
||||
if strings.HasPrefix(t, "\x00") || strings.HasSuffix(t, "*") {
|
||||
continue
|
||||
}
|
||||
tokens[i] = t + "*"
|
||||
}
|
||||
|
||||
result = strings.Join(tokens, " ")
|
||||
|
||||
for i, phrase := range phrases {
|
||||
placeholder := fmt.Sprintf("\x00PHRASE%d\x00", i)
|
||||
result = strings.ReplaceAll(result, placeholder, phrase)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// likeSearchColumns defines the core columns to search with LIKE queries.
|
||||
// These are the primary user-visible fields for each entity type.
|
||||
// Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input).
|
||||
var likeSearchColumns = map[string][]string{
|
||||
"media_file": {"title", "album", "artist", "album_artist"},
|
||||
"album": {"name", "album_artist"},
|
||||
"artist": {"name"},
|
||||
}
|
||||
|
||||
// likeSearchExpr generates LIKE-based search filters against core columns.
|
||||
// Each word in the query must match at least one column (AND between words),
|
||||
// and each word can match any column (OR within a word).
|
||||
// Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input).
|
||||
func likeSearchExpr(tableName string, s string) *searchFilter {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
log.Trace("Search using LIKE backend, query is empty", "table", tableName)
|
||||
return nil
|
||||
}
|
||||
columns, ok := likeSearchColumns[tableName]
|
||||
if !ok {
|
||||
log.Trace("Search using LIKE backend, couldn't find columns for this table", "table", tableName)
|
||||
return nil
|
||||
}
|
||||
words := strings.Fields(s)
|
||||
wordFilters := And{}
|
||||
for _, word := range words {
|
||||
colFilters := Or{}
|
||||
for _, col := range columns {
|
||||
colFilters = append(colFilters, Like{tableName + "." + col: "%" + word + "%"})
|
||||
}
|
||||
wordFilters = append(wordFilters, colFilters)
|
||||
}
|
||||
log.Trace("Search using LIKE backend", "query", wordFilters, "table", tableName)
|
||||
return &searchFilter{where: wordFilters}
|
||||
}
|
||||
|
||||
// ftsSearchColumns defines which FTS5 columns are included in general search.
|
||||
// Columns not listed here are indexed but not searched by default,
|
||||
// enabling future additions (comments, lyrics, bios) without affecting general search.
|
||||
var ftsSearchColumns = map[string]string{
|
||||
"media_file": "{title album artist album_artist sort_title sort_album_name sort_artist_name sort_album_artist_name disc_subtitle search_participants search_normalized}",
|
||||
"album": "{name sort_album_name album_artist search_participants discs catalog_num album_version search_normalized}",
|
||||
"artist": "{name sort_artist_name search_normalized}",
|
||||
}
|
||||
|
||||
// ftsColumnWeights defines BM25 weights for each FTS5 table column.
|
||||
// Higher weights make matches in that column rank higher in results.
|
||||
// The order must match the column order in the FTS5 table definition.
|
||||
var ftsColumnWeights = map[string][]float64{
|
||||
// title, album, artist, album_artist, sort_title, sort_album_name,
|
||||
// sort_artist_name, sort_album_artist_name, disc_subtitle,
|
||||
// search_participants, search_normalized
|
||||
"media_file_fts": {10, 5, 5, 5, 1, 1, 1, 1, 2, 3, 1},
|
||||
// name, sort_album_name, album_artist, search_participants,
|
||||
// discs, catalog_num, album_version, search_normalized
|
||||
"album_fts": {10, 1, 5, 3, 1, 2, 2, 1},
|
||||
// name, sort_artist_name, search_normalized
|
||||
"artist_fts": {10, 1, 1},
|
||||
}
|
||||
|
||||
// ftsSearchExpr generates an FTS5 MATCH-based search filter with BM25 relevance ranking.
|
||||
// It uses a WHERE IN subquery for filtering and a correlated subquery for BM25 ranking.
|
||||
// The correlated subquery approach is used instead of a CTE+JOIN because SQLite's bm25()
|
||||
// function cannot be referenced from an outer query that has GROUP BY (it silently returns
|
||||
// 0 rows). The correlated subquery evaluates bm25() in a direct FTS5 query context.
|
||||
// If the query produces no FTS tokens (e.g., punctuation-only like "!!!!!!!"),
|
||||
// it falls back to LIKE-based search.
|
||||
func ftsSearchExpr(tableName string, s string) *searchFilter {
|
||||
q := buildFTS5Query(s)
|
||||
if q == "" {
|
||||
s = strings.TrimSpace(strings.ReplaceAll(s, `"`, ""))
|
||||
if s != "" {
|
||||
log.Trace("Search using LIKE fallback for non-tokenizable query", "table", tableName, "query", s)
|
||||
return likeSearchExpr(tableName, s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ftsTable := tableName + "_fts"
|
||||
matchExpr := q
|
||||
if cols, ok := ftsSearchColumns[tableName]; ok {
|
||||
matchExpr = cols + " : (" + q + ")"
|
||||
}
|
||||
|
||||
// WHERE clause: filter by matching rowids
|
||||
whereFilter := Expr(
|
||||
tableName+".rowid IN (SELECT rowid FROM "+ftsTable+" WHERE "+ftsTable+" MATCH ?)",
|
||||
matchExpr,
|
||||
)
|
||||
|
||||
// Build BM25 weights string for the bm25() function
|
||||
weights, ok := ftsColumnWeights[ftsTable]
|
||||
if !ok {
|
||||
// Fallback: no weights available, filter only (no ranking)
|
||||
log.Trace("Search using FTS5 backend (no ranking)", "table", tableName, "query", q)
|
||||
return &searchFilter{where: whereFilter}
|
||||
}
|
||||
|
||||
// Build bm25 weight args string: "10, 5, 5, ..."
|
||||
weightStrs := make([]string, len(weights))
|
||||
for i, w := range weights {
|
||||
weightStrs[i] = fmt.Sprintf("%g", w)
|
||||
}
|
||||
bm25Args := strings.Join(weightStrs, ", ")
|
||||
|
||||
// ORDER BY clause: use a correlated subquery to compute BM25 rank.
|
||||
// This evaluates bm25() in a direct FTS5 query context (FROM fts_table),
|
||||
// which is required by SQLite. The correlated subquery matches the current
|
||||
// row's rowid to get its specific BM25 score.
|
||||
//
|
||||
// ORDER BY (SELECT bm25(fts_table, w1, w2, ...) FROM fts_table
|
||||
// WHERE fts_table MATCH ? AND fts_table.rowid = tableName.rowid)
|
||||
rankOrder := fmt.Sprintf(
|
||||
"(SELECT bm25(%s, %s) FROM %s WHERE %s MATCH ? AND %s.rowid = %s.rowid)",
|
||||
ftsTable, bm25Args, ftsTable, ftsTable, ftsTable, tableName,
|
||||
)
|
||||
|
||||
log.Trace("Search using FTS5 backend with BM25 ranking", "table", tableName, "query", q, "weights", bm25Args)
|
||||
return &searchFilter{
|
||||
where: whereFilter,
|
||||
rankOrder: rankOrder,
|
||||
rankArgs: []any{matchExpr},
|
||||
}
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = DescribeTable("buildFTS5Query",
|
||||
func(input, expected string) {
|
||||
Expect(buildFTS5Query(input)).To(Equal(expected))
|
||||
},
|
||||
Entry("returns empty string for empty input", "", ""),
|
||||
Entry("returns empty string for whitespace-only input", " ", ""),
|
||||
Entry("appends * to a single word for prefix matching", "beatles", "beatles*"),
|
||||
Entry("appends * to each word for prefix matching", "abbey road", "abbey* road*"),
|
||||
Entry("preserves quoted phrases without appending *", `"the beatles"`, `"the beatles"`),
|
||||
Entry("does not double-append * to existing prefix wildcard", "beat*", "beat*"),
|
||||
Entry("strips FTS5 operators and appends * to lowercased words", "AND OR NOT NEAR", "and* or* not* near*"),
|
||||
Entry("strips special FTS5 syntax characters and appends *", "test^col:val", "test* col* val*"),
|
||||
Entry("handles mixed phrases and words", `"the beatles" abbey`, `"the beatles" abbey*`),
|
||||
Entry("handles prefix with multiple words", "beat* abbey", "beat* abbey*"),
|
||||
Entry("collapses multiple spaces", "abbey road", "abbey* road*"),
|
||||
Entry("strips leading * from tokens and appends trailing *", "*livia", "livia*"),
|
||||
Entry("strips leading * and preserves existing trailing *", "*livia oliv*", "livia* oliv*"),
|
||||
Entry("strips standalone *", "*", ""),
|
||||
Entry("strips apostrophe from input", "Guns N' Roses", "Guns* N* Roses*"),
|
||||
Entry("converts slashed word to phrase+concat OR", "AC/DC", `("AC DC" OR ACDC*)`),
|
||||
Entry("converts hyphenated word to phrase+concat OR", "a-ha", `("a ha" OR aha*)`),
|
||||
Entry("converts partial hyphenated word to phrase+concat OR", "a-h", `("a h" OR ah*)`),
|
||||
Entry("converts hyphenated name to phrase+concat OR", "Jay-Z", `("Jay Z" OR JayZ*)`),
|
||||
Entry("converts contraction to phrase+concat OR", "it's", `("it s" OR its*)`),
|
||||
Entry("handles punctuated word mixed with plain words", "best of a-ha", `best* of* ("a ha" OR aha*)`),
|
||||
Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "rock* roll* vol* 2*"),
|
||||
Entry("preserves unicode characters with diacritics", "Björk début", "Björk* début*"),
|
||||
Entry("collapses dotted abbreviation into phrase", "R.E.M.", `"R E M"`),
|
||||
Entry("collapses abbreviation without trailing dot", "R.E.M", `"R E M"`),
|
||||
Entry("collapses abbreviation mixed with words", "best of R.E.M.", `best* of* "R E M"`),
|
||||
Entry("collapses two-letter abbreviation", "U.K.", `"U K"`),
|
||||
Entry("does not collapse single letter surrounded by words", "I am fine", "I* am* fine*"),
|
||||
Entry("does not collapse single standalone letter", "A test", "A* test*"),
|
||||
Entry("preserves quoted phrase with punctuation verbatim", `"ac/dc"`, `"ac/dc"`),
|
||||
Entry("preserves quoted abbreviation verbatim", `"R.E.M."`, `"R.E.M."`),
|
||||
Entry("returns empty string for punctuation-only input", "!!!!!!!", ""),
|
||||
Entry("returns empty string for mixed punctuation", "!@#$%^&", ""),
|
||||
Entry("returns empty string for empty quoted phrase", `""`, ""),
|
||||
)
|
||||
|
||||
var _ = DescribeTable("normalizeForFTS",
|
||||
func(expected string, values ...string) {
|
||||
Expect(normalizeForFTS(values...)).To(Equal(expected))
|
||||
},
|
||||
Entry("strips dots and concatenates", "REM", "R.E.M."),
|
||||
Entry("strips slash", "ACDC", "AC/DC"),
|
||||
Entry("strips hyphen", "Aha", "A-ha"),
|
||||
Entry("skips unchanged words", "", "The Beatles"),
|
||||
Entry("handles mixed input", "REM", "R.E.M.", "Automatic for the People"),
|
||||
Entry("deduplicates", "REM", "R.E.M.", "R.E.M."),
|
||||
Entry("strips apostrophe from word", "N", "Guns N' Roses"),
|
||||
Entry("handles multiple values with punctuation", "REM ACDC", "R.E.M.", "AC/DC"),
|
||||
)
|
||||
|
||||
var _ = DescribeTable("containsCJK",
|
||||
func(input string, expected bool) {
|
||||
Expect(containsCJK(input)).To(Equal(expected))
|
||||
},
|
||||
Entry("returns false for empty string", "", false),
|
||||
Entry("returns false for ASCII text", "hello world", false),
|
||||
Entry("returns false for Latin with diacritics", "Björk début", false),
|
||||
Entry("detects Chinese characters (Han)", "周杰伦", true),
|
||||
Entry("detects Japanese Hiragana", "こんにちは", true),
|
||||
Entry("detects Japanese Katakana", "カタカナ", true),
|
||||
Entry("detects Korean Hangul", "한국어", true),
|
||||
Entry("detects CJK mixed with Latin", "best of 周杰伦", true),
|
||||
Entry("detects single CJK character", "a曲b", true),
|
||||
)
|
||||
|
||||
var _ = Describe("likeSearchExpr", func() {
|
||||
It("returns nil for empty query", func() {
|
||||
Expect(likeSearchExpr("media_file", "")).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns nil for whitespace-only query", func() {
|
||||
Expect(likeSearchExpr("media_file", " ")).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns a filter with no ranking", func() {
|
||||
filter := likeSearchExpr("media_file", "周杰伦")
|
||||
Expect(filter).ToNot(BeNil())
|
||||
Expect(filter.where).ToNot(BeNil())
|
||||
Expect(filter.rankOrder).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("generates LIKE filters against core columns for single CJK word", func() {
|
||||
filter := likeSearchExpr("media_file", "周杰伦")
|
||||
sql, args, err := filter.where.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("OR"))
|
||||
Expect(sql).To(ContainSubstring("media_file.title LIKE"))
|
||||
Expect(sql).To(ContainSubstring("media_file.album LIKE"))
|
||||
Expect(sql).To(ContainSubstring("media_file.artist LIKE"))
|
||||
Expect(sql).To(ContainSubstring("media_file.album_artist LIKE"))
|
||||
Expect(args).To(HaveLen(4))
|
||||
for _, arg := range args {
|
||||
Expect(arg).To(Equal("%周杰伦%"))
|
||||
}
|
||||
})
|
||||
|
||||
It("generates AND of OR groups for multi-word query", func() {
|
||||
filter := likeSearchExpr("media_file", "周杰伦 greatest")
|
||||
sql, args, err := filter.where.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("AND"))
|
||||
Expect(args).To(HaveLen(8))
|
||||
})
|
||||
|
||||
It("uses correct columns for album table", func() {
|
||||
filter := likeSearchExpr("album", "周杰伦")
|
||||
sql, args, err := filter.where.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("album.name LIKE"))
|
||||
Expect(sql).To(ContainSubstring("album.album_artist LIKE"))
|
||||
Expect(args).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("uses correct columns for artist table", func() {
|
||||
filter := likeSearchExpr("artist", "周杰伦")
|
||||
sql, args, err := filter.where.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("artist.name LIKE"))
|
||||
Expect(args).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("returns nil for unknown table", func() {
|
||||
Expect(likeSearchExpr("unknown_table", "周杰伦")).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("ftsSearchExpr", func() {
|
||||
It("returns nil for empty query", func() {
|
||||
Expect(ftsSearchExpr("media_file", "")).To(BeNil())
|
||||
})
|
||||
|
||||
It("generates WHERE IN filter with correlated bm25 ranking for known tables", func() {
|
||||
filter := ftsSearchExpr("media_file", "beatles")
|
||||
Expect(filter).ToNot(BeNil())
|
||||
sql, args, err := filter.where.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("media_file.rowid IN"))
|
||||
Expect(sql).To(ContainSubstring("media_file_fts"))
|
||||
Expect(sql).To(ContainSubstring("MATCH"))
|
||||
Expect(args).To(HaveLen(1))
|
||||
matchExpr, ok := args[0].(string)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(matchExpr).To(HavePrefix("{title album artist album_artist"))
|
||||
Expect(matchExpr).To(ContainSubstring("beatles*"))
|
||||
Expect(filter.rankOrder).To(ContainSubstring("bm25(media_file_fts"))
|
||||
Expect(filter.rankOrder).To(ContainSubstring("MATCH"))
|
||||
Expect(filter.rankArgs).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("includes correct BM25 weights for media_file", func() {
|
||||
filter := ftsSearchExpr("media_file", "test")
|
||||
Expect(filter.rankOrder).To(ContainSubstring("bm25(media_file_fts, 10, 5, 5, 5, 1, 1, 1, 1, 2, 3, 1)"))
|
||||
})
|
||||
|
||||
It("includes correct BM25 weights for album", func() {
|
||||
filter := ftsSearchExpr("album", "test")
|
||||
Expect(filter.rankOrder).To(ContainSubstring("bm25(album_fts, 10, 1, 5, 3, 1, 2, 2, 1)"))
|
||||
})
|
||||
|
||||
It("includes correct BM25 weights for artist", func() {
|
||||
filter := ftsSearchExpr("artist", "test")
|
||||
Expect(filter.rankOrder).To(ContainSubstring("bm25(artist_fts, 10, 1, 1)"))
|
||||
})
|
||||
|
||||
It("generates correct FTS table name per entity", func() {
|
||||
for _, table := range []string{"media_file", "album", "artist"} {
|
||||
filter := ftsSearchExpr(table, "test")
|
||||
Expect(filter).ToNot(BeNil())
|
||||
sql, _, err := filter.where.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring(table + "_fts"))
|
||||
Expect(sql).To(ContainSubstring(table + ".rowid"))
|
||||
}
|
||||
})
|
||||
|
||||
It("wraps query with column filter for known tables", func() {
|
||||
filter := ftsSearchExpr("artist", "Beatles")
|
||||
sql, args, _ := filter.where.ToSql()
|
||||
Expect(sql).To(ContainSubstring("MATCH"))
|
||||
Expect(args[0]).To(Equal("{name sort_artist_name search_normalized} : (Beatles*)"))
|
||||
})
|
||||
|
||||
It("passes query without column filter for unknown tables (WHERE IN fallback)", func() {
|
||||
filter := ftsSearchExpr("unknown_table", "test")
|
||||
Expect(filter).ToNot(BeNil())
|
||||
Expect(filter.where).ToNot(BeNil())
|
||||
Expect(filter.rankOrder).To(BeEmpty())
|
||||
sql, args, err := filter.where.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("MATCH"))
|
||||
Expect(args[0]).To(Equal("test*"))
|
||||
})
|
||||
|
||||
It("preserves phrase queries inside column filter", func() {
|
||||
filter := ftsSearchExpr("media_file", `"the beatles"`)
|
||||
sql, args, _ := filter.where.ToSql()
|
||||
Expect(sql).To(ContainSubstring("MATCH"))
|
||||
Expect(args[0]).To(ContainSubstring(`"the beatles"`))
|
||||
})
|
||||
|
||||
It("preserves prefix queries inside column filter", func() {
|
||||
filter := ftsSearchExpr("media_file", "beat*")
|
||||
sql, args, _ := filter.where.ToSql()
|
||||
Expect(sql).To(ContainSubstring("MATCH"))
|
||||
Expect(args[0]).To(ContainSubstring("beat*"))
|
||||
})
|
||||
|
||||
It("falls back to LIKE search for punctuation-only query", func() {
|
||||
filter := ftsSearchExpr("media_file", "!!!!!!!")
|
||||
Expect(filter).ToNot(BeNil())
|
||||
Expect(filter.where).ToNot(BeNil())
|
||||
Expect(filter.rankOrder).To(BeEmpty())
|
||||
sql, args, err := filter.where.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("LIKE"))
|
||||
Expect(args).To(ContainElement("%!!!!!!!%"))
|
||||
})
|
||||
|
||||
It("returns nil for empty string even with LIKE fallback", func() {
|
||||
Expect(ftsSearchExpr("media_file", "")).To(BeNil())
|
||||
Expect(ftsSearchExpr("media_file", " ")).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns nil for empty quoted phrase", func() {
|
||||
Expect(ftsSearchExpr("media_file", `""`)).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("FTS5 Integration Search", func() {
|
||||
var (
|
||||
mr model.MediaFileRepository
|
||||
alr model.AlbumRepository
|
||||
arr model.ArtistRepository
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, adminUser)
|
||||
conn := GetDBXBuilder()
|
||||
mr = NewMediaFileRepository(ctx, conn)
|
||||
alr = NewAlbumRepository(ctx, conn)
|
||||
arr = NewArtistRepository(ctx, conn)
|
||||
})
|
||||
|
||||
Describe("MediaFile search", func() {
|
||||
It("finds media files by title", func() {
|
||||
results, err := mr.Search("Radioactivity", 0, 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].Title).To(Equal("Radioactivity"))
|
||||
Expect(results[0].ID).To(Equal(songRadioactivity.ID))
|
||||
})
|
||||
|
||||
It("finds media files by artist name", func() {
|
||||
results, err := mr.Search("Beatles", 0, 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(3))
|
||||
for _, r := range results {
|
||||
Expect(r.Artist).To(Equal("The Beatles"))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Album search", func() {
|
||||
It("finds albums by name", func() {
|
||||
results, err := alr.Search("Sgt Peppers", 0, 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].Name).To(Equal("Sgt Peppers"))
|
||||
Expect(results[0].ID).To(Equal(albumSgtPeppers.ID))
|
||||
})
|
||||
|
||||
It("finds albums with multi-word search", func() {
|
||||
results, err := alr.Search("Abbey Road", 0, 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(2))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Artist search", func() {
|
||||
It("finds artists by name", func() {
|
||||
results, err := arr.Search("Kraftwerk", 0, 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].Name).To(Equal("Kraftwerk"))
|
||||
Expect(results[0].ID).To(Equal(artistKraftwerk.ID))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("CJK search", func() {
|
||||
It("finds media files by CJK title", func() {
|
||||
results, err := mr.Search("プラチナ", 0, 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].Title).To(Equal("プラチナ・ジェット"))
|
||||
Expect(results[0].ID).To(Equal(songCJK.ID))
|
||||
})
|
||||
|
||||
It("finds media files by CJK artist name", func() {
|
||||
results, err := mr.Search("シートベルツ", 0, 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].Artist).To(Equal("シートベルツ"))
|
||||
})
|
||||
|
||||
It("finds albums by CJK artist name", func() {
|
||||
results, err := alr.Search("シートベルツ", 0, 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].Name).To(Equal("COWBOY BEBOP"))
|
||||
Expect(results[0].ID).To(Equal(albumCJK.ID))
|
||||
})
|
||||
|
||||
It("finds artists by CJK name", func() {
|
||||
results, err := arr.Search("シートベルツ", 0, 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].Name).To(Equal("シートベルツ"))
|
||||
Expect(results[0].ID).To(Equal(artistCJK.ID))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Album version search", func() {
|
||||
It("finds albums by version tag via FTS", func() {
|
||||
results, err := alr.Search("Deluxe", 0, 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].ID).To(Equal(albumWithVersion.ID))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Punctuation-only search", func() {
|
||||
It("finds media files with punctuation-only title", func() {
|
||||
results, err := mr.Search("!!!!!!!", 0, 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].Title).To(Equal("!!!!!!!"))
|
||||
Expect(results[0].ID).To(Equal(songPunctuation.ID))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Legacy backend fallback", func() {
|
||||
It("returns results using legacy LIKE-based search when configured", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Search.Backend = "legacy"
|
||||
|
||||
results, err := mr.Search("Radioactivity", 0, 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].Title).To(Equal("Radioactivity"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,6 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -14,127 +11,4 @@ var _ = Describe("sqlRepository", func() {
|
||||
Expect(formatFullText("legiao urbana")).To(Equal(" legiao urbana"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("legacySearchExpr", func() {
|
||||
It("returns nil for empty query", func() {
|
||||
Expect(legacySearchExpr("media_file", "")).To(BeNil())
|
||||
})
|
||||
|
||||
It("generates LIKE filter for single word", func() {
|
||||
filter := legacySearchExpr("media_file", "beatles")
|
||||
Expect(filter).ToNot(BeNil())
|
||||
sql, args, err := filter.where.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("media_file.full_text LIKE"))
|
||||
Expect(args).To(ContainElement("% beatles%"))
|
||||
})
|
||||
|
||||
It("generates AND of LIKE filters for multiple words", func() {
|
||||
filter := legacySearchExpr("media_file", "abbey road")
|
||||
Expect(filter).ToNot(BeNil())
|
||||
sql, args, err := filter.where.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("AND"))
|
||||
Expect(args).To(HaveLen(2))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("getSearchFilter", func() {
|
||||
It("returns FTS5 MATCH filter by default", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Search.Backend = "fts"
|
||||
conf.Server.Search.FullString = false
|
||||
|
||||
sqlizer := getSearchFilter("media_file", "test")
|
||||
Expect(sqlizer).ToNot(BeNil())
|
||||
sql, _, err := sqlizer.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("MATCH"))
|
||||
})
|
||||
|
||||
It("returns legacy LIKE filter when SearchBackend is legacy", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Search.Backend = "legacy"
|
||||
conf.Server.Search.FullString = false
|
||||
|
||||
sqlizer := getSearchFilter("media_file", "test")
|
||||
Expect(sqlizer).ToNot(BeNil())
|
||||
sql, _, err := sqlizer.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("LIKE"))
|
||||
})
|
||||
|
||||
It("falls back to legacy LIKE when SearchFullString is enabled", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Search.Backend = "fts"
|
||||
conf.Server.Search.FullString = true
|
||||
|
||||
sqlizer := getSearchFilter("media_file", "test")
|
||||
Expect(sqlizer).ToNot(BeNil())
|
||||
sql, _, err := sqlizer.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("LIKE"))
|
||||
})
|
||||
|
||||
It("routes CJK queries to LIKE filter", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Search.Backend = "fts"
|
||||
conf.Server.Search.FullString = false
|
||||
|
||||
sqlizer := getSearchFilter("media_file", "周杰伦")
|
||||
Expect(sqlizer).ToNot(BeNil())
|
||||
sql, _, err := sqlizer.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("LIKE"))
|
||||
Expect(sql).NotTo(ContainSubstring("MATCH"))
|
||||
})
|
||||
|
||||
It("returns nil for empty query", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Search.Backend = "fts"
|
||||
Expect(getSearchFilter("media_file", "")).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("applySearchFilter", func() {
|
||||
It("adds BM25 ranking for FTS5 queries", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Search.Backend = "fts"
|
||||
conf.Server.Search.FullString = false
|
||||
|
||||
sq := squirrel.Select("*").From("media_file")
|
||||
sq = applySearchFilter(sq, "media_file", "beatles", "media_file.rowid", "title")
|
||||
sql, _, err := sq.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("MATCH"))
|
||||
Expect(sql).To(ContainSubstring("bm25"))
|
||||
Expect(sql).To(ContainSubstring("title"))
|
||||
})
|
||||
|
||||
It("falls back to naturalOrder when query produces no filter", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Search.Backend = "legacy"
|
||||
|
||||
sq := squirrel.Select("*").From("media_file")
|
||||
sq = applySearchFilter(sq, "media_file", "", "media_file.rowid", "title")
|
||||
sql, _, err := sq.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("ORDER BY media_file.rowid"))
|
||||
Expect(sql).NotTo(ContainSubstring("title"))
|
||||
})
|
||||
|
||||
It("uses legacy LIKE when SearchBackend is legacy", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Search.Backend = "legacy"
|
||||
conf.Server.Search.FullString = false
|
||||
|
||||
sq := squirrel.Select("*").From("media_file")
|
||||
sq = applySearchFilter(sq, "media_file", "周杰伦", "media_file.rowid")
|
||||
sql, _, err := sq.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("LIKE"))
|
||||
Expect(sql).To(ContainSubstring("full_text"))
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -1 +1 @@
|
||||
-s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -- go run -race -tags netgo,sqlite_fts5 .
|
||||
-s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -- go run -race -tags netgo .
|
||||
|
||||
@@ -23,8 +23,6 @@ if [ ! -f "$postinstall_flag" ]; then
|
||||
# and not by root
|
||||
chown navidrome:navidrome /var/lib/navidrome/cache
|
||||
touch "$postinstall_flag"
|
||||
else
|
||||
navidrome service stop --configfile /etc/navidrome/navidrome.toml && navidrome service start --configfile /etc/navidrome/navidrome.toml
|
||||
fi
|
||||
|
||||
|
||||
|
||||
@@ -36,8 +36,7 @@
|
||||
"bitDepth": "Bitdybde",
|
||||
"sampleRate": "Samplingfrekvens",
|
||||
"missing": "Manglende",
|
||||
"libraryName": "Bibliotek",
|
||||
"composer": "Komponist"
|
||||
"libraryName": "Bibliotek"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Afspil senere",
|
||||
@@ -47,8 +46,7 @@
|
||||
"download": "Download",
|
||||
"playNext": "Afspil næste",
|
||||
"info": "Hent info",
|
||||
"showInPlaylist": "Vis i afspilningsliste",
|
||||
"instantMix": "Instant Mix"
|
||||
"showInPlaylist": "Vis i afspilningsliste"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -330,80 +328,6 @@
|
||||
"scanInProgress": "Scanning i gang...",
|
||||
"noLibrariesAssigned": "Ingen biblioteker tildelt denne bruger"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "Plugin |||| Plugins",
|
||||
"fields": {
|
||||
"id": "ID",
|
||||
"name": "Navn",
|
||||
"description": "Beskrivelse",
|
||||
"version": "Version",
|
||||
"author": "Forfatter",
|
||||
"website": "Hjemmeside",
|
||||
"permissions": "Tilladelser",
|
||||
"enabled": "Aktiveret",
|
||||
"status": "Status",
|
||||
"path": "Sti",
|
||||
"lastError": "Fejl",
|
||||
"hasError": "Fejl",
|
||||
"updatedAt": "Opdateret",
|
||||
"createdAt": "Installeret",
|
||||
"configKey": "Nøgle",
|
||||
"configValue": "Værdi",
|
||||
"allUsers": "Tillad alle brugere",
|
||||
"selectedUsers": "Valgte brugere",
|
||||
"allLibraries": "Tillad alle biblioteker",
|
||||
"selectedLibraries": "Valgte biblioteker"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Status",
|
||||
"info": "Pluginoplysninger",
|
||||
"configuration": "Konfiguration",
|
||||
"manifest": "Manifest",
|
||||
"usersPermission": "Brugertilladelse",
|
||||
"libraryPermission": "Bibliotekstilladelse"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Aktiveret",
|
||||
"disabled": "Deaktiveret"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Aktivér",
|
||||
"disable": "Deaktivér",
|
||||
"disabledDueToError": "Ret fejlen før aktivering",
|
||||
"disabledUsersRequired": "Vælg brugere før aktivering",
|
||||
"disabledLibrariesRequired": "Vælg biblioteker før aktivering",
|
||||
"addConfig": "Tilføj konfiguration",
|
||||
"rescan": "Genskan"
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "Plugin aktiveret",
|
||||
"disabled": "Plugin deaktiveret",
|
||||
"updated": "Plugin opdateret",
|
||||
"error": "Fejl ved opdatering af plugin"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "Konfigurationen skal være gyldig JSON"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "Konfigurér pluginet med nøgle-værdi-par. Lad stå tomt, hvis pluginet ikke kræver konfiguration.",
|
||||
"clickPermissions": "Klik på en tilladelse for detaljer",
|
||||
"noConfig": "Ingen konfiguration angivet",
|
||||
"allUsersHelp": "Når aktiveret, vil pluginet have adgang til alle brugere, inklusiv dem der oprettes i fremtiden.",
|
||||
"noUsers": "Ingen brugere valgt",
|
||||
"permissionReason": "Årsag",
|
||||
"usersRequired": "Dette plugin kræver adgang til brugeroplysninger. Vælg hvilke brugere pluginet kan tilgå, eller aktivér 'Tillad alle brugere'.",
|
||||
"allLibrariesHelp": "Når aktiveret, vil pluginet have adgang til alle biblioteker, inklusiv dem der oprettes i fremtiden.",
|
||||
"noLibraries": "Ingen biblioteker valgt",
|
||||
"librariesRequired": "Dette plugin kræver adgang til biblioteksoplysninger. Vælg hvilke biblioteker pluginet kan tilgå, eller aktivér 'Tillad alle biblioteker'.",
|
||||
"requiredHosts": "Påkrævede hosts",
|
||||
"configValidationError": "Konfigurationsvalidering mislykkedes:",
|
||||
"schemaRenderError": "Kan ikke vise konfigurationsformularen. Pluginets skema er muligvis ugyldigt."
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "nøgle",
|
||||
"configValue": "værdi"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -587,8 +511,7 @@
|
||||
"remove_all_missing_title": "Fjern alle manglende filer",
|
||||
"remove_all_missing_content": "Er du sikker på, at du vil fjerne alle manglende filer fra databasen? Dét vil permanent fjerne alle referencer til dem, inklusive deres afspilningstællere og vurderinger.",
|
||||
"noSimilarSongsFound": "Ingen lignende sange fundet",
|
||||
"noTopSongsFound": "Ingen topsange fundet",
|
||||
"startingInstantMix": "Indlæser Instant Mix..."
|
||||
"noTopSongsFound": "Ingen topsange fundet"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliotek",
|
||||
@@ -674,8 +597,7 @@
|
||||
"exportSuccess": "Konfigurationen eksporteret til udklipsholder i TOML-format",
|
||||
"exportFailed": "Kunne ikke kopiere konfigurationen",
|
||||
"devFlagsHeader": "Udviklingsflagget (med forbehold for ændring/fjernelse)",
|
||||
"devFlagsComment": "Disse er eksperimental-indstillinger og kan blive fjernet i fremtidige udgaver",
|
||||
"downloadToml": ""
|
||||
"devFlagsComment": "Disse er eksperimental-indstillinger og kan blive fjernet i fremtidige udgaver"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"languageName": "Euskara",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Abestia |||| Abesti",
|
||||
"name": "Abestia |||| Abestiak",
|
||||
"fields": {
|
||||
"albumArtist": "Albumaren artista",
|
||||
"duration": "Iraupena",
|
||||
@@ -10,7 +10,6 @@
|
||||
"playCount": "Erreprodukzioak",
|
||||
"title": "Titulua",
|
||||
"artist": "Artista",
|
||||
"composer": "Konpositorea",
|
||||
"album": "Albuma",
|
||||
"path": "Fitxategiaren bidea",
|
||||
"libraryName": "Liburutegia",
|
||||
@@ -34,9 +33,9 @@
|
||||
"grouping": "Multzokatzea",
|
||||
"mood": "Aldartea",
|
||||
"participants": "Partaide gehiago",
|
||||
"tags": "Etiketa gehiago",
|
||||
"mappedTags": "Esleitutako etiketak",
|
||||
"rawTags": "Etiketa gordinak",
|
||||
"tags": "Traola gehiago",
|
||||
"mappedTags": "Esleitutako traolak",
|
||||
"rawTags": "Traola gordinak",
|
||||
"missing": "Ez da aurkitu"
|
||||
},
|
||||
"actions": {
|
||||
@@ -47,12 +46,11 @@
|
||||
"shuffleAll": "Erreprodukzio aleatorioa",
|
||||
"download": "Deskargatu",
|
||||
"playNext": "Hurrengoa",
|
||||
"info": "Erakutsi informazioa",
|
||||
"instantMix": "Berehalako nahastea"
|
||||
"info": "Erakutsi informazioa"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Albuma |||| Album",
|
||||
"name": "Albuma |||| Albumak",
|
||||
"fields": {
|
||||
"albumArtist": "Albumaren artista",
|
||||
"artist": "Artista",
|
||||
@@ -68,7 +66,7 @@
|
||||
"date": "Recording Date",
|
||||
"originalDate": "Jatorrizkoa",
|
||||
"releaseDate": "Argitaratze-data",
|
||||
"releases": "Argitaratzea |||| Argitaratze",
|
||||
"releases": "Argitaratzea |||| Argitaratzeak",
|
||||
"released": "Argitaratua",
|
||||
"updatedAt": "Aktualizatze-data:",
|
||||
"comment": "Iruzkina",
|
||||
@@ -103,7 +101,7 @@
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Artista |||| Artista",
|
||||
"name": "Artista |||| Artistak",
|
||||
"fields": {
|
||||
"name": "Izena",
|
||||
"albumCount": "Album kopurua",
|
||||
@@ -332,80 +330,6 @@
|
||||
"scanInProgress": "Araketa abian da…",
|
||||
"noLibrariesAssigned": "Ez da liburutegirik egokitu erabiltzaile honentzat"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "Plugina |||| Plugin",
|
||||
"fields": {
|
||||
"id": "IDa",
|
||||
"name": "Izena",
|
||||
"description": "Deskribapena",
|
||||
"version": "Bertsioa",
|
||||
"author": "Autorea",
|
||||
"website": "Webgunea",
|
||||
"permissions": "Baimenak",
|
||||
"enabled": "Gaituta",
|
||||
"status": "Egoera",
|
||||
"path": "Bidea",
|
||||
"lastError": "Errorea",
|
||||
"hasError": "Errorea",
|
||||
"updatedAt": "Eguneratuta",
|
||||
"createdAt": "Instalatuta",
|
||||
"configKey": "Gakoa",
|
||||
"configValue": "Balioa",
|
||||
"allUsers": "Baimendu erabiltzaile guztiak",
|
||||
"selectedUsers": "Hautatutako erabiltzaileak",
|
||||
"allLibraries": "Baimendu liburutegi guztiak",
|
||||
"selectedLibraries": "Hautatutako liburutegiak"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Egoera",
|
||||
"info": "Pluginaren informazioa",
|
||||
"configuration": "Konfigurazioa",
|
||||
"manifest": "Manifestua",
|
||||
"usersPermission": "Erabiltzaileen baimenak",
|
||||
"libraryPermission": "Liburutegien baimenak"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Gaituta",
|
||||
"disabled": "Ezgaituta"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Gaitu",
|
||||
"disable": "Ezgaitu",
|
||||
"disabledDueToError": "Konpondu errorea gaitu baino lehen",
|
||||
"disabledUsersRequired": "Hautatu erabiltzaileak gaitu baino lehen",
|
||||
"disabledLibrariesRequired": "Hautatu liburutegiak gaitu baino lehen",
|
||||
"addConfig": "Gehitu konfigurazioa",
|
||||
"rescan": "Arakatu berriro"
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "Plugina gaituta",
|
||||
"disabled": "Plugina ezgaituta",
|
||||
"updated": "Plugina eguneratuta",
|
||||
"error": "Errorea plugina eguneratzean"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "Konfigurazioa baliozko JSON-a izan behar da"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "Konfiguratu plugina gako-balio bikoteak erabiliz. Utzi hutsik pluginak konfiguraziorik behar ez badu.",
|
||||
"configValidationError": "Huts egin du konfigurazioaren balidazioak:",
|
||||
"schemaRenderError": "Ezin izan da konfigurazioaren formularioa bihurtu. Litekeena da pluginaren eskema baliozkoa ez izatea.",
|
||||
"clickPermissions": "Sakatu baimen batean xehetasunetarako",
|
||||
"noConfig": "Ez da konfiguraziorik ezarri",
|
||||
"allUsersHelp": "Gaituta dagoenean, pluginak erabiltzaile guztiak atzitu ditzazke, baita etorkizunean sortuko direnak ere.",
|
||||
"noUsers": "Ez da erabiltzailerik hautatu",
|
||||
"permissionReason": "Arrazoia",
|
||||
"usersRequired": "Plugin honek erabiltzaileen informaziora sarbidea behar du. Hautatu zein erabiltzaile atzitu dezakeen pluginak, edo gaitu 'Baimendu erabiltzaile guztiak'.",
|
||||
"allLibrariesHelp": "Gaituta dagoenean, pluginak liburutegi guztietara izango du sarbidea, baita etorkizunean sortuko direnetara ere.",
|
||||
"noLibraries": "Ez da liburutegirik hautatu",
|
||||
"librariesRequired": "Plugin honek liburutegien informaziora sarbidea behar du. Hautatu zein liburutegi atzitu dezakeen pluginak, edo gaitu 'Baimendu liburutegi guztiak'.",
|
||||
"requiredHosts": "Beharrezko ostatatzaileak"
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "gakoa",
|
||||
"configValue": "balioa"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -559,7 +483,6 @@
|
||||
"transcodingEnabled": "Navidrome %{config}-ekin martxan dago eta, beraz, web-interfazeko transkodeketa-ataletik sistema-komandoak exekuta daitezke. Segurtasun arrazoiak tarteko, ezgaitzea gomendatzen dugu, eta transkodeketa-aukerak konfiguratzen ari zarenean bakarrik gaitzea.",
|
||||
"songsAddedToPlaylist": "Abesti bat zerrendara gehitu da |||| %{smart_count} abesti zerrendara gehitu dira",
|
||||
"noSimilarSongsFound": "Ez da antzeko abestirik aurkitu",
|
||||
"startingInstantMix": "Berehalako nahastea kargatzen…",
|
||||
"noTopSongsFound": "Ez da aparteko abestirik aurkitu",
|
||||
"noPlaylistsAvailable": "Ez dago zerrendarik erabilgarri",
|
||||
"delete_user_title": "Ezabatu '%{name}' erabiltzailea",
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"playCount": "Lejátszások",
|
||||
"title": "Cím",
|
||||
"artist": "Előadó",
|
||||
"composer": "Zeneszerző",
|
||||
"album": "Album",
|
||||
"path": "Elérési út",
|
||||
"libraryName": "Könyvtár",
|
||||
@@ -47,8 +46,7 @@
|
||||
"shuffleAll": "Keverés",
|
||||
"download": "Letöltés",
|
||||
"playNext": "Lejátszás következőként",
|
||||
"info": "Részletek",
|
||||
"instantMix": "Instant keverés"
|
||||
"info": "Részletek"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -327,80 +325,6 @@
|
||||
"scanInProgress": "Szkennelés folyamatban...",
|
||||
"noLibrariesAssigned": "Ehhez a felhasználóhoz nincsenek könyvtárak adva"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "Kiegészítő |||| Kiegészítők",
|
||||
"fields": {
|
||||
"id": "ID",
|
||||
"name": "Név",
|
||||
"description": "Leírás",
|
||||
"version": "Verzió",
|
||||
"author": "Fejlesztő",
|
||||
"website": "Weboldal",
|
||||
"permissions": "Engedélyek",
|
||||
"enabled": "Engedélyezve",
|
||||
"status": "Státusz",
|
||||
"path": "Útvonal",
|
||||
"lastError": "Hiba",
|
||||
"hasError": "Hiba",
|
||||
"updatedAt": "Frissítve",
|
||||
"createdAt": "Telepítve",
|
||||
"configKey": "Kulcs",
|
||||
"configValue": "Érték",
|
||||
"allUsers": "Összes felhasználó engedélyezése",
|
||||
"selectedUsers": "Kiválasztott felhasználók engedélyezése",
|
||||
"allLibraries": "Összes könyvtár engedélyezése",
|
||||
"selectedLibraries": "Kiválasztott könyvtárak engedélyezése"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Státusz",
|
||||
"info": "Kiegészítő információi",
|
||||
"configuration": "Konfiguráció",
|
||||
"manifest": "Manifest",
|
||||
"usersPermission": "Felhasználói engedélyek",
|
||||
"libraryPermission": "Könyvtári engedélyek"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Engedélyezve",
|
||||
"disabled": "Letiltva"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Engedélyezés",
|
||||
"disable": "Letiltás",
|
||||
"disabledDueToError": "Javítsd ki a kiegészítő hibáját",
|
||||
"disabledUsersRequired": "Válassz felhasználókat",
|
||||
"disabledLibrariesRequired": "Válassz könyvtárakat",
|
||||
"addConfig": "Konfiguráció hozzáadása",
|
||||
"rescan": "Újraszkennelés"
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "Kiegészítő engedélyezve",
|
||||
"disabled": "Kiegészítő letiltva",
|
||||
"updated": "Kiegészítő frissítve",
|
||||
"error": "Hiba történt a kiegészítő frissítése közben"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "A konfigurációs JSON érvénytelen"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "Konfiguráld a kiegészítőt kulcs-érték párokkal. Hagyd a mezőt üresen, ha nincs szükség konfigurációra.",
|
||||
"configValidationError": "Helytelen konfiguráció:",
|
||||
"schemaRenderError": "Nem sikerült megjeleníteni a konfigurációs űrlapot. A bővítmény sémája érvénytelen lehet.",
|
||||
"clickPermissions": "Kattints egy engedélyre a részletekért",
|
||||
"noConfig": "Nincs konfiguráció beállítva",
|
||||
"allUsersHelp": "Engedélyezés esetén ez a kiegészítő hozzá fog férni minden jelenlegi és jövőben létrehozott felhasználóhoz.",
|
||||
"noUsers": "Nincsenek kiválasztott felhasználók",
|
||||
"permissionReason": "Indok",
|
||||
"usersRequired": "Ez a kiegészítő hozzáférést kér felhasználói információkhoz. Válaszd ki, melyik felhasználókat érheti el, vagy az 'Összes felhasználó engedélyezése' opciót.",
|
||||
"allLibrariesHelp": "Engedélyezés esetén ez a kiegészítő hozzá fog férni minden jelenlegi és jövőben létrehozott könyvtárhoz.",
|
||||
"noLibraries": "Nincs kiválasztott könyvtár",
|
||||
"librariesRequired": "Ez a kiegészítő hozzáférést kér könyvtárinformációkhoz. Válaszd ki, melyik könyvtárakat érheti el, vagy az 'Összes könyvtár engedélyezése' opciót.",
|
||||
"requiredHosts": "Szükséges hostok"
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "kulcs",
|
||||
"configValue": "érték"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -478,7 +402,7 @@
|
||||
"loading": "Betöltés",
|
||||
"not_found": "Nem található",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Nincsenek %{name}.",
|
||||
"empty": "Nincs %{name} még.",
|
||||
"invite": "Szeretnél egyet hozzáadni?"
|
||||
},
|
||||
"input": {
|
||||
@@ -554,7 +478,6 @@
|
||||
"transcodingEnabled": "A Navidrome jelenleg a következőkkel fut %{config}, ez lehetővé teszi a rendszerparancsok futtatását az átkódolási beállításokból a webes felület segítségével. Javasoljuk, hogy biztonsági okokból tiltsd ezt le, és csak az átkódolási beállítások konfigurálásának idejére kapcsold be.",
|
||||
"songsAddedToPlaylist": "1 szám hozzáadva a lejátszási listához |||| %{smart_count} szám hozzáadva a lejátszási listához",
|
||||
"noSimilarSongsFound": "Nem találhatóak hasonló számok",
|
||||
"startingInstantMix": "Instant keverés töltődik...",
|
||||
"noTopSongsFound": "Nincsenek top számok",
|
||||
"noPlaylistsAvailable": "Nem áll rendelkezésre",
|
||||
"delete_user_title": "Felhasználó törlése '%{name}'",
|
||||
@@ -668,7 +591,6 @@
|
||||
"currentValue": "Jelenlegi érték",
|
||||
"configurationFile": "Konfigurációs fájl",
|
||||
"exportToml": "Konfiguráció exportálása (TOML)",
|
||||
"downloadToml": "Konfiguráció letöltése (TOML)",
|
||||
"exportSuccess": "Konfiguráció kiexportálva a vágólapra, TOML formában",
|
||||
"exportFailed": "Nem sikerült kimásolni a konfigurációt",
|
||||
"devFlagsHeader": "Fejlesztői beállítások (változások/eltávolítás jogát fenntartjuk)",
|
||||
|
||||
@@ -674,8 +674,7 @@
|
||||
"exportSuccess": "Configuração exportada para o clipboard em formato TOML",
|
||||
"exportFailed": "Falha ao copiar configuração",
|
||||
"devFlagsHeader": "Flags de Desenvolvimento (sujeitas a mudança/remoção)",
|
||||
"devFlagsComment": "Estas são configurações experimentais e podem ser removidas em versões futuras",
|
||||
"downloadToml": "Baixar configuração (TOML)"
|
||||
"devFlagsComment": "Estas são configurações experimentais e podem ser removidas em versões futuras"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,10 +9,10 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -27,7 +27,7 @@ var (
|
||||
)
|
||||
|
||||
func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, broker events.Broker,
|
||||
pls playlists.Playlists, m metrics.Metrics) model.Scanner {
|
||||
pls core.Playlists, m metrics.Metrics) model.Scanner {
|
||||
c := &controller{
|
||||
rootCtx: rootCtx,
|
||||
ds: ds,
|
||||
@@ -53,7 +53,7 @@ func (s *controller) getScanner() scanner {
|
||||
// CallScan starts an in-process scan of specific library/folder pairs.
|
||||
// If targets is empty, it scans all libraries.
|
||||
// This is meant to be called from the command line (see cmd/scan.go).
|
||||
func CallScan(ctx context.Context, ds model.DataStore, pls playlists.Playlists, fullScan bool, targets []model.ScanTarget) (<-chan *ProgressInfo, error) {
|
||||
func CallScan(ctx context.Context, ds model.DataStore, pls core.Playlists, fullScan bool, targets []model.ScanTarget) (<-chan *ProgressInfo, error) {
|
||||
release, err := lockScan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -98,7 +98,7 @@ type controller struct {
|
||||
cw artwork.CacheWarmer
|
||||
broker events.Broker
|
||||
metrics metrics.Metrics
|
||||
pls playlists.Playlists
|
||||
pls core.Playlists
|
||||
limiter *rate.Sometimes
|
||||
devExternalScanner bool
|
||||
count atomic.Uint32
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
@@ -31,7 +31,7 @@ var _ = Describe("Controller", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
||||
ds.MockedProperty = &tests.MockedPropertyRepo{}
|
||||
ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), core.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
})
|
||||
|
||||
It("includes last scan error", func() {
|
||||
|
||||
@@ -158,7 +158,7 @@ func writeTargetsToFile(targets []model.ScanTarget) (string, error) {
|
||||
|
||||
for _, target := range targets {
|
||||
if _, err := fmt.Fprintln(tmpFile, target.String()); err != nil {
|
||||
os.Remove(tmpFile.Name()) //nolint:gosec
|
||||
os.Remove(tmpFile.Name())
|
||||
return "", fmt.Errorf("failed to write to temp file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/chrono"
|
||||
)
|
||||
@@ -72,7 +72,7 @@ func (f *folderEntry) isOutdated() bool {
|
||||
func (f *folderEntry) toFolder() *model.Folder {
|
||||
folder := model.NewFolder(f.job.lib, f.path)
|
||||
folder.NumAudioFiles = len(f.audioFiles)
|
||||
if playlists.InPath(*folder) {
|
||||
if core.InPlaylistsPath(*folder) {
|
||||
folder.NumPlaylists = f.numPlaylists
|
||||
}
|
||||
folder.ImageFiles = slices.Collect(maps.Keys(f.imageFiles))
|
||||
|
||||
@@ -2,7 +2,6 @@ package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -268,10 +267,6 @@ func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error
|
||||
oldAlbumID := missing.AlbumID
|
||||
newAlbumID := target.AlbumID
|
||||
|
||||
// Preserve the original created_at from the missing file, so moved tracks
|
||||
// don't appear in "Recently Added"
|
||||
target.CreatedAt = missing.CreatedAt
|
||||
|
||||
// Update the target media file with the missing file's ID. This effectively "moves" the track
|
||||
// to the new location while keeping its annotations and references intact.
|
||||
target.ID = missing.ID
|
||||
@@ -303,14 +298,6 @@ func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error
|
||||
log.Warn(p.ctx, "Scanner: Could not reassign album annotations", "from", oldAlbumID, "to", newAlbumID, err)
|
||||
}
|
||||
|
||||
// Keep created_at field from previous instance of the album, so moved albums
|
||||
// don't appear in "Recently Added"
|
||||
if err := tx.Album(p.ctx).CopyAttributes(oldAlbumID, newAlbumID, "created_at"); err != nil {
|
||||
if !errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(p.ctx, "Scanner: Could not copy album created_at", "from", oldAlbumID, "to", newAlbumID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: RefreshPlayCounts will be called in later phases, so we don't need to call it here
|
||||
p.processedAlbumAnnotations[newAlbumID] = true
|
||||
}
|
||||
|
||||
@@ -724,120 +724,6 @@ var _ = Describe("phaseMissingTracks", func() {
|
||||
}) // End of Context "with multiple libraries"
|
||||
})
|
||||
|
||||
Describe("CreatedAt preservation (#5050)", func() {
|
||||
var albumRepo *tests.MockAlbumRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
albumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
||||
albumRepo.ReassignAnnotationCalls = make(map[string]string)
|
||||
albumRepo.CopyAttributesCalls = make(map[string]string)
|
||||
})
|
||||
|
||||
It("should preserve the missing track's created_at when moving within a library", func() {
|
||||
originalTime := time.Date(2020, 3, 15, 10, 0, 0, 0, time.UTC)
|
||||
missingTrack := model.MediaFile{
|
||||
ID: "1", PID: "A", Path: "old/song.mp3",
|
||||
AlbumID: "album-1",
|
||||
LibraryID: 1,
|
||||
CreatedAt: originalTime,
|
||||
Tags: model.Tags{"title": []string{"My Song"}},
|
||||
Size: 100,
|
||||
}
|
||||
matchedTrack := model.MediaFile{
|
||||
ID: "2", PID: "A", Path: "new/song.mp3",
|
||||
AlbumID: "album-1", // Same album
|
||||
LibraryID: 1,
|
||||
CreatedAt: time.Now(), // Much newer
|
||||
Tags: model.Tags{"title": []string{"My Song"}},
|
||||
Size: 100,
|
||||
}
|
||||
|
||||
_ = ds.MediaFile(ctx).Put(&missingTrack)
|
||||
_ = ds.MediaFile(ctx).Put(&matchedTrack)
|
||||
|
||||
in := &missingTracks{
|
||||
missing: []model.MediaFile{missingTrack},
|
||||
matched: []model.MediaFile{matchedTrack},
|
||||
}
|
||||
|
||||
_, err := phase.processMissingTracks(in)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
movedTrack, _ := ds.MediaFile(ctx).Get("1")
|
||||
Expect(movedTrack.Path).To(Equal("new/song.mp3"))
|
||||
Expect(movedTrack.CreatedAt).To(Equal(originalTime))
|
||||
})
|
||||
|
||||
It("should preserve created_at during cross-library moves with album change", func() {
|
||||
originalTime := time.Date(2019, 6, 1, 12, 0, 0, 0, time.UTC)
|
||||
missingTrack := model.MediaFile{
|
||||
ID: "missing-ca", PID: "B", Path: "lib1/song.mp3",
|
||||
AlbumID: "old-album",
|
||||
LibraryID: 1,
|
||||
CreatedAt: originalTime,
|
||||
}
|
||||
matchedTrack := model.MediaFile{
|
||||
ID: "matched-ca", PID: "B", Path: "lib2/song.mp3",
|
||||
AlbumID: "new-album",
|
||||
LibraryID: 2,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Set up albums so CopyAttributes can find them
|
||||
albumRepo.SetData(model.Albums{
|
||||
{ID: "old-album", LibraryID: 1, CreatedAt: originalTime},
|
||||
{ID: "new-album", LibraryID: 2, CreatedAt: time.Now()},
|
||||
})
|
||||
|
||||
_ = ds.MediaFile(ctx).Put(&missingTrack)
|
||||
_ = ds.MediaFile(ctx).Put(&matchedTrack)
|
||||
|
||||
err := phase.moveMatched(matchedTrack, missingTrack)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Track's created_at should be preserved from the missing file
|
||||
movedTrack, _ := ds.MediaFile(ctx).Get("missing-ca")
|
||||
Expect(movedTrack.CreatedAt).To(Equal(originalTime))
|
||||
|
||||
// Album's created_at should be copied from old to new
|
||||
Expect(albumRepo.CopyAttributesCalls).To(HaveKeyWithValue("old-album", "new-album"))
|
||||
|
||||
// Verify the new album's CreatedAt was actually updated
|
||||
newAlbum, err := albumRepo.Get("new-album")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(newAlbum.CreatedAt).To(Equal(originalTime))
|
||||
})
|
||||
|
||||
It("should not copy album created_at when album ID does not change", func() {
|
||||
originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
missingTrack := model.MediaFile{
|
||||
ID: "missing-same", PID: "C", Path: "dir1/song.mp3",
|
||||
AlbumID: "same-album",
|
||||
LibraryID: 1,
|
||||
CreatedAt: originalTime,
|
||||
}
|
||||
matchedTrack := model.MediaFile{
|
||||
ID: "matched-same", PID: "C", Path: "dir2/song.mp3",
|
||||
AlbumID: "same-album", // Same album
|
||||
LibraryID: 1,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
_ = ds.MediaFile(ctx).Put(&missingTrack)
|
||||
_ = ds.MediaFile(ctx).Put(&matchedTrack)
|
||||
|
||||
err := phase.moveMatched(matchedTrack, missingTrack)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Track's created_at should still be preserved
|
||||
movedTrack, _ := ds.MediaFile(ctx).Get("missing-same")
|
||||
Expect(movedTrack.CreatedAt).To(Equal(originalTime))
|
||||
|
||||
// CopyAttributes should NOT have been called (same album)
|
||||
Expect(albumRepo.CopyAttributesCalls).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Album Annotation Reassignment", func() {
|
||||
var (
|
||||
albumRepo *tests.MockAlbumRepo
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
ppl "github.com/google/go-pipeline/pkg/pipeline"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -21,12 +21,12 @@ type phasePlaylists struct {
|
||||
ctx context.Context
|
||||
scanState *scanState
|
||||
ds model.DataStore
|
||||
pls playlists.Playlists
|
||||
pls core.Playlists
|
||||
cw artwork.CacheWarmer
|
||||
refreshed atomic.Uint32
|
||||
}
|
||||
|
||||
func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls playlists.Playlists, cw artwork.CacheWarmer) *phasePlaylists {
|
||||
func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls core.Playlists, cw artwork.CacheWarmer) *phasePlaylists {
|
||||
return &phasePlaylists{
|
||||
ctx: ctx,
|
||||
scanState: scanState,
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
@@ -130,7 +130,7 @@ var _ = Describe("phasePlaylists", func() {
|
||||
|
||||
type mockPlaylists struct {
|
||||
mock.Mock
|
||||
playlists.Playlists
|
||||
core.Playlists
|
||||
}
|
||||
|
||||
func (p *mockPlaylists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
ppl "github.com/google/go-pipeline/pkg/pipeline"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
type scannerImpl struct {
|
||||
ds model.DataStore
|
||||
cw artwork.CacheWarmer
|
||||
pls playlists.Playlists
|
||||
pls core.Playlists
|
||||
}
|
||||
|
||||
// scanState holds the state of an in-progress scan, to be passed to the various phases
|
||||
|
||||
@@ -12,9 +12,9 @@ import (
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -40,7 +40,7 @@ func BenchmarkScan(b *testing.B) {
|
||||
ds := persistence.New(db.Db())
|
||||
conf.Server.DevExternalScanner = false
|
||||
s := scanner.New(context.Background(), ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
core.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
|
||||
fs := storagetest.FakeFS{}
|
||||
storagetest.Register("fake", &fs)
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -77,7 +77,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() {
|
||||
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
|
||||
|
||||
s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
core.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
|
||||
// Create two test libraries (let DB auto-assign IDs)
|
||||
lib1 = model.Library{Name: "Rock Collection", Path: "rock:///music"}
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -63,7 +63,7 @@ var _ = Describe("ScanFolders", Ordered, func() {
|
||||
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
|
||||
|
||||
s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
core.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
|
||||
lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"}
|
||||
Expect(ds.Library(ctx).Put(&lib)).To(Succeed())
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -84,7 +84,7 @@ var _ = Describe("Scanner", Ordered, func() {
|
||||
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
|
||||
|
||||
s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
core.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
|
||||
lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"}
|
||||
Expect(ds.Library(ctx).Put(&lib)).To(Succeed())
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user