Compare commits

..
Author SHA1 Message Date
Deluan 4994ae0aed fix artist refreshstats query
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-02 12:07:08 -05:00
Deluan 4f1732f186 wip - use unix socket
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-02 12:06:21 -05:00
deluan f0270dc48c WIP
# Conflicts:
#	persistence/artist_repository.go
2025-11-02 12:06:18 -05:00
Deluan 8d4feb242b wip
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-02 12:05:28 -05:00
Deluan dd635c4e30 convert schema to postgres
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-02 12:05:28 -05:00
1365 changed files with 49214 additions and 132569 deletions

No files matched your search

+5 -2
View File
@@ -9,9 +9,12 @@ ARG INSTALL_NODE="true"
ARG NODE_VERSION="lts/*"
RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
# Install additional OS packages
# [Optional] Uncomment this section to install additional OS packages.
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends ffmpeg
&& apt-get -y install --no-install-recommends libtag1-dev ffmpeg
# [Optional] Uncomment the next line to use go get to install anything else you need
# RUN go get -x <your-dependency-or-tool>
# [Optional] Uncomment this line to install global node packages.
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
+4 -2
View File
@@ -4,7 +4,7 @@
"dockerfile": "Dockerfile",
"args": {
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
"VARIANT": "1.26",
"VARIANT": "1.25",
// Options
"INSTALL_NODE": "true",
"NODE_VERSION": "v24"
@@ -54,10 +54,12 @@
4533,
4633
],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "make setup-dev",
// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode",
"remoteEnv": {
"ND_MUSICFOLDER": "./music",
"ND_DATAFOLDER": "./data"
}
}
}
-1
View File
@@ -15,5 +15,4 @@ dist
binaries
cache
music
music.old
!Dockerfile
@@ -0,0 +1,23 @@
name: 'Download TagLib'
description: 'Downloads and extracts the TagLib library, adding it to PKG_CONFIG_PATH'
inputs:
version:
description: 'Version of TagLib to download'
required: true
platform:
description: 'Platform to download TagLib for'
default: 'linux-amd64'
runs:
using: 'composite'
steps:
- name: Download TagLib
shell: bash
run: |
mkdir -p /tmp/taglib
cd /tmp
FILE=taglib-${{ inputs.platform }}.tar.gz
wget https://github.com/navidrome/cross-taglib/releases/download/v${{ inputs.version }}/${FILE}
tar -xzf ${FILE} -C taglib
PKG_CONFIG_PREFIX=/tmp/taglib
echo "PKG_CONFIG_PREFIX=${PKG_CONFIG_PREFIX}" >> $GITHUB_ENV
echo "PKG_CONFIG_PATH=${PKG_CONFIG_PATH}:${PKG_CONFIG_PREFIX}/lib/pkgconfig" >> $GITHUB_ENV
+4 -5
View File
@@ -53,13 +53,13 @@ runs:
- name: Login to Docker Hub
if: inputs.hub_username != '' && inputs.hub_password != ''
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ inputs.hub_username }}
password: ${{ inputs.hub_password }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -67,13 +67,12 @@ runs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Extract metadata for Docker image
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
token: ${{ inputs.github_token }}
labels: |
maintainer=deluan@navidrome.org
images: |
+7 -6
View File
@@ -8,7 +8,7 @@ jobs:
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
- uses: actions/github-script@v3
with:
# This snippet is public-domain, taken from
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
@@ -19,7 +19,8 @@ jobs:
const pull_user_id = ${{github.event.sender.id}};
const issue_number = await (async () => {
for await (const {data} of github.paginate.iterator(github.rest.pulls.list, {owner, repo})) {
const pulls = await github.pulls.list({owner, repo});
for await (const {data} of github.paginate.iterator(pulls)) {
for (const pull of data) {
if (pull.head.sha === pull_head_sha && pull.user.id === pull_user_id) {
return pull.number;
@@ -33,7 +34,7 @@ jobs:
return core.error(`No matching pull request found`);
}
const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id});
const {data: {artifacts}} = await github.actions.listWorkflowRunArtifacts({owner, repo, run_id});
if (!artifacts.length) {
return core.error(`No artifacts found`);
}
@@ -42,12 +43,12 @@ jobs:
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
}
const {data: comments} = await github.rest.issues.listComments({repo, owner, issue_number});
const {data: comments} = await github.issues.listComments({repo, owner, issue_number});
const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]');
if (existing_comment) {
core.info(`Updating comment ${existing_comment.id}`);
await github.rest.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
await github.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
} else {
core.info(`Creating a comment`);
await github.rest.issues.createComment({repo, owner, issue_number, body});
await github.issues.createComment({repo, owner, issue_number, body});
}
+53 -172
View File
@@ -14,6 +14,7 @@ concurrency:
cancel-in-progress: true
env:
CROSS_TAGLIB_VERSION: "2.1.1-1"
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
jobs:
@@ -24,7 +25,7 @@ jobs:
git_tag: ${{ steps.git-version.outputs.GIT_TAG }}
git_sha: ${{ steps.git-version.outputs.GIT_SHA }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
with:
fetch-depth: 0
fetch-tags: true
@@ -62,14 +63,15 @@ jobs:
name: Lint Go code
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
- name: Download TagLib
uses: ./.github/actions/download-taglib
with:
go-version-file: go.mod
version: ${{ env.CROSS_TAGLIB_VERSION }}
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
uses: golangci/golangci-lint-action@v8
with:
version: latest
problem-matchers: true
@@ -86,112 +88,25 @@ jobs:
exit 1
fi
- name: Run go generate
run: go generate ./...
- name: Verify no changes from go generate
run: |
git status --porcelain
if [ -n "$(git status --porcelain)" ]; then
echo 'Generated code is out of date. Run "make gen" and commit the changes'
exit 1
fi
go:
name: Test Go code
runs-on: ubuntu-latest
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v6
uses: actions/checkout@v5
- uses: actions/setup-go@v6
- name: Download TagLib
uses: ./.github/actions/download-taglib
with:
go-version-file: go.mod
version: ${{ env.CROSS_TAGLIB_VERSION }}
- name: Download dependencies
run: go mod download
- name: Test
run: go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v
- name: Test ndpgen
run: |
cd plugins/cmd/ndpgen
go test -shuffle=on -v
go build -o ndpgen .
./ndpgen --help
go-windows:
name: Test Go code (Windows)
runs-on: windows-2022
env:
FFMPEG_VERSION: "7.1"
FFMPEG_REPOSITORY: navidrome/ffmpeg-windows-builds
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- uses: msys2/setup-msys2@v2
with:
msystem: MINGW64
install: mingw-w64-x86_64-gcc
update: false
- name: Add mingw64 to PATH
shell: bash
run: echo "C:/msys64/mingw64/bin" >> $GITHUB_PATH
- name: Cache ffmpeg
id: ffmpeg-cache
uses: actions/cache@v5
with:
path: C:\ffmpeg
key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64
- name: Download ffmpeg
if: steps.ffmpeg-cache.outputs.cache-hit != 'true'
shell: pwsh
run: |
$asset = "ffmpeg-n${env:FFMPEG_VERSION}-latest-win64-gpl-${env:FFMPEG_VERSION}"
$url = "https://github.com/${env:FFMPEG_REPOSITORY}/releases/download/latest/$asset.zip"
Invoke-WebRequest -Uri $url -OutFile ffmpeg.zip
Expand-Archive ffmpeg.zip -DestinationPath C:\ffmpeg-extracted
New-Item -ItemType Directory -Force -Path C:\ffmpeg\bin | Out-Null
Copy-Item "C:\ffmpeg-extracted\$asset\bin\ffmpeg.exe" C:\ffmpeg\bin
Copy-Item "C:\ffmpeg-extracted\$asset\bin\ffprobe.exe" C:\ffmpeg\bin
- name: Add ffmpeg to PATH
shell: bash
run: echo "C:/ffmpeg/bin" >> $GITHUB_PATH
- name: Verify toolchain
shell: pwsh
run: |
go version
where.exe gcc
gcc --version
ffmpeg -version
ffprobe -version
- name: Download dependencies
shell: bash
run: go mod download
- name: Test
shell: bash
env:
CGO_ENABLED: "1"
run: go test -shuffle=on -tags netgo,sqlite_fts5 ./... -v
- name: Test ndpgen
shell: pwsh
run: |
cd plugins\cmd\ndpgen
go test -shuffle=on -v
go build -o ndpgen.exe .
.\ndpgen.exe --help
pkg-config --define-prefix --cflags --libs taglib # for debugging
go test -shuffle=on -tags netgo -race ./... -v
js:
name: Test JS code
@@ -199,7 +114,7 @@ jobs:
env:
NODE_OPTIONS: "--max_old_space_size=4096"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 24
@@ -230,7 +145,7 @@ jobs:
name: Lint i18n files
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- run: |
set -e
for file in resources/i18n/*.json; do
@@ -257,10 +172,10 @@ jobs:
build:
name: Build
needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled]
needs: [js, go, go-lint, i18n-lint, git-version, check-push-enabled]
strategy:
matrix:
platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]
platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]
runs-on: ubuntu-latest
env:
IS_LINUX: ${{ startsWith(matrix.platform, 'linux/') && 'true' || 'false' }}
@@ -276,7 +191,7 @@ jobs:
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- name: Prepare Docker Buildx
uses: ./.github/actions/prepare-docker
@@ -288,7 +203,7 @@ jobs:
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Build Binaries
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
@@ -299,9 +214,10 @@ jobs:
build-args: |
GIT_SHA=${{ env.GIT_SHA }}
GIT_TAG=${{ env.GIT_TAG }}
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
- name: Upload Binaries
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: navidrome-${{ env.PLATFORM }}
path: ./output
@@ -310,7 +226,7 @@ jobs:
- name: Build and push image by digest
id: push-image
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
@@ -319,6 +235,7 @@ jobs:
build-args: |
GIT_SHA=${{ env.GIT_SHA }}
GIT_TAG=${{ env.GIT_TAG }}
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
outputs: |
type=image,name=${{ steps.docker.outputs.hub_repository }},push-by-digest=true,name-canonical=true,push=${{ steps.docker.outputs.hub_enabled }}
type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true
@@ -331,7 +248,7 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
with:
name: digests-${{ env.PLATFORM }}
@@ -339,55 +256,18 @@ jobs:
if-no-files-found: error
retention-days: 1
push-manifest-ghcr:
name: Push to GHCR
permissions:
contents: read
packages: write
push-manifest:
name: Push Docker manifest
runs-on: ubuntu-latest
needs: [build, check-push-enabled]
if: needs.check-push-enabled.outputs.is_enabled == 'true'
env:
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- name: Download digests
uses: actions/download-artifact@v8
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Prepare Docker Buildx
uses: ./.github/actions/prepare-docker
id: docker
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list and push to ghcr.io
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map(select(startswith("ghcr.io"))) | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
- name: Inspect image in ghcr.io
run: |
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
push-manifest-dockerhub:
name: Push to Docker Hub
runs-on: ubuntu-latest
permissions:
contents: read
needs: [build, check-push-enabled]
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
continue-on-error: true
steps:
- uses: actions/checkout@v6
- name: Download digests
uses: actions/download-artifact@v8
uses: actions/download-artifact@v5
with:
path: /tmp/digests
pattern: digests-*
@@ -402,27 +282,28 @@ jobs:
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Create manifest list and push to ghcr.io
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
- name: Create manifest list and push to Docker Hub
uses: nick-fields/retry@v4
with:
timeout_minutes: 5
max_attempts: 3
retry_wait_seconds: 30
command: |
cd /tmp/digests
docker buildx imagetools create $(jq -cr '.tags | map(select(startswith("ghcr.io") | not)) | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
working-directory: /tmp/digests
if: vars.DOCKER_HUB_REPO != ''
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ vars.DOCKER_HUB_REPO }}@sha256:%s ' *)
- name: Inspect image in ghcr.io
run: |
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
- name: Inspect image in Docker Hub
if: vars.DOCKER_HUB_REPO != ''
run: |
docker buildx imagetools inspect ${{ vars.DOCKER_HUB_REPO }}:${{ steps.docker.outputs.version }}
cleanup-digests:
name: Cleanup digest artifacts
runs-on: ubuntu-latest
needs: [push-manifest-ghcr, push-manifest-dockerhub]
if: always() && needs.push-manifest-ghcr.result == 'success'
steps:
- name: Delete unnecessary digest artifacts
env:
GH_TOKEN: ${{ github.token }}
@@ -437,9 +318,9 @@ jobs:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@v5
with:
path: ./binaries
pattern: navidrome-windows*
@@ -458,7 +339,7 @@ jobs:
du -h binaries/msi/*.msi
- name: Upload MSI files
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: navidrome-windows-installers
path: binaries/msi/*.msi
@@ -471,12 +352,12 @@ jobs:
outputs:
package_list: ${{ steps.set-package-list.outputs.package_list }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
with:
fetch-depth: 0
fetch-tags: true
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@v5
with:
path: ./binaries
pattern: navidrome-*
@@ -489,7 +370,7 @@ jobs:
run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7
uses: goreleaser/goreleaser-action@v6
with:
version: '~> v2'
args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}"
@@ -502,7 +383,7 @@ jobs:
rm ./dist/*.tar.gz ./dist/*.zip
- name: Upload all-packages artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: packages
path: dist/navidrome_0*
@@ -525,13 +406,13 @@ jobs:
item: ${{ fromJson(needs.release.outputs.package_list) }}
steps:
- name: Download all-packages artifact
uses: actions/download-artifact@v8
uses: actions/download-artifact@v5
with:
name: packages
path: ./dist
- name: Upload all-packages artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: navidrome_linux_${{ matrix.item }}
path: dist/navidrome_0*_linux_${{ matrix.item }}
-138
View File
@@ -1,138 +0,0 @@
#!/bin/sh
set -e
I18N_DIR=resources/i18n
# Normalize JSON for deterministic comparison:
# remove empty/null attributes, sort keys alphabetically
process_json() {
jq 'walk(if type == "object" then with_entries(select(.value != null and .value != "" and .value != [] and .value != {})) | to_entries | sort_by(.key) | from_entries else . end)' "$1"
}
# Get list of all languages configured in the POEditor project
get_language_list() {
curl -s -X POST https://api.poeditor.com/v2/languages/list \
-d api_token="${POEDITOR_APIKEY}" \
-d id="${POEDITOR_PROJECTID}"
}
# Extract language name from the language list JSON given a language code
get_language_name() {
lang_code="$1"
lang_list="$2"
echo "$lang_list" | jq -r ".result.languages[] | select(.code == \"$lang_code\") | .name"
}
# Extract language code from a file path (e.g., "resources/i18n/fr.json" -> "fr")
get_lang_code() {
filepath="$1"
filename=$(basename "$filepath")
echo "${filename%.*}"
}
# Export the current translation for a language from POEditor (v2 API)
export_language() {
lang_code="$1"
response=$(curl -s -X POST https://api.poeditor.com/v2/projects/export \
-d api_token="${POEDITOR_APIKEY}" \
-d id="${POEDITOR_PROJECTID}" \
-d language="$lang_code" \
-d type="key_value_json")
url=$(echo "$response" | jq -r '.result.url')
if [ -z "$url" ] || [ "$url" = "null" ]; then
echo "Failed to export $lang_code: $response" >&2
return 1
fi
echo "$url"
}
# Flatten nested JSON to POEditor languages/update format.
# POEditor uses term + context pairs, where:
# term = the leaf key name
# context = the parent path as "key1"."key2"."key3" (empty for root keys)
flatten_to_poeditor() {
jq -c '[paths(scalars) as $p |
{
"term": ($p | last | tostring),
"context": (if ($p | length) > 1 then ($p[:-1] | map("\"" + tostring + "\"") | join(".")) else "" end),
"translation": {"content": getpath($p)}
}
]' "$1"
}
# Update translations for a language in POEditor via languages/update API
update_language() {
lang_code="$1"
file="$2"
flatten_to_poeditor "$file" > /tmp/poeditor_data.json
response=$(curl -s -X POST https://api.poeditor.com/v2/languages/update \
-d api_token="${POEDITOR_APIKEY}" \
-d id="${POEDITOR_PROJECTID}" \
-d language="$lang_code" \
--data-urlencode data@/tmp/poeditor_data.json)
rm -f /tmp/poeditor_data.json
status=$(echo "$response" | jq -r '.response.status')
if [ "$status" != "success" ]; then
echo "Failed to update $lang_code: $response" >&2
return 1
fi
parsed=$(echo "$response" | jq -r '.result.translations.parsed')
added=$(echo "$response" | jq -r '.result.translations.added')
updated=$(echo "$response" | jq -r '.result.translations.updated')
echo " Translations - parsed: $parsed, added: $added, updated: $updated"
}
# --- Main ---
if [ $# -eq 0 ]; then
echo "Usage: $0 <file1> [file2] ..."
echo "No files specified. Nothing to do."
exit 0
fi
lang_list=$(get_language_list)
upload_count=0
for file in "$@"; do
if [ ! -f "$file" ]; then
echo "Warning: File not found: $file, skipping"
continue
fi
lang_code=$(get_lang_code "$file")
lang_name=$(get_language_name "$lang_code" "$lang_list")
if [ -z "$lang_name" ]; then
echo "Warning: Language code '$lang_code' not found in POEditor, skipping $file"
continue
fi
echo "Processing $lang_name ($lang_code)..."
# Export current state from POEditor
url=$(export_language "$lang_code")
curl -sSL "$url" -o poeditor_export.json
# Normalize both files for comparison
process_json "$file" > local_normalized.json
process_json poeditor_export.json > remote_normalized.json
# Compare normalized versions
if diff -q local_normalized.json remote_normalized.json > /dev/null 2>&1; then
echo " No differences, skipping"
else
echo " Differences found, updating POEditor..."
update_language "$lang_code" "$file"
upload_count=$((upload_count + 1))
fi
rm -f poeditor_export.json local_normalized.json remote_normalized.json
done
echo ""
echo "Done. Updated $upload_count translation(s) in POEditor."
-32
View File
@@ -1,32 +0,0 @@
name: POEditor export
on:
push:
branches:
- master
paths:
- 'resources/i18n/*.json'
jobs:
push-translations:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'navidrome' }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 2
- name: Detect changed translation files
id: changed
run: |
CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD -- 'resources/i18n/*.json' | tr '\n' ' ')
echo "files=$CHANGED_FILES" >> $GITHUB_OUTPUT
echo "Changed translation files: $CHANGED_FILES"
- name: Push translations to POEditor
if: ${{ steps.changed.outputs.files != '' }}
env:
POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }}
POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
run: |
.github/workflows/push-translations.sh ${{ steps.changed.outputs.files }}
+2 -2
View File
@@ -12,7 +12,7 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v6
- uses: dessant/lock-threads@v5
with:
process-only: 'issues, prs'
issue-inactive-days: 120
@@ -28,7 +28,7 @@ jobs:
This pull request has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new issue for related bugs.
- uses: actions/stale@v10
- uses: actions/stale@v9
with:
operations-per-run: 999
days-before-issue-stale: 180
+2 -2
View File
@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'navidrome' }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- name: Get updated translations
id: poeditor
env:
@@ -24,7 +24,7 @@ jobs:
git status --porcelain
git diff
- name: Create Pull Request
uses: peter-evans/create-pull-request@v8
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.PAT }}
author: "navidrome-bot <navidrome-bot@navidrome.org>"
+1 -8
View File
@@ -17,25 +17,18 @@ master.zip
testDB
cache/*
*.swp
coverage.out
dist
music
music.old
*.db*
.gitinfo
docker-compose.yml
!contrib/docker-compose.yml
binaries
navidrome-*
/ndpgen
AGENTS.md
.github/prompts
.github/instructions
.github/git-commit-instructions.md
*.exe
*.test
*.wasm
*.ndp
openspec/
go.work*
.worktrees/
*.wasm
-8
View File
@@ -2,7 +2,6 @@ version: "2"
run:
build-tags:
- netgo
- sqlite_fts5
linters:
enable:
- asasalint
@@ -40,11 +39,6 @@ linters:
enable:
- nilness
exclusions:
rules:
- linters:
- gosec
path: _test\.go
text: "G703"
generated: lax
presets:
- comments
@@ -55,7 +49,6 @@ linters:
- third_party$
- builtin$
- examples$
- node_modules
formatters:
exclusions:
generated: lax
@@ -63,4 +56,3 @@ formatters:
- third_party$
- builtin$
- examples$
- node_modules
+1 -1
View File
@@ -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]
```
+35 -58
View File
@@ -2,10 +2,10 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcros
########################################################################################################################
### Build xx (original image: tonistiigi/xx)
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS xx-build
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.19 AS xx-build
# v1.9.0
ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50
# v1.5.0
ENV XX_VERSION=b4e4c451c778822e6742bfc9d9a91d7c7d885c8a
RUN apk add -U --no-cache git
RUN git clone https://github.com/tonistiigi/xx && \
@@ -24,6 +24,26 @@ RUN cd /out && \
FROM scratch AS xx
COPY --from=xx-build /out/ /usr/bin/
########################################################################################################################
### Get TagLib
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.19 AS taglib-build
ARG TARGETPLATFORM
ARG CROSS_TAGLIB_VERSION=2.1.1-1
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
# wget in busybox can't follow redirects
RUN <<EOT
apk add --no-cache wget
PLATFORM=$(echo ${TARGETPLATFORM} | tr '/' '-')
FILE=taglib-${PLATFORM}.tar.gz
DOWNLOAD_URL=${CROSS_TAGLIB_RELEASES_URL}${FILE}
wget ${DOWNLOAD_URL}
mkdir /taglib
tar -xzf ${FILE} -C /taglib
EOT
########################################################################################################################
### Build Navidrome UI
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/node:lts-alpine AS ui
@@ -42,47 +62,8 @@ FROM scratch AS ui-bundle
COPY --from=ui /build /build
########################################################################################################################
### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen)
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-alpine AS build-alpine
COPY --from=xx / /
ARG TARGETPLATFORM
RUN apk add --no-cache clang lld file git
RUN xx-apk add --no-cache gcc musl-dev zlib-dev
RUN xx-verify --setup
WORKDIR /workspace
RUN --mount=type=bind,source=. \
--mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/go/pkg/mod \
go mod download
ARG GIT_SHA
ARG GIT_TAG
RUN --mount=type=bind,source=. \
--mount=from=ui,source=/build,target=./ui/build,ro \
--mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/go/pkg/mod <<EOT
set -e
xx-go --wrap
export CGO_ENABLED=1
# -latomic is required on 32-bit arm (arm/v6, arm/v7) so SQLite's 64-bit atomics resolve.
go build -tags=netgo,sqlite_fts5 -ldflags="-w -s \
-linkmode=external -extldflags '-latomic' \
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
-o /out/navidrome .
# Fail the build if the binary is accidentally statically linked: dlopen (and
# therefore native libwebp detection) only works with a dynamic interpreter.
file /out/navidrome | grep -q "dynamically linked" || { echo "ERROR: /out/navidrome is not dynamically linked"; file /out/navidrome; exit 1; }
EOT
########################################################################################################################
### Build Navidrome binary for standalone distribution (static glibc, cross-compiled)
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-trixie AS base
### Build Navidrome binary
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.25-bookworm AS base
RUN apt-get update && apt-get install -y clang lld
COPY --from=xx / /
WORKDIR /workspace
@@ -107,11 +88,13 @@ RUN --mount=type=bind,source=. \
--mount=from=ui,source=/build,target=./ui/build,ro \
--mount=from=osxcross,src=/osxcross/SDK,target=/xx-sdk,ro \
--mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/go/pkg/mod <<EOT
--mount=type=cache,target=/go/pkg/mod \
--mount=from=taglib-build,target=/taglib,src=/taglib,ro <<EOT
# Setup CGO cross-compilation environment
xx-go --wrap
export CGO_ENABLED=1
export PKG_CONFIG_PATH=/taglib/lib/pkgconfig
cat $(go env GOENV)
# Only Darwin (macOS) requires clang (default), Windows requires gcc, everything else can use any compiler.
@@ -125,7 +108,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} .
@@ -139,32 +122,26 @@ COPY --from=build /out /
########################################################################################################################
### Build Final Image
FROM public.ecr.aws/docker/library/alpine:3.20 AS final
FROM public.ecr.aws/docker/library/alpine:3.19 AS final
LABEL maintainer="deluan@navidrome.org"
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
# Install runtime dependencies
# - libwebp + symlinks: enables native WebP encoding via purego/dlopen
RUN apk add -U --no-cache ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \
for lib in libwebp libwebpdemux libwebpmux; do \
target=$(ls /usr/lib/$lib.so.* 2>/dev/null | head -1) && \
[ -n "$target" ] && ln -sf "$target" /usr/lib/$lib.so; \
done
# Install ffmpeg and mpv
RUN apk add -U --no-cache ffmpeg mpv sqlite
# Copy navidrome binary (musl build for Docker, enables native libwebp)
COPY --from=build-alpine /out/navidrome /app/
# Copy navidrome binary
COPY --from=build /out/navidrome /app/
VOLUME ["/data", "/music"]
ENV ND_MUSICFOLDER=/music
ENV ND_DATAFOLDER=/data
ENV ND_CONFIGFILE=/data/navidrome.toml
ENV ND_PORT=4533
ENV ND_ENABLEWEBPENCODING=true
ENV GODEBUG="asyncpreemptoff=1"
RUN touch /.nddockerenv
EXPOSE ${ND_PORT}
WORKDIR /app
ENV PATH="/app:${PATH}"
ENTRYPOINT ["/app/navidrome"]
+39 -69
View File
@@ -1,12 +1,6 @@
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
NODE_VERSION=$(shell cat .nvmrc)
comma:=,
GO_BUILD_TAGS=netgo,sqlite_fts5$(if $(EXTRA_BUILD_TAGS),$(comma)$(EXTRA_BUILD_TAGS))
# Set global environment variables, required for most targets
export ND_ENABLEINSIGHTSCOLLECTOR=false
ifneq ("$(wildcard .git/HEAD)","")
GIT_SHA=$(shell git rev-parse --short HEAD)
GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)-SNAPSHOT
@@ -15,12 +9,14 @@ GIT_SHA=source_archive
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT
endif
SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,linux/riscv64,darwin/amd64,darwin/arm64,windows/amd64,windows/386
SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,darwin/amd64,darwin/arm64,windows/amd64,windows/386
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
DOCKER_TAG ?= deluan/navidrome:develop
GOLANGCI_LINT_VERSION ?= v2.12.0
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
CROSS_TAGLIB_VERSION ?= 2.1.1-1
GOLANGCI_LINT_VERSION ?= v2.5.0
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
@@ -30,11 +26,11 @@ setup: check_env download-deps install-golangci-lint setup-git ##@1_Run_First In
.PHONY: setup
dev: check_env ##@Development Start Navidrome in development mode, with hot-reload for both frontend and backend
npx foreman -j Procfile.dev -p 4533 start
ND_ENABLEINSIGHTSCOLLECTOR="false" npx foreman -j Procfile.dev -p 4533 start
.PHONY: dev
server: check_go_env buildjs ##@Development Start the backend in development mode
go tool reflex -d none -c reflex.conf
@ND_ENABLEINSIGHTSCOLLECTOR="false" go tool reflex -d none -c reflex.conf
.PHONY: server
stop: ##@Development Stop development servers (UI and backend)
@@ -46,23 +42,19 @@ 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
cd plugins/cmd/ndpgen && go test ./......
.PHONY: test-ndpgen
testall: test test-ndpgen test-i18n test-js ##@Development Run Go and JS tests
testall: test-race test-i18n test-js ##@Development Run Go and JS tests
.PHONY: testall
test-race: ##@Development Run Go tests with race detector
go test -tags $(GO_BUILD_TAGS) -race -shuffle=on $(PKG)
go test -tags netgo -race -shuffle=on ./...
.PHONY: test-race
test-js: ##@Development Run JS tests
@@ -75,8 +67,8 @@ test-i18n: ##@Development Validate all translations files
install-golangci-lint: ##@Development Install golangci-lint if not present
@INSTALL=false; \
if PATH=./bin:$$PATH which golangci-lint > /dev/null 2>&1; then \
CURRENT_VERSION=$$(PATH=./bin:$$PATH golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \
if PATH=$$PATH:./bin which golangci-lint > /dev/null 2>&1; then \
CURRENT_VERSION=$$(PATH=$$PATH:./bin golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \
REQUIRED_VERSION=$$(echo "$(GOLANGCI_LINT_VERSION)" | sed 's/^v//'); \
if [ "$$CURRENT_VERSION" != "$$REQUIRED_VERSION" ]; then \
echo "Found golangci-lint $$CURRENT_VERSION, but $$REQUIRED_VERSION is required. Reinstalling..."; \
@@ -93,7 +85,7 @@ install-golangci-lint: ##@Development Install golangci-lint if not present
.PHONY: install-golangci-lint
lint: install-golangci-lint ##@Development Lint Go code
PATH=./bin:$$PATH golangci-lint run --timeout 5m
PATH=$$PATH:./bin golangci-lint run -v --timeout 5m
.PHONY: lint
lintall: lint ##@Development Lint Go and JS code
@@ -108,18 +100,9 @@ format: ##@Development Format code
.PHONY: format
wire: check_go_env ##@Development Update Dependency Injection
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
go tool wire gen -tags=netgo ./...
.PHONY: wire
gen: check_go_env ##@Development Run go generate for code generation
go generate ./...
cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host
cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -python -rust
cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust
cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities
go mod tidy -C plugins/pdk/go
.PHONY: gen
snapshots: ##@Development Update (GoLang) Snapshot tests
UPDATE_SNAPSHOTS=true go tool ginkgo ./server/subsonic/responses/...
.PHONY: snapshots
@@ -144,14 +127,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
@@ -176,6 +159,7 @@ docker-build: ##@Cross_Compilation Cross-compile for any supported platform (che
--platform $(PLATFORMS) \
--build-arg GIT_TAG=${GIT_TAG} \
--build-arg GIT_SHA=${GIT_SHA} \
--build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \
--output "./binaries" --target binary .
.PHONY: docker-build
@@ -187,6 +171,7 @@ docker-image: ##@Cross_Compilation Build Docker image, tagged as `deluan/navidro
--platform $(IMAGE_PLATFORMS) \
--build-arg GIT_TAG=${GIT_TAG} \
--build-arg GIT_SHA=${GIT_SHA} \
--build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \
--tag $(DOCKER_TAG) .
.PHONY: docker-image
@@ -199,8 +184,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 \
@@ -211,7 +196,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
@@ -230,39 +215,6 @@ get-music: ##@Development Download some free music from Navidrome's demo instanc
.PHONY: get-music
##########################################
#### Worktrees
WORKTREES_DIR := .worktrees
wt: check_go_env ##@Worktrees Create and setup a git worktree. Usage: make wt name=feature-name [go=1]
@if [ -z "${name}" ]; then echo "Usage: make wt name=<branch-name> [go=1]"; exit 1; fi
@mkdir -p $(WORKTREES_DIR)
@echo "Creating worktree for branch '${name}'..."
@git worktree add $(WORKTREES_DIR)/${name} -b ${name} 2>/dev/null || \
git worktree add $(WORKTREES_DIR)/${name} ${name}
@if [ -n "${go}" ]; then \
./scripts/setup-worktree.sh $(WORKTREES_DIR)/${name} --go-only; \
else \
./scripts/setup-worktree.sh $(WORKTREES_DIR)/${name}; \
fi
@echo "\nWorktree ready at $(WORKTREES_DIR)/${name}"
@echo " cd $(WORKTREES_DIR)/${name}"
.PHONY: wt
rm-wt: ##@Worktrees Remove a git worktree. Usage: make rm-wt name=feature-name
@if [ -z "${name}" ]; then echo "Usage: make rm-wt name=<branch-name>"; exit 1; fi
@if [ ! -d "$(WORKTREES_DIR)/${name}" ]; then echo "Worktree '${name}' not found in $(WORKTREES_DIR)/"; exit 1; fi
@echo "Removing worktree '${name}'..."
@git worktree remove --force $(WORKTREES_DIR)/${name}
@echo "Worktree '${name}' removed."
@echo "Note: branch '${name}' still exists. Delete it with: git branch -D ${name}"
.PHONY: rm-wt
ls-wt: ##@Worktrees List all active git worktrees
@git worktree list
.PHONY: ls-wt
##########################################
#### Miscellaneous
@@ -314,6 +266,24 @@ deprecated:
@echo "WARNING: This target is deprecated and will be removed in future releases. Use 'make build' instead."
.PHONY: deprecated
# Generate Go code from plugins/api/api.proto
plugin-gen: check_go_env ##@Development Generate Go code from plugins protobuf files
go generate ./plugins/...
.PHONY: plugin-gen
plugin-examples: check_go_env ##@Development Build all example plugins
$(MAKE) -C plugins/examples clean all
.PHONY: plugin-examples
plugin-clean: check_go_env ##@Development Clean all plugins
$(MAKE) -C plugins/examples clean
$(MAKE) -C plugins/testdata clean
.PHONY: plugin-clean
plugin-tests: check_go_env ##@Development Build all test plugins
$(MAKE) -C plugins/testdata clean all
.PHONY: plugin-tests
.DEFAULT_GOAL := help
HELP_FUN = \
-217
View File
@@ -1,217 +0,0 @@
package deezer
import (
bytes "bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/microcosm-cc/bluemonday"
"github.com/navidrome/navidrome/log"
)
const apiBaseURL = "https://api.deezer.com"
const authBaseURL = "https://auth.deezer.com"
var (
ErrNotFound = errors.New("deezer: not found")
)
type httpDoer interface {
Do(req *http.Request) (*http.Response, error)
}
type client struct {
httpDoer httpDoer
jwt jwtToken
}
func newClient(hc httpDoer) *client {
return &client{
httpDoer: hc,
}
}
func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
params := url.Values{}
params.Add("q", name)
params.Add("order", "RANKING")
params.Add("limit", strconv.Itoa(limit))
req, err := http.NewRequestWithContext(ctx, "GET", apiBaseURL+"/search/artist", nil)
if err != nil {
return nil, err
}
req.URL.RawQuery = params.Encode()
var results SearchArtistResults
err = c.makeRequest(req, &results)
if err != nil {
return nil, err
}
if len(results.Data) == 0 {
return nil, ErrNotFound
}
return results.Data, nil
}
func (c *client) makeRequest(req *http.Request, response any) error {
log.Trace(req.Context(), fmt.Sprintf("Sending Deezer %s request", req.Method), "url", req.URL)
resp, err := c.httpDoer.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode != 200 {
return c.parseError(data)
}
return json.Unmarshal(data, response)
}
func (c *client) parseError(data []byte) error {
var deezerError Error
err := json.Unmarshal(data, &deezerError)
if err != nil {
return err
}
return fmt.Errorf("deezer error(%d): %s", deezerError.Error.Code, deezerError.Error.Message)
}
func (c *client) getRelatedArtists(ctx context.Context, artistID int) ([]Artist, error) {
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/artist/%d/related", apiBaseURL, artistID), nil)
if err != nil {
return nil, err
}
var results RelatedArtists
err = c.makeRequest(req, &results)
if err != nil {
return nil, err
}
return results.Data, nil
}
func (c *client) getTopTracks(ctx context.Context, artistID int, limit int) ([]Track, error) {
params := url.Values{}
params.Add("limit", strconv.Itoa(limit))
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/artist/%d/top", apiBaseURL, artistID), nil)
if err != nil {
return nil, err
}
req.URL.RawQuery = params.Encode()
var results TopTracks
err = c.makeRequest(req, &results)
if err != nil {
return nil, err
}
return results.Data, nil
}
const pipeAPIURL = "https://pipe.deezer.com/api"
var strictPolicy = bluemonday.StrictPolicy()
func (c *client) getArtistBio(ctx context.Context, artistID int, lang string) (string, error) {
jwt, err := c.getJWT(ctx)
if err != nil {
return "", fmt.Errorf("deezer: failed to get JWT: %w", err)
}
query := map[string]any{
"operationName": "ArtistBio",
"variables": map[string]any{
"artistId": strconv.Itoa(artistID),
},
"query": `query ArtistBio($artistId: String!) {
artist(artistId: $artistId) {
bio {
full
}
}
}`,
}
body, err := json.Marshal(query)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, "POST", pipeAPIURL, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept-Language", lang)
req.Header.Set("Authorization", "Bearer "+jwt)
log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", lang)
resp, err := c.httpDoer.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("deezer: failed to fetch biography: %s", resp.Status)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
type graphQLResponse struct {
Data struct {
Artist struct {
Bio struct {
Full string `json:"full"`
} `json:"bio"`
} `json:"artist"`
} `json:"data"`
Errors []struct {
Message string `json:"message"`
}
}
var result graphQLResponse
if err := json.Unmarshal(data, &result); err != nil {
return "", fmt.Errorf("deezer: failed to parse GraphQL response: %w", err)
}
if len(result.Errors) > 0 {
var errs []error
for m := range result.Errors {
errs = append(errs, errors.New(result.Errors[m].Message))
}
err := errors.Join(errs...)
return "", fmt.Errorf("deezer: GraphQL error: %w", err)
}
if result.Data.Artist.Bio.Full == "" {
return "", errors.New("deezer: biography not found")
}
return cleanBio(result.Data.Artist.Bio.Full), nil
}
func cleanBio(bio string) string {
bio = strings.ReplaceAll(bio, "</p>", "\n")
return strictPolicy.Sanitize(bio)
}
-101
View File
@@ -1,101 +0,0 @@
package deezer
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sync"
"time"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/navidrome/navidrome/log"
)
type jwtToken struct {
token string
expiresAt time.Time
mu sync.RWMutex
}
func (j *jwtToken) get() (string, bool) {
j.mu.RLock()
defer j.mu.RUnlock()
if time.Now().Before(j.expiresAt) {
return j.token, true
}
return "", false
}
func (j *jwtToken) set(token string, expiresIn time.Duration) {
j.mu.Lock()
defer j.mu.Unlock()
j.token = token
j.expiresAt = time.Now().Add(expiresIn)
}
func (c *client) getJWT(ctx context.Context) (string, error) {
// Check if we have a valid cached token
if token, valid := c.jwt.get(); valid {
return token, nil
}
// Fetch a new anonymous token
req, err := http.NewRequestWithContext(ctx, "GET", authBaseURL+"/login/anonymous?jo=p&rto=c", nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/json")
resp, err := c.httpDoer.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("deezer: failed to get JWT token: %s", resp.Status)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
type authResponse struct {
JWT string `json:"jwt"` //nolint:gosec
}
var result authResponse
if err := json.Unmarshal(data, &result); err != nil {
return "", fmt.Errorf("deezer: failed to parse auth response: %w", err)
}
if result.JWT == "" {
return "", errors.New("deezer: no JWT token in response")
}
// Parse JWT to get actual expiration time
token, err := jwt.ParseString(result.JWT, jwt.WithVerify(false), jwt.WithValidate(false))
if err != nil {
return "", fmt.Errorf("deezer: failed to parse JWT token: %w", err)
}
// Calculate TTL with a 1-minute buffer for clock skew and network delays
expiresAt, ok := token.Expiration()
if !ok || expiresAt.IsZero() {
return "", errors.New("deezer: JWT token has no expiration time")
}
ttl := time.Until(expiresAt) - 1*time.Minute
if ttl <= 0 {
return "", errors.New("deezer: JWT token already expired or expires too soon")
}
c.jwt.set(result.JWT, ttl)
log.Trace(ctx, "Fetched new Deezer JWT token", "expiresAt", expiresAt, "ttl", ttl)
return result.JWT, nil
}
-294
View File
@@ -1,294 +0,0 @@
package deezer
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"sync"
"time"
"github.com/lestrrat-go/jwx/v3/jwt"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("JWT Authentication", func() {
var httpClient *fakeHttpClient
var client *client
var ctx context.Context
BeforeEach(func() {
httpClient = &fakeHttpClient{}
client = newClient(httpClient)
ctx = context.Background()
})
Describe("getJWT", func() {
Context("with a valid JWT response", func() {
It("successfully fetches and caches a JWT token", func() {
testJWT := createTestJWT(5 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
})
token, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token).To(Equal(testJWT))
})
It("returns the cached token on subsequent calls", func() {
testJWT := createTestJWT(5 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
})
// First call should fetch from API
token1, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token1).To(Equal(testJWT))
Expect(httpClient.lastRequest.URL.Path).To(Equal("/login/anonymous"))
// Second call should return cached token without hitting API
httpClient.lastRequest = nil // Clear last request to verify no new request is made
token2, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token2).To(Equal(testJWT))
Expect(httpClient.lastRequest).To(BeNil()) // No new request made
})
It("parses the JWT expiration time correctly", func() {
expectedExpiration := time.Now().Add(5 * time.Minute)
testToken, err := jwt.NewBuilder().
Expiration(expectedExpiration).
Build()
Expect(err).To(BeNil())
testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
Expect(err).To(BeNil())
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))),
})
token, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token).ToNot(BeEmpty())
// Verify the token is cached until close to expiration
// The cache should expire 1 minute before the JWT expires
expectedCacheExpiry := expectedExpiration.Add(-1 * time.Minute)
Expect(client.jwt.expiresAt).To(BeTemporally("~", expectedCacheExpiry, 2*time.Second))
})
})
Context("with JWT tokens that expire soon", func() {
It("rejects tokens that expire in less than 1 minute", func() {
// Create a token that expires in 30 seconds (less than 1-minute buffer)
testJWT := createTestJWT(30 * time.Second)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
})
_, err := client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
})
It("rejects already expired tokens", func() {
// Create a token that expired 1 minute ago
testJWT := createTestJWT(-1 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
})
_, err := client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
})
It("accepts tokens that expire in more than 1 minute", func() {
// Create a token that expires in 2 minutes (just over the 1-minute buffer)
testJWT := createTestJWT(2 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
})
token, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token).ToNot(BeEmpty())
})
})
Context("with invalid responses", func() {
It("handles HTTP error responses", func() {
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 500,
Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
})
_, err := client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to get JWT token"))
})
It("handles malformed JSON responses", func() {
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{invalid json}`)),
})
_, err := client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to parse auth response"))
})
It("handles responses with empty JWT field", func() {
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"jwt":""}`)),
})
_, err := client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(Equal("deezer: no JWT token in response"))
})
It("handles invalid JWT tokens", func() {
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"jwt":"not-a-valid-jwt"}`)),
})
_, err := client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to parse JWT token"))
})
It("rejects JWT tokens without expiration", func() {
// Create a JWT without expiration claim
testToken, err := jwt.NewBuilder().
Claim("custom", "value").
Build()
Expect(err).To(BeNil())
// Verify token has no expiration
_, hasExp := testToken.Expiration()
Expect(hasExp).To(BeFalse())
testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
Expect(err).To(BeNil())
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))),
})
_, err = client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(Equal("deezer: JWT token has no expiration time"))
})
})
Context("token caching behavior", func() {
It("fetches a new token when the cached token expires", func() {
// First token expires in 5 minutes
firstJWT := createTestJWT(5 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, firstJWT))),
})
token1, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token1).To(Equal(firstJWT))
// Manually expire the cached token
client.jwt.expiresAt = time.Now().Add(-1 * time.Second)
// Second token with different expiration (10 minutes)
secondJWT := createTestJWT(10 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, secondJWT))),
})
token2, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token2).To(Equal(secondJWT))
Expect(token2).ToNot(Equal(token1))
})
})
})
Describe("jwtToken cache", func() {
var cache *jwtToken
BeforeEach(func() {
cache = &jwtToken{}
})
It("returns false for expired tokens", func() {
cache.set("test-token", -1*time.Second) // Already expired
token, valid := cache.get()
Expect(valid).To(BeFalse())
Expect(token).To(BeEmpty())
})
It("returns true for valid tokens", func() {
cache.set("test-token", 4*time.Minute)
token, valid := cache.get()
Expect(valid).To(BeTrue())
Expect(token).To(Equal("test-token"))
})
It("is thread-safe for concurrent access", func() {
wg := sync.WaitGroup{}
// Writer goroutine
wg.Go(func() {
for i := range 100 {
cache.set(fmt.Sprintf("token-%d", i), 1*time.Hour)
time.Sleep(1 * time.Millisecond)
}
})
// Reader goroutine
wg.Go(func() {
for range 100 {
cache.get()
time.Sleep(1 * time.Millisecond)
}
})
// Wait for both goroutines to complete
wg.Wait()
// Verify final state is valid
token, valid := cache.get()
Expect(valid).To(BeTrue())
Expect(token).To(HavePrefix("token-"))
})
})
})
// createTestJWT creates a valid JWT token for testing purposes
func createTestJWT(expiresIn time.Duration) string {
token, err := jwt.NewBuilder().
Expiration(time.Now().Add(expiresIn)).
Build()
if err != nil {
panic(fmt.Sprintf("failed to create test JWT: %v", err))
}
signed, err := jwt.Sign(token, jwt.WithInsecureNoSignature())
if err != nil {
panic(fmt.Sprintf("failed to sign test JWT: %v", err))
}
return string(signed)
}
-210
View File
@@ -1,210 +0,0 @@
package deezer
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("client", func() {
var httpClient *fakeHttpClient
var client *client
BeforeEach(func() {
httpClient = &fakeHttpClient{}
client = newClient(httpClient)
})
Describe("ArtistImages", func() {
It("returns artist images from a successful request", func() {
f, err := os.Open("tests/fixtures/deezer.search.artist.json")
Expect(err).To(BeNil())
httpClient.mock("https://api.deezer.com/search/artist", http.Response{Body: f, StatusCode: 200})
artists, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
Expect(err).To(BeNil())
Expect(artists).To(HaveLen(17))
Expect(artists[0].Name).To(Equal("Michael Jackson"))
Expect(artists[0].PictureXl).To(Equal("https://cdn-images.dzcdn.net/images/artist/97fae13b2b30e4aec2e8c9e0c7839d92/1000x1000-000000-80-0-0.jpg"))
})
It("fails if artist was not found", func() {
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)),
})
_, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
Expect(err).To(MatchError(ErrNotFound))
})
})
Describe("TopTracks", func() {
It("returns top tracks with artist and album info from a successful request", func() {
f, err := os.Open("tests/fixtures/deezer.artist.top.json")
Expect(err).To(BeNil())
httpClient.mock("https://api.deezer.com/artist/27/top", http.Response{Body: f, StatusCode: 200})
tracks, err := client.getTopTracks(GinkgoT().Context(), 27, 5)
Expect(err).To(BeNil())
Expect(tracks).To(HaveLen(5))
// Verify first track has all expected fields
Expect(tracks[0].Title).To(Equal("Instant Crush (feat. Julian Casablancas)"))
Expect(tracks[0].Artist.Name).To(Equal("Daft Punk"))
Expect(tracks[0].Album.Title).To(Equal("Random Access Memories"))
// Verify second track
Expect(tracks[1].Title).To(Equal("One More Time"))
Expect(tracks[1].Artist.Name).To(Equal("Daft Punk"))
Expect(tracks[1].Album.Title).To(Equal("Discovery"))
})
})
Describe("ArtistBio", func() {
BeforeEach(func() {
// Mock the JWT token endpoint with a valid JWT that expires in 5 minutes
testJWT := createTestJWT(5 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
})
})
It("returns artist bio from a successful request", func() {
f, err := os.Open("tests/fixtures/deezer.artist.bio.en.json")
Expect(err).To(BeNil())
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
bio, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
Expect(err).To(BeNil())
Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
Expect(bio).ToNot(ContainSubstring("<p>"))
Expect(bio).ToNot(ContainSubstring("</p>"))
})
It("uses the provided language", func() {
f, err := os.Open("tests/fixtures/deezer.artist.bio.fr.json")
Expect(err).To(BeNil())
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
_, err = client.getArtistBio(GinkgoT().Context(), 27, "fr")
Expect(err).To(BeNil())
Expect(httpClient.lastRequest.Header.Get("Accept-Language")).To(Equal("fr"))
})
It("includes the JWT token in the request", func() {
f, err := os.Open("tests/fixtures/deezer.artist.bio.en.json")
Expect(err).To(BeNil())
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
_, err = client.getArtistBio(GinkgoT().Context(), 27, "en")
Expect(err).To(BeNil())
// Verify that the Authorization header has the Bearer token format
authHeader := httpClient.lastRequest.Header.Get("Authorization")
Expect(authHeader).To(HavePrefix("Bearer "))
Expect(len(authHeader)).To(BeNumerically(">", 20)) // JWT tokens are longer than 20 chars
})
It("handles GraphQL errors", func() {
errorResponse := `{
"data": {
"artist": {
"bio": {
"full": ""
}
}
},
"errors": [
{
"message": "Artist not found"
},
{
"message": "Invalid artist ID"
}
]
}`
httpClient.mock("https://pipe.deezer.com/api", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(errorResponse)),
})
_, err := client.getArtistBio(GinkgoT().Context(), 999, "en")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("GraphQL error"))
Expect(err.Error()).To(ContainSubstring("Artist not found"))
Expect(err.Error()).To(ContainSubstring("Invalid artist ID"))
})
It("handles empty biography", func() {
emptyBioResponse := `{
"data": {
"artist": {
"bio": {
"full": ""
}
}
}
}`
httpClient.mock("https://pipe.deezer.com/api", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(emptyBioResponse)),
})
_, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
Expect(err).To(MatchError("deezer: biography not found"))
})
It("handles JWT token fetch failure", func() {
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 500,
Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
})
_, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to get JWT"))
})
It("handles JWT token that expires too soon", func() {
// Create a JWT that expires in 30 seconds (less than the 1-minute buffer)
expiredJWT := createTestJWT(30 * time.Second)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, expiredJWT))),
})
_, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
})
})
})
type fakeHttpClient struct {
responses map[string]*http.Response
lastRequest *http.Request
}
func (c *fakeHttpClient) mock(url string, response http.Response) {
if c.responses == nil {
c.responses = make(map[string]*http.Response)
}
c.responses[url] = &response
}
func (c *fakeHttpClient) Do(req *http.Request) (*http.Response, error) {
c.lastRequest = req
u := req.URL
u.RawQuery = ""
if resp, ok := c.responses[u.String()]; ok {
return resp, nil
}
panic("URL not mocked: " + u.String())
}
-171
View File
@@ -1,171 +0,0 @@
package deezer
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("deezerAgent", func() {
var ctx context.Context
BeforeEach(func() {
ctx = context.Background()
DeferCleanup(configtest.SetupConfig())
conf.Server.Deezer.Enabled = true
})
Describe("deezerConstructor", func() {
It("uses configured languages", func() {
conf.Server.Deezer.Languages = []string{"pt", "en"}
agent := deezerConstructor(&tests.MockDataStore{}).(*deezerAgent)
Expect(agent.languages).To(Equal([]string{"pt", "en"}))
})
})
Describe("GetArtistBiography - Language Fallback", func() {
var agent *deezerAgent
var httpClient *langAwareHttpClient
BeforeEach(func() {
httpClient = newLangAwareHttpClient()
// Mock search artist (returns Michael Jackson)
fSearch, _ := os.Open("tests/fixtures/deezer.search.artist.json")
httpClient.searchResponse = &http.Response{Body: fSearch, StatusCode: 200}
// Mock JWT token
testJWT := createTestJWT(5 * time.Minute)
httpClient.jwtResponse = &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
}
})
setupAgent := func(languages []string) {
conf.Server.Deezer.Languages = languages
agent = &deezerAgent{
dataStore: &tests.MockDataStore{},
client: newClient(httpClient),
languages: languages,
}
}
It("returns content in first language when available (1 bio API call)", func() {
setupAgent([]string{"fr", "en"})
// French biography available
fFr, _ := os.Open("tests/fixtures/deezer.artist.bio.fr.json")
httpClient.bioResponses["fr"] = &http.Response{Body: fFr, StatusCode: 200}
bio, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
Expect(err).ToNot(HaveOccurred())
Expect(bio).To(ContainSubstring("Guy-Manuel de Homem Christo et Thomas Bangalter"))
Expect(httpClient.bioRequestCount).To(Equal(1))
Expect(httpClient.bioRequests[0].Header.Get("Accept-Language")).To(Equal("fr"))
})
It("falls back to second language when first returns empty (2 bio API calls)", func() {
setupAgent([]string{"ja", "en"})
// Japanese returns empty biography
fJa, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
httpClient.bioResponses["ja"] = &http.Response{Body: fJa, StatusCode: 200}
// English returns full biography
fEn, _ := os.Open("tests/fixtures/deezer.artist.bio.en.json")
httpClient.bioResponses["en"] = &http.Response{Body: fEn, StatusCode: 200}
bio, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
Expect(err).ToNot(HaveOccurred())
Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
Expect(httpClient.bioRequestCount).To(Equal(2))
Expect(httpClient.bioRequests[0].Header.Get("Accept-Language")).To(Equal("ja"))
Expect(httpClient.bioRequests[1].Header.Get("Accept-Language")).To(Equal("en"))
})
It("returns ErrNotFound when all languages return empty", func() {
setupAgent([]string{"ja", "xx"})
// Both languages return empty biography
fJa, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
httpClient.bioResponses["ja"] = &http.Response{Body: fJa, StatusCode: 200}
fXx, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
httpClient.bioResponses["xx"] = &http.Response{Body: fXx, StatusCode: 200}
_, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
Expect(err).To(MatchError(agents.ErrNotFound))
Expect(httpClient.bioRequestCount).To(Equal(2))
})
})
})
// langAwareHttpClient is a mock HTTP client that returns different responses based on the Accept-Language header
type langAwareHttpClient struct {
searchResponse *http.Response
jwtResponse *http.Response
bioResponses map[string]*http.Response
bioRequests []*http.Request
bioRequestCount int
}
func newLangAwareHttpClient() *langAwareHttpClient {
return &langAwareHttpClient{
bioResponses: make(map[string]*http.Response),
bioRequests: make([]*http.Request, 0),
}
}
func (c *langAwareHttpClient) Do(req *http.Request) (*http.Response, error) {
// Handle search artist request
if req.URL.Host == "api.deezer.com" && req.URL.Path == "/search/artist" {
if c.searchResponse != nil {
return c.searchResponse, nil
}
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)),
}, nil
}
// Handle JWT token request
if req.URL.Host == "auth.deezer.com" && req.URL.Path == "/login/anonymous" {
if c.jwtResponse != nil {
return c.jwtResponse, nil
}
return &http.Response{
StatusCode: 500,
Body: io.NopCloser(bytes.NewBufferString(`{"error":"no mock"}`)),
}, nil
}
// Handle bio request (GraphQL API)
if req.URL.Host == "pipe.deezer.com" && req.URL.Path == "/api" {
c.bioRequestCount++
c.bioRequests = append(c.bioRequests, req)
lang := req.Header.Get("Accept-Language")
if resp, ok := c.bioResponses[lang]; ok {
return resp, nil
}
// Return empty bio by default
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"data":{"artist":{"bio":{"full":""}}}}`)),
}, nil
}
panic("URL not mocked: " + req.URL.String())
}
-66
View File
@@ -1,66 +0,0 @@
package deezer
type SearchArtistResults struct {
Data []Artist `json:"data"`
Total int `json:"total"`
Next string `json:"next"`
}
type Artist struct {
ID int `json:"id"`
Name string `json:"name"`
Link string `json:"link"`
Picture string `json:"picture"`
PictureSmall string `json:"picture_small"`
PictureMedium string `json:"picture_medium"`
PictureBig string `json:"picture_big"`
PictureXl string `json:"picture_xl"`
NbAlbum int `json:"nb_album"`
NbFan int `json:"nb_fan"`
Radio bool `json:"radio"`
Tracklist string `json:"tracklist"`
Type string `json:"type"`
}
type Error struct {
Error struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `json:"error"`
}
type RelatedArtists struct {
Data []Artist `json:"data"`
Total int `json:"total"`
}
type TopTracks struct {
Data []Track `json:"data"`
Total int `json:"total"`
Next string `json:"next"`
}
type Track struct {
ID int `json:"id"`
Title string `json:"title"`
Link string `json:"link"`
Duration int `json:"duration"`
Rank int `json:"rank"`
Preview string `json:"preview"`
Artist Artist `json:"artist"`
Album Album `json:"album"`
Contributors []Artist `json:"contributors"`
}
type Album struct {
ID int `json:"id"`
Title string `json:"title"`
Cover string `json:"cover"`
CoverSmall string `json:"cover_small"`
CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"`
CoverXl string `json:"cover_xl"`
Tracklist string `json:"tracklist"`
Type string `json:"type"`
}
-301
View File
@@ -1,301 +0,0 @@
// Package gotaglib provides an alternative metadata extractor using go-taglib,
// a pure Go (WASM-based) implementation of TagLib.
//
// This extractor aims for parity with the CGO-based taglib extractor. It uses
// TagLib's PropertyMap interface for standard tags. The File handle API provides
// efficient access to format-specific tags (ID3v2 frames, MP4 atoms, ASF attributes)
// through a single file open operation.
//
// This extractor is registered under the name "taglib". It only works with a filesystem
// (fs.FS) and does not support direct local file paths. Files returned by the filesystem
// must implement io.ReadSeeker for go-taglib to read them.
package gotaglib
import (
"errors"
"fmt"
"io"
"io/fs"
"runtime/debug"
"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"
"go.senan.xyz/taglib"
)
type extractor struct {
fs fs.FS
}
func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
results := make(map[string]metadata.Info)
for _, path := range files {
props, err := e.extractMetadata(path)
if err != nil {
continue
}
results[path] = *props
}
return results, nil
}
func (e extractor) Version() string {
bi, ok := debug.ReadBuildInfo()
if ok {
for _, dep := range bi.Deps {
if dep.Path == "go.senan.xyz/taglib" {
if dep.Replace != nil {
return dep.Replace.Version
}
return dep.Version
}
}
}
return "unknown"
}
func (e extractor) extractMetadata(filePath string) (info *metadata.Info, err error) {
// Recover from panics in the WASM runtime that can occur during any taglib
// operation (opening, reading tags, or reading properties). This catches crashes
// from malformed files or WASM runtime issues (e.g., wazero mmap failures on
// hardened systems with MemoryDenyWriteExecute=true).
debug.SetPanicOnFault(true)
defer func() {
if r := recover(); r != nil {
log.Error("gotaglib: WASM runtime panic reading file. Skipping", "filePath", filePath, "panic", r)
debug.PrintStack()
err = fmt.Errorf("WASM runtime panic: %v", r)
}
}()
f, close, err := e.openFile(filePath)
if err != nil {
log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err)
return nil, err
}
defer close()
// Get all tags and properties in one go
allTags := f.AllTags()
props := f.Properties()
// Map properties to AudioProperties
ap := metadata.AudioProperties{
Duration: props.Length.Round(time.Millisecond * 10),
BitRate: int(props.Bitrate),
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)
normalizedTags := make(map[string][]string, len(allTags.Tags))
for key, values := range allTags.Tags {
lowerKey := strings.ToLower(key)
normalizedTags[lowerKey] = values
}
// Process format-specific raw tags
processRawTags(allTags, normalizedTags)
// Parse track/disc totals from "N/Total" format
parseTuple(normalizedTags, "track")
parseTuple(normalizedTags, "disc")
// Adjust some ID3 tags
parseLyrics(normalizedTags)
parseTIPL(normalizedTags)
delete(normalizedTags, "tmcl") // TMCL is already parsed by TagLib
// Determine if file has embedded picture
hasPicture := len(props.Images) > 0
return &metadata.Info{
Tags: normalizedTags,
AudioProperties: ap,
HasPicture: hasPicture,
}, nil
}
// openFile opens the file at filePath using the extractor's filesystem.
// It returns a TagLib File handle and a cleanup function to close resources.
func (e extractor) openFile(filePath string) (f *taglib.File, closeFunc func(), err error) {
// Open the file from the filesystem
file, err := e.fs.Open(filePath)
if err != nil {
return nil, nil, err
}
rs, isSeekable := file.(io.ReadSeeker)
if !isSeekable {
file.Close()
return nil, nil, errors.New("file is not seekable")
}
// WithFilename provides a format detection hint via the file extension,
// since OpenStream alone relies on content-sniffing which fails for some files.
f, err = taglib.OpenStream(rs,
taglib.WithReadStyle(taglib.ReadStyleFast),
taglib.WithFilename(filePath),
)
if err != nil {
file.Close()
return nil, nil, err
}
closeFunc = func() {
f.Close()
file.Close()
}
return f, closeFunc, nil
}
// parseTuple parses track/disc numbers in "N/Total" format and separates them.
// For example, tracknumber="2/10" becomes tracknumber="2" and tracktotal="10".
func parseTuple(tags map[string][]string, prop string) {
tagName := prop + "number"
tagTotal := prop + "total"
if value, ok := tags[tagName]; ok && len(value) > 0 {
parts := strings.Split(value[0], "/")
tags[tagName] = []string{parts[0]}
if len(parts) == 2 {
tags[tagTotal] = []string{parts[1]}
}
}
}
// parseLyrics ensures lyrics tags have a language code.
// If lyrics exist without a language code, they are moved to "lyrics:xxx".
func parseLyrics(tags map[string][]string) {
lyrics := tags["lyrics"]
if len(lyrics) > 0 {
tags["lyrics:xxx"] = lyrics
delete(tags, "lyrics")
}
}
// processRawTags processes format-specific raw tags based on the detected file format.
// This handles ID3v2 frames (MP3/WAV/AIFF), MP4 atoms, and ASF attributes.
func processRawTags(allTags taglib.AllTags, normalizedTags map[string][]string) {
switch allTags.Format {
case taglib.FormatMPEG, taglib.FormatWAV, taglib.FormatAIFF:
parseID3v2Frames(allTags.Raw, normalizedTags)
case taglib.FormatMP4:
parseMP4Atoms(allTags.Raw, normalizedTags)
case taglib.FormatASF:
parseASFAttributes(allTags.Raw, normalizedTags)
}
}
// parseID3v2Frames processes ID3v2 raw frames to extract USLT/SYLT with language codes.
// This extracts language-specific lyrics that the standard Tags() doesn't provide.
func parseID3v2Frames(rawFrames map[string][]string, tags map[string][]string) {
// Process frames that have language-specific data
for key, values := range rawFrames {
lowerKey := strings.ToLower(key)
// Handle USLT:xxx and SYLT:xxx (lyrics with language codes)
if strings.HasPrefix(lowerKey, "uslt:") || strings.HasPrefix(lowerKey, "sylt:") {
parts := strings.SplitN(lowerKey, ":", 2)
if len(parts) == 2 && parts[1] != "" {
lang := parts[1]
lyricsKey := "lyrics:" + lang
tags[lyricsKey] = append(tags[lyricsKey], values...)
}
}
}
// If we found any language-specific lyrics from ID3v2 frames, remove the generic lyrics
for key := range tags {
if strings.HasPrefix(key, "lyrics:") && key != "lyrics" {
delete(tags, "lyrics")
break
}
}
}
const iTunesKeyPrefix = "----:com.apple.iTunes:"
// parseMP4Atoms processes MP4 raw atoms to get iTunes-specific tags.
func parseMP4Atoms(rawAtoms map[string][]string, tags map[string][]string) {
// Process all atoms and add them to tags
for key, values := range rawAtoms {
// Strip iTunes prefix and convert to lowercase
normalizedKey := strings.TrimPrefix(key, iTunesKeyPrefix)
normalizedKey = strings.ToLower(normalizedKey)
// Only add if the tag doesn't already exist (avoid duplication with PropertyMap)
if _, exists := tags[normalizedKey]; !exists {
tags[normalizedKey] = values
}
}
}
// parseASFAttributes processes ASF raw attributes to get WMA-specific tags.
func parseASFAttributes(rawAttrs map[string][]string, tags map[string][]string) {
// Process all attributes and add them to tags
for key, values := range rawAttrs {
normalizedKey := strings.ToLower(key)
// Only add if the tag doesn't already exist (avoid duplication with PropertyMap)
if _, exists := tags[normalizedKey]; !exists {
tags[normalizedKey] = values
}
}
}
// These are the only roles we support, based on Picard's tag map:
// https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html
var tiplMapping = map[string]string{
"arranger": "arranger",
"engineer": "engineer",
"producer": "producer",
"mix": "mixer",
"DJ-mix": "djmixer",
}
// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format:
//
// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson".
//
// and breaks it down into a map of roles and names, e.g.:
//
// {"arranger": ["Andrew Powell"], "engineer": ["Chris Blair", "Pat Stapley"], "producer": ["Eric Woolfson"]}.
func parseTIPL(tags map[string][]string) {
tipl := tags["tipl"]
if len(tipl) == 0 {
return
}
addRole := func(currentRole string, currentValue []string) {
if currentRole != "" && len(currentValue) > 0 {
role := tiplMapping[currentRole]
tags[role] = append(tags[role], strings.Join(currentValue, " "))
}
}
var currentRole string
var currentValue []string
for part := range strings.SplitSeq(tipl[0], " ") {
if _, ok := tiplMapping[part]; ok {
addRole(currentRole, currentValue)
currentRole = part
currentValue = nil
continue
}
currentValue = append(currentValue, part)
}
addRole(currentRole, currentValue)
delete(tags, "tipl")
}
var _ local.Extractor = (*extractor)(nil)
func init() {
local.RegisterExtractor("taglib", func(fsys fs.FS, baseDir string) local.Extractor {
return &extractor{fsys}
})
conf.AddHook(func() {
log.Debug("go-taglib version", "version", extractor{}.Version())
})
}
-443
View File
@@ -1,443 +0,0 @@
package listenbrainz
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
. "github.com/onsi/gomega/gstruct"
)
var _ = Describe("listenBrainzAgent", func() {
var ds model.DataStore
var ctx context.Context
var agent *listenBrainzAgent
var httpClient *tests.FakeHttpClient
var track *model.MediaFile
BeforeEach(func() {
ds = &tests.MockDataStore{}
ctx = context.Background()
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
httpClient = &tests.FakeHttpClient{}
agent = listenBrainzConstructor(ds)
agent.client = newClient("http://localhost:8080", httpClient)
track = &model.MediaFile{
ID: "123",
Title: "Track Title",
Album: "Track Album",
Artist: "Track Artist",
TrackNumber: 1,
MbzRecordingID: "mbz-123",
MbzAlbumID: "mbz-456",
MbzReleaseGroupID: "mbz-789",
Duration: 142.2,
Participants: map[model.Role]model.ParticipantList{
model.RoleArtist: []model.Participant{
{Artist: model.Artist{ID: "ar-1", Name: "Artist 1", MbzArtistID: "mbz-111"}},
{Artist: model.Artist{ID: "ar-2", Name: "Artist 2", MbzArtistID: "mbz-222"}},
},
},
}
})
Describe("formatListen", func() {
It("constructs the listenInfo properly", func() {
lr := agent.formatListen(track)
Expect(lr).To(MatchAllFields(Fields{
"ListenedAt": Equal(0),
"TrackMetadata": MatchAllFields(Fields{
"ArtistName": Equal(track.Artist),
"TrackName": Equal(track.Title),
"ReleaseName": Equal(track.Album),
"AdditionalInfo": MatchAllFields(Fields{
"SubmissionClient": Equal(consts.AppName),
"SubmissionClientVersion": Equal(consts.Version),
"TrackNumber": Equal(track.TrackNumber),
"RecordingMBID": Equal(track.MbzRecordingID),
"ReleaseMBID": Equal(track.MbzAlbumID),
"ReleaseGroupMBID": Equal(track.MbzReleaseGroupID),
"ArtistNames": ConsistOf("Artist 1", "Artist 2"),
"ArtistMBIDs": ConsistOf("mbz-111", "mbz-222"),
"DurationMs": Equal(142200),
}),
}),
}))
})
})
Describe("NowPlaying", func() {
It("updates NowPlaying successfully", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
err := agent.NowPlaying(ctx, "user-1", track, 0)
Expect(err).ToNot(HaveOccurred())
})
It("returns ErrNotAuthorized if user is not linked", func() {
err := agent.NowPlaying(ctx, "user-2", track, 0)
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
})
})
Describe("Scrobble", func() {
var sc scrobbler.Scrobble
BeforeEach(func() {
sc = scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}
})
It("sends a Scrobble successfully", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
err := agent.Scrobble(ctx, "user-1", sc)
Expect(err).ToNot(HaveOccurred())
})
It("sets the Timestamp properly", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
err := agent.Scrobble(ctx, "user-1", sc)
Expect(err).ToNot(HaveOccurred())
decoder := json.NewDecoder(httpClient.SavedRequest.Body)
var lr listenBrainzRequestBody
err = decoder.Decode(&lr)
Expect(err).ToNot(HaveOccurred())
Expect(lr.Payload[0].ListenedAt).To(Equal(int(sc.TimeStamp.Unix())))
})
It("returns ErrNotAuthorized if user is not linked", func() {
err := agent.Scrobble(ctx, "user-2", sc)
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
})
It("returns ErrRetryLater on error 503", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 503, "error": "Cannot submit listens to queue, please try again later."}`)),
StatusCode: 503,
}
err := agent.Scrobble(ctx, "user-1", sc)
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
})
It("returns ErrRetryLater on error 500", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 500, "error": "Something went wrong. Please try again."}`)),
StatusCode: 500,
}
err := agent.Scrobble(ctx, "user-1", sc)
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
})
It("returns ErrRetryLater on http errors", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Bad Gateway`)),
StatusCode: 500,
}
err := agent.Scrobble(ctx, "user-1", sc)
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
})
It("returns ErrUnrecoverable on other errors", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 400, "error": "BadRequest: Invalid JSON document submitted."}`)),
StatusCode: 400,
}
err := agent.Scrobble(ctx, "user-1", sc)
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
})
})
Describe("GetArtistUrl", func() {
var agent *listenBrainzAgent
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("BASE_URL", httpClient)
agent = listenBrainzConstructor(ds)
agent.client = client
})
It("returns artist url when MBID present", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.homepage.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetArtistURL(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")).To(Equal("http://projectmili.com/"))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
})
It("returns error when url not present", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.no_homepage.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
})
It("returns error when fetch calls fails", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
})
It("returns error when ListenBrainz returns an error", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 400,"error": "artist mbid 1 is not valid."}`)),
StatusCode: 400,
}
_, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
})
})
Describe("GetTopSongs", func() {
var agent *listenBrainzAgent
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("BASE_URL", httpClient)
agent = listenBrainzConstructor(ds)
agent.client = client
})
It("returns error when fetch calls", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Path).To(Equal("/1/popularity/top-recordings-for-artist/d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
})
It("returns an error on listenbrainz error", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code":400,"error":"artist_mbid: '1' is not a valid uuid"}`)),
StatusCode: 400,
}
_, err := agent.GetArtistTopSongs(ctx, "", "", "1", 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Path).To(Equal("/1/popularity/top-recordings-for-artist/1"))
})
It("returns all tracks when asked", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
data, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 2)
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]agents.Song{
{
ID: "",
Name: "world.execute(me);",
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
Artist: "Mili",
ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
Album: "Miracle Milk",
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
Duration: 211912,
},
{
ID: "",
Name: "String Theocracy",
MBID: "afa2c83d-b17f-4029-b9da-790ea9250cf9",
Artist: "Mili",
ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
Album: "String Theocracy",
AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e",
Duration: 174000,
},
}))
})
It("returns only one track when prompted", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
data, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]agents.Song{
{
ID: "",
Name: "world.execute(me);",
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
Artist: "Mili",
ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
Album: "Miracle Milk",
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
Duration: 211912,
},
}))
})
})
Describe("GetSimilarArtists", func() {
var agent *listenBrainzAgent
var httpClient *tests.FakeHttpClient
baseUrl := "https://labs.api.listenbrainz.org/similar-artists/json?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&artist_mbids="
mbid := "db92a151-1ac2-438b-bc43-b82e149ddd50"
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("BASE_URL", httpClient)
agent = listenBrainzConstructor(ds)
agent.client = client
})
It("returns error when fetch calls", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetSimilarArtists(ctx, "", "", mbid, 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
})
It("returns an error on listenbrainz error", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
StatusCode: 400,
}
_, err := agent.GetSimilarArtists(ctx, "", "", "1", 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
})
It("returns all data on call", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
Expect(resp).To(Equal([]agents.Artist{
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"},
{MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha"},
}))
})
It("returns subset of data on call", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 1)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
Expect(resp).To(Equal([]agents.Artist{
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"},
}))
})
})
Describe("GetSimilarTracks", func() {
var agent *listenBrainzAgent
var httpClient *tests.FakeHttpClient
mbid := "8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"
baseUrl := "https://labs.api.listenbrainz.org/similar-recordings/json?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&recording_mbids="
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("BASE_URL", httpClient)
agent = listenBrainzConstructor(ds)
agent.client = client
})
It("returns error when fetch calls", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
})
It("returns an error on listenbrainz error", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
StatusCode: 400,
}
_, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", "1", 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
})
It("returns all data on call", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
Expect(resp).To(Equal([]agents.Song{
{
ID: "",
Name: "Take On Me",
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
ISRC: "",
Artist: "aha",
ArtistMBID: "",
Album: "Hunting High and Low",
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Duration: 0,
},
{
ID: "",
Name: "Wake Me Up Before You GoGo",
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
ISRC: "",
Artist: "Wham!",
ArtistMBID: "",
Album: "Make It Big",
AlbumMBID: "c143d542-48dc-446b-b523-1762da721638",
Duration: 0,
},
}))
})
It("returns subset of data on call", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 1)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
Expect(resp).To(Equal([]agents.Song{
{
ID: "",
Name: "Take On Me",
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
ISRC: "",
Artist: "aha",
ArtistMBID: "",
Album: "Hunting High and Low",
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Duration: 0,
},
}))
})
})
})
-378
View File
@@ -1,378 +0,0 @@
package listenbrainz
import (
"bytes"
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"path"
"slices"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
)
const (
lbzApiUrl = "https://api.listenbrainz.org/1/"
labsBase = "https://labs.api.listenbrainz.org/"
)
var (
ErrorNotFound = errors.New("listenbrainz: not found")
)
type listenBrainzError struct {
Code int
Message string
}
func (e *listenBrainzError) Error() string {
return fmt.Sprintf("ListenBrainz error(%d): %s", e.Code, e.Message)
}
type httpDoer interface {
Do(req *http.Request) (*http.Response, error)
}
func newClient(baseURL string, hc httpDoer) *client {
return &client{baseURL, hc}
}
type client struct {
baseURL string
hc httpDoer
}
type listenBrainzResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Error string `json:"error"`
Status string `json:"status"`
Valid bool `json:"valid"`
UserName string `json:"user_name"`
}
type listenBrainzRequest struct {
ApiKey string //nolint:gosec
Body listenBrainzRequestBody
}
type listenBrainzRequestBody struct {
ListenType listenType `json:"listen_type,omitempty"`
Payload []listenInfo `json:"payload,omitempty"`
}
type listenType string
const (
Single listenType = "single"
PlayingNow listenType = "playing_now"
)
type listenInfo struct {
ListenedAt int `json:"listened_at,omitempty"`
TrackMetadata trackMetadata `json:"track_metadata"`
}
type trackMetadata struct {
ArtistName string `json:"artist_name,omitempty"`
TrackName string `json:"track_name,omitempty"`
ReleaseName string `json:"release_name,omitempty"`
AdditionalInfo additionalInfo `json:"additional_info"`
}
type additionalInfo struct {
SubmissionClient string `json:"submission_client,omitempty"`
SubmissionClientVersion string `json:"submission_client_version,omitempty"`
TrackNumber int `json:"tracknumber,omitempty"`
ArtistNames []string `json:"artist_names,omitempty"`
ArtistMBIDs []string `json:"artist_mbids,omitempty"`
RecordingMBID string `json:"recording_mbid,omitempty"`
ReleaseMBID string `json:"release_mbid,omitempty"`
ReleaseGroupMBID string `json:"release_group_mbid,omitempty"`
DurationMs int `json:"duration_ms,omitempty"`
}
func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrainzResponse, error) {
r := &listenBrainzRequest{
ApiKey: apiKey,
}
response, err := c.makeAuthenticatedRequest(ctx, http.MethodGet, "validate-token", r)
if err != nil {
return nil, err
}
return response, nil
}
func (c *client) updateNowPlaying(ctx context.Context, apiKey string, li listenInfo) error {
r := &listenBrainzRequest{
ApiKey: apiKey,
Body: listenBrainzRequestBody{
ListenType: PlayingNow,
Payload: []listenInfo{li},
},
}
resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, "submit-listens", r)
if err != nil {
return err
}
if resp.Status != "ok" {
log.Warn(ctx, "ListenBrainz: NowPlaying was not accepted", "status", resp.Status)
}
return nil
}
func (c *client) scrobble(ctx context.Context, apiKey string, li listenInfo) error {
r := &listenBrainzRequest{
ApiKey: apiKey,
Body: listenBrainzRequestBody{
ListenType: Single,
Payload: []listenInfo{li},
},
}
resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, "submit-listens", r)
if err != nil {
return err
}
if resp.Status != "ok" {
log.Warn(ctx, "ListenBrainz: Scrobble was not accepted", "status", resp.Status)
}
return nil
}
func (c *client) path(endpoint string) (string, error) {
u, err := url.Parse(c.baseURL)
if err != nil {
return "", err
}
u.Path = path.Join(u.Path, endpoint)
return u.String(), nil
}
func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) {
b, _ := json.Marshal(r.Body)
uri, err := c.path(endpoint)
if err != nil {
return nil, err
}
req, _ := http.NewRequestWithContext(ctx, method, uri, bytes.NewBuffer(b))
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
if r.ApiKey != "" {
req.Header.Add("Authorization", fmt.Sprintf("Token %s", r.ApiKey))
}
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz %s request", req.Method), "url", req.URL)
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var response listenBrainzResponse
jsonErr := decoder.Decode(&response)
if resp.StatusCode != 200 && jsonErr != nil {
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
if jsonErr != nil {
return nil, jsonErr
}
if response.Code != 0 && response.Code != 200 {
return &response, &listenBrainzError{Code: response.Code, Message: response.Error}
}
return &response, nil
}
type lbzHttpError struct {
Code int `json:"code"`
Error string `json:"error"`
}
func (c *client) makeGenericRequest(ctx context.Context, method string, endpoint string, params url.Values) (*http.Response, error) {
req, _ := http.NewRequestWithContext(ctx, method, lbzApiUrl+endpoint, nil)
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
req.URL.RawQuery = params.Encode()
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz %s request", req.Method), "url", req.URL)
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
// On a 200 code, there is no code. Decode using using error message if it exists
if resp.StatusCode != 200 {
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var lbzError lbzHttpError
jsonErr := decoder.Decode(&lbzError)
if jsonErr != nil {
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
return nil, &listenBrainzError{Code: lbzError.Code, Message: lbzError.Error}
}
return resp, err
}
type artistMetadataResult struct {
Rels struct {
OfficialHomepage string `json:"official homepage,omitempty"`
} `json:"rels,omitzero"`
}
func (c *client) getArtistUrl(ctx context.Context, mbid string) (string, error) {
params := url.Values{}
params.Add("artist_mbids", mbid)
resp, err := c.makeGenericRequest(ctx, http.MethodGet, "metadata/artist", params)
if err != nil {
return "", err
}
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var response []artistMetadataResult
jsonErr := decoder.Decode(&response)
if jsonErr != nil {
return "", fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
if len(response) == 0 || response[0].Rels.OfficialHomepage == "" {
return "", ErrorNotFound
}
return response[0].Rels.OfficialHomepage, nil
}
type trackInfo struct {
ArtistName string `json:"artist_name"`
ArtistMBIDs []string `json:"artist_mbids"`
DurationMs uint32 `json:"length"`
RecordingName string `json:"recording_name"`
RecordingMbid string `json:"recording_mbid"`
ReleaseName string `json:"release_name"`
ReleaseMBID string `json:"release_mbid"`
}
func (c *client) getArtistTopSongs(ctx context.Context, mbid string, count int) ([]trackInfo, error) {
resp, err := c.makeGenericRequest(ctx, http.MethodGet, "popularity/top-recordings-for-artist/"+mbid, url.Values{})
if err != nil {
return nil, err
}
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var response []trackInfo
jsonErr := decoder.Decode(&response)
if jsonErr != nil {
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
if len(response) > count {
return response[0:count], nil
}
return response, nil
}
type artist struct {
MBID string `json:"artist_mbid"`
Name string `json:"name"`
Score int `json:"score"`
}
func (c *client) getSimilarArtists(ctx context.Context, mbid string, limit int) ([]artist, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-artists/json", nil)
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
req.URL.RawQuery = url.Values{
"artist_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.ArtistAlgorithm},
}.Encode()
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL)
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var artists []artist
jsonErr := decoder.Decode(&artists)
if jsonErr != nil {
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
if len(artists) > limit {
return artists[:limit], nil
}
return artists, nil
}
type recording struct {
MBID string `json:"recording_mbid"`
Name string `json:"recording_name"`
Artist string `json:"artist_credit_name"`
ReleaseName string `json:"release_name"`
ReleaseMBID string `json:"release_mbid"`
Score int `json:"score"`
}
func (c *client) getSimilarRecordings(ctx context.Context, mbid string, limit int) ([]recording, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-recordings/json", nil)
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
req.URL.RawQuery = url.Values{
"recording_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.TrackAlgorithm},
}.Encode()
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL)
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var recordings []recording
jsonErr := decoder.Decode(&recordings)
if jsonErr != nil {
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
// For whatever reason, labs API isn't guaranteed to give results in the proper order
// and may also provide duplicates. See listenbrainz.labs.similar-recordings-real-out-of-order.json
// generated from https://labs.api.listenbrainz.org/similar-recordings/json?recording_mbids=8f3471b5-7e6a-48da-86a9-c1c07a0f47ae&algorithm=session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30
slices.SortFunc(recordings, func(a, b recording) int {
return cmp.Or(
cmp.Compare(b.Score, a.Score), // Sort by score descending
cmp.Compare(a.MBID, b.MBID), // Then by MBID ascending to ensure deterministic order for duplicates
)
})
recordings = slices.CompactFunc(recordings, func(a, b recording) bool {
return a.MBID == b.MBID
})
if len(recordings) > limit {
return recordings[:limit], nil
}
return recordings, nil
}
-464
View File
@@ -1,464 +0,0 @@
package listenbrainz
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("client", func() {
var httpClient *tests.FakeHttpClient
var client *client
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client = newClient("BASE_URL/", httpClient)
})
Describe("listenBrainzResponse", func() {
It("parses a response properly", func() {
var response listenBrainzResponse
err := json.Unmarshal([]byte(`{"code": 200, "message": "Message", "user_name": "UserName", "valid": true, "status": "ok", "error": "Error"}`), &response)
Expect(err).ToNot(HaveOccurred())
Expect(response.Code).To(Equal(200))
Expect(response.Message).To(Equal("Message"))
Expect(response.UserName).To(Equal("UserName"))
Expect(response.Valid).To(BeTrue())
Expect(response.Status).To(Equal("ok"))
Expect(response.Error).To(Equal("Error"))
})
})
Describe("validateToken", func() {
BeforeEach(func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)),
StatusCode: 200,
}
})
It("formats the request properly", func() {
_, err := client.validateToken(context.Background(), "LB-TOKEN")
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/validate-token"))
Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("parses and returns the response", func() {
res, err := client.validateToken(context.Background(), "LB-TOKEN")
Expect(err).ToNot(HaveOccurred())
Expect(res.Valid).To(Equal(true))
Expect(res.UserName).To(Equal("ListenBrainzUser"))
})
})
Context("with listenInfo", func() {
var li listenInfo
BeforeEach(func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)),
StatusCode: 200,
}
li = listenInfo{
TrackMetadata: trackMetadata{
ArtistName: "Track Artist",
TrackName: "Track Title",
ReleaseName: "Track Album",
AdditionalInfo: additionalInfo{
TrackNumber: 1,
ArtistNames: []string{"Artist 1", "Artist 2"},
ArtistMBIDs: []string{"mbz-789", "mbz-012"},
RecordingMBID: "mbz-123",
ReleaseMBID: "mbz-456",
DurationMs: 142200,
},
},
}
})
Describe("updateNowPlaying", func() {
It("formats the request properly", func() {
Expect(client.updateNowPlaying(context.Background(), "LB-TOKEN", li)).To(Succeed())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens"))
Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
f, _ := os.ReadFile("tests/fixtures/listenbrainz.nowplaying.request.json")
Expect(body).To(MatchJSON(f))
})
})
Describe("scrobble", func() {
BeforeEach(func() {
li.ListenedAt = 1635000000
})
It("formats the request properly", func() {
Expect(client.scrobble(context.Background(), "LB-TOKEN", li)).To(Succeed())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens"))
Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
f, _ := os.ReadFile("tests/fixtures/listenbrainz.scrobble.request.json")
Expect(body).To(MatchJSON(f))
})
})
})
Context("getArtistUrl", func() {
baseUrl := "https://api.listenbrainz.org/1/metadata/artist?"
It("handles a malformed request with status code", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 400,"error": "artist mbid 1 is not valid."}`)),
StatusCode: 400,
}
_, err := client.getArtistUrl(context.Background(), "1")
Expect(err.Error()).To(Equal("ListenBrainz error(400): artist mbid 1 is not valid."))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=1"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("handles a malformed request without meaningful body", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(``)),
StatusCode: 501,
}
_, err := client.getArtistUrl(context.Background(), "1")
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (501)"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=1"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("It returns not found when the artist has no official homepage", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.no_homepage.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := client.getArtistUrl(context.Background(), "7c2cc610-f998-43ef-a08f-dae3344b8973")
Expect(err.Error()).To(Equal("listenbrainz: not found"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=7c2cc610-f998-43ef-a08f-dae3344b8973"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("It returns data when the artist has a homepage", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.homepage.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
url, err := client.getArtistUrl(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")
Expect(err).ToNot(HaveOccurred())
Expect(url).To(Equal("http://projectmili.com/"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
})
Context("getArtistTopSongs", func() {
baseUrl := "https://api.listenbrainz.org/1/popularity/top-recordings-for-artist/"
It("handles a malformed request with status code", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code":400,"error":"artist_mbid: '1' is not a valid uuid"}`)),
StatusCode: 400,
}
_, err := client.getArtistTopSongs(context.Background(), "1", 50)
Expect(err.Error()).To(Equal("ListenBrainz error(400): artist_mbid: '1' is not a valid uuid"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("handles a malformed request without standard body", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(``)),
StatusCode: 500,
}
_, err := client.getArtistTopSongs(context.Background(), "1", 1)
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (500)"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("It returns all tracks when given the opportunity", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
data, err := client.getArtistTopSongs(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 5)
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]trackInfo{
{
ArtistName: "Mili",
ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
DurationMs: 211912,
RecordingName: "world.execute(me);",
RecordingMbid: "9980309d-3480-4e7e-89ce-fce971a452be",
ReleaseName: "Miracle Milk",
ReleaseMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
},
{
ArtistName: "Mili",
ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
DurationMs: 174000,
RecordingName: "String Theocracy",
RecordingMbid: "afa2c83d-b17f-4029-b9da-790ea9250cf9",
ReleaseName: "String Theocracy",
ReleaseMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e",
},
}))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("It returns a subset of tracks when allowed", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
data, err := client.getArtistTopSongs(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]trackInfo{
{
ArtistName: "Mili",
ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
DurationMs: 211912,
RecordingName: "world.execute(me);",
RecordingMbid: "9980309d-3480-4e7e-89ce-fce971a452be",
ReleaseName: "Miracle Milk",
ReleaseMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
},
}))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
})
Context("getSimilarArtists", func() {
var algorithm string
BeforeEach(func() {
algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
DeferCleanup(configtest.SetupConfig())
})
getUrl := func(mbid string) string {
return fmt.Sprintf("https://labs.api.listenbrainz.org/similar-artists/json?algorithm=%s&artist_mbids=%s", algorithm, mbid)
}
mbid := "db92a151-1ac2-438b-bc43-b82e149ddd50"
It("handles a malformed request with status code", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
StatusCode: 400,
}
_, err := client.getSimilarArtists(context.Background(), "1", 2)
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (400)"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl("1")))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("handles real data properly", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarArtists(context.Background(), mbid, 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]artist{
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
{MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha", Score: 792},
}))
})
It("truncates data when requested", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarArtists(context.Background(), "db92a151-1ac2-438b-bc43-b82e149ddd50", 1)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]artist{
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
}))
})
It("fetches a different endpoint when algorithm changes", func() {
algorithm = "session_based_days_1825_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30"
conf.Server.ListenBrainz.ArtistAlgorithm = algorithm
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarArtists(context.Background(), mbid, 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]artist{
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
{MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha", Score: 792},
}))
})
})
Context("getSimilarRecordings", func() {
var algorithm string
BeforeEach(func() {
algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
DeferCleanup(configtest.SetupConfig())
})
getUrl := func(mbid string) string {
return fmt.Sprintf("https://labs.api.listenbrainz.org/similar-recordings/json?algorithm=%s&recording_mbids=%s", algorithm, mbid)
}
mbid := "8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"
It("handles a malformed request with status code", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
StatusCode: 400,
}
_, err := client.getSimilarRecordings(context.Background(), "1", 2)
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (400)"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl("1")))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("handles real data properly", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarRecordings(context.Background(), mbid, 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]recording{
{
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
Name: "Take On Me",
Artist: "aha",
ReleaseName: "Hunting High and Low",
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Score: 124,
},
{
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
Name: "Wake Me Up Before You GoGo",
Artist: "Wham!",
ReleaseName: "Make It Big",
ReleaseMBID: "c143d542-48dc-446b-b523-1762da721638",
Score: 65,
},
}))
})
It("truncates data when requested", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarRecordings(context.Background(), mbid, 1)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]recording{
{
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
Name: "Take On Me",
Artist: "aha",
ReleaseName: "Hunting High and Low",
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Score: 124,
},
}))
})
It("properly sorts by score and truncates duplicates", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings-real-out-of-order.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
// There are actually 5 items. The dedup should happen FIRST
resp, err := client.getSimilarRecordings(context.Background(), mbid, 4)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]recording{
{
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
Name: "Take On Me",
Artist: "aha",
ReleaseName: "Hunting High and Low",
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Score: 124,
},
{
MBID: "e4b347be-ecb2-44ff-aaa8-3d4c517d7ea5",
Name: "Everybody Wants to Rule the World",
Artist: "Tears for Fears",
ReleaseName: "Songs From the Big Chair",
ReleaseMBID: "21f19b06-81f1-347a-add5-5d0c77696597",
Score: 68,
},
{
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
Name: "Wake Me Up Before You GoGo",
Artist: "Wham!",
ReleaseName: "Make It Big",
ReleaseMBID: "c143d542-48dc-446b-b523-1762da721638",
Score: 65,
},
{
MBID: "ef4c6855-949e-4e22-b41e-8e0a2d372d5f",
Name: "Tainted Love",
Artist: "Soft Cell",
ReleaseName: "Non-Stop Erotic Cabaret",
ReleaseMBID: "1acaa870-6e0c-4b6e-9e91-fdec4e5ea4b1",
Score: 61,
},
}))
})
It("uses a different algorithm when configured", func() {
algorithm = "session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30"
conf.Server.ListenBrainz.TrackAlgorithm = algorithm
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarRecordings(context.Background(), mbid, 1)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]recording{
{
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
Name: "Take On Me",
Artist: "aha",
ReleaseName: "Hunting High and Low",
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Score: 124,
},
}))
})
})
})
@@ -1,4 +1,4 @@
package gotaglib
package taglib
import (
"io/fs"
@@ -96,7 +96,7 @@ var _ = Describe("Extractor", func() {
}
BeforeEach(func() {
e = &extractor{fs: os.DirFS(".")}
e = &extractor{}
})
Describe("ReplayGain", func() {
@@ -151,7 +151,11 @@ var _ = Describe("Extractor", func() {
unsSylt := makeLyrics("xxx", "unspecified SYLT")
unsUslt := makeLyrics("xxx", "unspecified")
Expect(lyrics).To(ConsistOf(engSylt, engUslt, unsSylt, unsUslt))
// Why is the order inconsistent between runs? Nobody knows
Expect(lyrics).To(Or(
Equal(model.LyricList{engSylt, engUslt, unsSylt, unsUslt}),
Equal(model.LyricList{unsSylt, unsUslt, engSylt, engUslt}),
))
})
DescribeTable("format-specific lyrics", func(file string, isId3 bool) {
+9
View File
@@ -0,0 +1,9 @@
//go:build !windows
package taglib
import "C"
func getFilename(s string) *C.char {
return C.CString(s)
}
+96
View File
@@ -0,0 +1,96 @@
//go:build windows
package taglib
// From https://github.com/orofarne/gowchar
/*
#include <wchar.h>
const size_t SIZEOF_WCHAR_T = sizeof(wchar_t);
void gowchar_set (wchar_t *arr, int pos, wchar_t val)
{
arr[pos] = val;
}
wchar_t gowchar_get (wchar_t *arr, int pos)
{
return arr[pos];
}
*/
import "C"
import (
"fmt"
"unicode/utf16"
"unicode/utf8"
)
var SIZEOF_WCHAR_T C.size_t = C.size_t(C.SIZEOF_WCHAR_T)
func getFilename(s string) *C.wchar_t {
wstr, _ := StringToWcharT(s)
return wstr
}
func StringToWcharT(s string) (*C.wchar_t, C.size_t) {
switch SIZEOF_WCHAR_T {
case 2:
return stringToWchar2(s) // Windows
case 4:
return stringToWchar4(s) // Unix
default:
panic(fmt.Sprintf("Invalid sizeof(wchar_t) = %v", SIZEOF_WCHAR_T))
}
panic("?!!")
}
// Windows
func stringToWchar2(s string) (*C.wchar_t, C.size_t) {
var slen int
s1 := s
for len(s1) > 0 {
r, size := utf8.DecodeRuneInString(s1)
if er, _ := utf16.EncodeRune(r); er == '\uFFFD' {
slen += 1
} else {
slen += 2
}
s1 = s1[size:]
}
slen++ // \0
res := C.malloc(C.size_t(slen) * SIZEOF_WCHAR_T)
var i int
for len(s) > 0 {
r, size := utf8.DecodeRuneInString(s)
if r1, r2 := utf16.EncodeRune(r); r1 != '\uFFFD' {
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r1))
i++
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r2))
i++
} else {
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r))
i++
}
s = s[size:]
}
C.gowchar_set((*C.wchar_t)(res), C.int(slen-1), C.wchar_t(0)) // \0
return (*C.wchar_t)(res), C.size_t(slen)
}
// Unix
func stringToWchar4(s string) (*C.wchar_t, C.size_t) {
slen := utf8.RuneCountInString(s)
slen++ // \0
res := C.malloc(C.size_t(slen) * SIZEOF_WCHAR_T)
var i int
for len(s) > 0 {
r, size := utf8.DecodeRuneInString(s)
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r))
s = s[size:]
i++
}
C.gowchar_set((*C.wchar_t)(res), C.int(slen-1), C.wchar_t(0)) // \0
return (*C.wchar_t)(res), C.size_t(slen)
}
+178
View File
@@ -0,0 +1,178 @@
package taglib
import (
"io/fs"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/storage/local"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/metadata"
)
type extractor struct {
baseDir string
}
func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
results := make(map[string]metadata.Info)
for _, path := range files {
props, err := e.extractMetadata(path)
if err != nil {
continue
}
results[path] = *props
}
return results, nil
}
func (e extractor) Version() string {
return Version()
}
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
fullPath := filepath.Join(e.baseDir, filePath)
tags, err := Read(fullPath)
if err != nil {
log.Warn("extractor: Error reading metadata from file. Skipping", "filePath", fullPath, err)
return nil, err
}
// Parse audio properties
ap := metadata.AudioProperties{}
ap.BitRate = parseProp(tags, "__bitrate")
ap.Channels = parseProp(tags, "__channels")
ap.SampleRate = parseProp(tags, "__samplerate")
ap.BitDepth = parseProp(tags, "__bitspersample")
length := parseProp(tags, "__lengthinmilliseconds")
ap.Duration = (time.Millisecond * time.Duration(length)).Round(time.Millisecond * 10)
// Extract basic tags
parseBasicTag(tags, "__title", "title")
parseBasicTag(tags, "__artist", "artist")
parseBasicTag(tags, "__album", "album")
parseBasicTag(tags, "__comment", "comment")
parseBasicTag(tags, "__genre", "genre")
parseBasicTag(tags, "__year", "year")
parseBasicTag(tags, "__track", "tracknumber")
// Parse track/disc totals
parseTuple := func(prop string) {
tagName := prop + "number"
tagTotal := prop + "total"
if value, ok := tags[tagName]; ok && len(value) > 0 {
parts := strings.Split(value[0], "/")
tags[tagName] = []string{parts[0]}
if len(parts) == 2 {
tags[tagTotal] = []string{parts[1]}
}
}
}
parseTuple("track")
parseTuple("disc")
// Adjust some ID3 tags
parseLyrics(tags)
parseTIPL(tags)
delete(tags, "tmcl") // TMCL is already parsed by TagLib
return &metadata.Info{
Tags: tags,
AudioProperties: ap,
HasPicture: tags["has_picture"] != nil && len(tags["has_picture"]) > 0 && tags["has_picture"][0] == "true",
}, nil
}
// parseLyrics make sure lyrics tags have language
func parseLyrics(tags map[string][]string) {
lyrics := tags["lyrics"]
if len(lyrics) > 0 {
tags["lyrics:xxx"] = lyrics
delete(tags, "lyrics")
}
}
// These are the only roles we support, based on Picard's tag map:
// https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html
var tiplMapping = map[string]string{
"arranger": "arranger",
"engineer": "engineer",
"producer": "producer",
"mix": "mixer",
"DJ-mix": "djmixer",
}
// parseProp parses a property from the tags map and sets it to the target integer.
// It also deletes the property from the tags map after parsing.
func parseProp(tags map[string][]string, prop string) int {
if value, ok := tags[prop]; ok && len(value) > 0 {
v, _ := strconv.Atoi(value[0])
delete(tags, prop)
return v
}
return 0
}
// parseBasicTag checks if a basic tag (like __title, __artist, etc.) exists in the tags map.
// If it does, it moves the value to a more appropriate tag name (like title, artist, etc.),
// and deletes the basic tag from the map. If the target tag already exists, it ignores the basic tag.
func parseBasicTag(tags map[string][]string, basicName string, tagName string) {
basicValue := tags[basicName]
if len(basicValue) == 0 {
return
}
delete(tags, basicName)
if len(tags[tagName]) == 0 {
tags[tagName] = basicValue
}
}
// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format:
//
// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson".
//
// and breaks it down into a map of roles and names, e.g.:
//
// {"arranger": ["Andrew Powell"], "engineer": ["Chris Blair", "Pat Stapley"], "producer": ["Eric Woolfson"]}.
func parseTIPL(tags map[string][]string) {
tipl := tags["tipl"]
if len(tipl) == 0 {
return
}
addRole := func(currentRole string, currentValue []string) {
if currentRole != "" && len(currentValue) > 0 {
role := tiplMapping[currentRole]
tags[role] = append(tags[role], strings.Join(currentValue, " "))
}
}
var currentRole string
var currentValue []string
for _, part := range strings.Split(tipl[0], " ") {
if _, ok := tiplMapping[part]; ok {
addRole(currentRole, currentValue)
currentRole = part
currentValue = nil
continue
}
currentValue = append(currentValue, part)
}
addRole(currentRole, currentValue)
delete(tags, "tipl")
}
var _ local.Extractor = (*extractor)(nil)
func init() {
local.RegisterExtractor("taglib", func(_ fs.FS, baseDir string) local.Extractor {
// ignores fs, as taglib extractor only works with local files
return &extractor{baseDir}
})
conf.AddHook(func() {
log.Debug("TagLib version", "version", Version())
})
}
@@ -1,4 +1,4 @@
package gotaglib
package taglib
import (
"testing"
@@ -9,9 +9,9 @@ import (
. "github.com/onsi/gomega"
)
func TestGoTagLib(t *testing.T) {
func TestTagLib(t *testing.T) {
tests.Init(t, true)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "GoTagLib Suite")
RunSpecs(t, "TagLib Suite")
}
@@ -1,11 +1,10 @@
package gotaglib
package taglib
import (
"io/fs"
"os"
"strings"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -15,7 +14,7 @@ var _ = Describe("Extractor", func() {
var e *extractor
BeforeEach(func() {
e = &extractor{fs: os.DirFS(".")}
e = &extractor{}
})
Describe("Parse", func() {
@@ -81,11 +80,12 @@ var _ = Describe("Extractor", func() {
Expect(err).To(BeNil())
Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
// TagLib 1.12 returns 18, previous versions return 39.
// TabLib 1.12 returns 18, previous versions return 39.
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
Expect(m.AudioProperties.Channels).To(BeElementOf(2))
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
Expect(m.HasPicture).To(BeTrue())
})
@@ -106,7 +106,7 @@ var _ = Describe("Extractor", func() {
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_album_gain", []string{albumGain}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_gain", []string{albumGain}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{albumGain}),
))
Expect(m.Tags).To(Or(
@@ -128,17 +128,6 @@ var _ = Describe("Extractor", func() {
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"}))
// Still as of TagLib v2.2.1, TagLib only maps values in ID3, MP4, and ASF tags
// to `originaldate`.
if strings.HasSuffix(file, ".mp3") || strings.HasSuffix(file, ".wav") || strings.HasSuffix(file, ".aiff") || strings.HasSuffix(file, ".m4a") || strings.HasSuffix(file, ".wma") {
Expect(m.Tags).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"}))
}
// MP3Tag sets `ORIGYEAR` in several formats for which it has no built-in mapping
// for original release dates.
Expect(m.Tags).To(Or(
HaveKeyWithValue("origyear", []string{"1998-07-28"}),
HaveKeyWithValue("----:com.apple.itunes:origyear", []string{"1998-07-28"}),
))
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
Expect(m.Tags).To(Or(
@@ -185,9 +174,6 @@ var _ = Describe("Extractor", func() {
Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false, true),
// ffmpeg -f lavfi -i "sine=frequency=1100:duration=1" -c:a libopus test.opus (tags added via mutagen)
Entry("correctly parses opus tags (#4998)", "test.opus", "1s", 1, 48000, 0, "+5.12 dB", "0.11345678", "+5.12 dB", "0.11345678", false, true),
// ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma
// Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order
Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false, true),
@@ -214,9 +200,6 @@ var _ = Describe("Extractor", func() {
// Only run permission tests if we are not root
RegularUserContext("when run without root privileges", func() {
BeforeEach(func() {
tests.SkipOnWindows("uses Unix file permission bits")
// Use root fs for absolute paths in temp directory
e = &extractor{fs: os.DirFS("/")}
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
@@ -229,25 +212,20 @@ var _ = Describe("Extractor", func() {
})
It("correctly handle unreadable file due to insufficient read permission", func() {
// Strip leading slash for DirFS rooted at "/"
_, err := e.extractMetadata(accessForbiddenFile[1:])
_, err := e.extractMetadata(accessForbiddenFile)
Expect(err).To(MatchError(os.ErrPermission))
})
It("skips the file if it cannot be read", func() {
// Get current working directory to construct paths relative to root
cwd, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
// Strip leading slash for DirFS rooted at "/"
files := []string{
cwd[1:] + "/tests/fixtures/test.mp3",
cwd[1:] + "/tests/fixtures/test.ogg",
accessForbiddenFile[1:],
"tests/fixtures/test.mp3",
"tests/fixtures/test.ogg",
accessForbiddenFile,
}
mds, err := e.Parse(files...)
Expect(err).NotTo(HaveOccurred())
Expect(mds).To(HaveLen(2))
Expect(mds).ToNot(HaveKey(accessForbiddenFile[1:]))
Expect(mds).ToNot(HaveKey(accessForbiddenFile))
})
})
})
+299
View File
@@ -0,0 +1,299 @@
#include <stdlib.h>
#include <string.h>
#define TAGLIB_STATIC
#include <apeproperties.h>
#include <apetag.h>
#include <aifffile.h>
#include <asffile.h>
#include <dsffile.h>
#include <fileref.h>
#include <flacfile.h>
#include <id3v2tag.h>
#include <unsynchronizedlyricsframe.h>
#include <synchronizedlyricsframe.h>
#include <mp4file.h>
#include <mpegfile.h>
#include <opusfile.h>
#include <tpropertymap.h>
#include <vorbisfile.h>
#include <wavfile.h>
#include <wavfile.h>
#include <wavpackfile.h>
#include "taglib_wrapper.h"
char has_cover(const TagLib::FileRef f);
static char TAGLIB_VERSION[16];
char* taglib_version() {
snprintf((char *)TAGLIB_VERSION, 16, "%d.%d.%d", TAGLIB_MAJOR_VERSION, TAGLIB_MINOR_VERSION, TAGLIB_PATCH_VERSION);
return (char *)TAGLIB_VERSION;
}
int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
TagLib::FileRef f(filename, true, TagLib::AudioProperties::Fast);
if (f.isNull()) {
return TAGLIB_ERR_PARSE;
}
if (!f.audioProperties()) {
return TAGLIB_ERR_AUDIO_PROPS;
}
// Add audio properties to the tags
const TagLib::AudioProperties *props(f.audioProperties());
goPutInt(id, (char *)"__lengthinmilliseconds", props->lengthInMilliseconds());
goPutInt(id, (char *)"__bitrate", props->bitrate());
goPutInt(id, (char *)"__channels", props->channels());
goPutInt(id, (char *)"__samplerate", props->sampleRate());
// Extract bits per sample for supported formats
int bitsPerSample = 0;
if (const auto* apeProperties{ dynamic_cast<const TagLib::APE::Properties*>(props) })
bitsPerSample = apeProperties->bitsPerSample();
else if (const auto* asfProperties{ dynamic_cast<const TagLib::ASF::Properties*>(props) })
bitsPerSample = asfProperties->bitsPerSample();
else if (const auto* flacProperties{ dynamic_cast<const TagLib::FLAC::Properties*>(props) })
bitsPerSample = flacProperties->bitsPerSample();
else if (const auto* mp4Properties{ dynamic_cast<const TagLib::MP4::Properties*>(props) })
bitsPerSample = mp4Properties->bitsPerSample();
else if (const auto* wavePackProperties{ dynamic_cast<const TagLib::WavPack::Properties*>(props) })
bitsPerSample = wavePackProperties->bitsPerSample();
else if (const auto* aiffProperties{ dynamic_cast<const TagLib::RIFF::AIFF::Properties*>(props) })
bitsPerSample = aiffProperties->bitsPerSample();
else if (const auto* wavProperties{ dynamic_cast<const TagLib::RIFF::WAV::Properties*>(props) })
bitsPerSample = wavProperties->bitsPerSample();
else if (const auto* dsfProperties{ dynamic_cast<const TagLib::DSF::Properties*>(props) })
bitsPerSample = dsfProperties->bitsPerSample();
if (bitsPerSample > 0) {
goPutInt(id, (char *)"__bitspersample", bitsPerSample);
}
// Send all properties to the Go map
TagLib::PropertyMap tags = f.file()->properties();
// Make sure at least the basic properties are extracted
TagLib::Tag *basic = f.file()->tag();
if (!basic->isEmpty()) {
if (!basic->title().isEmpty()) {
tags.insert("__title", basic->title());
}
if (!basic->artist().isEmpty()) {
tags.insert("__artist", basic->artist());
}
if (!basic->album().isEmpty()) {
tags.insert("__album", basic->album());
}
if (!basic->comment().isEmpty()) {
tags.insert("__comment", basic->comment());
}
if (!basic->genre().isEmpty()) {
tags.insert("__genre", basic->genre());
}
if (basic->year() > 0) {
tags.insert("__year", TagLib::String::number(basic->year()));
}
if (basic->track() > 0) {
tags.insert("__track", TagLib::String::number(basic->track()));
}
}
TagLib::ID3v2::Tag *id3Tags = NULL;
// Get some extended/non-standard ID3-only tags (ex: iTunes extended frames)
TagLib::MPEG::File *mp3File(dynamic_cast<TagLib::MPEG::File *>(f.file()));
if (mp3File != NULL) {
id3Tags = mp3File->ID3v2Tag();
}
if (id3Tags == NULL) {
TagLib::RIFF::WAV::File *wavFile(dynamic_cast<TagLib::RIFF::WAV::File *>(f.file()));
if (wavFile != NULL && wavFile->hasID3v2Tag()) {
id3Tags = wavFile->ID3v2Tag();
}
}
if (id3Tags == NULL) {
TagLib::RIFF::AIFF::File *aiffFile(dynamic_cast<TagLib::RIFF::AIFF::File *>(f.file()));
if (aiffFile && aiffFile->hasID3v2Tag()) {
id3Tags = aiffFile->tag();
}
}
// Yes, it is possible to have ID3v2 tags in FLAC. However, that can cause problems
// with many players, so they will not be parsed
if (id3Tags != NULL) {
const auto &frames = id3Tags->frameListMap();
for (const auto &kv: frames) {
if (kv.first == "USLT") {
for (const auto &tag: kv.second) {
TagLib::ID3v2::UnsynchronizedLyricsFrame *frame = dynamic_cast<TagLib::ID3v2::UnsynchronizedLyricsFrame *>(tag);
if (frame == NULL) continue;
tags.erase("LYRICS");
const auto bv = frame->language();
char language[4] = {'x', 'x', 'x', '\0'};
if (bv.size() == 3) {
strncpy(language, bv.data(), 3);
}
char *val = const_cast<char*>(frame->text().toCString(true));
goPutLyrics(id, language, val);
}
} else if (kv.first == "SYLT") {
for (const auto &tag: kv.second) {
TagLib::ID3v2::SynchronizedLyricsFrame *frame = dynamic_cast<TagLib::ID3v2::SynchronizedLyricsFrame *>(tag);
if (frame == NULL) continue;
const auto bv = frame->language();
char language[4] = {'x', 'x', 'x', '\0'};
if (bv.size() == 3) {
strncpy(language, bv.data(), 3);
}
const auto format = frame->timestampFormat();
if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMilliseconds) {
for (const auto &line: frame->synchedText()) {
char *text = const_cast<char*>(line.text.toCString(true));
goPutLyricLine(id, language, text, line.time);
}
} else if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMpegFrames) {
const int sampleRate = props->sampleRate();
if (sampleRate != 0) {
for (const auto &line: frame->synchedText()) {
const int timeInMs = (line.time * 1000) / sampleRate;
char *text = const_cast<char*>(line.text.toCString(true));
goPutLyricLine(id, language, text, timeInMs);
}
}
}
}
} else if (kv.first == "TIPL"){
if (!kv.second.isEmpty()) {
tags.insert(kv.first, kv.second.front()->toString());
}
}
}
}
// M4A may have some iTunes specific tags not captured by the PropertyMap interface
TagLib::MP4::File *m4afile(dynamic_cast<TagLib::MP4::File *>(f.file()));
if (m4afile != NULL) {
const auto itemListMap = m4afile->tag()->itemMap();
for (const auto item: itemListMap) {
char *key = const_cast<char*>(item.first.toCString(true));
for (const auto value: item.second.toStringList()) {
char *val = const_cast<char*>(value.toCString(true));
goPutM4AStr(id, key, val);
}
}
}
// WMA/ASF files may have additional tags not captured by the PropertyMap interface
TagLib::ASF::File *asfFile(dynamic_cast<TagLib::ASF::File *>(f.file()));
if (asfFile != NULL) {
const TagLib::ASF::Tag *asfTags{asfFile->tag()};
const auto itemListMap = asfTags->attributeListMap();
for (const auto item : itemListMap) {
char *key = const_cast<char*>(item.first.toCString(true));
for (auto j = item.second.begin();
j != item.second.end(); ++j) {
char *val = const_cast<char*>(j->toString().toCString(true));
goPutStr(id, key, val);
}
}
}
// Send all collected tags to the Go map
for (TagLib::PropertyMap::ConstIterator i = tags.begin(); i != tags.end();
++i) {
char *key = const_cast<char*>(i->first.toCString(true));
for (TagLib::StringList::ConstIterator j = i->second.begin();
j != i->second.end(); ++j) {
char *val = const_cast<char*>((*j).toCString(true));
goPutStr(id, key, val);
}
}
// Cover art has to be handled separately
if (has_cover(f)) {
goPutStr(id, (char *)"has_picture", (char *)"true");
}
return 0;
}
// Detect if the file has cover art. Returns 1 if the file has cover art, 0 otherwise.
char has_cover(const TagLib::FileRef f) {
char hasCover = 0;
// ----- MP3
if (TagLib::MPEG::File * mp3File{dynamic_cast<TagLib::MPEG::File *>(f.file())}) {
if (mp3File->ID3v2Tag()) {
const auto &frameListMap{mp3File->ID3v2Tag()->frameListMap()};
hasCover = !frameListMap["APIC"].isEmpty();
}
}
// ----- FLAC
else if (TagLib::FLAC::File * flacFile{dynamic_cast<TagLib::FLAC::File *>(f.file())}) {
hasCover = !flacFile->pictureList().isEmpty();
}
// ----- MP4
else if (TagLib::MP4::File * mp4File{dynamic_cast<TagLib::MP4::File *>(f.file())}) {
auto &coverItem{mp4File->tag()->itemMap()["covr"]};
TagLib::MP4::CoverArtList coverArtList{coverItem.toCoverArtList()};
hasCover = !coverArtList.isEmpty();
}
// ----- Ogg
else if (TagLib::Ogg::Vorbis::File * vorbisFile{dynamic_cast<TagLib::Ogg::Vorbis::File *>(f.file())}) {
hasCover = !vorbisFile->tag()->pictureList().isEmpty();
}
// ----- Opus
else if (TagLib::Ogg::Opus::File * opusFile{dynamic_cast<TagLib::Ogg::Opus::File *>(f.file())}) {
hasCover = !opusFile->tag()->pictureList().isEmpty();
}
// ----- WAV
else if (TagLib::RIFF::WAV::File * wavFile{ dynamic_cast<TagLib::RIFF::WAV::File*>(f.file()) }) {
if (wavFile->hasID3v2Tag()) {
const auto& frameListMap{ wavFile->ID3v2Tag()->frameListMap() };
hasCover = !frameListMap["APIC"].isEmpty();
}
}
// ----- AIFF
else if (TagLib::RIFF::AIFF::File * aiffFile{ dynamic_cast<TagLib::RIFF::AIFF::File *>(f.file())}) {
if (aiffFile->hasID3v2Tag()) {
const auto& frameListMap{ aiffFile->tag()->frameListMap() };
hasCover = !frameListMap["APIC"].isEmpty();
}
}
// ----- WMA
else if (TagLib::ASF::File * asfFile{dynamic_cast<TagLib::ASF::File *>(f.file())}) {
const TagLib::ASF::Tag *tag{ asfFile->tag() };
hasCover = tag && tag->attributeListMap().contains("WM/Picture");
}
// ----- DSF
else if (TagLib::DSF::File * dsffile{ dynamic_cast<TagLib::DSF::File *>(f.file())}) {
const TagLib::ID3v2::Tag *tag { dsffile->tag() };
hasCover = tag && !tag->frameListMap()["APIC"].isEmpty();
}
// ----- WAVPAK (APE tag)
else if (TagLib::WavPack::File * wvFile{dynamic_cast<TagLib::WavPack::File *>(f.file())}) {
if (wvFile->hasAPETag()) {
// This is the particular string that Picard uses
hasCover = !wvFile->APETag()->itemListMap()["COVER ART (FRONT)"].isEmpty();
}
}
return hasCover;
}
+157
View File
@@ -0,0 +1,157 @@
package taglib
/*
#cgo !windows pkg-config: --define-prefix taglib
#cgo windows pkg-config: taglib
#cgo illumos LDFLAGS: -lstdc++ -lsendfile
#cgo linux darwin CXXFLAGS: -std=c++11
#cgo darwin LDFLAGS: -L/opt/homebrew/opt/taglib/lib
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "taglib_wrapper.h"
*/
import "C"
import (
"encoding/json"
"fmt"
"os"
"runtime/debug"
"strconv"
"strings"
"sync"
"sync/atomic"
"unsafe"
"github.com/navidrome/navidrome/log"
)
const iTunesKeyPrefix = "----:com.apple.itunes:"
func Version() string {
return C.GoString(C.taglib_version())
}
func Read(filename string) (tags map[string][]string, err error) {
// Do not crash on failures in the C code/library
debug.SetPanicOnFault(true)
defer func() {
if r := recover(); r != nil {
log.Error("extractor: recovered from panic when reading tags", "file", filename, "error", r)
err = fmt.Errorf("extractor: recovered from panic: %s", r)
}
}()
fp := getFilename(filename)
defer C.free(unsafe.Pointer(fp))
id, m, release := newMap()
defer release()
log.Trace("extractor: reading tags", "filename", filename, "map_id", id)
res := C.taglib_read(fp, C.ulong(id))
switch res {
case C.TAGLIB_ERR_PARSE:
// Check additional case whether the file is unreadable due to permission
file, fileErr := os.OpenFile(filename, os.O_RDONLY, 0600)
defer file.Close()
if os.IsPermission(fileErr) {
return nil, fmt.Errorf("navidrome does not have permission: %w", fileErr)
} else if fileErr != nil {
return nil, fmt.Errorf("cannot parse file media file: %w", fileErr)
} else {
return nil, fmt.Errorf("cannot parse file media file")
}
case C.TAGLIB_ERR_AUDIO_PROPS:
return nil, fmt.Errorf("can't get audio properties from file")
}
if log.IsGreaterOrEqualTo(log.LevelDebug) {
j, _ := json.Marshal(m)
log.Trace("extractor: read tags", "tags", string(j), "filename", filename, "id", id)
} else {
log.Trace("extractor: read tags", "tags", m, "filename", filename, "id", id)
}
return m, nil
}
type tagMap map[string][]string
var allMaps sync.Map
var mapsNextID atomic.Uint32
func newMap() (uint32, tagMap, func()) {
id := mapsNextID.Add(1)
m := tagMap{}
allMaps.Store(id, m)
return id, m, func() {
allMaps.Delete(id)
}
}
func doPutTag(id C.ulong, key string, val *C.char) {
if key == "" {
return
}
r, _ := allMaps.Load(uint32(id))
m := r.(tagMap)
k := strings.ToLower(key)
v := strings.TrimSpace(C.GoString(val))
m[k] = append(m[k], v)
}
//export goPutM4AStr
func goPutM4AStr(id C.ulong, key *C.char, val *C.char) {
k := C.GoString(key)
// Special for M4A, do not catch keys that have no actual name
k = strings.TrimPrefix(k, iTunesKeyPrefix)
doPutTag(id, k, val)
}
//export goPutStr
func goPutStr(id C.ulong, key *C.char, val *C.char) {
doPutTag(id, C.GoString(key), val)
}
//export goPutInt
func goPutInt(id C.ulong, key *C.char, val C.int) {
valStr := strconv.Itoa(int(val))
vp := C.CString(valStr)
defer C.free(unsafe.Pointer(vp))
goPutStr(id, key, vp)
}
//export goPutLyrics
func goPutLyrics(id C.ulong, lang *C.char, val *C.char) {
doPutTag(id, "lyrics:"+C.GoString(lang), val)
}
//export goPutLyricLine
func goPutLyricLine(id C.ulong, lang *C.char, text *C.char, time C.int) {
language := C.GoString(lang)
line := C.GoString(text)
timeGo := int64(time)
ms := timeGo % 1000
timeGo /= 1000
sec := timeGo % 60
timeGo /= 60
minimum := timeGo % 60
formattedLine := fmt.Sprintf("[%02d:%02d.%02d]%s\n", minimum, sec, ms/10, line)
key := "lyrics:" + language
r, _ := allMaps.Load(uint32(id))
m := r.(tagMap)
k := strings.ToLower(key)
existing, ok := m[k]
if ok {
existing[0] += formattedLine
} else {
m[k] = []string{formattedLine}
}
}
+24
View File
@@ -0,0 +1,24 @@
#define TAGLIB_ERR_PARSE -1
#define TAGLIB_ERR_AUDIO_PROPS -2
#ifdef __cplusplus
extern "C" {
#endif
#ifdef WIN32
#define FILENAME_CHAR_T wchar_t
#else
#define FILENAME_CHAR_T char
#endif
extern void goPutM4AStr(unsigned long id, char *key, char *val);
extern void goPutStr(unsigned long id, char *key, char *val);
extern void goPutInt(unsigned long id, char *key, int val);
extern void goPutLyrics(unsigned long id, char *lang, char *val);
extern void goPutLyricLine(unsigned long id, char *lang, char *text, int time);
int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id);
char* taglib_version();
#ifdef __cplusplus
}
#endif
+185 -184
View File
@@ -1,186 +1,187 @@
package cmd
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/spf13/cobra"
)
var (
backupCount int
backupDir string
force bool
restorePath string
)
func init() {
rootCmd.AddCommand(backupRoot)
backupCmd.Flags().StringVarP(&backupDir, "backup-dir", "d", "", "directory to manually make backup")
backupRoot.AddCommand(backupCmd)
pruneCmd.Flags().StringVarP(&backupDir, "backup-dir", "d", "", "directory holding Navidrome backups")
pruneCmd.Flags().IntVarP(&backupCount, "keep-count", "k", -1, "specify the number of backups to keep. 0 remove ALL backups, and negative values mean to use the default from configuration")
pruneCmd.Flags().BoolVarP(&force, "force", "f", false, "bypass warning when backup count is zero")
backupRoot.AddCommand(pruneCmd)
restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "path of backup database to restore")
restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning")
_ = restoreCommand.MarkFlagRequired("backup-file")
backupRoot.AddCommand(restoreCommand)
}
var (
backupRoot = &cobra.Command{
Use: "backup",
Aliases: []string{"bkp"},
Short: "Create, restore and prune database backups",
Long: "Create, restore and prune database backups",
}
backupCmd = &cobra.Command{
Use: "create",
Short: "Create a backup database",
Long: "Manually backup Navidrome database. This will ignore BackupCount",
Run: func(cmd *cobra.Command, _ []string) {
runBackup(cmd.Context())
},
}
pruneCmd = &cobra.Command{
Use: "prune",
Short: "Prune database backups",
Long: "Manually prune database backups according to backup rules",
Run: func(cmd *cobra.Command, _ []string) {
runPrune(cmd.Context())
},
}
restoreCommand = &cobra.Command{
Use: "restore",
Short: "Restore Navidrome database",
Long: "Restore Navidrome database from a backup. This must be done offline",
Run: func(cmd *cobra.Command, _ []string) {
runRestore(cmd.Context())
},
}
)
func runBackup(ctx context.Context) {
if backupDir != "" {
conf.Server.Backup.Path = backupDir
}
idx := strings.LastIndex(conf.Server.DbPath, "?")
var path string
if idx == -1 {
path = conf.Server.DbPath
} else {
path = conf.Server.DbPath[:idx]
}
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
return
}
start := time.Now()
path, err := db.Backup(ctx)
if err != nil {
log.Fatal("Error backing up database", "backup path", conf.Server.BasePath, err)
}
elapsed := time.Since(start)
log.Info("Backup complete", "elapsed", elapsed, "path", path)
}
func runPrune(ctx context.Context) {
if backupDir != "" {
conf.Server.Backup.Path = backupDir
}
if backupCount != -1 {
conf.Server.Backup.Count = backupCount
}
if conf.Server.Backup.Count == 0 && !force {
fmt.Println("Warning: pruning ALL backups")
fmt.Printf("Please enter YES (all caps) to continue: ")
var input string
_, err := fmt.Scanln(&input)
if input != "YES" || err != nil {
log.Warn("Prune cancelled")
return
}
}
idx := strings.LastIndex(conf.Server.DbPath, "?")
var path string
if idx == -1 {
path = conf.Server.DbPath
} else {
path = conf.Server.DbPath[:idx]
}
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
return
}
start := time.Now()
count, err := db.Prune(ctx)
if err != nil {
log.Fatal("Error pruning up database", "backup path", conf.Server.BasePath, err)
}
elapsed := time.Since(start)
log.Info("Prune complete", "elapsed", elapsed, "successfully pruned", count)
}
func runRestore(ctx context.Context) {
idx := strings.LastIndex(conf.Server.DbPath, "?")
var path string
if idx == -1 {
path = conf.Server.DbPath
} else {
path = conf.Server.DbPath[:idx]
}
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
return
}
if !force {
fmt.Println("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.")
fmt.Printf("Please enter YES (all caps) to continue: ")
var input string
_, err := fmt.Scanln(&input)
if input != "YES" || err != nil {
log.Warn("Restore cancelled")
return
}
}
start := time.Now()
err := db.Restore(ctx, restorePath)
if err != nil {
log.Fatal("Error restoring database", "backup path", conf.Server.BasePath, err)
}
elapsed := time.Since(start)
log.Info("Restore complete", "elapsed", elapsed)
}
//
//import (
// "context"
// "fmt"
// "os"
// "strings"
// "time"
//
// "github.com/navidrome/navidrome/conf"
// "github.com/navidrome/navidrome/db"
// "github.com/navidrome/navidrome/log"
// "github.com/spf13/cobra"
//)
//
//var (
// backupCount int
// backupDir string
// force bool
// restorePath string
//)
//
//func init() {
// rootCmd.AddCommand(backupRoot)
//
// backupCmd.Flags().StringVarP(&backupDir, "backup-dir", "d", "", "directory to manually make backup")
// backupRoot.AddCommand(backupCmd)
//
// pruneCmd.Flags().StringVarP(&backupDir, "backup-dir", "d", "", "directory holding Navidrome backups")
// pruneCmd.Flags().IntVarP(&backupCount, "keep-count", "k", -1, "specify the number of backups to keep. 0 remove ALL backups, and negative values mean to use the default from configuration")
// pruneCmd.Flags().BoolVarP(&force, "force", "f", false, "bypass warning when backup count is zero")
// backupRoot.AddCommand(pruneCmd)
//
// restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "path of backup database to restore")
// restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning")
// _ = restoreCommand.MarkFlagRequired("backup-file")
// backupRoot.AddCommand(restoreCommand)
//}
//
//var (
// backupRoot = &cobra.Command{
// Use: "backup",
// Aliases: []string{"bkp"},
// Short: "Create, restore and prune database backups",
// Long: "Create, restore and prune database backups",
// }
//
// backupCmd = &cobra.Command{
// Use: "create",
// Short: "Create a backup database",
// Long: "Manually backup Navidrome database. This will ignore BackupCount",
// Run: func(cmd *cobra.Command, _ []string) {
// runBackup(cmd.Context())
// },
// }
//
// pruneCmd = &cobra.Command{
// Use: "prune",
// Short: "Prune database backups",
// Long: "Manually prune database backups according to backup rules",
// Run: func(cmd *cobra.Command, _ []string) {
// runPrune(cmd.Context())
// },
// }
//
// restoreCommand = &cobra.Command{
// Use: "restore",
// Short: "Restore Navidrome database",
// Long: "Restore Navidrome database from a backup. This must be done offline",
// Run: func(cmd *cobra.Command, _ []string) {
// runRestore(cmd.Context())
// },
// }
//)
//
//func runBackup(ctx context.Context) {
// if backupDir != "" {
// conf.Server.Backup.Path = backupDir
// }
//
// idx := strings.LastIndex(conf.Server.DbPath, "?")
// var path string
//
// if idx == -1 {
// path = conf.Server.DbPath
// } else {
// path = conf.Server.DbPath[:idx]
// }
//
// if _, err := os.Stat(path); os.IsNotExist(err) {
// log.Fatal("No existing database", "path", path)
// return
// }
//
// start := time.Now()
// path, err := db.Backup(ctx)
// if err != nil {
// log.Fatal("Error backing up database", "backup path", conf.Server.BasePath, err)
// }
//
// elapsed := time.Since(start)
// log.Info("Backup complete", "elapsed", elapsed, "path", path)
//}
//
//func runPrune(ctx context.Context) {
// if backupDir != "" {
// conf.Server.Backup.Path = backupDir
// }
//
// if backupCount != -1 {
// conf.Server.Backup.Count = backupCount
// }
//
// if conf.Server.Backup.Count == 0 && !force {
// fmt.Println("Warning: pruning ALL backups")
// fmt.Printf("Please enter YES (all caps) to continue: ")
// var input string
// _, err := fmt.Scanln(&input)
//
// if input != "YES" || err != nil {
// log.Warn("Prune cancelled")
// return
// }
// }
//
// idx := strings.LastIndex(conf.Server.DbPath, "?")
// var path string
//
// if idx == -1 {
// path = conf.Server.DbPath
// } else {
// path = conf.Server.DbPath[:idx]
// }
//
// if _, err := os.Stat(path); os.IsNotExist(err) {
// log.Fatal("No existing database", "path", path)
// return
// }
//
// start := time.Now()
// count, err := db.Prune(ctx)
// if err != nil {
// log.Fatal("Error pruning up database", "backup path", conf.Server.BasePath, err)
// }
//
// elapsed := time.Since(start)
//
// log.Info("Prune complete", "elapsed", elapsed, "successfully pruned", count)
//}
//
//func runRestore(ctx context.Context) {
// idx := strings.LastIndex(conf.Server.DbPath, "?")
// var path string
//
// if idx == -1 {
// path = conf.Server.DbPath
// } else {
// path = conf.Server.DbPath[:idx]
// }
//
// if _, err := os.Stat(path); os.IsNotExist(err) {
// log.Fatal("No existing database", "path", path)
// return
// }
//
// if !force {
// fmt.Println("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.")
// fmt.Printf("Please enter YES (all caps) to continue: ")
// var input string
// _, err := fmt.Scanln(&input)
//
// if input != "YES" || err != nil {
// log.Warn("Restore cancelled")
// return
// }
// }
//
// start := time.Now()
// err := db.Restore(ctx, restorePath)
// if err != nil {
// log.Fatal("Error restoring database", "backup path", conf.Server.BasePath, err)
// }
//
// elapsed := time.Since(start)
// log.Info("Restore complete", "elapsed", elapsed)
//}
+48 -201
View File
@@ -7,19 +7,14 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/ioutils"
"github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/utils/str"
"github.com/navidrome/navidrome/persistence"
"github.com/spf13/cobra"
)
@@ -28,7 +23,6 @@ var (
outputFile string
userID string
outputFormat string
syncFlag bool
)
type displayPlaylist struct {
@@ -50,15 +44,6 @@ func init() {
listCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID")
listCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]")
plsCmd.AddCommand(listCommand)
exportCommand.Flags().StringVarP(&playlistID, "playlist", "p", "", "playlist name or ID")
exportCommand.Flags().StringVarP(&outputFile, "output", "o", "", "output directory")
exportCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID")
plsCmd.AddCommand(exportCommand)
importCommand.Flags().StringVarP(&userID, "user", "u", "", "owner username or ID (default: first admin)")
importCommand.Flags().BoolVar(&syncFlag, "sync", false, "mark imported playlists as synced")
plsCmd.AddCommand(importCommand)
}
var (
@@ -67,7 +52,7 @@ var (
Short: "Export playlists",
Long: "Export Navidrome playlists to M3U files",
Run: func(cmd *cobra.Command, args []string) {
runExporter(cmd.Context())
runExporter()
},
}
@@ -75,168 +60,89 @@ var (
Use: "list",
Short: "List playlists",
Run: func(cmd *cobra.Command, args []string) {
runList(cmd.Context())
},
}
exportCommand = &cobra.Command{
Use: "export",
Short: "Export playlists to M3U files",
Long: "Export one or more Navidrome playlists to M3U files",
Run: func(cmd *cobra.Command, args []string) {
runExport(cmd.Context())
},
}
importCommand = &cobra.Command{
Use: "import [files...]",
Short: "Import M3U playlists",
Long: "Import one or more M3U files as Navidrome playlists",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
runImport(cmd.Context(), args)
runList()
},
}
)
func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.Playlists {
options := model.QueryOptions{Sort: sort}
if userID != "" {
user, err := getUser(ctx, userID, ds)
if err != nil {
log.Fatal(ctx, "Error retrieving user", "username or id", userID)
}
options.Filters = squirrel.Eq{"owner_id": user.ID}
}
pls, err := ds.Playlist(ctx).GetAll(options)
if err != nil {
log.Fatal(ctx, "Failed to retrieve playlists", err)
}
return pls
}
func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist {
playlist, err := ds.Playlist(ctx).GetWithTracks(nameOrID, true, false)
func runExporter() {
sqlDB := db.Db()
ds := persistence.New(sqlDB)
ctx := auth.WithAdminUser(context.Background(), ds)
playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true, false)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
log.Fatal("Error retrieving playlist", "name", playlistID, err)
}
if errors.Is(err, model.ErrNotFound) {
playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": playlistID}})
if err != nil {
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
log.Fatal("Error retrieving playlist", "name", playlistID, err)
}
if len(playlists) > 0 {
playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true, false)
if err != nil {
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
log.Fatal("Error retrieving playlist", "name", playlistID, err)
}
}
}
if playlist == nil {
log.Fatal("Playlist not found", "name", nameOrID)
log.Fatal("Playlist not found", "name", playlistID)
}
return playlist
}
func runExporter(ctx context.Context) {
ds, ctx := getAdminContext(ctx)
playlist := findPlaylist(ctx, ds, playlistID)
pls := playlist.ToM3U8()
if outputFile == "-" || outputFile == "" {
println(pls)
return
}
err := os.WriteFile(outputFile, []byte(pls), 0600)
err = os.WriteFile(outputFile, []byte(pls), 0600)
if err != nil {
log.Fatal("Error writing to the output file", "file", outputFile, err)
}
}
func runExport(ctx context.Context) {
ds, ctx := getAdminContext(ctx)
if playlistID != "" && outputFile == "" {
playlist := findPlaylist(ctx, ds, playlistID)
println(playlist.ToM3U8())
return
}
if outputFile == "" {
log.Fatal("Output directory (-o) is required for bulk export or when filtering by user")
}
info, err := os.Stat(outputFile)
if err != nil || !info.IsDir() {
log.Fatal("Output path must be an existing directory", "path", outputFile)
}
if playlistID != "" {
pls := findPlaylist(ctx, ds, playlistID)
filename := str.SanitizeFilename(pls.Name) + ".m3u"
path := filepath.Join(outputFile, filename)
err := os.WriteFile(path, []byte(pls.ToM3U8()), 0600)
if err != nil {
log.Fatal("Error writing playlist", "file", path, err)
}
fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path)
return
}
allPls := fetchPlaylists(ctx, ds, "name")
nameCounts := make(map[string]int)
for _, pls := range allPls {
nameCounts[str.SanitizeFilename(pls.Name)]++
}
exported := 0
for _, pls := range allPls {
plsWithTracks, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false)
if err != nil {
log.Error("Error loading playlist tracks", "playlist", pls.Name, err)
continue
}
sanitized := str.SanitizeFilename(pls.Name)
filename := sanitized + ".m3u"
if nameCounts[sanitized] > 1 {
shortID := pls.ID
if len(shortID) > 6 {
shortID = shortID[:6]
}
filename = sanitized + "_" + shortID + ".m3u"
}
path := filepath.Join(outputFile, filename)
err = os.WriteFile(path, []byte(plsWithTracks.ToM3U8()), 0600)
if err != nil {
log.Error("Error writing playlist", "file", path, err)
continue
}
fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path)
exported++
}
fmt.Printf("\nExported %d playlists to %s\n", exported, outputFile)
}
func runList(ctx context.Context) {
func runList() {
if outputFormat != "csv" && outputFormat != "json" {
log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat)
}
ds, ctx := getAdminContext(ctx)
allPls := fetchPlaylists(ctx, ds, "owner_name")
sqlDB := db.Db()
ds := persistence.New(sqlDB)
ctx := auth.WithAdminUser(context.Background(), ds)
options := model.QueryOptions{Sort: "owner_name"}
if userID != "" {
user, err := ds.User(ctx).FindByUsername(userID)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Fatal("Error retrieving user by name", "name", userID, err)
}
if errors.Is(err, model.ErrNotFound) {
user, err = ds.User(ctx).Get(userID)
if err != nil {
log.Fatal("Error retrieving user by id", "id", userID, err)
}
}
options.Filters = squirrel.Eq{"owner_id": user.ID}
}
playlists, err := ds.Playlist(ctx).GetAll(options)
if err != nil {
log.Fatal(ctx, "Failed to retrieve playlists", err)
}
if outputFormat == "csv" {
w := csv.NewWriter(os.Stdout)
_ = w.Write([]string{"playlist id", "playlist name", "owner id", "owner name", "public"})
for _, playlist := range allPls {
for _, playlist := range playlists {
_ = w.Write([]string{playlist.ID, playlist.Name, playlist.OwnerID, playlist.OwnerName, strconv.FormatBool(playlist.Public)})
}
w.Flush()
} else {
display := make(displayPlaylists, len(allPls))
for idx, playlist := range allPls {
display := make(displayPlaylists, len(playlists))
for idx, playlist := range playlists {
display[idx].Id = playlist.ID
display[idx].Name = playlist.Name
display[idx].OwnerId = playlist.OwnerID
@@ -248,62 +154,3 @@ func runList(ctx context.Context) {
fmt.Printf("%s\n", j)
}
}
func runImport(ctx context.Context, files []string) {
ds, ctx := getAdminContext(ctx)
if userID != "" {
user, err := getUser(ctx, userID, ds)
if err != nil {
log.Fatal(ctx, "Error retrieving user", "username or id", userID)
}
ctx = request.WithUser(ctx, *user)
}
pls := playlists.NewPlaylists(ds, core.NewImageUploadService())
for _, file := range files {
absPath, err := filepath.Abs(file)
if err != nil {
log.Error("Error resolving path", "file", file, err)
fmt.Fprintf(os.Stderr, "Error: could not resolve path %s: %v\n", file, err)
continue
}
totalLines := countM3UTrackLines(absPath)
imported, err := pls.ImportFile(ctx, absPath, syncFlag)
if err != nil {
log.Error("Error importing playlist", "file", absPath, err)
fmt.Fprintf(os.Stderr, "Error importing %s: %v\n", file, err)
continue
}
matched := len(imported.Tracks)
if totalLines > 0 {
notFound := totalLines - matched
fmt.Printf("Imported \"%s\" — %d/%d tracks matched (%d not found)\n", imported.Name, matched, totalLines, notFound)
} else {
fmt.Printf("Imported \"%s\" — %d tracks\n", imported.Name, matched)
}
}
}
func countM3UTrackLines(path string) int {
file, err := os.Open(path)
if err != nil {
return 0
}
defer file.Close()
count := 0
reader := ioutils.UTF8Reader(file)
for line := range slice.LinesFrom(reader) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
count++
}
return count
}
+716
View File
@@ -0,0 +1,716 @@
package cmd
import (
"cmp"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"text/tabwriter"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/plugins"
"github.com/navidrome/navidrome/plugins/schema"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/slice"
"github.com/spf13/cobra"
)
const (
pluginPackageExtension = ".ndp"
pluginDirPermissions = 0700
pluginFilePermissions = 0600
)
func init() {
pluginCmd := &cobra.Command{
Use: "plugin",
Short: "Manage Navidrome plugins",
Long: "Commands for managing Navidrome plugins",
}
listCmd := &cobra.Command{
Use: "list",
Short: "List installed plugins",
Long: "List all installed plugins with their metadata",
Run: pluginList,
}
infoCmd := &cobra.Command{
Use: "info [pluginPackage|pluginName]",
Short: "Show details of a plugin",
Long: "Show detailed information about a plugin package (.ndp file) or an installed plugin",
Args: cobra.ExactArgs(1),
Run: pluginInfo,
}
installCmd := &cobra.Command{
Use: "install [pluginPackage]",
Short: "Install a plugin from a .ndp file",
Long: "Install a Navidrome Plugin Package (.ndp) file",
Args: cobra.ExactArgs(1),
Run: pluginInstall,
}
removeCmd := &cobra.Command{
Use: "remove [pluginName]",
Short: "Remove an installed plugin",
Long: "Remove a plugin by name",
Args: cobra.ExactArgs(1),
Run: pluginRemove,
}
updateCmd := &cobra.Command{
Use: "update [pluginPackage]",
Short: "Update an existing plugin",
Long: "Update an installed plugin with a new version from a .ndp file",
Args: cobra.ExactArgs(1),
Run: pluginUpdate,
}
refreshCmd := &cobra.Command{
Use: "refresh [pluginName]",
Short: "Reload a plugin without restarting Navidrome",
Long: "Reload and recompile a plugin without needing to restart Navidrome",
Args: cobra.ExactArgs(1),
Run: pluginRefresh,
}
devCmd := &cobra.Command{
Use: "dev [folder_path]",
Short: "Create symlink to development folder",
Long: "Create a symlink from a plugin development folder to the plugins directory for easier development",
Args: cobra.ExactArgs(1),
Run: pluginDev,
}
pluginCmd.AddCommand(listCmd, infoCmd, installCmd, removeCmd, updateCmd, refreshCmd, devCmd)
rootCmd.AddCommand(pluginCmd)
}
// Validation helpers
func validatePluginPackageFile(path string) error {
if !utils.FileExists(path) {
return fmt.Errorf("plugin package not found: %s", path)
}
if filepath.Ext(path) != pluginPackageExtension {
return fmt.Errorf("not a valid plugin package: %s (expected %s extension)", path, pluginPackageExtension)
}
return nil
}
func validatePluginDirectory(pluginsDir, pluginName string) (string, error) {
pluginDir := filepath.Join(pluginsDir, pluginName)
if !utils.FileExists(pluginDir) {
return "", fmt.Errorf("plugin not found: %s (path: %s)", pluginName, pluginDir)
}
return pluginDir, nil
}
func resolvePluginPath(pluginDir string) (resolvedPath string, isSymlink bool, err error) {
// Check if it's a directory or a symlink
lstat, err := os.Lstat(pluginDir)
if err != nil {
return "", false, fmt.Errorf("failed to stat plugin: %w", err)
}
isSymlink = lstat.Mode()&os.ModeSymlink != 0
if isSymlink {
// Resolve the symlink target
targetDir, err := os.Readlink(pluginDir)
if err != nil {
return "", true, fmt.Errorf("failed to resolve symlink: %w", err)
}
// If target is a relative path, make it absolute
if !filepath.IsAbs(targetDir) {
targetDir = filepath.Join(filepath.Dir(pluginDir), targetDir)
}
// Verify the target exists and is a directory
targetInfo, err := os.Stat(targetDir)
if err != nil {
return "", true, fmt.Errorf("failed to access symlink target %s: %w", targetDir, err)
}
if !targetInfo.IsDir() {
return "", true, fmt.Errorf("symlink target is not a directory: %s", targetDir)
}
return targetDir, true, nil
} else if !lstat.IsDir() {
return "", false, fmt.Errorf("not a valid plugin directory: %s", pluginDir)
}
return pluginDir, false, nil
}
// Package handling helpers
func loadAndValidatePackage(ndpPath string) (*plugins.PluginPackage, error) {
if err := validatePluginPackageFile(ndpPath); err != nil {
return nil, err
}
pkg, err := plugins.LoadPackage(ndpPath)
if err != nil {
return nil, fmt.Errorf("failed to load plugin package: %w", err)
}
return pkg, nil
}
func extractAndSetupPlugin(ndpPath, targetDir string) error {
if err := plugins.ExtractPackage(ndpPath, targetDir); err != nil {
return fmt.Errorf("failed to extract plugin package: %w", err)
}
ensurePluginDirPermissions(targetDir)
return nil
}
// Display helpers
func displayPluginTableRow(w *tabwriter.Writer, discovery plugins.PluginDiscoveryEntry) {
if discovery.Error != nil {
// Handle global errors (like directory read failure)
if discovery.ID == "" {
log.Error("Failed to read plugins directory", "folder", conf.Server.Plugins.Folder, discovery.Error)
return
}
// Handle individual plugin errors - show them in the table
fmt.Fprintf(w, "%s\tERROR\tERROR\tERROR\tERROR\t%v\n", discovery.ID, discovery.Error)
return
}
// Mark symlinks with an indicator
nameDisplay := discovery.Manifest.Name
if discovery.IsSymlink {
nameDisplay = nameDisplay + " (dev)"
}
// Convert capabilities to strings
capabilities := slice.Map(discovery.Manifest.Capabilities, func(cap schema.PluginManifestCapabilitiesElem) string {
return string(cap)
})
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
discovery.ID,
nameDisplay,
cmp.Or(discovery.Manifest.Author, "-"),
cmp.Or(discovery.Manifest.Version, "-"),
strings.Join(capabilities, ", "),
cmp.Or(discovery.Manifest.Description, "-"))
}
func displayTypedPermissions(permissions schema.PluginManifestPermissions, indent string) {
if permissions.Http != nil {
fmt.Printf("%shttp:\n", indent)
fmt.Printf("%s Reason: %s\n", indent, permissions.Http.Reason)
fmt.Printf("%s Allow Local Network: %t\n", indent, permissions.Http.AllowLocalNetwork)
fmt.Printf("%s Allowed URLs:\n", indent)
for urlPattern, methodEnums := range permissions.Http.AllowedUrls {
methods := make([]string, len(methodEnums))
for i, methodEnum := range methodEnums {
methods[i] = string(methodEnum)
}
fmt.Printf("%s %s: [%s]\n", indent, urlPattern, strings.Join(methods, ", "))
}
fmt.Println()
}
if permissions.Config != nil {
fmt.Printf("%sconfig:\n", indent)
fmt.Printf("%s Reason: %s\n", indent, permissions.Config.Reason)
fmt.Println()
}
if permissions.Scheduler != nil {
fmt.Printf("%sscheduler:\n", indent)
fmt.Printf("%s Reason: %s\n", indent, permissions.Scheduler.Reason)
fmt.Println()
}
if permissions.Websocket != nil {
fmt.Printf("%swebsocket:\n", indent)
fmt.Printf("%s Reason: %s\n", indent, permissions.Websocket.Reason)
fmt.Printf("%s Allow Local Network: %t\n", indent, permissions.Websocket.AllowLocalNetwork)
fmt.Printf("%s Allowed URLs: [%s]\n", indent, strings.Join(permissions.Websocket.AllowedUrls, ", "))
fmt.Println()
}
if permissions.Cache != nil {
fmt.Printf("%scache:\n", indent)
fmt.Printf("%s Reason: %s\n", indent, permissions.Cache.Reason)
fmt.Println()
}
if permissions.Artwork != nil {
fmt.Printf("%sartwork:\n", indent)
fmt.Printf("%s Reason: %s\n", indent, permissions.Artwork.Reason)
fmt.Println()
}
if permissions.Subsonicapi != nil {
allowedUsers := "All Users"
if len(permissions.Subsonicapi.AllowedUsernames) > 0 {
allowedUsers = strings.Join(permissions.Subsonicapi.AllowedUsernames, ", ")
}
fmt.Printf("%ssubsonicapi:\n", indent)
fmt.Printf("%s Reason: %s\n", indent, permissions.Subsonicapi.Reason)
fmt.Printf("%s Allow Admins: %t\n", indent, permissions.Subsonicapi.AllowAdmins)
fmt.Printf("%s Allowed Usernames: [%s]\n", indent, allowedUsers)
fmt.Println()
}
}
func displayPluginDetails(manifest *schema.PluginManifest, fileInfo *pluginFileInfo, permInfo *pluginPermissionInfo) {
fmt.Println("\nPlugin Information:")
fmt.Printf(" Name: %s\n", manifest.Name)
fmt.Printf(" Author: %s\n", manifest.Author)
fmt.Printf(" Version: %s\n", manifest.Version)
fmt.Printf(" Description: %s\n", manifest.Description)
fmt.Print(" Capabilities: ")
capabilities := make([]string, len(manifest.Capabilities))
for i, cap := range manifest.Capabilities {
capabilities[i] = string(cap)
}
fmt.Print(strings.Join(capabilities, ", "))
fmt.Println()
// Display manifest permissions using the typed permissions
fmt.Println(" Required Permissions:")
displayTypedPermissions(manifest.Permissions, " ")
// Print file information if available
if fileInfo != nil {
fmt.Println("Package Information:")
fmt.Printf(" File: %s\n", fileInfo.path)
fmt.Printf(" Size: %d bytes (%.2f KB)\n", fileInfo.size, float64(fileInfo.size)/1024)
fmt.Printf(" SHA-256: %s\n", fileInfo.hash)
fmt.Printf(" Modified: %s\n", fileInfo.modTime.Format(time.RFC3339))
}
// Print file permissions information if available
if permInfo != nil {
fmt.Println("File Permissions:")
fmt.Printf(" Plugin Directory: %s (%s)\n", permInfo.dirPath, permInfo.dirMode)
if permInfo.isSymlink {
fmt.Printf(" Symlink Target: %s (%s)\n", permInfo.targetPath, permInfo.targetMode)
}
fmt.Printf(" Manifest File: %s\n", permInfo.manifestMode)
if permInfo.wasmMode != "" {
fmt.Printf(" WASM File: %s\n", permInfo.wasmMode)
}
}
}
type pluginFileInfo struct {
path string
size int64
hash string
modTime time.Time
}
type pluginPermissionInfo struct {
dirPath string
dirMode string
isSymlink bool
targetPath string
targetMode string
manifestMode string
wasmMode string
}
func getFileInfo(path string) *pluginFileInfo {
fileInfo, err := os.Stat(path)
if err != nil {
log.Error("Failed to get file information", err)
return nil
}
return &pluginFileInfo{
path: path,
size: fileInfo.Size(),
hash: calculateSHA256(path),
modTime: fileInfo.ModTime(),
}
}
func getPermissionInfo(pluginDir string) *pluginPermissionInfo {
// Get plugin directory permissions
dirInfo, err := os.Lstat(pluginDir)
if err != nil {
log.Error("Failed to get plugin directory permissions", err)
return nil
}
permInfo := &pluginPermissionInfo{
dirPath: pluginDir,
dirMode: dirInfo.Mode().String(),
}
// Check if it's a symlink
if dirInfo.Mode()&os.ModeSymlink != 0 {
permInfo.isSymlink = true
// Get target path and permissions
targetPath, err := os.Readlink(pluginDir)
if err == nil {
if !filepath.IsAbs(targetPath) {
targetPath = filepath.Join(filepath.Dir(pluginDir), targetPath)
}
permInfo.targetPath = targetPath
if targetInfo, err := os.Stat(targetPath); err == nil {
permInfo.targetMode = targetInfo.Mode().String()
}
}
}
// Get manifest file permissions
manifestPath := filepath.Join(pluginDir, "manifest.json")
if manifestInfo, err := os.Stat(manifestPath); err == nil {
permInfo.manifestMode = manifestInfo.Mode().String()
}
// Get WASM file permissions (look for .wasm files)
entries, err := os.ReadDir(pluginDir)
if err == nil {
for _, entry := range entries {
if filepath.Ext(entry.Name()) == ".wasm" {
wasmPath := filepath.Join(pluginDir, entry.Name())
if wasmInfo, err := os.Stat(wasmPath); err == nil {
permInfo.wasmMode = wasmInfo.Mode().String()
break // Just show the first WASM file found
}
}
}
}
return permInfo
}
// Command implementations
func pluginList(cmd *cobra.Command, args []string) {
discoveries := plugins.DiscoverPlugins(conf.Server.Plugins.Folder)
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tNAME\tAUTHOR\tVERSION\tCAPABILITIES\tDESCRIPTION")
for _, discovery := range discoveries {
displayPluginTableRow(w, discovery)
}
w.Flush()
}
func pluginInfo(cmd *cobra.Command, args []string) {
path := args[0]
pluginsDir := conf.Server.Plugins.Folder
var manifest *schema.PluginManifest
var fileInfo *pluginFileInfo
var permInfo *pluginPermissionInfo
if filepath.Ext(path) == pluginPackageExtension {
// It's a package file
pkg, err := loadAndValidatePackage(path)
if err != nil {
log.Fatal("Failed to load plugin package", err)
}
manifest = pkg.Manifest
fileInfo = getFileInfo(path)
// No permission info for package files
} else {
// It's a plugin name
pluginDir, err := validatePluginDirectory(pluginsDir, path)
if err != nil {
log.Fatal("Plugin validation failed", err)
}
manifest, err = plugins.LoadManifest(pluginDir)
if err != nil {
log.Fatal("Failed to load plugin manifest", err)
}
// Get permission info for installed plugins
permInfo = getPermissionInfo(pluginDir)
}
displayPluginDetails(manifest, fileInfo, permInfo)
}
func pluginInstall(cmd *cobra.Command, args []string) {
ndpPath := args[0]
pluginsDir := conf.Server.Plugins.Folder
pkg, err := loadAndValidatePackage(ndpPath)
if err != nil {
log.Fatal("Package validation failed", err)
}
// Create target directory based on plugin name
targetDir := filepath.Join(pluginsDir, pkg.Manifest.Name)
// Check if plugin already exists
if utils.FileExists(targetDir) {
log.Fatal("Plugin already installed", "name", pkg.Manifest.Name, "path", targetDir,
"use", "navidrome plugin update")
}
if err := extractAndSetupPlugin(ndpPath, targetDir); err != nil {
log.Fatal("Plugin installation failed", err)
}
fmt.Printf("Plugin '%s' v%s installed successfully\n", pkg.Manifest.Name, pkg.Manifest.Version)
}
func pluginRemove(cmd *cobra.Command, args []string) {
pluginName := args[0]
pluginsDir := conf.Server.Plugins.Folder
pluginDir, err := validatePluginDirectory(pluginsDir, pluginName)
if err != nil {
log.Fatal("Plugin validation failed", err)
}
_, isSymlink, err := resolvePluginPath(pluginDir)
if err != nil {
log.Fatal("Failed to resolve plugin path", err)
}
if isSymlink {
// For symlinked plugins (dev mode), just remove the symlink
if err := os.Remove(pluginDir); err != nil {
log.Fatal("Failed to remove plugin symlink", "name", pluginName, err)
}
fmt.Printf("Development plugin symlink '%s' removed successfully (target directory preserved)\n", pluginName)
} else {
// For regular plugins, remove the entire directory
if err := os.RemoveAll(pluginDir); err != nil {
log.Fatal("Failed to remove plugin directory", "name", pluginName, err)
}
fmt.Printf("Plugin '%s' removed successfully\n", pluginName)
}
}
func pluginUpdate(cmd *cobra.Command, args []string) {
ndpPath := args[0]
pluginsDir := conf.Server.Plugins.Folder
pkg, err := loadAndValidatePackage(ndpPath)
if err != nil {
log.Fatal("Package validation failed", err)
}
// Check if plugin exists
targetDir := filepath.Join(pluginsDir, pkg.Manifest.Name)
if !utils.FileExists(targetDir) {
log.Fatal("Plugin not found", "name", pkg.Manifest.Name, "path", targetDir,
"use", "navidrome plugin install")
}
// Create a backup of the existing plugin
backupDir := targetDir + ".bak." + time.Now().Format("20060102150405")
if err := os.Rename(targetDir, backupDir); err != nil {
log.Fatal("Failed to backup existing plugin", err)
}
// Extract the new package
if err := extractAndSetupPlugin(ndpPath, targetDir); err != nil {
// Restore backup if extraction failed
os.RemoveAll(targetDir)
_ = os.Rename(backupDir, targetDir) // Ignore error as we're already in a fatal path
log.Fatal("Plugin update failed", err)
}
// Remove the backup
os.RemoveAll(backupDir)
fmt.Printf("Plugin '%s' updated to v%s successfully\n", pkg.Manifest.Name, pkg.Manifest.Version)
}
func pluginRefresh(cmd *cobra.Command, args []string) {
pluginName := args[0]
pluginsDir := conf.Server.Plugins.Folder
pluginDir, err := validatePluginDirectory(pluginsDir, pluginName)
if err != nil {
log.Fatal("Plugin validation failed", err)
}
resolvedPath, isSymlink, err := resolvePluginPath(pluginDir)
if err != nil {
log.Fatal("Failed to resolve plugin path", err)
}
if isSymlink {
log.Debug("Processing symlinked plugin", "name", pluginName, "link", pluginDir, "target", resolvedPath)
}
fmt.Printf("Refreshing plugin '%s'...\n", pluginName)
// Get the plugin manager and refresh
mgr := GetPluginManager(cmd.Context())
log.Debug("Scanning plugins directory", "path", pluginsDir)
mgr.ScanPlugins()
log.Info("Waiting for plugin compilation to complete", "name", pluginName)
// Wait for compilation to complete
if err := mgr.EnsureCompiled(pluginName); err != nil {
log.Fatal("Failed to compile refreshed plugin", "name", pluginName, err)
}
log.Info("Plugin compilation completed successfully", "name", pluginName)
fmt.Printf("Plugin '%s' refreshed successfully\n", pluginName)
}
func pluginDev(cmd *cobra.Command, args []string) {
sourcePath, err := filepath.Abs(args[0])
if err != nil {
log.Fatal("Invalid path", "path", args[0], err)
}
pluginsDir := conf.Server.Plugins.Folder
// Validate source directory and manifest
if err := validateDevSource(sourcePath); err != nil {
log.Fatal("Source validation failed", err)
}
// Load manifest to get plugin name
manifest, err := plugins.LoadManifest(sourcePath)
if err != nil {
log.Fatal("Failed to load plugin manifest", "path", filepath.Join(sourcePath, "manifest.json"), err)
}
pluginName := cmp.Or(manifest.Name, filepath.Base(sourcePath))
targetPath := filepath.Join(pluginsDir, pluginName)
// Handle existing target
if err := handleExistingTarget(targetPath, sourcePath); err != nil {
log.Fatal("Failed to handle existing target", err)
}
// Create target directory if needed
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
log.Fatal("Failed to create plugins directory", "path", filepath.Dir(targetPath), err)
}
// Create the symlink
if err := os.Symlink(sourcePath, targetPath); err != nil {
log.Fatal("Failed to create symlink", "source", sourcePath, "target", targetPath, err)
}
fmt.Printf("Development symlink created: '%s' -> '%s'\n", targetPath, sourcePath)
fmt.Println("Plugin can be refreshed with: navidrome plugin refresh", pluginName)
}
// Utility functions
func validateDevSource(sourcePath string) error {
sourceInfo, err := os.Stat(sourcePath)
if err != nil {
return fmt.Errorf("source folder not found: %s (%w)", sourcePath, err)
}
if !sourceInfo.IsDir() {
return fmt.Errorf("source path is not a directory: %s", sourcePath)
}
manifestPath := filepath.Join(sourcePath, "manifest.json")
if !utils.FileExists(manifestPath) {
return fmt.Errorf("source folder missing manifest.json: %s", sourcePath)
}
return nil
}
func handleExistingTarget(targetPath, sourcePath string) error {
if !utils.FileExists(targetPath) {
return nil // Nothing to handle
}
// Check if it's already a symlink to our source
existingLink, err := os.Readlink(targetPath)
if err == nil && existingLink == sourcePath {
fmt.Printf("Symlink already exists and points to the correct source\n")
return fmt.Errorf("symlink already exists") // This will cause early return in caller
}
// Handle case where target exists but is not a symlink to our source
fmt.Printf("Target path '%s' already exists.\n", targetPath)
fmt.Print("Do you want to replace it? (y/N): ")
var response string
_, err = fmt.Scanln(&response)
if err != nil || strings.ToLower(response) != "y" {
if err != nil {
log.Debug("Error reading input, assuming 'no'", err)
}
return fmt.Errorf("operation canceled")
}
// Remove existing target
if err := os.RemoveAll(targetPath); err != nil {
return fmt.Errorf("failed to remove existing target %s: %w", targetPath, err)
}
return nil
}
func ensurePluginDirPermissions(dir string) {
if err := os.Chmod(dir, pluginDirPermissions); err != nil {
log.Error("Failed to set plugin directory permissions", "dir", dir, err)
}
// Apply permissions to all files in the directory
entries, err := os.ReadDir(dir)
if err != nil {
log.Error("Failed to read plugin directory", "dir", dir, err)
return
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
info, err := os.Stat(path)
if err != nil {
log.Error("Failed to stat file", "path", path, err)
continue
}
mode := os.FileMode(pluginFilePermissions) // Files
if info.IsDir() {
mode = os.FileMode(pluginDirPermissions) // Directories
ensurePluginDirPermissions(path) // Recursive
}
if err := os.Chmod(path, mode); err != nil {
log.Error("Failed to set file permissions", "path", path, err)
}
}
}
func calculateSHA256(filePath string) string {
file, err := os.Open(filePath)
if err != nil {
log.Error("Failed to open file for hashing", err)
return "N/A"
}
defer file.Close()
hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil {
log.Error("Failed to calculate hash", err)
return "N/A"
}
return hex.EncodeToString(hasher.Sum(nil))
}
+193
View File
@@ -0,0 +1,193 @@
package cmd
import (
"io"
"os"
"path/filepath"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/spf13/cobra"
)
var _ = Describe("Plugin CLI Commands", func() {
var tempDir string
var cmd *cobra.Command
var stdOut *os.File
var origStdout *os.File
var outReader *os.File
// Helper to create a test plugin with the given name and details
createTestPlugin := func(name, author, version string, capabilities []string) string {
pluginDir := filepath.Join(tempDir, name)
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
// Create a properly formatted capabilities JSON array
capabilitiesJSON := `"` + strings.Join(capabilities, `", "`) + `"`
manifest := `{
"name": "` + name + `",
"author": "` + author + `",
"version": "` + version + `",
"description": "Plugin for testing",
"website": "https://test.navidrome.org/` + name + `",
"capabilities": [` + capabilitiesJSON + `],
"permissions": {}
}`
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
// Create a dummy WASM file
wasmContent := []byte("dummy wasm content for testing")
Expect(os.WriteFile(filepath.Join(pluginDir, "plugin.wasm"), wasmContent, 0600)).To(Succeed())
return pluginDir
}
// Helper to execute a command and return captured output
captureOutput := func(reader io.Reader) string {
stdOut.Close()
outputBytes, err := io.ReadAll(reader)
Expect(err).NotTo(HaveOccurred())
return string(outputBytes)
}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tempDir = GinkgoT().TempDir()
// Setup config
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = tempDir
// Create a command for testing
cmd = &cobra.Command{Use: "test"}
// Setup stdout capture
origStdout = os.Stdout
var err error
outReader, stdOut, err = os.Pipe()
Expect(err).NotTo(HaveOccurred())
os.Stdout = stdOut
DeferCleanup(func() {
os.Stdout = origStdout
})
})
AfterEach(func() {
os.Stdout = origStdout
if stdOut != nil {
stdOut.Close()
}
if outReader != nil {
outReader.Close()
}
})
Describe("Plugin list command", func() {
It("should list installed plugins", func() {
// Create test plugins
createTestPlugin("plugin1", "Test Author", "1.0.0", []string{"MetadataAgent"})
createTestPlugin("plugin2", "Another Author", "2.1.0", []string{"Scrobbler"})
// Execute command
pluginList(cmd, []string{})
// Verify output
output := captureOutput(outReader)
Expect(output).To(ContainSubstring("plugin1"))
Expect(output).To(ContainSubstring("Test Author"))
Expect(output).To(ContainSubstring("1.0.0"))
Expect(output).To(ContainSubstring("MetadataAgent"))
Expect(output).To(ContainSubstring("plugin2"))
Expect(output).To(ContainSubstring("Another Author"))
Expect(output).To(ContainSubstring("2.1.0"))
Expect(output).To(ContainSubstring("Scrobbler"))
})
})
Describe("Plugin info command", func() {
It("should display information about an installed plugin", func() {
// Create test plugin with multiple capabilities
createTestPlugin("test-plugin", "Test Author", "1.0.0",
[]string{"MetadataAgent", "Scrobbler"})
// Execute command
pluginInfo(cmd, []string{"test-plugin"})
// Verify output
output := captureOutput(outReader)
Expect(output).To(ContainSubstring("Name: test-plugin"))
Expect(output).To(ContainSubstring("Author: Test Author"))
Expect(output).To(ContainSubstring("Version: 1.0.0"))
Expect(output).To(ContainSubstring("Description: Plugin for testing"))
Expect(output).To(ContainSubstring("Capabilities: MetadataAgent, Scrobbler"))
})
})
Describe("Plugin remove command", func() {
It("should remove a regular plugin directory", func() {
// Create test plugin
pluginDir := createTestPlugin("regular-plugin", "Test Author", "1.0.0",
[]string{"MetadataAgent"})
// Execute command
pluginRemove(cmd, []string{"regular-plugin"})
// Verify output
output := captureOutput(outReader)
Expect(output).To(ContainSubstring("Plugin 'regular-plugin' removed successfully"))
// Verify directory is actually removed
_, err := os.Stat(pluginDir)
Expect(os.IsNotExist(err)).To(BeTrue())
})
It("should remove only the symlink for a development plugin", func() {
// Create a real source directory
sourceDir := filepath.Join(GinkgoT().TempDir(), "dev-plugin-source")
Expect(os.MkdirAll(sourceDir, 0755)).To(Succeed())
manifest := `{
"name": "dev-plugin",
"author": "Dev Author",
"version": "0.1.0",
"description": "Development plugin for testing",
"website": "https://test.navidrome.org/dev-plugin",
"capabilities": ["Scrobbler"],
"permissions": {}
}`
Expect(os.WriteFile(filepath.Join(sourceDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
// Create a dummy WASM file
wasmContent := []byte("dummy wasm content for testing")
Expect(os.WriteFile(filepath.Join(sourceDir, "plugin.wasm"), wasmContent, 0600)).To(Succeed())
// Create a symlink in the plugins directory
symlinkPath := filepath.Join(tempDir, "dev-plugin")
Expect(os.Symlink(sourceDir, symlinkPath)).To(Succeed())
// Execute command
pluginRemove(cmd, []string{"dev-plugin"})
// Verify output
output := captureOutput(outReader)
Expect(output).To(ContainSubstring("Development plugin symlink 'dev-plugin' removed successfully"))
Expect(output).To(ContainSubstring("target directory preserved"))
// Verify the symlink is removed but source directory exists
_, err := os.Lstat(symlinkPath)
Expect(os.IsNotExist(err)).To(BeTrue())
_, err = os.Stat(sourceDir)
Expect(err).NotTo(HaveOccurred())
})
})
})
+40 -61
View File
@@ -9,24 +9,18 @@ import (
"time"
"github.com/go-chi/chi/v5/middleware"
_ "github.com/navidrome/navidrome/adapters/taglib"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/scheduler"
"github.com/navidrome/navidrome/server/backgrounds"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"golang.org/x/sync/errgroup"
// Import adapters to register them
_ "github.com/navidrome/navidrome/adapters/deezer"
_ "github.com/navidrome/navidrome/adapters/gotaglib"
_ "github.com/navidrome/navidrome/adapters/lastfm"
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
)
var (
@@ -86,7 +80,6 @@ func runNavidrome(ctx context.Context) {
g.Go(startPlaybackServer(ctx))
g.Go(schedulePeriodicBackup(ctx))
g.Go(startInsightsCollector(ctx))
g.Go(scheduleDBOptimizer(ctx))
g.Go(startPluginManager(ctx))
g.Go(runInitialScan(ctx))
if conf.Server.Scanner.Enabled {
@@ -194,8 +187,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)
@@ -242,51 +234,37 @@ func startScanWatcher(ctx context.Context) func() error {
func schedulePeriodicBackup(ctx context.Context) func() error {
return func() error {
schedule := conf.Server.Backup.Schedule
if schedule == "" {
log.Info(ctx, "Periodic backup is DISABLED")
return nil
}
schedulerInstance := scheduler.GetInstance()
log.Info("Scheduling periodic backup", "schedule", schedule)
_, err := schedulerInstance.Add(schedule, func() {
start := time.Now()
path, err := db.Backup(ctx)
elapsed := time.Since(start)
if err != nil {
log.Error(ctx, "Error backing up database", "elapsed", elapsed, err)
return
}
log.Info(ctx, "Backup complete", "elapsed", elapsed, "path", path)
count, err := db.Prune(ctx)
if err != nil {
log.Error(ctx, "Error pruning database", "error", err)
} else if count > 0 {
log.Info(ctx, "Successfully pruned old files", "count", count)
} else {
log.Info(ctx, "No backups pruned")
}
})
return err
}
}
func scheduleDBOptimizer(ctx context.Context) func() error {
return func() error {
log.Info(ctx, "Scheduling DB optimizer", "schedule", consts.OptimizeDBSchedule)
schedulerInstance := scheduler.GetInstance()
_, err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() {
if scanner.IsScanning() {
log.Debug(ctx, "Skipping DB optimization because a scan is in progress")
return
}
db.Optimize(ctx)
})
return err
//schedule := conf.Server.Backup.Schedule
//if schedule == "" {
// log.Info(ctx, "Periodic backup is DISABLED")
// return nil
//}
//
//schedulerInstance := scheduler.GetInstance()
//
//log.Info("Scheduling periodic backup", "schedule", schedule)
//_, err := schedulerInstance.Add(schedule, func() {
// start := time.Now()
// path, err := db.Backup(ctx)
// elapsed := time.Since(start)
// if err != nil {
// log.Error(ctx, "Error backing up database", "elapsed", elapsed, err)
// return
// }
// log.Info(ctx, "Backup complete", "elapsed", elapsed, "path", path)
//
// count, err := db.Prune(ctx)
// if err != nil {
// log.Error(ctx, "Error pruning database", "error", err)
// } else if count > 0 {
// log.Info(ctx, "Successfully pruned old files", "count", count)
// } else {
// log.Info(ctx, "No backups pruned")
// }
//})
//
//return err
return nil
}
}
@@ -336,20 +314,23 @@ func startPlaybackServer(ctx context.Context) func() error {
// startPluginManager starts the plugin manager, if configured.
func startPluginManager(ctx context.Context) func() error {
return func() error {
manager := GetPluginManager(ctx)
if !conf.Server.Plugins.Enabled {
log.Debug("Plugin system is DISABLED")
log.Debug("Plugins are DISABLED")
return nil
}
log.Info(ctx, "Starting plugin manager")
return manager.Start(ctx)
// Get the manager instance and scan for plugins
manager := GetPluginManager(ctx)
manager.ScanPlugins()
return nil
}
}
// TODO: Implement some struct tags to map flags to viper
func init() {
cobra.OnInitialize(func() {
conf.InitConfig(cfgFile, true)
conf.InitConfig(cfgFile)
})
rootCmd.PersistentFlags().StringVarP(&cfgFile, "configfile", "c", "", `config file (default "./navidrome.toml")`)
@@ -377,7 +358,6 @@ func init() {
rootCmd.Flags().Duration("scaninterval", viper.GetDuration("scaninterval"), "how frequently to scan for changes in your music library")
rootCmd.Flags().String("uiloginbackgroundurl", viper.GetString("uiloginbackgroundurl"), "URL to a backaground image used in the Login page")
rootCmd.Flags().Bool("enabletranscodingconfig", viper.GetBool("enabletranscodingconfig"), "enables transcoding configuration in the UI")
rootCmd.Flags().Bool("enabletranscodingcancellation", viper.GetBool("enabletranscodingcancellation"), "enables transcoding context cancellation")
rootCmd.Flags().String("transcodingcachesize", viper.GetString("transcodingcachesize"), "size of transcoding cache")
rootCmd.Flags().String("imagecachesize", viper.GetString("imagecachesize"), "size of image (art work) cache. set to 0 to disable cache")
rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized")
@@ -401,7 +381,6 @@ func init() {
_ = viper.BindPFlag("prometheus.metricspath", rootCmd.Flags().Lookup("prometheus.metricspath"))
_ = viper.BindPFlag("enabletranscodingconfig", rootCmd.Flags().Lookup("enabletranscodingconfig"))
_ = viper.BindPFlag("enabletranscodingcancellation", rootCmd.Flags().Lookup("enabletranscodingcancellation"))
_ = viper.BindPFlag("transcodingcachesize", rootCmd.Flags().Lookup("transcodingcachesize"))
_ = viper.BindPFlag("imagecachesize", rootCmd.Flags().Lookup("imagecachesize"))
}
+4 -57
View File
@@ -1,18 +1,13 @@
package cmd
import (
"bufio"
"context"
"encoding/gob"
"fmt"
"os"
"strings"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/utils/pl"
@@ -22,15 +17,11 @@ import (
var (
fullScan bool
subprocess bool
targets []string
targetFile string
)
func init() {
scanCmd.Flags().BoolVarP(&fullScan, "full", "f", false, "check all subfolders, ignoring timestamps")
scanCmd.Flags().BoolVarP(&subprocess, "subprocess", "", false, "run as subprocess (internal use)")
scanCmd.Flags().StringArrayVarP(&targets, "target", "t", []string{}, "list of libraryID:folderPath pairs, can be repeated (e.g., \"-t 1:Music/Rock -t 1:Music/Jazz -t 2:Classical\")")
scanCmd.Flags().StringVar(&targetFile, "target-file", "", "path to file containing targets (one libraryID:folderPath per line)")
rootCmd.AddCommand(scanCmd)
}
@@ -72,30 +63,14 @@ func trackScanAsSubprocess(ctx context.Context, progress <-chan *scanner.Progres
}
func runScanner(ctx context.Context) {
defer db.Init(ctx)()
sqlDB := db.Db()
defer db.Db().Close()
ds := persistence.New(sqlDB)
pls := playlists.NewPlaylists(ds, core.NewImageUploadService())
pls := core.NewPlaylists(ds)
// Parse targets from command line or file
var scanTargets []model.ScanTarget
var err error
if targetFile != "" {
scanTargets, err = readTargetsFromFile(targetFile)
if err != nil {
log.Fatal(ctx, "Failed to read targets from file", err)
}
log.Info(ctx, "Scanning specific folders from file", "numTargets", len(scanTargets))
} else if len(targets) > 0 {
scanTargets, err = model.ParseTargets(targets)
if err != nil {
log.Fatal(ctx, "Failed to parse targets", err)
}
log.Info(ctx, "Scanning specific folders", "numTargets", len(scanTargets))
}
progress, err := scanner.CallScan(ctx, ds, pls, fullScan, scanTargets)
progress, err := scanner.CallScan(ctx, ds, pls, fullScan)
if err != nil {
log.Fatal(ctx, "Failed to scan", err)
}
@@ -107,31 +82,3 @@ func runScanner(ctx context.Context) {
trackScanInteractively(ctx, progress)
}
}
// readTargetsFromFile reads scan targets from a file, one per line.
// Each line should be in the format "libraryID:folderPath".
// Empty lines and lines starting with # are ignored.
func readTargetsFromFile(filePath string) ([]model.ScanTarget, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open target file: %w", err)
}
defer file.Close()
var targetStrings []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Skip empty lines and comments
if line == "" {
continue
}
targetStrings = append(targetStrings, line)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read target file: %w", err)
}
return model.ParseTargets(targetStrings)
}
-89
View File
@@ -1,89 +0,0 @@
package cmd
import (
"os"
"path/filepath"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("readTargetsFromFile", func() {
var tempDir string
BeforeEach(func() {
var err error
tempDir, err = os.MkdirTemp("", "navidrome-test-")
Expect(err).ToNot(HaveOccurred())
})
AfterEach(func() {
os.RemoveAll(tempDir)
})
It("reads valid targets from file", func() {
filePath := filepath.Join(tempDir, "targets.txt")
content := "1:Music/Rock\n2:Music/Jazz\n3:Classical\n"
err := os.WriteFile(filePath, []byte(content), 0600)
Expect(err).ToNot(HaveOccurred())
targets, err := readTargetsFromFile(filePath)
Expect(err).ToNot(HaveOccurred())
Expect(targets).To(HaveLen(3))
Expect(targets[0]).To(Equal(model.ScanTarget{LibraryID: 1, FolderPath: "Music/Rock"}))
Expect(targets[1]).To(Equal(model.ScanTarget{LibraryID: 2, FolderPath: "Music/Jazz"}))
Expect(targets[2]).To(Equal(model.ScanTarget{LibraryID: 3, FolderPath: "Classical"}))
})
It("skips empty lines", func() {
filePath := filepath.Join(tempDir, "targets.txt")
content := "1:Music/Rock\n\n2:Music/Jazz\n\n"
err := os.WriteFile(filePath, []byte(content), 0600)
Expect(err).ToNot(HaveOccurred())
targets, err := readTargetsFromFile(filePath)
Expect(err).ToNot(HaveOccurred())
Expect(targets).To(HaveLen(2))
})
It("trims whitespace", func() {
filePath := filepath.Join(tempDir, "targets.txt")
content := " 1:Music/Rock \n\t2:Music/Jazz\t\n"
err := os.WriteFile(filePath, []byte(content), 0600)
Expect(err).ToNot(HaveOccurred())
targets, err := readTargetsFromFile(filePath)
Expect(err).ToNot(HaveOccurred())
Expect(targets).To(HaveLen(2))
Expect(targets[0].FolderPath).To(Equal("Music/Rock"))
Expect(targets[1].FolderPath).To(Equal("Music/Jazz"))
})
It("returns error for non-existent file", func() {
_, err := readTargetsFromFile("/nonexistent/file.txt")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to open target file"))
})
It("returns error for invalid target format", func() {
filePath := filepath.Join(tempDir, "targets.txt")
content := "invalid-format\n"
err := os.WriteFile(filePath, []byte(content), 0600)
Expect(err).ToNot(HaveOccurred())
_, err = readTargetsFromFile(filePath)
Expect(err).To(HaveOccurred())
})
It("handles mixed valid and empty lines", func() {
filePath := filepath.Join(tempDir, "targets.txt")
content := "\n1:Music/Rock\n\n\n2:Music/Jazz\n\n"
err := os.WriteFile(filePath, []byte(content), 0600)
Expect(err).ToNot(HaveOccurred())
targets, err := readTargetsFromFile(filePath)
Expect(err).ToNot(HaveOccurred())
Expect(targets).To(HaveLen(2))
})
})
-1
View File
@@ -248,7 +248,6 @@ ExecStart={{.Path|cmdEscape}}{{range .Arguments}} {{.|cmd}}{{end}}
TimeoutStopSec=20
RestartSec=120
EnvironmentFile=-/etc/sysconfig/{{.Name}}
Environment="ND_SYSTEMD_PRIORITY_LOGGING=1"
DevicePolicy=closed
NoNewPrivileges=yes
-477
View File
@@ -1,477 +0,0 @@
package cmd
import (
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"strings"
"syscall"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/spf13/cobra"
"golang.org/x/term"
)
var (
email string
libraryIds []int
name string
removeEmail bool
removeName bool
setAdmin bool
setPassword bool
setRegularUser bool
)
func init() {
rootCmd.AddCommand(userRoot)
userCreateCommand.Flags().StringVarP(&userID, "username", "u", "", "username")
userCreateCommand.Flags().StringVarP(&email, "email", "e", "", "New user email")
userCreateCommand.Flags().IntSliceVarP(&libraryIds, "library-ids", "i", []int{}, "Comma-separated list of library IDs. Set the user's accessible libraries. If empty, the user can access all libraries. This is incompatible with admin, as admin can always access all libraries")
userCreateCommand.Flags().BoolVarP(&setAdmin, "admin", "a", false, "If set, make the user an admin. This user will have access to every library")
userCreateCommand.Flags().StringVar(&name, "name", "", "New user's name (this is separate from username used to log in)")
_ = userCreateCommand.MarkFlagRequired("username")
userRoot.AddCommand(userCreateCommand)
userDeleteCommand.Flags().StringVarP(&userID, "user", "u", "", "username or id")
_ = userDeleteCommand.MarkFlagRequired("user")
userRoot.AddCommand(userDeleteCommand)
userEditCommand.Flags().StringVarP(&userID, "user", "u", "", "username or id")
userEditCommand.Flags().BoolVar(&setAdmin, "set-admin", false, "If set, make the user an admin")
userEditCommand.Flags().BoolVar(&setRegularUser, "set-regular", false, "If set, make the user a non-admin")
userEditCommand.MarkFlagsMutuallyExclusive("set-admin", "set-regular")
userEditCommand.Flags().BoolVar(&removeEmail, "remove-email", false, "If set, clear the user's email")
userEditCommand.Flags().StringVarP(&email, "email", "e", "", "New user email")
userEditCommand.MarkFlagsMutuallyExclusive("email", "remove-email")
userEditCommand.Flags().BoolVar(&removeName, "remove-name", false, "If set, clear the user's name")
userEditCommand.Flags().StringVar(&name, "name", "", "New user name (this is separate from username used to log in)")
userEditCommand.MarkFlagsMutuallyExclusive("name", "remove-name")
userEditCommand.Flags().BoolVar(&setPassword, "set-password", false, "If set, the user's new password will be prompted on the CLI")
userEditCommand.Flags().IntSliceVarP(&libraryIds, "library-ids", "i", []int{}, "Comma-separated list of library IDs. Set the user's accessible libraries by id")
_ = userEditCommand.MarkFlagRequired("user")
userRoot.AddCommand(userEditCommand)
userListCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]")
userRoot.AddCommand(userListCommand)
}
var (
userRoot = &cobra.Command{
Use: "user",
Short: "Administer users",
Long: "Create, delete, list, or update users",
}
userCreateCommand = &cobra.Command{
Use: "create",
Aliases: []string{"c"},
Short: "Create a new user",
Run: func(cmd *cobra.Command, args []string) {
runCreateUser(cmd.Context())
},
}
userDeleteCommand = &cobra.Command{
Use: "delete",
Aliases: []string{"d"},
Short: "Deletes an existing user",
Run: func(cmd *cobra.Command, args []string) {
runDeleteUser(cmd.Context())
},
}
userEditCommand = &cobra.Command{
Use: "edit",
Aliases: []string{"e"},
Short: "Edit a user",
Long: "Edit the password, admin status, and/or library access",
Run: func(cmd *cobra.Command, args []string) {
runUserEdit(cmd.Context())
},
}
userListCommand = &cobra.Command{
Use: "list",
Short: "List users",
Run: func(cmd *cobra.Command, args []string) {
runUserList(cmd.Context())
},
}
)
func promptPassword() string {
for {
fmt.Print("Enter new password (press enter with no password to cancel): ")
// This cast is necessary for some platforms
password, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert
if err != nil {
log.Fatal("Error getting password", err)
}
fmt.Print("\nConfirm new password (press enter with no password to cancel): ")
confirmation, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert
if err != nil {
log.Fatal("Error getting password confirmation", err)
}
// clear the line.
fmt.Println()
pass := string(password)
confirm := string(confirmation)
if pass == "" {
return ""
}
if pass == confirm {
return pass
}
fmt.Println("Password and password confirmation do not match")
}
}
func libraryError(libraries model.Libraries) error {
ids := make([]int, len(libraries))
for idx, library := range libraries {
ids[idx] = library.ID
}
return fmt.Errorf("not all available libraries found. Requested ids: %v, Found libraries: %v", libraryIds, ids)
}
func runCreateUser(ctx context.Context) {
password := promptPassword()
if password == "" {
log.Fatal("Empty password provided, user creation cancelled")
}
user := model.User{
UserName: userID,
Email: email,
Name: name,
IsAdmin: setAdmin,
NewPassword: password,
}
if user.Name == "" {
user.Name = userID
}
ds, ctx := getAdminContext(ctx)
err := ds.WithTx(func(tx model.DataStore) error {
existingUser, err := tx.User(ctx).FindByUsername(userID)
if existingUser != nil {
return fmt.Errorf("existing user '%s'", userID)
}
if err != nil && !errors.Is(err, model.ErrNotFound) {
return fmt.Errorf("failed to check existing username: %w", err)
}
if len(libraryIds) > 0 && !setAdmin {
user.Libraries, err = tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
if err != nil {
return err
}
if len(user.Libraries) != len(libraryIds) {
return libraryError(user.Libraries)
}
} else {
user.Libraries, err = tx.Library(ctx).GetAll()
if err != nil {
return err
}
}
err = tx.User(ctx).Put(&user)
if err != nil {
return err
}
updatedIds := make([]int, len(user.Libraries))
for idx, lib := range user.Libraries {
updatedIds[idx] = lib.ID
}
err = tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
return err
})
if err != nil {
log.Fatal(ctx, err)
}
log.Info(ctx, "Successfully created user", "id", user.ID, "username", user.UserName)
}
func runDeleteUser(ctx context.Context) {
ds, ctx := getAdminContext(ctx)
var err error
var user *model.User
err = ds.WithTx(func(tx model.DataStore) error {
count, err := tx.User(ctx).CountAll()
if err != nil {
return err
}
if count == 1 {
return errors.New("refusing to delete the last user")
}
user, err = getUser(ctx, userID, tx)
if err != nil {
return err
}
return tx.User(ctx).Delete(user.ID)
})
if err != nil {
log.Fatal(ctx, "Failed to delete user", err)
}
log.Info(ctx, "Deleted user", "username", user.UserName)
}
func runUserEdit(ctx context.Context) {
ds, ctx := getAdminContext(ctx)
var err error
var user *model.User
changes := []string{}
err = ds.WithTx(func(tx model.DataStore) error {
var newLibraries model.Libraries
user, err = getUser(ctx, userID, tx)
if err != nil {
return err
}
if len(libraryIds) > 0 && !setAdmin {
libraries, err := tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
if err != nil {
return err
}
if len(libraries) != len(libraryIds) {
return libraryError(libraries)
}
newLibraries = libraries
changes = append(changes, "updated library ids")
}
if setAdmin && !user.IsAdmin {
libraries, err := tx.Library(ctx).GetAll()
if err != nil {
return err
}
user.IsAdmin = true
user.Libraries = libraries
changes = append(changes, "set admin")
newLibraries = libraries
}
if setRegularUser && user.IsAdmin {
user.IsAdmin = false
changes = append(changes, "set regular user")
}
if setPassword {
password := promptPassword()
if password != "" {
user.NewPassword = password
changes = append(changes, "updated password")
}
}
if email != "" && email != user.Email {
user.Email = email
changes = append(changes, "updated email")
} else if removeEmail && user.Email != "" {
user.Email = ""
changes = append(changes, "removed email")
}
if name != "" && name != user.Name {
user.Name = name
changes = append(changes, "updated name")
} else if removeName && user.Name != "" {
user.Name = ""
changes = append(changes, "removed name")
}
if len(changes) == 0 {
return nil
}
err := tx.User(ctx).Put(user)
if err != nil {
return err
}
if len(newLibraries) > 0 {
updatedIds := make([]int, len(newLibraries))
for idx, lib := range newLibraries {
updatedIds[idx] = lib.ID
}
err := tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
if err != nil {
return err
}
}
return nil
})
if err != nil {
log.Fatal(ctx, "Failed to update user", err)
}
if len(changes) == 0 {
log.Info(ctx, "No changes for user", "user", user.UserName)
} else {
log.Info(ctx, "Updated user", "user", user.UserName, "changes", strings.Join(changes, ", "))
}
}
type displayLibrary struct {
ID int `json:"id"`
Path string `json:"path"`
}
type displayUser struct {
Id string `json:"id"`
Username string `json:"username"`
Name string `json:"name"`
Email string `json:"email"`
Admin bool `json:"admin"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastAccess *time.Time `json:"lastAccess"`
LastLogin *time.Time `json:"lastLogin"`
Libraries []displayLibrary `json:"libraries"`
}
func runUserList(ctx context.Context) {
if outputFormat != "csv" && outputFormat != "json" {
log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat)
}
ds, ctx := getAdminContext(ctx)
users, err := ds.User(ctx).ReadAll()
if err != nil {
log.Fatal(ctx, "Failed to retrieve users", err)
}
userList := users.(model.Users)
if outputFormat == "csv" {
w := csv.NewWriter(os.Stdout)
_ = w.Write([]string{
"user id",
"username",
"user's name",
"user email",
"admin",
"created at",
"updated at",
"last access",
"last login",
"libraries",
})
for _, user := range userList {
paths := make([]string, len(user.Libraries))
for idx, library := range user.Libraries {
paths[idx] = fmt.Sprintf("%d:%s", library.ID, library.Path)
}
var lastAccess, lastLogin string
if user.LastAccessAt != nil {
lastAccess = user.LastAccessAt.Format(time.RFC3339Nano)
} else {
lastAccess = "never"
}
if user.LastLoginAt != nil {
lastLogin = user.LastLoginAt.Format(time.RFC3339Nano)
} else {
lastLogin = "never"
}
_ = w.Write([]string{
user.ID,
user.UserName,
user.Name,
user.Email,
strconv.FormatBool(user.IsAdmin),
user.CreatedAt.Format(time.RFC3339Nano),
user.UpdatedAt.Format(time.RFC3339Nano),
lastAccess,
lastLogin,
fmt.Sprintf("'%s'", strings.Join(paths, "|")),
})
}
w.Flush()
} else {
users := make([]displayUser, len(userList))
for idx, user := range userList {
paths := make([]displayLibrary, len(user.Libraries))
for idx, library := range user.Libraries {
paths[idx].ID = library.ID
paths[idx].Path = library.Path
}
users[idx].Id = user.ID
users[idx].Username = user.UserName
users[idx].Name = user.Name
users[idx].Email = user.Email
users[idx].Admin = user.IsAdmin
users[idx].CreatedAt = user.CreatedAt
users[idx].UpdatedAt = user.UpdatedAt
users[idx].LastAccess = user.LastAccessAt
users[idx].LastLogin = user.LastLoginAt
users[idx].Libraries = paths
}
j, _ := json.Marshal(users)
fmt.Printf("%s\n", j)
}
}
-42
View File
@@ -1,42 +0,0 @@
package cmd
import (
"context"
"errors"
"fmt"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/persistence"
)
func getAdminContext(ctx context.Context) (model.DataStore, context.Context) {
sqlDB := db.Db()
ds := persistence.New(sqlDB)
ctx = auth.WithAdminUser(ctx, ds)
u, _ := request.UserFrom(ctx)
if !u.IsAdmin {
log.Fatal(ctx, "There must be at least one admin user to run this command.")
}
return ds, ctx
}
func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) {
user, err := ds.User(ctx).FindByUsername(id)
if err != nil && !errors.Is(err, model.ErrNotFound) {
return nil, fmt.Errorf("finding user by name: %w", err)
}
if errors.Is(err, model.ErrNotFound) {
user, err = ds.User(ctx).Get(id)
if err != nil {
return nil, fmt.Errorf("finding user by id: %w", err)
}
}
return user, nil
}
+49 -69
View File
@@ -1,6 +1,6 @@
// Code generated by Wire. DO NOT EDIT.
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo sqlite_fts5"
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo"
//go:build !wireinject
// +build !wireinject
@@ -9,21 +9,16 @@ package cmd
import (
"context"
"github.com/google/wire"
"github.com/navidrome/navidrome/adapters/lastfm"
"github.com/navidrome/navidrome/adapters/listenbrainz"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/agents/lastfm"
"github.com/navidrome/navidrome/core/agents/listenbrainz"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
@@ -37,10 +32,7 @@ import (
)
import (
_ "github.com/navidrome/navidrome/adapters/deezer"
_ "github.com/navidrome/navidrome/adapters/gotaglib"
_ "github.com/navidrome/navidrome/adapters/lastfm"
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
_ "github.com/navidrome/navidrome/adapters/taglib"
)
// Injectors from wire_injectors.go:
@@ -55,7 +47,9 @@ func CreateServer() *server.Server {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
broker := events.GetBroker()
insights := metrics.GetInstance(dataStore)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, metricsMetrics)
insights := metrics.GetInstance(dataStore, manager)
serverServer := server.New(dataStore, broker, insights)
return serverServer
}
@@ -64,25 +58,21 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
share := core.NewShare(dataStore)
imageUploadService := core.NewImageUploadService()
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
insights := metrics.GetInstance(dataStore)
playlists := core.NewPlaylists(dataStore)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, metricsMetrics)
insights := metrics.GetInstance(dataStore, manager)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
matcherMatcher := matcher.New(dataStore)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
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)
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, imageUploadService)
broker := events.GetBroker()
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, scannerScanner)
library := core.NewLibrary(dataStore, scannerScanner, watcher, broker)
router := nativeapi.New(dataStore, share, playlists, insights, library)
return router
}
@@ -91,28 +81,23 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
manager := plugins.GetManager(dataStore, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
matcherMatcher := matcher.New(dataStore)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
transcodingCache := stream.GetTranscodingCache()
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
transcodingCache := core.GetTranscodingCache()
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
share := core.NewShare(dataStore)
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
players := core.NewPlayers(dataStore)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
imageUploadService := core.NewImageUploadService()
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
broker := events.GetBroker()
playlists := core.NewPlaylists(dataStore)
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore)
lyricsLyrics := lyrics.NewLyrics(manager)
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, scannerScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
return router
}
@@ -121,15 +106,13 @@ func CreatePublicRouter() *public.Router {
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
manager := plugins.GetManager(dataStore, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
matcherMatcher := matcher.New(dataStore)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
transcodingCache := stream.GetTranscodingCache()
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
transcodingCache := core.GetTranscodingCache()
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
share := core.NewShare(dataStore)
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
@@ -153,7 +136,9 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
func CreateInsights() metrics.Insights {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
insights := metrics.GetInstance(dataStore)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, metricsMetrics)
insights := metrics.GetInstance(dataStore, manager)
return insights
}
@@ -164,23 +149,21 @@ func CreatePrometheus() metrics.Metrics {
return metricsMetrics
}
func CreateScanner(ctx context.Context) model.Scanner {
func CreateScanner(ctx context.Context) scanner.Scanner {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
manager := plugins.GetManager(dataStore, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
matcherMatcher := matcher.New(dataStore)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
imageUploadService := core.NewImageUploadService()
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
return modelScanner
broker := events.GetBroker()
playlists := core.NewPlaylists(dataStore)
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
return scannerScanner
}
func CreateScanWatcher(ctx context.Context) scanner.Watcher {
@@ -188,18 +171,16 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
manager := plugins.GetManager(dataStore, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
matcherMatcher := matcher.New(dataStore)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
imageUploadService := core.NewImageUploadService()
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, modelScanner)
broker := events.GetBroker()
playlists := core.NewPlaylists(dataStore)
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, scannerScanner)
return watcher
}
@@ -210,20 +191,19 @@ func GetPlaybackServer() playback.PlaybackServer {
return playbackServer
}
func getPluginManager() *plugins.Manager {
func getPluginManager() plugins.Manager {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
manager := plugins.GetManager(dataStore, metricsMetrics)
return manager
}
// wire_injectors.go:
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)), wire.Bind(new(core.Scanner), new(scanner.Scanner)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
func GetPluginManager(ctx context.Context) *plugins.Manager {
func GetPluginManager(ctx context.Context) plugins.Manager {
manager := getPluginManager()
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
return manager
+10 -16
View File
@@ -6,16 +6,14 @@ import (
"context"
"github.com/google/wire"
"github.com/navidrome/navidrome/adapters/lastfm"
"github.com/navidrome/navidrome/adapters/listenbrainz"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/agents/lastfm"
"github.com/navidrome/navidrome/core/agents/listenbrainz"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
@@ -41,17 +39,13 @@ var allProviders = wire.NewSet(
events.GetBroker,
scanner.New,
scanner.GetWatcher,
plugins.GetManager,
metrics.GetPrometheusInstance,
db.Db,
plugins.GetManager,
sonic.New,
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)),
wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)),
wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)),
wire.Bind(new(agents.PluginLoader), new(plugins.Manager)),
wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)),
wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)),
wire.Bind(new(core.Scanner), new(scanner.Scanner)),
wire.Bind(new(core.Watcher), new(scanner.Watcher)),
)
@@ -109,7 +103,7 @@ func CreatePrometheus() metrics.Metrics {
))
}
func CreateScanner(ctx context.Context) model.Scanner {
func CreateScanner(ctx context.Context) scanner.Scanner {
panic(wire.Build(
allProviders,
))
@@ -127,13 +121,13 @@ func GetPlaybackServer() playback.PlaybackServer {
))
}
func getPluginManager() *plugins.Manager {
func getPluginManager() plugins.Manager {
panic(wire.Build(
allProviders,
))
}
func GetPluginManager(ctx context.Context) *plugins.Manager {
func GetPluginManager(ctx context.Context) plugins.Manager {
manager := getPluginManager()
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
return manager
+4
View 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.
-6
View File
@@ -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
+5 -1
View File
@@ -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
-8
View File
@@ -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
+127 -402
View File
@@ -1,24 +1,21 @@
package conf
import (
"cmp"
"fmt"
"net/url"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"time"
"github.com/bmatcuk/doublestar/v4"
"github.com/dustin/go-humanize"
"github.com/go-viper/encoding/ini"
"github.com/kr/pretty"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/scheduler"
"github.com/navidrome/navidrome/utils/run"
"github.com/robfig/cron/v3"
"github.com/spf13/viper"
)
@@ -27,7 +24,6 @@ type configOptions struct {
Address string
Port int
UnixSocketPerm string
EnforceNonRootUser bool
MusicFolder string
DataFolder string
CacheFolder string
@@ -45,10 +41,8 @@ type configOptions struct {
UIWelcomeMessage string
MaxSidebarPlaylists int
EnableTranscodingConfig bool
EnableTranscodingCancellation bool
EnableDownloads bool
EnableExternalServices bool
EnableM3UExternalAlbumArt bool
EnableInsightsCollector bool
EnableMediaFileCoverArt bool
TranscodingCacheSize string
@@ -61,8 +55,7 @@ type configOptions struct {
SmartPlaylistRefreshDelay time.Duration
AutoTranscodeDownload bool
DefaultDownsamplingFormat string
Search searchOptions `json:",omitzero"`
Matcher matcherOptions `json:",omitzero"`
SearchFullString bool
RecentlyAddedByModTime bool
PreferSortTags bool
IgnoredArticles string
@@ -71,18 +64,13 @@ type configOptions struct {
MPVPath string
MPVCmdTemplate string
CoverArtPriority string
CoverArtQuality int
EnableWebPEncoding bool
CoverJpegQuality int
ArtistArtPriority string
ArtistImageFolder string
DiscArtPriority string
LyricsPriority string
EnableGravatar bool
EnableFavourites bool
EnableStarRating bool
EnableUserEditing bool
EnableArtworkUpload bool
MaxImageUploadSize string
EnableSharing bool
ShareURL string
DefaultShareExpiration time.Duration
@@ -90,20 +78,19 @@ type configOptions struct {
DefaultTheme string
DefaultLanguage string
DefaultUIVolume int
UISearchDebounceMs int
UICoverArtSize int
EnableReplayGain bool
EnableCoverAnimation bool
EnableNowPlaying bool
UIPlaybackReportInterval time.Duration
GATrackingID string
EnableLogRedacting bool
AuthRequestLimit int
AuthWindowLength time.Duration
PasswordEncryptionKey string
ExtAuth extAuthOptions
ReverseProxyUserHeader string
ReverseProxyWhitelist string
Plugins pluginsOptions
HTTPHeaders httpHeaderOptions `json:",omitzero"`
PluginConfig map[string]map[string]string
HTTPSecurityHeaders secureOptions `json:",omitzero"`
Prometheus prometheusOptions `json:",omitzero"`
Scanner scannerOptions `json:",omitzero"`
Jukebox jukeboxOptions `json:",omitzero"`
@@ -112,41 +99,37 @@ type configOptions struct {
Inspect inspectOptions `json:",omitzero"`
Subsonic subsonicOptions `json:",omitzero"`
LastFM lastfmOptions `json:",omitzero"`
Spotify spotifyOptions `json:",omitzero"`
Deezer deezerOptions `json:",omitzero"`
ListenBrainz listenBrainzOptions `json:",omitzero"`
EnableScrobbleHistory bool
Tags map[string]TagConf `json:",omitempty"`
Tags map[string]TagConf `json:",omitempty"`
Agents string
// DevFlags. These are used to enable/disable debugging and incomplete features
DevLogLevels map[string]string `json:",omitempty"`
DevLogSourceLine bool
DevEnableProfiler bool
DevAutoCreateAdminPassword string
DevAutoLoginUsername string
DevActivityPanel bool
DevActivityPanelUpdateRate time.Duration
DevSidebarPlaylists bool
DevShowArtistPage bool
DevUIShowConfig bool
DevNewEventStream bool
DevOffsetOptimize int
DevArtworkMaxRequests int
DevArtworkThrottleBacklogLimit int
DevArtworkThrottleBacklogTimeout time.Duration
DevArtistInfoTimeToLive time.Duration
DevAlbumInfoTimeToLive time.Duration
DevExternalScanner bool
DevScannerThreads uint
DevSelectiveWatcher bool
DevInsightsInitialDelay time.Duration
DevEnablePlayerInsights bool
DevEnablePluginsInsights bool
DevPluginCompilationTimeout time.Duration
DevExternalArtistFetchMultiplier float64
DevOptimizeDB bool
DevPreserveUnicodeInExternalCalls bool
DevEnableMediaFileProbe bool
DevLogLevels map[string]string `json:",omitempty"`
DevLogSourceLine bool
DevEnableProfiler bool
DevAutoCreateAdminPassword string
DevAutoLoginUsername string
DevActivityPanel bool
DevActivityPanelUpdateRate time.Duration
DevSidebarPlaylists bool
DevShowArtistPage bool
DevUIShowConfig bool
DevNewEventStream bool
DevOffsetOptimize int
DevArtworkMaxRequests int
DevArtworkThrottleBacklogLimit int
DevArtworkThrottleBacklogTimeout time.Duration
DevArtistInfoTimeToLive time.Duration
DevAlbumInfoTimeToLive time.Duration
DevExternalScanner bool
DevScannerThreads uint
DevInsightsInitialDelay time.Duration
DevEnablePlayerInsights bool
DevEnablePluginsInsights bool
DevPluginCompilationTimeout time.Duration
DevExternalArtistFetchMultiplier float64
}
type scannerOptions struct {
@@ -164,12 +147,9 @@ type scannerOptions struct {
type subsonicOptions struct {
AppendSubtitle bool
AppendAlbumVersion bool
ArtistParticipations bool
DefaultReportRealPath bool
EnableAverageRating bool
LegacyClients string
MinimalClients string
}
type TagConf struct {
@@ -183,38 +163,34 @@ type TagConf struct {
type lastfmOptions struct {
Enabled bool
ApiKey string //nolint:gosec
Secret string //nolint:gosec
ApiKey string
Secret string
Language string
ScrobbleFirstArtistOnly bool
}
// Computed values
Languages []string // Computed from Language, split by comma
type spotifyOptions struct {
ID string
Secret string
}
type deezerOptions struct {
Enabled bool
Language string
// Computed values
Languages []string // Computed from Language, split by comma
Enabled bool
}
type listenBrainzOptions struct {
Enabled bool
BaseURL string
ArtistAlgorithm string
TrackAlgorithm string
Enabled bool
BaseURL string
}
type httpHeaderOptions struct {
FrameOptions string
type secureOptions struct {
CustomFrameOptionsValue string
}
type prometheusOptions struct {
Enabled bool
MetricsPath string
Password string //nolint:gosec
Password string
}
type AudioDeviceDefinition []string
@@ -245,40 +221,9 @@ type inspectOptions struct {
}
type pluginsOptions struct {
Enabled bool
Folder string
CacheSize string
AutoReload bool
LogLevel string
}
type extAuthOptions struct {
TrustedSources string
UserHeader string
LogoutURL string
}
type searchOptions struct {
Backend string
FullString bool
}
type matcherOptions struct {
PreferStarred bool
FuzzyThreshold int
}
// logFatal prints a fatal error message to stderr and exits.
// Overridden in tests to allow testing fatal paths.
var logFatal = func(args ...any) {
_, _ = fmt.Fprintln(os.Stderr, append([]any{"FATAL:"}, args...)...)
os.Exit(1)
}
var getEUID = os.Geteuid
var currentGOOS = func() string {
return runtime.GOOS
Enabled bool
Folder string
CacheSize string
}
var (
@@ -290,35 +235,25 @@ func LoadFromFile(confFile string) {
viper.SetConfigFile(confFile)
err := viper.ReadInConfig()
if err != nil {
logFatal("Error reading config file:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error reading config file:", err)
os.Exit(1)
}
Load(true)
}
func Load(noConfigDump bool) {
parseIniFileConfiguration()
remapEnvVarKeysFromConfig()
// Map deprecated options to their new names for backwards compatibility
mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader")
mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality")
mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
err := viper.Unmarshal(&Server)
if err != nil {
logFatal("Error parsing config:", err)
}
// Validate non-root user early, before any filesystem operations
if err := validateEnforceNonRootUser(); err != nil {
logFatal(err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
os.Exit(1)
}
err = os.MkdirAll(Server.DataFolder, os.ModePerm)
if err != nil {
logFatal("Error creating data path:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", err)
os.Exit(1)
}
if Server.CacheFolder == "" {
@@ -326,12 +261,8 @@ func Load(noConfigDump bool) {
}
err = os.MkdirAll(Server.CacheFolder, os.ModePerm)
if err != nil {
logFatal("Error creating cache path:", err)
}
err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm)
if err != nil {
logFatal("Error creating artwork path:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating cache path:", err)
os.Exit(1)
}
if Server.Plugins.Enabled {
@@ -340,7 +271,8 @@ func Load(noConfigDump bool) {
}
err = os.MkdirAll(Server.Plugins.Folder, 0700)
if err != nil {
logFatal("Error creating plugins path:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating plugins path:", err)
os.Exit(1)
}
}
@@ -352,7 +284,8 @@ func Load(noConfigDump bool) {
if Server.Backup.Path != "" {
err = os.MkdirAll(Server.Backup.Path, os.ModePerm)
if err != nil {
logFatal("Error creating backup path:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating backup path:", err)
os.Exit(1)
}
}
@@ -360,15 +293,10 @@ func Load(noConfigDump bool) {
if Server.LogFile != "" {
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error()))
_, _ = fmt.Fprintf(os.Stderr, "FATAL: Error opening log file %s: %s\n", Server.LogFile, err.Error())
os.Exit(1)
}
log.SetOutput(out)
} else if os.Getenv("ND_SYSTEMD_PRIORITY_LOGGING") != "" && os.Getenv("JOURNAL_STREAM") != "" {
// When running under systemd, prepend syslog priority prefixes so
// journald assigns the correct severity to each log line.
// Note that we have an additional environment variable, as JOURNAL_STREAM
// can be present in a systemd environment even if not running as a systemd service
log.EnableJournalFormat()
}
log.SetLevelString(Server.LogLevel)
@@ -381,19 +309,16 @@ func Load(noConfigDump bool) {
validateBackupSchedule,
validatePlaylistsPath,
validatePurgeMissingOption,
validateMaxImageUploadSize,
validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL),
)
if err != nil {
logFatal(err)
os.Exit(1)
}
Server.Search.Backend = normalizeSearchBackend(Server.Search.Backend)
if Server.BaseURL != "" {
u, err := url.Parse(Server.BaseURL)
if err != nil {
logFatal("Invalid BaseURL:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Invalid BaseURL:", err)
os.Exit(1)
}
Server.BasePath = u.Path
u.Path = ""
@@ -402,18 +327,9 @@ func Load(noConfigDump bool) {
Server.BaseScheme = u.Scheme
}
// Log configuration source
if Server.ConfigFile != "" {
log.Info("Loaded configuration", "file", Server.ConfigFile)
} else if hasNDEnvVars() {
log.Info("No configuration file found. Loaded configuration only from environment variables")
} else {
log.Warn("No configuration file found. Using default values. To specify a config file, use the --configfile flag or set the ND_CONFIGFILE environment variable.")
}
// Print current configuration if log level is Debug
if log.IsGreaterOrEqualTo(log.LevelDebug) && !noConfigDump {
prettyConf := pretty.Sprintf("Configuration: %# v", Server)
prettyConf := pretty.Sprintf("Loaded configuration from '%s': %# v", Server.ConfigFile, Server)
if Server.EnableLogRedacting {
prettyConf = log.Redact(prettyConf)
}
@@ -424,36 +340,13 @@ func Load(noConfigDump bool) {
disableExternalServices()
}
// Make sure we don't have empty PIDs
Server.PID.Album = cmp.Or(Server.PID.Album, consts.DefaultAlbumPID)
Server.PID.Track = cmp.Or(Server.PID.Track, consts.DefaultTrackPID)
// Parse LastFM.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage)
Server.LastFM.Languages = parseLanguages(Server.LastFM.Language)
// Parse Deezer.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage)
Server.Deezer.Languages = parseLanguages(Server.Deezer.Language)
// Deprecated options
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")
logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality")
logDeprecatedOptions("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
// Removed options
logRemovedOptions("Spotify.ID", "Spotify.Secret")
// Validate other options
if Server.UICoverArtSize < 200 || Server.UICoverArtSize > 1200 {
newValue := max(200, min(1200, Server.UICoverArtSize))
log.Warn("UICoverArtSize must be between 200 and 1200, clamping", "value", Server.UICoverArtSize, "newValue", newValue)
Server.UICoverArtSize = newValue
if Server.Scanner.Extractor != consts.DefaultScannerExtractor {
log.Warn(fmt.Sprintf("Extractor '%s' is not implemented, using 'taglib'", Server.Scanner.Extractor))
Server.Scanner.Extractor = consts.DefaultScannerExtractor
}
logDeprecatedOptions("Scanner.GenreSeparators")
logDeprecatedOptions("Scanner.GroupAlbumReleases")
logDeprecatedOptions("DevEnableBufferedScrobble") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
// Call init hooks
for _, hook := range hooks {
@@ -461,75 +354,15 @@ func Load(noConfigDump bool) {
}
}
func logDeprecatedOptions(oldName, newName string) {
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(oldName, ".", "_"))
newEnvVar := "ND_" + strings.ToUpper(strings.ReplaceAll(newName, ".", "_"))
logWarning := func(oldName, newName string) {
if newName != "" {
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release. Please use the new '%s'", oldName, newName))
} else {
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", oldName))
}
}
if os.Getenv(envVar) != "" {
logWarning(envVar, newEnvVar)
}
if viper.InConfig(oldName) {
logWarning(oldName, newName)
}
}
// logRemovedOptions checks if the option is set, and if yes, outputs a warning message saying the option is
// not available anymore
func logRemovedOptions(options ...string) {
func logDeprecatedOptions(options ...string) {
for _, option := range options {
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_"))
logWarning := func(option string) {
log.Warn(fmt.Sprintf("Option '%s' is not available anymore and will be ignored. Please remove it from your config", option))
if os.Getenv(envVar) != "" {
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", envVar))
}
if viper.InConfig(option) {
logWarning(option)
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", option))
}
if os.Getenv(envVar) != "" {
logWarning(envVar)
}
}
}
// remapEnvVarKeysFromConfig detects ND_-prefixed keys in the config file (users mistakenly
// using environment variable names) and remaps them to canonical Viper keys with a warning.
func remapEnvVarKeysFromConfig() {
for _, key := range viper.AllKeys() {
if !strings.HasPrefix(key, "nd_") || !viper.InConfig(key) {
continue
}
stripped := strings.TrimPrefix(key, "nd_")
canonicalKey := strings.ReplaceAll(stripped, "_", ".")
displayNDKey := "ND_" + strings.ToUpper(stripped)
displayCanonical := toPascalCase(canonicalKey)
if viper.InConfig(canonicalKey) {
logFatal(fmt.Sprintf(
"Config file contains both '%s' and '%s'. Remove the ND_-prefixed version. "+
"The 'ND_' prefix is only needed for environment variables, not config file keys.",
displayNDKey, displayCanonical,
))
return
}
viper.Set(canonicalKey, viper.Get(key))
_, _ = fmt.Fprintf(os.Stderr, "WARNING: Config key '%s' uses environment variable naming. Use '%s' instead. "+
"The 'ND_' prefix is only needed for environment variables.\n",
displayNDKey, displayCanonical,
)
}
}
// mapDeprecatedOption is used to provide backwards compatibility for deprecated options. It should be called after
// the config has been read by viper, but before unmarshalling it into the Config struct.
func mapDeprecatedOption(legacyName, newName string) {
if viper.IsSet(legacyName) {
viper.Set(newName, viper.Get(legacyName))
}
}
@@ -539,18 +372,21 @@ func mapDeprecatedOption(legacyName, newName string) {
func parseIniFileConfiguration() {
cfgFile := viper.ConfigFileUsed()
if strings.ToLower(filepath.Ext(cfgFile)) == ".ini" {
var iniConfig map[string]any
var iniConfig map[string]interface{}
err := viper.Unmarshal(&iniConfig)
if err != nil {
logFatal("Error parsing config:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
os.Exit(1)
}
cfg, ok := iniConfig["default"].(map[string]any)
if !ok {
logFatal("Error parsing config: missing [default] section:", iniConfig)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config: missing [default] section:", iniConfig)
os.Exit(1)
}
err = viper.MergeConfigMap(cfg)
if err != nil {
logFatal("Error parsing config:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
os.Exit(1)
}
}
}
@@ -558,8 +394,8 @@ func parseIniFileConfiguration() {
func disableExternalServices() {
log.Info("All external integrations are DISABLED!")
Server.EnableInsightsCollector = false
Server.EnableM3UExternalAlbumArt = false
Server.LastFM.Enabled = false
Server.Spotify.ID = ""
Server.Deezer.Enabled = false
Server.ListenBrainz.Enabled = false
Server.Agents = ""
@@ -569,61 +405,34 @@ func disableExternalServices() {
}
func validatePlaylistsPath() error {
for path := range strings.SplitSeq(Server.PlaylistsPath, string(filepath.ListSeparator)) {
for _, path := range strings.Split(Server.PlaylistsPath, string(filepath.ListSeparator)) {
_, err := doublestar.Match(path, "")
if err != nil {
return fmt.Errorf("invalid PlaylistsPath %q: %w", path, err)
log.Error("Invalid PlaylistsPath", "path", path, err)
return err
}
}
return nil
}
// parseLanguages parses a comma-separated language string into a slice.
// It trims whitespace from each entry and ensures at least [DefaultInfoLanguage] is returned.
func parseLanguages(lang string) []string {
var languages []string
for l := range strings.SplitSeq(lang, ",") {
l = strings.TrimSpace(l)
if l != "" {
languages = append(languages, l)
}
}
if len(languages) == 0 {
return []string{consts.DefaultInfoLanguage}
}
return languages
}
func validatePurgeMissingOption() error {
allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull}
valid := slices.Contains(allowedValues, Server.Scanner.PurgeMissing)
valid := false
for _, v := range allowedValues {
if v == Server.Scanner.PurgeMissing {
valid = true
break
}
}
if !valid {
err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
err := fmt.Errorf("Invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
log.Error(err.Error())
Server.Scanner.PurgeMissing = consts.PurgeMissingNever
return err
}
return nil
}
func validateMaxImageUploadSize() error {
if _, err := humanize.ParseBytes(Server.MaxImageUploadSize); err != nil {
return fmt.Errorf("invalid MaxImageUploadSize %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", Server.MaxImageUploadSize, err)
}
return nil
}
func validateEnforceNonRootUser() error {
if !Server.EnforceNonRootUser || currentGOOS() == "windows" {
return nil
}
if getEUID() == 0 {
return fmt.Errorf("EnforceNonRootUser is enabled but Navidrome is running as root")
}
return nil
}
func validateScanSchedule() error {
if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" {
Server.Scanner.Schedule = ""
@@ -645,58 +454,17 @@ func validateBackupSchedule() error {
}
func validateSchedule(schedule, field string) (string, error) {
_, err := scheduler.ParseCrontab(schedule)
if _, err := time.ParseDuration(schedule); err == nil {
schedule = "@every " + schedule
}
c := cron.New()
id, err := c.AddFunc(schedule, func() {})
if err != nil {
return schedule, fmt.Errorf("invalid %s %q (see https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format): %w", field, schedule, err)
log.Error(fmt.Sprintf("Invalid %s. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", field), "schedule", schedule, err)
} else {
c.Remove(id)
}
return schedule, nil
}
// validateURL checks if the provided URL is valid and has either http or https scheme.
// It returns a function that can be used as a hook to validate URLs in the config.
func validateURL(optionName, optionURL string) func() error {
return func() error {
if optionURL == "" {
return nil
}
u, err := url.Parse(optionURL)
if err != nil {
return fmt.Errorf("invalid %s %q: %w", optionName, optionURL, err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("invalid scheme for %s: '%s'. Only 'http' and 'https' are allowed", optionName, u.Scheme)
}
if u.Host == "" || u.Opaque != "" {
return fmt.Errorf("invalid %s: '%s'. A full http(s) URL with a non-empty host is required", optionName, optionURL)
}
return nil
}
}
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"
}
}
// toPascalCase converts a dotted lowercase config key to PascalCase for display.
// Example: "scanner.schedule" → "Scanner.Schedule"
func toPascalCase(key string) string {
if key == "" {
return ""
}
parts := strings.Split(key, ".")
for i, part := range parts {
if len(part) > 0 {
parts[i] = strings.ToUpper(part[:1]) + part[1:]
}
}
return strings.Join(parts, ".")
return schedule, err
}
// AddHook is used to register initialization code that should run as soon as the config is loaded
@@ -704,16 +472,6 @@ func AddHook(hook func()) {
hooks = append(hooks, hook)
}
// hasNDEnvVars checks if any ND_ prefixed environment variables are set (excluding ND_CONFIGFILE)
func hasNDEnvVars() bool {
for _, env := range os.Environ() {
if strings.HasPrefix(env, "ND_") && !strings.HasPrefix(env, "ND_CONFIGFILE=") {
return true
}
}
return false
}
func setViperDefaults() {
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
viper.SetDefault("cachefolder", "")
@@ -723,7 +481,6 @@ func setViperDefaults() {
viper.SetDefault("address", "0.0.0.0")
viper.SetDefault("port", 4533)
viper.SetDefault("unixsocketperm", "0660")
viper.SetDefault("enforcenonrootuser", false)
viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout)
viper.SetDefault("baseurl", "")
viper.SetDefault("tlscert", "")
@@ -732,7 +489,6 @@ func setViperDefaults() {
viper.SetDefault("uiwelcomemessage", "")
viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists)
viper.SetDefault("enabletranscodingconfig", false)
viper.SetDefault("enabletranscodingcancellation", false)
viper.SetDefault("transcodingcachesize", "100MB")
viper.SetDefault("imagecachesize", "100MB")
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
@@ -743,27 +499,19 @@ func setViperDefaults() {
viper.SetDefault("smartPlaylistRefreshDelay", 5*time.Second)
viper.SetDefault("enabledownloads", true)
viper.SetDefault("enableexternalservices", true)
viper.SetDefault("enablem3uexternalalbumart", false)
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("matcher.preferstarred", true)
viper.SetDefault("matcher.fuzzythreshold", 85)
viper.SetDefault("searchfullstring", false)
viper.SetDefault("recentlyaddedbymodtime", false)
viper.SetDefault("prefersorttags", false)
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
viper.SetDefault("ffmpegpath", "")
viper.SetDefault("mpvpath", "")
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display %f --input-ipc-server=%s")
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
viper.SetDefault("coverartquality", 75)
viper.SetDefault("enablewebpencoding", false)
viper.SetDefault("coverjpegquality", 75)
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
viper.SetDefault("artistimagefolder", "")
viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded")
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
viper.SetDefault("enablegravatar", false)
viper.SetDefault("enablefavourites", true)
@@ -772,14 +520,9 @@ func setViperDefaults() {
viper.SetDefault("defaulttheme", "Dark")
viper.SetDefault("defaultlanguage", "")
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs)
viper.SetDefault("uicoverartsize", consts.DefaultUICoverArtSize)
viper.SetDefault("enablereplaygain", true)
viper.SetDefault("enablecoveranimation", true)
viper.SetDefault("enablenowplaying", true)
viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval)
viper.SetDefault("enableartworkupload", true)
viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize)
viper.SetDefault("enablesharing", false)
viper.SetDefault("shareurl", "")
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
@@ -790,9 +533,8 @@ func setViperDefaults() {
viper.SetDefault("authrequestlimit", 5)
viper.SetDefault("authwindowlength", 20*time.Second)
viper.SetDefault("passwordencryptionkey", "")
viper.SetDefault("extauth.userheader", "Remote-User")
viper.SetDefault("extauth.trustedsources", "")
viper.SetDefault("extauth.logouturl", "")
viper.SetDefault("reverseproxyuserheader", "Remote-User")
viper.SetDefault("reverseproxywhitelist", "")
viper.SetDefault("prometheus.enabled", false)
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
viper.SetDefault("prometheus.password", "")
@@ -811,26 +553,21 @@ func setViperDefaults() {
viper.SetDefault("scanner.followsymlinks", true)
viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever)
viper.SetDefault("subsonic.appendsubtitle", true)
viper.SetDefault("subsonic.appendalbumversion", true)
viper.SetDefault("subsonic.artistparticipations", false)
viper.SetDefault("subsonic.defaultreportrealpath", false)
viper.SetDefault("subsonic.enableaveragerating", true)
viper.SetDefault("subsonic.legacyclients", "DSub")
viper.SetDefault("subsonic.minimalclients", "SubMusic")
viper.SetDefault("agents", "deezer,lastfm,listenbrainz")
viper.SetDefault("agents", "lastfm,spotify,deezer")
viper.SetDefault("lastfm.enabled", true)
viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage)
viper.SetDefault("lastfm.language", "en")
viper.SetDefault("lastfm.apikey", "")
viper.SetDefault("lastfm.secret", "")
viper.SetDefault("lastfm.scrobblefirstartistonly", false)
viper.SetDefault("spotify.id", "")
viper.SetDefault("spotify.secret", "")
viper.SetDefault("deezer.enabled", true)
viper.SetDefault("deezer.language", consts.DefaultInfoLanguage)
viper.SetDefault("listenbrainz.enabled", true)
viper.SetDefault("listenbrainz.baseurl", consts.DefaultListenBrainzBaseURL)
viper.SetDefault("listenbrainz.artistalgorithm", consts.DefaultListenBrainzArtistAlgorithm)
viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm)
viper.SetDefault("enablescrobblehistory", true)
viper.SetDefault("httpheaders.frameoptions", "DENY")
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")
viper.SetDefault("backup.path", "")
viper.SetDefault("backup.schedule", "")
viper.SetDefault("backup.count", 0)
@@ -841,10 +578,8 @@ func setViperDefaults() {
viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit)
viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout)
viper.SetDefault("plugins.folder", "")
viper.SetDefault("plugins.enabled", true)
viper.SetDefault("plugins.cachesize", "200MB")
viper.SetDefault("plugins.autoreload", false)
viper.SetDefault("plugins.loglevel", "")
viper.SetDefault("plugins.enabled", false)
viper.SetDefault("plugins.cachesize", "100MB")
// DevFlags. These are used to enable/disable debugging and incomplete features
viper.SetDefault("devlogsourceline", false)
@@ -858,36 +593,27 @@ func setViperDefaults() {
viper.SetDefault("devuishowconfig", true)
viper.SetDefault("devneweventstream", true)
viper.SetDefault("devoffsetoptimize", 50000)
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2))
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/3))
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive)
viper.SetDefault("devexternalscanner", true)
viper.SetDefault("devscannerthreads", 5)
viper.SetDefault("devselectivewatcher", true)
viper.SetDefault("devinsightsinitialdelay", consts.InsightsInitialDelay)
viper.SetDefault("devenableplayerinsights", true)
viper.SetDefault("devenablepluginsinsights", true)
viper.SetDefault("devplugincompilationtimeout", time.Minute)
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
viper.SetDefault("devoptimizedb", true)
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
viper.SetDefault("devenablemediafileprobe", true)
}
func init() {
setViperDefaults()
}
func InitConfig(cfgFile string, loadEnvVars bool) {
func InitConfig(cfgFile string) {
codecRegistry := viper.NewCodecRegistry()
_ = codecRegistry.RegisterCodec("ini", ini.Codec{
LoadOptions: ini.LoadOptions{
UnescapeValueDoubleQuotes: true,
UnescapeValueCommentSymbols: true,
},
})
_ = codecRegistry.RegisterCodec("ini", ini.Codec{})
viper.SetOptions(viper.WithCodecRegistry(codecRegistry))
cfgFile = getConfigFile(cfgFile)
@@ -901,16 +627,15 @@ func InitConfig(cfgFile string, loadEnvVars bool) {
}
_ = viper.BindEnv("port")
if loadEnvVars {
viper.SetEnvPrefix("ND")
replacer := strings.NewReplacer(".", "_")
viper.SetEnvKeyReplacer(replacer)
viper.AutomaticEnv()
}
viper.SetEnvPrefix("ND")
replacer := strings.NewReplacer(".", "_")
viper.SetEnvKeyReplacer(replacer)
viper.AutomaticEnv()
err := viper.ReadInConfig()
if viper.ConfigFileUsed() != "" && err != nil {
logFatal("Navidrome could not open config file:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Navidrome could not open config file: ", err)
os.Exit(1)
}
}
@@ -922,7 +647,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
}
}
+1 -272
View File
@@ -2,7 +2,6 @@ package conf_test
import (
"fmt"
"os"
"path/filepath"
"testing"
@@ -25,272 +24,6 @@ var _ = Describe("Configuration", func() {
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("loglevel", "error")
conf.ResetConf()
// Panic instead of exiting on fatal errors to allow testing error conditions
DeferCleanup(conf.SetLogFatal(func(args ...any) {
panic(fmt.Sprint(args...))
}))
})
Describe("ParseLanguages", func() {
It("parses single language", func() {
Expect(conf.ParseLanguages("en")).To(Equal([]string{"en"}))
})
It("parses multiple comma-separated languages", func() {
Expect(conf.ParseLanguages("pt,en")).To(Equal([]string{"pt", "en"}))
})
It("trims whitespace from languages", func() {
Expect(conf.ParseLanguages(" pt , en ")).To(Equal([]string{"pt", "en"}))
})
It("returns default 'en' when empty", func() {
Expect(conf.ParseLanguages("")).To(Equal([]string{"en"}))
})
It("returns default 'en' when only whitespace", func() {
Expect(conf.ParseLanguages(" ")).To(Equal([]string{"en"}))
})
It("handles multiple languages with various spacing", func() {
Expect(conf.ParseLanguages("ja, pt, en")).To(Equal([]string{"ja", "pt", "en"}))
})
})
Describe("ValidateURL", func() {
It("accepts a valid http URL", func() {
fn := conf.ValidateURL("TestOption", "http://example.com/path")
Expect(fn()).To(Succeed())
})
It("accepts a valid https URL", func() {
fn := conf.ValidateURL("TestOption", "https://example.com/path")
Expect(fn()).To(Succeed())
})
It("rejects a URL with no scheme", func() {
fn := conf.ValidateURL("TestOption", "example.com/path")
Expect(fn()).To(MatchError(ContainSubstring("invalid scheme")))
})
It("rejects a URL with an unsupported scheme", func() {
fn := conf.ValidateURL("TestOption", "javascript://example.com/path")
Expect(fn()).To(MatchError(ContainSubstring("invalid scheme")))
})
It("accepts an empty URL (optional config)", func() {
fn := conf.ValidateURL("TestOption", "")
Expect(fn()).To(Succeed())
})
It("includes the option name in the error message", func() {
fn := conf.ValidateURL("MyOption", "ftp://example.com")
Expect(fn()).To(MatchError(ContainSubstring("MyOption")))
})
It("rejects a URL that cannot be parsed", func() {
fn := conf.ValidateURL("TestOption", "://invalid")
Expect(fn()).To(HaveOccurred())
})
It("rejects a URL without a host", func() {
fn := conf.ValidateURL("TestOption", "http:///path")
Expect(fn()).To(MatchError(ContainSubstring("non-empty host is required")))
})
})
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("ToPascalCase",
func(input, expected string) {
Expect(conf.ToPascalCase(input)).To(Equal(expected))
},
Entry("simple key", "address", "Address"),
Entry("dotted key", "scanner.schedule", "Scanner.Schedule"),
Entry("already capitalized", "Address", "Address"),
Entry("multi-segment", "lastfm.enabled", "Lastfm.Enabled"),
Entry("empty string", "", ""),
)
Describe("remapEnvVarKeysFromConfig", func() {
BeforeEach(func() {
viper.Reset()
conf.SetViperDefaults()
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("loglevel", "error")
conf.ResetConf()
})
It("remaps ND_-prefixed keys to canonical keys", func() {
filename := filepath.Join("testdata", "cfg_nd_keys.toml")
conf.InitConfig(filename, false)
conf.Load(true)
Expect(conf.Server.Address).To(Equal("127.0.0.1"))
Expect(conf.Server.Port).To(Equal(4531))
Expect(conf.Server.Scanner.Schedule).To(Equal("@every 1h"))
})
It("exits with fatal error when both ND_ and canonical key exist", func() {
filename := filepath.Join("testdata", "cfg_nd_conflict.toml")
conf.InitConfig(filename, false)
Expect(func() { conf.Load(true) }).To(PanicWith(And(
ContainSubstring("ND_ADDRESS"),
ContainSubstring("Address"),
ContainSubstring("only needed for environment variables"),
)))
})
It("does nothing when no ND_ keys are present", func() {
filename := filepath.Join("testdata", "cfg.toml")
conf.InitConfig(filename, false)
conf.Load(true)
// Verify normal config loading still works
Expect(conf.Server.MusicFolder).To(Equal("/toml/music"))
})
})
Describe("logFatal", func() {
var invalidPath string
BeforeEach(func() {
viper.Reset()
conf.SetViperDefaults()
viper.SetDefault("loglevel", "error")
conf.ResetConf()
// Create a file so that any path under it is invalid on all OSes
f, err := os.CreateTemp(GinkgoT().TempDir(), "blocker")
Expect(err).ToNot(HaveOccurred())
f.Close()
invalidPath = filepath.Join(f.Name(), "subdir")
})
It("is called when LoadFromFile gets an invalid config file", func() {
Expect(func() {
conf.LoadFromFile(filepath.Join(invalidPath, "file.toml"))
}).To(PanicWith(ContainSubstring("Error reading config file")))
})
It("is called when DataFolder is not writable", func() {
viper.SetDefault("datafolder", invalidPath)
Expect(func() {
conf.Load(true)
}).To(PanicWith(ContainSubstring("Error creating data path")))
})
It("is called when CacheFolder is not writable", func() {
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("cachefolder", invalidPath)
Expect(func() {
conf.Load(true)
}).To(PanicWith(ContainSubstring("Error creating cache path")))
})
It("is called when LogFile path is not writable", func() {
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("logfile", filepath.Join(invalidPath, "log.txt"))
Expect(func() {
conf.Load(true)
}).To(PanicWith(ContainSubstring("Error opening log file")))
})
It("is called when BaseURL is invalid", func() {
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("baseurl", "://invalid")
Expect(func() {
conf.Load(true)
}).To(PanicWith(ContainSubstring("Invalid BaseURL")))
})
})
Describe("ValidateMaxImageUploadSize", func() {
BeforeEach(func() {
viper.Reset()
conf.SetViperDefaults()
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("loglevel", "error")
conf.ResetConf()
})
DescribeTable("accepts valid size values",
func(input string) {
conf.Server.MaxImageUploadSize = input
Expect(conf.ValidateMaxImageUploadSize()).To(Succeed())
},
Entry("megabytes", "10MB"),
Entry("gigabytes", "1GB"),
Entry("raw bytes", "10485760"),
Entry("mebibytes", "10MiB"),
Entry("lower case", "50mb"),
)
DescribeTable("rejects invalid size values",
func(input string) {
conf.Server.MaxImageUploadSize = input
Expect(conf.ValidateMaxImageUploadSize()).To(MatchError(ContainSubstring("invalid MaxImageUploadSize")))
},
Entry("garbage string", "not-a-size"),
Entry("negative-looking", "-10MB"),
)
})
Describe("EnforceNonRootUser", func() {
It("defaults to false", func() {
conf.Load(true)
Expect(conf.Server.EnforceNonRootUser).To(BeFalse())
})
It("allows startup for non-root users when enabled", func() {
DeferCleanup(conf.SetRuntimeInfoForTest("linux", 1000))
viper.Set("enforcenonrootuser", true)
conf.Load(true)
Expect(conf.Server.EnforceNonRootUser).To(BeTrue())
})
It("exits when enabled and running as root without having created a data folder", func() {
// Create a path that doesn't exist yet
tempBase := GinkgoT().TempDir()
nonExistentDataFolder := filepath.Join(tempBase, "nonexistent", "data")
DeferCleanup(conf.SetRuntimeInfoForTest("linux", 0))
viper.Set("enforcenonrootuser", true)
viper.Set("datafolder", nonExistentDataFolder)
// Attempt to load config as root user - should fail before creating directories
Expect(func() {
conf.Load(true)
}).To(PanicWith(ContainSubstring("EnforceNonRootUser is enabled but Navidrome is running as root")))
// Verify that the data folder was NOT created
Expect(nonExistentDataFolder).ToNot(BeAnExistingFile())
})
It("is a no-op on non-unix platforms", func() {
DeferCleanup(conf.SetRuntimeInfoForTest("windows", 0))
viper.Set("enforcenonrootuser", true)
conf.Load(true)
Expect(conf.Server.EnforceNonRootUser).To(BeTrue())
})
})
DescribeTable("should load configuration from",
@@ -298,7 +31,7 @@ var _ = Describe("Configuration", func() {
filename := filepath.Join("testdata", "cfg."+format)
// Initialize config with the test file
conf.InitConfig(filename, false)
conf.InitConfig(filename)
// Load the configuration (with noConfigDump=true)
conf.Load(true)
@@ -306,10 +39,6 @@ var _ = Describe("Configuration", func() {
Expect(conf.Server.MusicFolder).To(Equal(fmt.Sprintf("/%s/music", format)))
Expect(conf.Server.UIWelcomeMessage).To(Equal("Welcome " + format))
Expect(conf.Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"}))
Expect(conf.Server.Tags["artist"].Split).To(Equal([]string{";"}))
// Check deprecated option mapping
Expect(conf.Server.ExtAuth.UserHeader).To(Equal("X-Auth-User"))
// The config file used should be the one we created
Expect(conf.Server.ConfigFile).To(Equal(filename))
-27
View File
@@ -5,30 +5,3 @@ func ResetConf() {
}
var SetViperDefaults = setViperDefaults
var ParseLanguages = parseLanguages
var ValidateURL = validateURL
var NormalizeSearchBackend = normalizeSearchBackend
var ToPascalCase = toPascalCase
var ValidateMaxImageUploadSize = validateMaxImageUploadSize
func SetRuntimeInfoForTest(goos string, euid int) func() {
oldGOOS := currentGOOS
oldEUID := getEUID
currentGOOS = func() string { return goos }
getEUID = func() int { return euid }
return func() {
currentGOOS = oldGOOS
getEUID = oldEUID
}
}
func SetLogFatal(f func(...any)) func() {
old := logFatal
logFatal = f
return func() { logFatal = old }
}
+2 -4
View File
@@ -1,8 +1,6 @@
[default]
MusicFolder = /ini/music
UIWelcomeMessage = 'Welcome ini' ; Just a comment to test the LoadOptions
ReverseProxyUserHeader = 'X-Auth-User'
UIWelcomeMessage = Welcome ini
[Tags]
Custom.Aliases = ini,test
artist.Split = ";" # Should be able to read ; as a separator
Custom.Aliases = ini,test
-4
View File
@@ -1,11 +1,7 @@
{
"musicFolder": "/json/music",
"uiWelcomeMessage": "Welcome json",
"reverseProxyUserHeader": "X-Auth-User",
"Tags": {
"artist": {
"split": ";"
},
"custom": {
"aliases": [
"json",
-3
View File
@@ -1,8 +1,5 @@
musicFolder = "/toml/music"
uiWelcomeMessage = "Welcome toml"
ReverseProxyUserHeader = "X-Auth-User"
Tags.artist.Split = ';'
[Tags.custom]
aliases = ["toml", "test"]
-3
View File
@@ -1,9 +1,6 @@
musicFolder: "/yaml/music"
uiWelcomeMessage: "Welcome yaml"
reverseProxyUserHeader: "X-Auth-User"
Tags:
artist:
split: [";"]
custom:
aliases:
- yaml
-2
View File
@@ -1,2 +0,0 @@
ND_ADDRESS = "127.0.0.1"
Address = "0.0.0.0"
-3
View File
@@ -1,3 +0,0 @@
ND_ADDRESS = "127.0.0.1"
ND_PORT = 4531
ND_SCANNER_SCHEDULE = "@every 1h"
+5 -33
View File
@@ -56,8 +56,6 @@ const (
ServerReadHeaderTimeout = 3 * time.Second
DefaultInfoLanguage = "en"
ArtistInfoTimeToLive = 24 * time.Hour
AlbumInfoTimeToLive = 7 * 24 * time.Hour
UpdateLastAccessFrequency = time.Minute
@@ -65,31 +63,20 @@ const (
I18nFolder = "i18n"
ScanIgnoreFile = ".ndignore"
ArtworkFolder = "artwork"
PlaceholderArtistArt = "artist-placeholder.webp"
PlaceholderAlbumArt = "album-placeholder.webp"
PlaceholderAvatar = "logo-192x192.png"
DefaultUIVolume = 100
DefaultUISearchDebounceMs = 200
DefaultUIPlaybackReportInterval = time.Minute
PlaceholderArtistArt = "artist-placeholder.webp"
PlaceholderAlbumArt = "album-placeholder.webp"
PlaceholderAvatar = "logo-192x192.png"
UICoverArtSize = 300
DefaultUIVolume = 100
DefaultHttpClientTimeOut = 10 * time.Second
DefaultListenBrainzBaseURL = "https://api.listenbrainz.org/1/"
DefaultListenBrainzArtistAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
DefaultListenBrainzTrackAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
DefaultScannerExtractor = "taglib"
DefaultWatcherWait = 5 * time.Second
Zwsp = string('\u200b')
)
const (
DefaultUICoverArtSize = 300
DefaultMaxImageUploadSize = "10MB"
)
// Prometheus options
const (
PrometheusDefaultPath = "/metrics"
@@ -108,13 +95,6 @@ const (
DefaultCacheCleanUpInterval = 10 * time.Minute
)
// Entity types
const (
EntityArtist = "artist"
EntityPlaylist = "playlist"
EntityRadio = "radio"
)
const (
AlbumPlayCountModeAbsolute = "absolute"
AlbumPlayCountModeNormalized = "normalized"
@@ -167,17 +147,9 @@ var (
DefaultBitRate: 256,
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
},
{
Name: "flac audio",
TargetFormat: "flac",
DefaultBitRate: 0,
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -",
},
}
)
var HTTPUserAgent = "Navidrome" + "/" + Version
var (
VariousArtists = "Various Artists"
// TODO This will be dynamic when using disambiguation
-4
View File
@@ -1,4 +0,0 @@
{
"url": "https://context7.com/navidrome/navidrome",
"public_key": "pk_WqzhKScNKWQ84J4n0oG0J"
}
+1 -1
View File
@@ -7,6 +7,6 @@ A new agent must comply with these simple implementation rules:
2) Implement one or more of the `*Retriever()` interfaces. That's where the agent's logic resides.
3) Register itself (in its `init()` function).
For an agent to be used it needs to be listed in the `Agents` config option (default is `"deezer,lastfm"`). The order dictates the priority of the agents
For an agent to be used it needs to be listed in the `Agents` config option (default is `"lastfm,spotify"`). The order dictates the priority of the agents
For a simple Agent example, look at the [local_agent](local_agent.go) agent source code.
+118 -126
View File
@@ -22,8 +22,6 @@ type PluginLoader interface {
LoadMediaAgent(name string) (Interface, bool)
}
// Agents is a meta-agent that aggregates multiple built-in and plugin agents. It tries each enabled agent in order
// until one returns valid data.
type Agents struct {
ds model.DataStore
pluginLoader PluginLoader
@@ -66,7 +64,6 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent {
if a.pluginLoader != nil {
availablePlugins = a.pluginLoader.PluginNames("MetadataAgent")
}
log.Trace("Available MetadataAgent plugins", "plugins", availablePlugins)
configuredAgents := strings.Split(conf.Server.Agents, ",")
@@ -90,7 +87,7 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent {
} else if isPlugin {
validAgents = append(validAgents, enabledAgent{name: name, isPlugin: true})
} else {
log.Debug("Unknown agent ignored", "name", name)
log.Warn("Unknown agent ignored", "name", name)
}
}
return validAgents
@@ -131,14 +128,26 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str
case consts.VariousArtistsID:
return "", nil
}
return callAgentMethod(ctx, a, "GetArtistMBID", func(ag Interface) (string, error) {
start := time.Now()
for _, enabledAgent := range a.getEnabledAgentNames() {
ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(ArtistMBIDRetriever)
if !ok {
return "", ErrNotFound
continue
}
return retriever.GetArtistMBID(ctx, id, name)
})
mbid, err := retriever.GetArtistMBID(ctx, id, name)
if mbid != "" && err == nil {
log.Debug(ctx, "Got MBID", "agent", ag.AgentName(), "artist", name, "mbid", mbid, "elapsed", time.Since(start))
return mbid, nil
}
}
return "", ErrNotFound
}
func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
@@ -148,14 +157,26 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin
case consts.VariousArtistsID:
return "", nil
}
return callAgentMethod(ctx, a, "GetArtistURL", func(ag Interface) (string, error) {
start := time.Now()
for _, enabledAgent := range a.getEnabledAgentNames() {
ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(ArtistURLRetriever)
if !ok {
return "", ErrNotFound
continue
}
return retriever.GetArtistURL(ctx, id, name, mbid)
})
url, err := retriever.GetArtistURL(ctx, id, name, mbid)
if url != "" && err == nil {
log.Debug(ctx, "Got External Url", "agent", ag.AgentName(), "artist", name, "url", url, "elapsed", time.Since(start))
return url, nil
}
}
return "", ErrNotFound
}
func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
@@ -165,14 +186,26 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string)
case consts.VariousArtistsID:
return "", nil
}
return callAgentMethod(ctx, a, "GetArtistBiography", func(ag Interface) (string, error) {
start := time.Now()
for _, enabledAgent := range a.getEnabledAgentNames() {
ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(ArtistBiographyRetriever)
if !ok {
return "", ErrNotFound
continue
}
return retriever.GetArtistBiography(ctx, id, name, mbid)
})
bio, err := retriever.GetArtistBiography(ctx, id, name, mbid)
if err == nil {
log.Debug(ctx, "Got Biography", "agent", ag.AgentName(), "artist", name, "len", len(bio), "elapsed", time.Since(start))
return bio, nil
}
}
return "", ErrNotFound
}
// GetSimilarArtists returns similar artists by id, name, and/or mbid. Because some artists returned from an enabled
@@ -220,14 +253,26 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]
case consts.VariousArtistsID:
return nil, nil
}
return callAgentSliceMethod(ctx, a, "GetArtistImages", func(ag Interface) ([]ExternalImage, error) {
start := time.Now()
for _, enabledAgent := range a.getEnabledAgentNames() {
ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(ArtistImageRetriever)
if !ok {
return nil, ErrNotFound
continue
}
return retriever.GetArtistImages(ctx, id, name, mbid)
})
images, err := retriever.GetArtistImages(ctx, id, name, mbid)
if len(images) > 0 && err == nil {
log.Debug(ctx, "Got Images", "agent", ag.AgentName(), "artist", name, "images", images, "elapsed", time.Since(start))
return images, nil
}
}
return nil, ErrNotFound
}
// GetArtistTopSongs returns top songs by id, name, and/or mbid. Because some songs returned from an enabled
@@ -242,127 +287,77 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str
overLimit := int(float64(count) * conf.Server.DevExternalArtistFetchMultiplier)
return callAgentSliceMethod(ctx, a, "GetArtistTopSongs", func(ag Interface) ([]Song, error) {
start := time.Now()
for _, enabledAgent := range a.getEnabledAgentNames() {
ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(ArtistTopSongsRetriever)
if !ok {
return nil, ErrNotFound
continue
}
return retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit)
})
songs, err := retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit)
if len(songs) > 0 && err == nil {
log.Debug(ctx, "Got Top Songs", "agent", ag.AgentName(), "artist", artistName, "songs", songs, "elapsed", time.Since(start))
return songs, nil
}
}
return nil, ErrNotFound
}
func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) {
if name == consts.UnknownAlbum {
return nil, ErrNotFound
}
return callAgentMethod(ctx, a, "GetAlbumInfo", func(ag Interface) (*AlbumInfo, error) {
start := time.Now()
for _, enabledAgent := range a.getEnabledAgentNames() {
ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(AlbumInfoRetriever)
if !ok {
return nil, ErrNotFound
continue
}
return retriever.GetAlbumInfo(ctx, name, artist, mbid)
})
album, err := retriever.GetAlbumInfo(ctx, name, artist, mbid)
if err == nil {
log.Debug(ctx, "Got Album Info", "agent", ag.AgentName(), "album", name, "artist", artist,
"mbid", mbid, "elapsed", time.Since(start))
return album, nil
}
}
return nil, ErrNotFound
}
func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]ExternalImage, error) {
if name == consts.UnknownAlbum {
return nil, ErrNotFound
}
return callAgentSliceMethod(ctx, a, "GetAlbumImages", func(ag Interface) ([]ExternalImage, error) {
start := time.Now()
for _, enabledAgent := range a.getEnabledAgentNames() {
ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(AlbumImageRetriever)
if !ok {
return nil, ErrNotFound
}
return retriever.GetAlbumImages(ctx, name, artist, mbid)
})
}
// GetSimilarSongsByTrack returns similar songs for a given track.
func (a *Agents) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
return callAgentSliceMethod(ctx, a, "GetSimilarSongsByTrack", func(ag Interface) ([]Song, error) {
retriever, ok := ag.(SimilarSongsByTrackRetriever)
if !ok {
return nil, ErrNotFound
}
return retriever.GetSimilarSongsByTrack(ctx, id, name, artist, mbid, count)
})
}
// GetSimilarSongsByAlbum returns similar songs for a given album.
func (a *Agents) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
return callAgentSliceMethod(ctx, a, "GetSimilarSongsByAlbum", func(ag Interface) ([]Song, error) {
retriever, ok := ag.(SimilarSongsByAlbumRetriever)
if !ok {
return nil, ErrNotFound
}
return retriever.GetSimilarSongsByAlbum(ctx, id, name, artist, mbid, count)
})
}
// GetSimilarSongsByArtist returns similar songs for a given artist.
func (a *Agents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]Song, error) {
switch id {
case consts.UnknownArtistID:
return nil, ErrNotFound
case consts.VariousArtistsID:
return nil, nil
}
return callAgentSliceMethod(ctx, a, "GetSimilarSongsByArtist", func(ag Interface) ([]Song, error) {
retriever, ok := ag.(SimilarSongsByArtistRetriever)
if !ok {
return nil, ErrNotFound
}
return retriever.GetSimilarSongsByArtist(ctx, id, name, mbid, count)
})
}
func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error)) (T, error) {
var zero T
start := time.Now()
for _, enabledAgent := range agents.getEnabledAgentNames() {
ag := agents.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
result, err := fn(ag)
if err != nil {
log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err)
continue
}
if result != zero {
log.Debug(ctx, "Got result", "method", methodName, "agent", ag.AgentName(), "elapsed", time.Since(start))
return result, nil
}
}
return zero, ErrNotFound
}
func callAgentSliceMethod[T any](ctx context.Context, agents *Agents, methodName string, fn func(Interface) ([]T, error)) ([]T, error) {
start := time.Now()
for _, enabledAgent := range agents.getEnabledAgentNames() {
ag := agents.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
results, err := fn(ag)
if err != nil {
log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err)
continue
}
if len(results) > 0 {
log.Debug(ctx, "Got results", "method", methodName, "agent", ag.AgentName(), "count", len(results), "elapsed", time.Since(start))
return results, nil
images, err := retriever.GetAlbumImages(ctx, name, artist, mbid)
if len(images) > 0 && err == nil {
log.Debug(ctx, "Got Album Images", "agent", ag.AgentName(), "album", name, "artist", artist,
"mbid", mbid, "elapsed", time.Since(start))
return images, nil
}
}
return nil, ErrNotFound
@@ -377,6 +372,3 @@ var _ ArtistImageRetriever = (*Agents)(nil)
var _ ArtistTopSongsRetriever = (*Agents)(nil)
var _ AlbumInfoRetriever = (*Agents)(nil)
var _ AlbumImageRetriever = (*Agents)(nil)
var _ SimilarSongsByTrackRetriever = (*Agents)(nil)
var _ SimilarSongsByAlbumRetriever = (*Agents)(nil)
var _ SimilarSongsByArtistRetriever = (*Agents)(nil)
+10 -109
View File
@@ -295,77 +295,11 @@ var _ = Describe("Agents", func() {
Expect(mock.Args).To(BeEmpty())
})
})
Describe("GetSimilarSongsByTrack", func() {
It("returns on first match", func() {
Expect(ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2)).To(Equal([]Song{{
Name: "Similar Song",
MBID: "mbid555",
}}))
Expect(mock.Args).To(HaveExactElements("123", "test song", "test artist", "mb123", 2))
})
It("skips the agent if it returns an error", func() {
mock.Err = errors.New("error")
_, err := ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2)
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(HaveExactElements("123", "test song", "test artist", "mb123", 2))
})
It("interrupts if the context is canceled", func() {
cancel()
_, err := ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2)
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(BeEmpty())
})
})
Describe("GetSimilarSongsByAlbum", func() {
It("returns on first match", func() {
Expect(ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2)).To(Equal([]Song{{
Name: "Album Similar Song",
MBID: "mbid666",
}}))
Expect(mock.Args).To(HaveExactElements("123", "test album", "test artist", "mb123", 2))
})
It("skips the agent if it returns an error", func() {
mock.Err = errors.New("error")
_, err := ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2)
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(HaveExactElements("123", "test album", "test artist", "mb123", 2))
})
It("interrupts if the context is canceled", func() {
cancel()
_, err := ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2)
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(BeEmpty())
})
})
Describe("GetSimilarSongsByArtist", func() {
It("returns on first match", func() {
Expect(ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2)).To(Equal([]Song{{
Name: "Artist Similar Song",
MBID: "mbid777",
}}))
Expect(mock.Args).To(HaveExactElements("123", "test artist", "mb123", 2))
})
It("skips the agent if it returns an error", func() {
mock.Err = errors.New("error")
_, err := ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2)
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(HaveExactElements("123", "test artist", "mb123", 2))
})
It("interrupts if the context is canceled", func() {
cancel()
_, err := ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2)
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(BeEmpty())
})
})
})
})
type mockAgent struct {
Args []any
Args []interface{}
Err error
}
@@ -374,7 +308,7 @@ func (a *mockAgent) AgentName() string {
}
func (a *mockAgent) GetArtistMBID(_ context.Context, id string, name string) (string, error) {
a.Args = []any{id, name}
a.Args = []interface{}{id, name}
if a.Err != nil {
return "", a.Err
}
@@ -382,7 +316,7 @@ func (a *mockAgent) GetArtistMBID(_ context.Context, id string, name string) (st
}
func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (string, error) {
a.Args = []any{id, name, mbid}
a.Args = []interface{}{id, name, mbid}
if a.Err != nil {
return "", a.Err
}
@@ -390,7 +324,7 @@ func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (stri
}
func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string) (string, error) {
a.Args = []any{id, name, mbid}
a.Args = []interface{}{id, name, mbid}
if a.Err != nil {
return "", a.Err
}
@@ -398,7 +332,7 @@ func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string)
}
func (a *mockAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) {
a.Args = []any{id, name, mbid}
a.Args = []interface{}{id, name, mbid}
if a.Err != nil {
return nil, a.Err
}
@@ -409,7 +343,7 @@ func (a *mockAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([
}
func (a *mockAgent) GetSimilarArtists(_ context.Context, id, name, mbid string, limit int) ([]Artist, error) {
a.Args = []any{id, name, mbid, limit}
a.Args = []interface{}{id, name, mbid, limit}
if a.Err != nil {
return nil, a.Err
}
@@ -420,7 +354,7 @@ func (a *mockAgent) GetSimilarArtists(_ context.Context, id, name, mbid string,
}
func (a *mockAgent) GetArtistTopSongs(_ context.Context, id, artistName, mbid string, count int) ([]Song, error) {
a.Args = []any{id, artistName, mbid, count}
a.Args = []interface{}{id, artistName, mbid, count}
if a.Err != nil {
return nil, a.Err
}
@@ -431,7 +365,7 @@ func (a *mockAgent) GetArtistTopSongs(_ context.Context, id, artistName, mbid st
}
func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) {
a.Args = []any{name, artist, mbid}
a.Args = []interface{}{name, artist, mbid}
if a.Err != nil {
return nil, a.Err
}
@@ -443,39 +377,6 @@ func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string)
}, nil
}
func (a *mockAgent) GetSimilarSongsByTrack(_ context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
a.Args = []any{id, name, artist, mbid, count}
if a.Err != nil {
return nil, a.Err
}
return []Song{{
Name: "Similar Song",
MBID: "mbid555",
}}, nil
}
func (a *mockAgent) GetSimilarSongsByAlbum(_ context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
a.Args = []any{id, name, artist, mbid, count}
if a.Err != nil {
return nil, a.Err
}
return []Song{{
Name: "Album Similar Song",
MBID: "mbid666",
}}, nil
}
func (a *mockAgent) GetSimilarSongsByArtist(_ context.Context, id, name, mbid string, count int) ([]Song, error) {
a.Args = []any{id, name, mbid, count}
if a.Err != nil {
return nil, a.Err
}
return []Song{{
Name: "Artist Similar Song",
MBID: "mbid777",
}}, nil
}
type emptyAgent struct {
Interface
}
@@ -488,12 +389,12 @@ type testImageAgent struct {
Name string
Images []ExternalImage
Err error
Args []any
Args []interface{}
}
func (t *testImageAgent) AgentName() string { return t.Name }
func (t *testImageAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) {
t.Args = []any{id, name, mbid}
t.Args = []interface{}{id, name, mbid}
return t.Images, t.Err
}
+83
View File
@@ -0,0 +1,83 @@
package deezer
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"github.com/navidrome/navidrome/log"
)
const apiBaseURL = "https://api.deezer.com"
var (
ErrNotFound = errors.New("deezer: not found")
)
type httpDoer interface {
Do(req *http.Request) (*http.Response, error)
}
type client struct {
httpDoer httpDoer
}
func newClient(hc httpDoer) *client {
return &client{hc}
}
func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
params := url.Values{}
params.Add("q", name)
params.Add("limit", strconv.Itoa(limit))
req, err := http.NewRequestWithContext(ctx, "GET", apiBaseURL+"/search/artist", nil)
if err != nil {
return nil, err
}
req.URL.RawQuery = params.Encode()
var results SearchArtistResults
err = c.makeRequest(req, &results)
if err != nil {
return nil, err
}
if len(results.Data) == 0 {
return nil, ErrNotFound
}
return results.Data, nil
}
func (c *client) makeRequest(req *http.Request, response interface{}) error {
log.Trace(req.Context(), fmt.Sprintf("Sending Deezer %s request", req.Method), "url", req.URL)
resp, err := c.httpDoer.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode != 200 {
return c.parseError(data)
}
return json.Unmarshal(data, response)
}
func (c *client) parseError(data []byte) error {
var deezerError Error
err := json.Unmarshal(data, &deezerError)
if err != nil {
return err
}
return fmt.Errorf("deezer error(%d): %s", deezerError.Error.Code, deezerError.Error.Message)
}
+68
View File
@@ -0,0 +1,68 @@
package deezer
import (
"bytes"
"context"
"io"
"net/http"
"os"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("client", func() {
var httpClient *fakeHttpClient
var client *client
BeforeEach(func() {
httpClient = &fakeHttpClient{}
client = newClient(httpClient)
})
Describe("ArtistImages", func() {
It("returns artist images from a successful request", func() {
f, err := os.Open("tests/fixtures/deezer.search.artist.json")
Expect(err).To(BeNil())
httpClient.mock("https://api.deezer.com/search/artist", http.Response{Body: f, StatusCode: 200})
artists, err := client.searchArtists(context.TODO(), "Michael Jackson", 20)
Expect(err).To(BeNil())
Expect(artists).To(HaveLen(17))
Expect(artists[0].Name).To(Equal("Michael Jackson"))
Expect(artists[0].PictureXl).To(Equal("https://cdn-images.dzcdn.net/images/artist/97fae13b2b30e4aec2e8c9e0c7839d92/1000x1000-000000-80-0-0.jpg"))
})
It("fails if artist was not found", func() {
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)),
})
_, err := client.searchArtists(context.TODO(), "Michael Jackson", 20)
Expect(err).To(MatchError(ErrNotFound))
})
})
})
type fakeHttpClient struct {
responses map[string]*http.Response
lastRequest *http.Request
}
func (c *fakeHttpClient) mock(url string, response http.Response) {
if c.responses == nil {
c.responses = make(map[string]*http.Response)
}
c.responses[url] = &response
}
func (c *fakeHttpClient) Do(req *http.Request) (*http.Response, error) {
c.lastRequest = req
u := req.URL
u.RawQuery = ""
if resp, ok := c.responses[u.String()]; ok {
return resp, nil
}
panic("URL not mocked: " + u.String())
}
@@ -3,7 +3,6 @@ package deezer
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
@@ -13,7 +12,6 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/slice"
)
const deezerAgentName = "deezer"
@@ -26,14 +24,10 @@ const deezerArtistSearchLimit = 50
type deezerAgent struct {
dataStore model.DataStore
client *client
languages []string
}
func deezerConstructor(dataStore model.DataStore) agents.Interface {
agent := &deezerAgent{
dataStore: dataStore,
languages: conf.Server.Deezer.Languages,
}
agent := &deezerAgent{dataStore: dataStore}
httpClient := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
@@ -87,82 +81,13 @@ func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, e
return nil, err
}
log.Trace(ctx, "Artists found", "count", len(artists), "searched_name", name)
for i := range artists {
log.Trace(ctx, fmt.Sprintf("Artists found #%d", i), "name", artists[i].Name, "id", artists[i].ID, "link", artists[i].Link)
if i > 2 {
break
}
}
// If the first one has the same name, that's the one
if !strings.EqualFold(artists[0].Name, name) {
log.Trace(ctx, "Top artist do not match", "searched_name", name, "found_name", artists[0].Name)
return nil, agents.ErrNotFound
}
log.Trace(ctx, "Found artist", "name", artists[0].Name, "id", artists[0].ID, "link", artists[0].Link)
return &artists[0], err
}
func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) {
artist, err := s.searchArtist(ctx, name)
if err != nil {
return nil, err
}
related, err := s.client.getRelatedArtists(ctx, artist.ID)
if err != nil {
return nil, err
}
res := slice.Map(related, func(r Artist) agents.Artist {
return agents.Artist{
Name: r.Name,
}
})
if len(res) > limit {
res = res[:limit]
}
return res, nil
}
func (s *deezerAgent) GetArtistTopSongs(ctx context.Context, _, artistName, _ string, count int) ([]agents.Song, error) {
artist, err := s.searchArtist(ctx, artistName)
if err != nil {
return nil, err
}
tracks, err := s.client.getTopTracks(ctx, artist.ID, count)
if err != nil {
return nil, err
}
res := slice.Map(tracks, func(r Track) agents.Song {
return agents.Song{
Name: r.Title,
Album: r.Album.Title,
Duration: uint32(r.Duration * 1000), // Convert seconds to milliseconds
}
})
return res, nil
}
func (s *deezerAgent) GetArtistBiography(ctx context.Context, _, name, _ string) (string, error) {
artist, err := s.searchArtist(ctx, name)
if err != nil {
return "", err
}
for _, lang := range s.languages {
bio, err := s.client.getArtistBio(ctx, artist.ID, lang)
if err == nil && bio != "" {
return bio, nil
}
log.Debug(ctx, "Deezer/artist.bio returned empty/error, trying next language", "artist", name, "lang", lang, err)
}
return "", agents.ErrNotFound
}
func init() {
conf.AddHook(func() {
if conf.Server.Deezer.Enabled {
File renamed without changes.
+31
View File
@@ -0,0 +1,31 @@
package deezer
type SearchArtistResults struct {
Data []Artist `json:"data"`
Total int `json:"total"`
Next string `json:"next"`
}
type Artist struct {
ID int `json:"id"`
Name string `json:"name"`
Link string `json:"link"`
Picture string `json:"picture"`
PictureSmall string `json:"picture_small"`
PictureMedium string `json:"picture_medium"`
PictureBig string `json:"picture_big"`
PictureXl string `json:"picture_xl"`
NbAlbum int `json:"nb_album"`
NbFan int `json:"nb_fan"`
Radio bool `json:"radio"`
Tracklist string `json:"tracklist"`
Type string `json:"type"`
}
type Error struct {
Error struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `json:"error"`
}
@@ -35,35 +35,4 @@ var _ = Describe("Responses", func() {
Expect(errorResp.Error.Message).To(Equal("Missing parameters: q"))
})
})
Describe("Related Artists", func() {
It("parses the related artists response correctly", func() {
var resp RelatedArtists
body, err := os.ReadFile("tests/fixtures/deezer.artist.related.json")
Expect(err).To(BeNil())
err = json.Unmarshal(body, &resp)
Expect(err).To(BeNil())
Expect(resp.Data).To(HaveLen(20))
justice := resp.Data[0]
Expect(justice.Name).To(Equal("Justice"))
Expect(justice.ID).To(Equal(6404))
})
})
Describe("Top Tracks", func() {
It("parses the top tracks response correctly", func() {
var resp TopTracks
body, err := os.ReadFile("tests/fixtures/deezer.artist.top.json")
Expect(err).To(BeNil())
err = json.Unmarshal(body, &resp)
Expect(err).To(BeNil())
Expect(resp.Data).To(HaveLen(5))
track := resp.Data[0]
Expect(track.Title).To(Equal("Instant Crush (feat. Julian Casablancas)"))
Expect(track.ID).To(Equal(67238732))
Expect(track.Album.Title).To(Equal("Random Access Memories"))
})
})
})
+2 -45
View File
@@ -22,7 +22,6 @@ type AlbumInfo struct {
}
type Artist struct {
ID string
Name string
MBID string
}
@@ -33,15 +32,8 @@ type ExternalImage struct {
}
type Song struct {
ID string
Name string
MBID string
ISRC string
Artist string
ArtistMBID string
Album string
AlbumMBID string
Duration uint32 // Duration in milliseconds, 0 means unknown
Name string
MBID string
}
var (
@@ -82,41 +74,6 @@ type ArtistTopSongsRetriever interface {
GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error)
}
// SimilarSongsByTrackRetriever provides similar songs based on a specific track
type SimilarSongsByTrackRetriever interface {
// GetSimilarSongsByTrack returns songs similar to the given track.
// Parameters:
// - id: local mediafile ID
// - name: track title
// - artist: artist name
// - mbid: MusicBrainz recording ID (may be empty)
// - count: maximum number of results
GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error)
}
// SimilarSongsByAlbumRetriever provides similar songs based on an album
type SimilarSongsByAlbumRetriever interface {
// GetSimilarSongsByAlbum returns songs similar to tracks on the given album.
// Parameters:
// - id: local album ID
// - name: album name
// - artist: album artist name
// - mbid: MusicBrainz release ID (may be empty)
// - count: maximum number of results
GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error)
}
// SimilarSongsByArtistRetriever provides similar songs based on an artist
type SimilarSongsByArtistRetriever interface {
// GetSimilarSongsByArtist returns songs similar to the artist's catalog.
// Parameters:
// - id: local artist ID
// - name: artist name
// - mbid: MusicBrainz artist ID (may be empty)
// - count: maximum number of results
GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]Song, error)
}
var Map map[string]Constructor
func Register(name string, init Constructor) {
@@ -26,25 +26,18 @@ const (
sessionKeyProperty = "LastFMSessionKey"
)
var ignoredContent = []string{
// Empty Artist/Album
var ignoredBiographies = []string{
// Unknown Artist
`<a href="https://www.last.fm/music/`,
}
var lastFMReadMoreRegex = regexp.MustCompile(`\s*<a href="https://www\.last\.fm/music/[^"]*">Read more on Last\.fm</a>\.?`)
func cleanContent(content string) string {
return strings.TrimSpace(lastFMReadMoreRegex.ReplaceAllString(content, ""))
}
type lastfmAgent struct {
ds model.DataStore
sessionKeys *agents.SessionKeys
apiKey string
secret string
languages []string
lang string
client *client
httpClient httpDoer
getInfoMutex sync.Mutex
}
@@ -54,7 +47,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
}
l := &lastfmAgent{
ds: ds,
languages: conf.Server.LastFM.Languages,
lang: conf.Server.LastFM.Language,
apiKey: conf.Server.LastFM.ApiKey,
secret: conf.Server.LastFM.Secret,
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
@@ -63,8 +56,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
Timeout: consts.DefaultHttpClientTimeOut,
}
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
l.httpClient = chc
l.client = newClient(l.apiKey, l.secret, chc)
l.client = newClient(l.apiKey, l.secret, l.lang, chc)
return l
}
@@ -74,47 +66,22 @@ func (l *lastfmAgent) AgentName() string {
var imageRegex = regexp.MustCompile(`u\/(\d+)`)
// isValidContent checks if content is non-empty and not in the ignored list
func isValidContent(content string) bool {
content = strings.TrimSpace(content)
if content == "" {
return false
}
for _, ign := range ignoredContent {
if strings.HasPrefix(content, ign) {
return false
}
}
return true
}
func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
var a *Album
var resp agents.AlbumInfo
for _, lang := range l.languages {
var err error
a, err = l.callAlbumGetInfo(ctx, name, artist, mbid, lang)
if err != nil {
return nil, err
}
resp.Name = a.Name
resp.MBID = a.MBID
resp.URL = a.URL
if isValidContent(a.Description.Summary) {
resp.Description = cleanContent(a.Description.Summary)
return &resp, nil
}
log.Debug(ctx, "LastFM/album.getInfo returned empty/ignored description, trying next language", "album", name, "artist", artist, "lang", lang)
a, err := l.callAlbumGetInfo(ctx, name, artist, mbid)
if err != nil {
return nil, err
}
// This condition should not be hit (languages default to ["en"]), but just in case
if a == nil {
return nil, agents.ErrNotFound
}
return &resp, nil
return &agents.AlbumInfo{
Name: a.Name,
MBID: a.MBID,
Description: a.Description.Summary,
URL: a.URL,
}, nil
}
func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
a, err := l.callAlbumGetInfo(ctx, name, artist, mbid, l.languages[0])
a, err := l.callAlbumGetInfo(ctx, name, artist, mbid)
if err != nil {
return nil, err
}
@@ -149,7 +116,7 @@ func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid str
}
func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
a, err := l.callArtistGetInfo(ctx, name, l.languages[0])
a, err := l.callArtistGetInfo(ctx, name)
if err != nil {
return "", err
}
@@ -160,7 +127,7 @@ func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string)
}
func (l *lastfmAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
a, err := l.callArtistGetInfo(ctx, name, l.languages[0])
a, err := l.callArtistGetInfo(ctx, name)
if err != nil {
return "", err
}
@@ -171,17 +138,20 @@ func (l *lastfmAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (
}
func (l *lastfmAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
for _, lang := range l.languages {
a, err := l.callArtistGetInfo(ctx, name, lang)
if err != nil {
return "", err
}
if isValidContent(a.Bio.Summary) {
return cleanContent(a.Bio.Summary), nil
}
log.Debug(ctx, "LastFM/artist.getInfo returned empty/ignored biography, trying next language", "artist", name, "lang", lang)
a, err := l.callArtistGetInfo(ctx, name)
if err != nil {
return "", err
}
return "", agents.ErrNotFound
a.Bio.Summary = strings.TrimSpace(a.Bio.Summary)
if a.Bio.Summary == "" {
return "", agents.ErrNotFound
}
for _, ign := range ignoredBiographies {
if strings.HasPrefix(a.Bio.Summary, ign) {
return "", nil
}
}
return a.Bio.Summary, nil
}
func (l *lastfmAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
@@ -220,34 +190,14 @@ func (l *lastfmAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbi
return res, nil
}
func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
resp, err := l.callTrackGetSimilar(ctx, name, artist, count)
if err != nil {
return nil, err
}
if len(resp) == 0 {
return nil, agents.ErrNotFound
}
res := make([]agents.Song, 0, len(resp))
for _, t := range resp {
res = append(res, agents.Song{
Name: t.Name,
MBID: t.MBID,
Artist: t.Artist.Name,
ArtistMBID: t.Artist.MBID,
})
}
return res, nil
}
var (
artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name
)
var artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
log.Debug(ctx, "Getting artist images from Last.fm", "name", name)
a, err := l.callArtistGetInfo(ctx, name, l.languages[0])
hc := http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
a, err := l.callArtistGetInfo(ctx, name)
if err != nil {
return nil, fmt.Errorf("get artist info: %w", err)
}
@@ -255,7 +205,7 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
if err != nil {
return nil, fmt.Errorf("create artist image request: %w", err)
}
resp, err := l.httpClient.Do(req)
resp, err := hc.Do(req)
if err != nil {
return nil, fmt.Errorf("get artist url: %w", err)
}
@@ -272,29 +222,24 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
return res, nil
}
for _, attr := range n.Attr {
if attr.Key != "content" {
continue
}
if strings.Contains(attr.Val, artistIgnoredImage) {
log.Debug(ctx, "Artist image is ignored default image", "name", name, "url", attr.Val)
return res, nil
}
res = []agents.ExternalImage{
{URL: attr.Val},
if attr.Key == "content" {
res = []agents.ExternalImage{
{URL: attr.Val},
}
break
}
}
return res, nil
}
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string, lang string) (*Album, error) {
a, err := l.client.albumGetInfo(ctx, name, artist, mbid, lang)
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string) (*Album, error) {
a, err := l.client.albumGetInfo(ctx, name, artist, mbid)
var lfErr *lastFMError
isLastFMError := errors.As(err, &lfErr)
if mbid != "" && (isLastFMError && lfErr.Code == 6) {
log.Debug(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid)
return l.callAlbumGetInfo(ctx, name, artist, "", lang)
return l.callAlbumGetInfo(ctx, name, artist, "")
}
if err != nil {
@@ -308,11 +253,11 @@ func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid s
return a, nil
}
func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) {
func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string) (*Artist, error) {
l.getInfoMutex.Lock()
defer l.getInfoMutex.Unlock()
a, err := l.client.artistGetInfo(ctx, name, lang)
a, err := l.client.artistGetInfo(ctx, name)
if err != nil {
log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, err)
return nil, err
@@ -338,20 +283,11 @@ func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName str
return t.Track, nil
}
func (l *lastfmAgent) callTrackGetSimilar(ctx context.Context, name, artist string, count int) ([]SimilarTrack, error) {
s, err := l.client.trackGetSimilar(ctx, name, artist, count)
if err != nil {
log.Error(ctx, "Error calling LastFM/track.getSimilar", "track", name, "artist", artist, err)
return nil, err
func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile) string {
if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[model.RoleArtist]) > 0 {
return track.Participants[model.RoleArtist][0].Name
}
return s.Track, nil
}
func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile, role model.Role, displayName string) string {
if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[role]) > 0 {
return track.Participants[role][0].Name
}
return displayName
return track.Artist
}
func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
@@ -361,13 +297,13 @@ func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *mode
}
err = l.client.updateNowPlaying(ctx, sk, ScrobbleInfo{
artist: l.getArtistForScrobble(track, model.RoleArtist, track.Artist),
artist: l.getArtistForScrobble(track),
track: track.Title,
album: track.Album,
trackNumber: track.TrackNumber,
mbid: track.MbzRecordingID,
duration: int(track.Duration),
albumArtist: l.getArtistForScrobble(track, model.RoleAlbumArtist, track.AlbumArtist),
albumArtist: track.AlbumArtist,
})
if err != nil {
log.Warn(ctx, "Last.fm client.updateNowPlaying returned error", "track", track.Title, err)
@@ -387,13 +323,13 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
return nil
}
err = l.client.scrobble(ctx, sk, ScrobbleInfo{
artist: l.getArtistForScrobble(&s.MediaFile, model.RoleArtist, s.Artist),
artist: l.getArtistForScrobble(&s.MediaFile),
track: s.Title,
album: s.Album,
trackNumber: s.TrackNumber,
mbid: s.MbzRecordingID,
duration: int(s.Duration),
albumArtist: l.getArtistForScrobble(&s.MediaFile, model.RoleAlbumArtist, s.AlbumArtist),
albumArtist: s.AlbumArtist,
timestamp: s.TimeStamp,
})
if err == nil {
@@ -416,10 +352,6 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
return err == nil && sk != ""
}
func (l *lastfmAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
return nil
}
func init() {
conf.AddHook(func() {
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
@@ -6,7 +6,6 @@ import (
"errors"
"io"
"net/http"
"net/url"
"os"
"strconv"
"time"
@@ -39,12 +38,12 @@ var _ = Describe("lastfmAgent", func() {
})
Describe("lastFMConstructor", func() {
When("Agent is properly configured", func() {
It("uses configured api key and languages", func() {
conf.Server.LastFM.Languages = []string{"pt", "en"}
It("uses configured api key and language", func() {
conf.Server.LastFM.Language = "pt"
agent := lastFMConstructor(ds)
Expect(agent.apiKey).To(Equal("123"))
Expect(agent.secret).To(Equal("secret"))
Expect(agent.languages).To(Equal([]string{"pt", "en"}))
Expect(agent.lang).To(Equal("pt"))
})
})
When("Agent is disabled", func() {
@@ -72,7 +71,7 @@ var _ = Describe("lastfmAgent", func() {
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", httpClient)
client := newClient("API_KEY", "SECRET", "pt", httpClient)
agent = lastFMConstructor(ds)
agent.client = client
})
@@ -80,7 +79,7 @@ var _ = Describe("lastfmAgent", func() {
It("returns the biography", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetArtistBiography(ctx, "123", "U2", "")).To(Equal("U2 é uma das mais importantes bandas de rock de todos os tempos. Formada em 1976 em Dublin, composta por Bono (vocalista e guitarrista), The Edge (guitarrista, pianista e backing vocal), Adam Clayton (baixista), Larry Mullen, Jr. (baterista e percussionista).\n\nDesde a década de 80, U2 é uma das bandas mais populares no mundo. Seus shows são únicos e um verdadeiro festival de efeitos especiais, além de serem um dos que mais arrecadam anualmente."))
Expect(agent.GetArtistBiography(ctx, "123", "U2", "")).To(Equal("U2 é uma das mais importantes bandas de rock de todos os tempos. Formada em 1976 em Dublin, composta por Bono (vocalista e guitarrista), The Edge (guitarrista, pianista e backing vocal), Adam Clayton (baixista), Larry Mullen, Jr. (baterista e percussionista).\n\nDesde a década de 80, U2 é uma das bandas mais populares no mundo. Seus shows são únicos e um verdadeiro festival de efeitos especiais, além de serem um dos que mais arrecadam anualmente. <a href=\"https://www.last.fm/music/U2\">Read more on Last.fm</a>"))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
})
@@ -102,129 +101,12 @@ var _ = Describe("lastfmAgent", func() {
})
})
Describe("Language Fallback", func() {
Describe("GetArtistBiography", func() {
var agent *lastfmAgent
var httpClient *langAwareHttpClient
BeforeEach(func() {
httpClient = newLangAwareHttpClient()
})
It("returns content in first language when available (1 API call)", func() {
conf.Server.LastFM.Languages = []string{"pt", "en"}
agent = lastFMConstructor(ds)
agent.client = newClient("API_KEY", "SECRET", httpClient)
// Portuguese biography available
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
httpClient.responses["pt"] = http.Response{Body: f, StatusCode: 200}
bio, err := agent.GetArtistBiography(ctx, "123", "U2", "")
Expect(err).ToNot(HaveOccurred())
Expect(bio).To(ContainSubstring("U2 é uma das mais importantes bandas de rock"))
Expect(httpClient.requestCount).To(Equal(1))
Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("pt"))
})
It("falls back to second language when first returns empty (2 API calls)", func() {
conf.Server.LastFM.Languages = []string{"ja", "en"}
agent = lastFMConstructor(ds)
agent.client = newClient("API_KEY", "SECRET", httpClient)
// Japanese returns empty/ignored biography (actual Last.fm response with just "Read more" link)
fJa, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
// English returns full biography
fEn, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.en.json")
httpClient.responses["en"] = http.Response{Body: fEn, StatusCode: 200}
bio, err := agent.GetArtistBiography(ctx, "123", "Legião Urbana", "")
Expect(err).ToNot(HaveOccurred())
Expect(bio).To(ContainSubstring("Legião Urbana was a Brazilian post-punk band"))
Expect(httpClient.requestCount).To(Equal(2))
Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("ja"))
Expect(httpClient.requests[1].URL.Query().Get("lang")).To(Equal("en"))
})
It("returns ErrNotFound when all languages return empty", func() {
conf.Server.LastFM.Languages = []string{"ja", "xx"}
agent = lastFMConstructor(ds)
agent.client = newClient("API_KEY", "SECRET", httpClient)
// Both languages return empty/ignored biography (using actual Last.fm response format)
fJa, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
// Second language also returns empty
fXx, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
httpClient.responses["xx"] = http.Response{Body: fXx, StatusCode: 200}
_, err := agent.GetArtistBiography(ctx, "123", "Legião Urbana", "")
Expect(err).To(MatchError(agents.ErrNotFound))
Expect(httpClient.requestCount).To(Equal(2))
})
})
Describe("GetAlbumInfo", func() {
var agent *lastfmAgent
var httpClient *langAwareHttpClient
BeforeEach(func() {
httpClient = newLangAwareHttpClient()
})
It("falls back to second language when first returns empty description (2 API calls)", func() {
conf.Server.LastFM.Languages = []string{"ja", "en"}
agent = lastFMConstructor(ds)
agent.client = newClient("API_KEY", "SECRET", httpClient)
// Japanese returns album without wiki/description (actual Last.fm response)
fJa, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
// English returns album with description
fEn, _ := os.Open("tests/fixtures/lastfm.album.getinfo.en.json")
httpClient.responses["en"] = http.Response{Body: fEn, StatusCode: 200}
albumInfo, err := agent.GetAlbumInfo(ctx, "Dois", "Legião Urbana", "")
Expect(err).ToNot(HaveOccurred())
Expect(albumInfo.Name).To(Equal("Dois"))
Expect(albumInfo.Description).To(ContainSubstring("segundo álbum de estúdio"))
Expect(httpClient.requestCount).To(Equal(2))
Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("ja"))
Expect(httpClient.requests[1].URL.Query().Get("lang")).To(Equal("en"))
})
It("returns album without description when all languages return empty", func() {
conf.Server.LastFM.Languages = []string{"ja", "xx"}
agent = lastFMConstructor(ds)
agent.client = newClient("API_KEY", "SECRET", httpClient)
// Both languages return album without description
fJa, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
fXx, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
httpClient.responses["xx"] = http.Response{Body: fXx, StatusCode: 200}
albumInfo, err := agent.GetAlbumInfo(ctx, "Dois", "Legião Urbana", "")
Expect(err).ToNot(HaveOccurred())
Expect(albumInfo.Name).To(Equal("Dois"))
Expect(albumInfo.Description).To(BeEmpty())
Expect(httpClient.requestCount).To(Equal(2))
})
})
})
Describe("GetSimilarArtists", func() {
var agent *lastfmAgent
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", httpClient)
client := newClient("API_KEY", "SECRET", "pt", httpClient)
agent = lastFMConstructor(ds)
agent.client = client
})
@@ -262,7 +144,7 @@ var _ = Describe("lastfmAgent", func() {
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", httpClient)
client := newClient("API_KEY", "SECRET", "pt", httpClient)
agent = lastFMConstructor(ds)
agent.client = client
})
@@ -295,54 +177,6 @@ var _ = Describe("lastfmAgent", func() {
})
})
Describe("GetSimilarSongsByTrack", func() {
var agent *lastfmAgent
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds)
agent.client = client
})
It("returns similar songs", func() {
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetSimilarSongsByTrack(ctx, "123", "Just Can't Get Enough", "Depeche Mode", "", 5)).To(Equal([]agents.Song{
{Name: "Dreaming of Me", MBID: "027b553e-7c74-3ed4-a95e-1d4fea51f174", Artist: "Depeche Mode", ArtistMBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"},
{Name: "Everything Counts", MBID: "5a5a3ca4-bdb8-4641-a674-9b54b9b319a6", Artist: "Depeche Mode", ArtistMBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"},
{Name: "Don't You Want Me", MBID: "", Artist: "The Human League", ArtistMBID: "7adaabfb-acfb-47bc-8c7c-59471c2f0db8"},
{Name: "Tainted Love", MBID: "", Artist: "Soft Cell", ArtistMBID: "7fb50287-029d-47cc-825a-235ca28024b2"},
{Name: "Blue Monday", MBID: "727e84c6-1b56-31dd-a958-a5f46305cec0", Artist: "New Order", ArtistMBID: "f1106b17-dcbb-45f6-b938-199ccfab50cc"},
}))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("track")).To(Equal("Just Can't Get Enough"))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Depeche Mode"))
})
It("returns ErrNotFound when no similar songs found", func() {
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := agent.GetSimilarSongsByTrack(ctx, "123", "UnknownTrack", "UnknownArtist", "", 3)
Expect(err).To(MatchError(agents.ErrNotFound))
Expect(httpClient.RequestCount).To(Equal(1))
})
It("returns an error if Last.fm call fails", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetSimilarSongsByTrack(ctx, "123", "Believe", "Cher", "", 3)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
})
It("returns an error if Last.fm call returns an error", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
_, err := agent.GetSimilarSongsByTrack(ctx, "123", "Believe", "Cher", "", 3)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
})
})
Describe("Scrobbling", func() {
var agent *lastfmAgent
var httpClient *tests.FakeHttpClient
@@ -350,7 +184,7 @@ var _ = Describe("lastfmAgent", func() {
BeforeEach(func() {
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", httpClient)
client := newClient("API_KEY", "SECRET", "en", httpClient)
agent = lastFMConstructor(ds)
agent.client = client
track = &model.MediaFile{
@@ -367,10 +201,6 @@ var _ = Describe("lastfmAgent", func() {
{Artist: model.Artist{ID: "ar-1", Name: "First Artist"}},
{Artist: model.Artist{ID: "ar-2", Name: "Second Artist"}},
},
model.RoleAlbumArtist: []model.Participant{
{Artist: model.Artist{ID: "ar-1", Name: "First Album Artist"}},
{Artist: model.Artist{ID: "ar-2", Name: "Second Album Artist"}},
},
},
}
})
@@ -383,8 +213,7 @@ var _ = Describe("lastfmAgent", func() {
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
sentParams, _ := url.ParseQuery(string(body))
sentParams := httpClient.SavedRequest.URL.Query()
Expect(sentParams.Get("method")).To(Equal("track.updateNowPlaying"))
Expect(sentParams.Get("sk")).To(Equal("SK-1"))
Expect(sentParams.Get("track")).To(Equal(track.Title))
@@ -400,24 +229,6 @@ var _ = Describe("lastfmAgent", func() {
err := agent.NowPlaying(ctx, "user-2", track, 0)
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
})
When("ScrobbleFirstArtistOnly is true", func() {
BeforeEach(func() {
conf.Server.LastFM.ScrobbleFirstArtistOnly = true
})
It("uses only the first artist", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
err := agent.NowPlaying(ctx, "user-1", track, 0)
Expect(err).ToNot(HaveOccurred())
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
sentParams, _ := url.ParseQuery(string(body))
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
})
})
})
Describe("scrobble", func() {
@@ -429,8 +240,7 @@ var _ = Describe("lastfmAgent", func() {
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
sentParams, _ := url.ParseQuery(string(body))
sentParams := httpClient.SavedRequest.URL.Query()
Expect(sentParams.Get("method")).To(Equal("track.scrobble"))
Expect(sentParams.Get("sk")).To(Equal("SK-1"))
Expect(sentParams.Get("track")).To(Equal(track.Title))
@@ -455,10 +265,8 @@ var _ = Describe("lastfmAgent", func() {
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts})
Expect(err).ToNot(HaveOccurred())
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
sentParams, _ := url.ParseQuery(string(body))
sentParams := httpClient.SavedRequest.URL.Query()
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
})
})
@@ -524,7 +332,7 @@ var _ = Describe("lastfmAgent", func() {
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", httpClient)
client := newClient("API_KEY", "SECRET", "pt", httpClient)
agent = lastFMConstructor(ds)
agent.client = client
})
@@ -535,7 +343,7 @@ var _ = Describe("lastfmAgent", func() {
Expect(agent.GetAlbumInfo(ctx, "Believe", "Cher", "03c91c40-49a6-44a7-90e7-a700edf97a62")).To(Equal(&agents.AlbumInfo{
Name: "Believe",
MBID: "03c91c40-49a6-44a7-90e7-a700edf97a62",
Description: "Believe is the twenty-third studio album by American singer-actress Cher, released on November 10, 1998 by Warner Bros. Records. The RIAA certified it Quadruple Platinum on December 23, 1999, recognizing four million shipments in the United States; Worldwide, the album has sold more than 20 million copies, making it the biggest-selling album of her career. In 1999 the album received three Grammy Awards nominations including \"Record of the Year\", \"Best Pop Album\" and winning \"Best Dance Recording\" for the single \"Believe\". It was released by Warner Bros. Records at the end of 1998. The album was executive produced by Rob",
Description: "Believe is the twenty-third studio album by American singer-actress Cher, released on November 10, 1998 by Warner Bros. Records. The RIAA certified it Quadruple Platinum on December 23, 1999, recognizing four million shipments in the United States; Worldwide, the album has sold more than 20 million copies, making it the biggest-selling album of her career. In 1999 the album received three Grammy Awards nominations including \"Record of the Year\", \"Best Pop Album\" and winning \"Best Dance Recording\" for the single \"Believe\". It was released by Warner Bros. Records at the end of 1998. The album was executive produced by Rob <a href=\"https://www.last.fm/music/Cher/Believe\">Read more on Last.fm</a>.",
URL: "https://www.last.fm/music/Cher/Believe",
}))
Expect(httpClient.RequestCount).To(Equal(1))
@@ -585,101 +393,4 @@ var _ = Describe("lastfmAgent", func() {
})
})
})
Describe("GetArtistImages", func() {
var agent *lastfmAgent
var apiClient *tests.FakeHttpClient
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
apiClient = &tests.FakeHttpClient{}
httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", apiClient)
agent = lastFMConstructor(ds)
agent.client = client
agent.httpClient = httpClient
})
It("returns the artist image from the page", func() {
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.html")
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
Expect(err).ToNot(HaveOccurred())
Expect(images).To(HaveLen(1))
Expect(images[0].URL).To(Equal("https://lastfm.freetls.fastly.net/i/u/ar0/818148bf682d429dc21b59a73ef6f68e.png"))
})
It("returns empty list if image is the ignored default image", func() {
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.ignored.html")
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
Expect(err).ToNot(HaveOccurred())
Expect(images).To(BeEmpty())
})
It("returns empty list if page has no meta tags", func() {
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html")
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
Expect(err).ToNot(HaveOccurred())
Expect(images).To(BeEmpty())
})
It("returns error if API call fails", func() {
apiClient.Err = errors.New("api error")
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("get artist info"))
})
It("returns error if scraper call fails", func() {
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
httpClient.Err = errors.New("scraper error")
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("get artist url"))
})
})
})
// langAwareHttpClient is a mock HTTP client that returns different responses based on the lang parameter
type langAwareHttpClient struct {
responses map[string]http.Response
requests []*http.Request
requestCount int
}
func newLangAwareHttpClient() *langAwareHttpClient {
return &langAwareHttpClient{
responses: make(map[string]http.Response),
requests: make([]*http.Request, 0),
}
}
func (c *langAwareHttpClient) Do(req *http.Request) (*http.Response, error) {
c.requestCount++
c.requests = append(c.requests, req)
lang := req.URL.Query().Get("lang")
if resp, ok := c.responses[lang]; ok {
return &resp, nil
}
// Return default empty response if no specific response is configured
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{}`)),
}, nil
}
@@ -44,7 +44,7 @@ func NewRouter(ds model.DataStore) *Router {
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
r.client = newClient(r.apiKey, r.secret, hc)
r.client = newClient(r.apiKey, r.secret, "en", hc)
return r
}
@@ -65,7 +65,7 @@ func (s *Router) routes() http.Handler {
}
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{
resp := map[string]interface{}{
"apiKey": s.apiKey,
}
u, _ := request.UserFrom(r.Context())
@@ -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
}
@@ -34,23 +34,24 @@ type httpDoer interface {
Do(req *http.Request) (*http.Response, error)
}
func newClient(apiKey string, secret string, hc httpDoer) *client {
return &client{apiKey, secret, hc}
func newClient(apiKey string, secret string, lang string, hc httpDoer) *client {
return &client{apiKey, secret, lang, hc}
}
type client struct {
apiKey string
secret string
lang string
hc httpDoer
}
func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string, lang string) (*Album, error) {
func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string) (*Album, error) {
params := url.Values{}
params.Add("method", "album.getInfo")
params.Add("album", name)
params.Add("artist", artist)
params.Add("mbid", mbid)
params.Add("lang", lang)
params.Add("lang", c.lang)
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {
return nil, err
@@ -58,11 +59,11 @@ func (c *client) albumGetInfo(ctx context.Context, name string, artist string, m
return &response.Album, nil
}
func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) {
func (c *client) artistGetInfo(ctx context.Context, name string) (*Artist, error) {
params := url.Values{}
params.Add("method", "artist.getInfo")
params.Add("artist", name)
params.Add("lang", lang)
params.Add("lang", c.lang)
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {
return nil, err
@@ -94,19 +95,6 @@ func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int)
return &response.TopTracks, nil
}
func (c *client) trackGetSimilar(ctx context.Context, name, artist string, limit int) (*SimilarTracks, error) {
params := url.Values{}
params.Add("method", "track.getSimilar")
params.Add("track", name)
params.Add("artist", artist)
params.Add("limit", strconv.Itoa(limit))
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {
return nil, err
}
return &response.SimilarTracks, nil
}
func (c *client) GetToken(ctx context.Context) (string, error) {
params := url.Values{}
params.Add("method", "auth.getToken")
@@ -197,15 +185,8 @@ func (c *client) makeRequest(ctx context.Context, method string, params url.Valu
c.sign(params)
}
var req *http.Request
if method == http.MethodPost {
body := strings.NewReader(params.Encode())
req, _ = http.NewRequestWithContext(ctx, method, apiBaseUrl, body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
} else {
req, _ = http.NewRequestWithContext(ctx, method, apiBaseUrl, nil)
req.URL.RawQuery = params.Encode()
}
req, _ := http.NewRequestWithContext(ctx, method, apiBaseUrl, nil)
req.URL.RawQuery = params.Encode()
log.Trace(ctx, fmt.Sprintf("Sending Last.fm %s request", req.Method), "url", req.URL)
resp, err := c.hc.Do(req)
@@ -22,7 +22,7 @@ var _ = Describe("client", func() {
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client = newClient("API_KEY", "SECRET", httpClient)
client = newClient("API_KEY", "SECRET", "pt", httpClient)
})
Describe("albumGetInfo", func() {
@@ -30,7 +30,7 @@ var _ = Describe("client", func() {
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
album, err := client.albumGetInfo(context.Background(), "Believe", "U2", "mbid-1234", "pt")
album, err := client.albumGetInfo(context.Background(), "Believe", "U2", "mbid-1234")
Expect(err).To(BeNil())
Expect(album.Name).To(Equal("Believe"))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?album=Believe&api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=mbid-1234&method=album.getInfo"))
@@ -42,7 +42,7 @@ var _ = Describe("client", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
artist, err := client.artistGetInfo(context.Background(), "U2", "pt")
artist, err := client.artistGetInfo(context.Background(), "U2")
Expect(err).To(BeNil())
Expect(artist.Name).To(Equal("U2"))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo"))
@@ -54,7 +54,7 @@ var _ = Describe("client", func() {
StatusCode: 500,
}
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
_, err := client.artistGetInfo(context.Background(), "U2")
Expect(err).To(MatchError("last.fm http status: (500)"))
})
@@ -64,7 +64,7 @@ var _ = Describe("client", func() {
StatusCode: 400,
}
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
_, err := client.artistGetInfo(context.Background(), "U2")
Expect(err).To(MatchError(&lastFMError{Code: 3, Message: "Invalid Method - No method with that name in this package"}))
})
@@ -74,14 +74,14 @@ var _ = Describe("client", func() {
StatusCode: 200,
}
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
_, err := client.artistGetInfo(context.Background(), "U2")
Expect(err).To(MatchError(&lastFMError{Code: 6, Message: "The artist you supplied could not be found"}))
})
It("fails if HttpClient.Do() returns error", func() {
httpClient.Err = errors.New("generic error")
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
_, err := client.artistGetInfo(context.Background(), "U2")
Expect(err).To(MatchError("generic error"))
})
@@ -91,7 +91,7 @@ var _ = Describe("client", func() {
StatusCode: 200,
}
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
_, err := client.artistGetInfo(context.Background(), "U2")
Expect(err).To(MatchError("invalid character '<' looking for beginning of value"))
})
@@ -121,30 +121,6 @@ var _ = Describe("client", func() {
})
})
Describe("trackGetSimilar", func() {
It("returns similar tracks for a successful response", func() {
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
similar, err := client.trackGetSimilar(context.Background(), "Just Can't Get Enough", "Depeche Mode", 5)
Expect(err).To(BeNil())
Expect(len(similar.Track)).To(Equal(5))
Expect(similar.Track[0].Name).To(Equal("Dreaming of Me"))
Expect(similar.Track[0].Artist.Name).To(Equal("Depeche Mode"))
Expect(similar.Track[0].Match).To(Equal(1.0))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=Depeche+Mode&format=json&limit=5&method=track.getSimilar&track=Just+Can%27t+Get+Enough"))
})
It("returns empty list when no similar tracks found", func() {
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
similar, err := client.trackGetSimilar(context.Background(), "UnknownTrack", "UnknownArtist", 3)
Expect(err).To(BeNil())
Expect(similar.Track).To(BeEmpty())
})
})
Describe("GetToken", func() {
It("returns a token when the request is successful", func() {
httpClient.Res = http.Response{
@@ -178,74 +154,6 @@ var _ = Describe("client", func() {
})
})
Describe("scrobble", func() {
It("sends parameters in request body for POST", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"scrobbles":{"scrobble":{"ignoredMessage":{"code":"0"}},"@attr":{"accepted":1}}}`)),
StatusCode: 200,
}
info := ScrobbleInfo{
artist: "U2",
track: "One",
album: "Achtung Baby",
trackNumber: 1,
duration: 276,
albumArtist: "U2",
}
err := client.scrobble(context.Background(), "SESSION_KEY", info)
Expect(err).To(BeNil())
req := httpClient.SavedRequest
Expect(req.Method).To(Equal(http.MethodPost))
Expect(req.Header.Get("Content-Type")).To(Equal("application/x-www-form-urlencoded"))
Expect(req.URL.RawQuery).To(BeEmpty())
body, _ := io.ReadAll(req.Body)
bodyParams, _ := url.ParseQuery(string(body))
Expect(bodyParams.Get("method")).To(Equal("track.scrobble"))
Expect(bodyParams.Get("artist")).To(Equal("U2"))
Expect(bodyParams.Get("track")).To(Equal("One"))
Expect(bodyParams.Get("sk")).To(Equal("SESSION_KEY"))
Expect(bodyParams.Get("api_key")).To(Equal("API_KEY"))
Expect(bodyParams.Get("api_sig")).ToNot(BeEmpty())
})
})
Describe("updateNowPlaying", func() {
It("sends parameters in request body for POST", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"nowplaying":{"ignoredMessage":{"code":"0"}}}`)),
StatusCode: 200,
}
info := ScrobbleInfo{
artist: "U2",
track: "One",
album: "Achtung Baby",
trackNumber: 1,
duration: 276,
albumArtist: "U2",
}
err := client.updateNowPlaying(context.Background(), "SESSION_KEY", info)
Expect(err).To(BeNil())
req := httpClient.SavedRequest
Expect(req.Method).To(Equal(http.MethodPost))
Expect(req.Header.Get("Content-Type")).To(Equal("application/x-www-form-urlencoded"))
Expect(req.URL.RawQuery).To(BeEmpty())
body, _ := io.ReadAll(req.Body)
bodyParams, _ := url.ParseQuery(string(body))
Expect(bodyParams.Get("method")).To(Equal("track.updateNowPlaying"))
Expect(bodyParams.Get("artist")).To(Equal("U2"))
Expect(bodyParams.Get("track")).To(Equal("One"))
Expect(bodyParams.Get("sk")).To(Equal("SESSION_KEY"))
Expect(bodyParams.Get("api_key")).To(Equal("API_KEY"))
Expect(bodyParams.Get("api_sig")).ToNot(BeEmpty())
})
})
Describe("sign", func() {
It("adds an api_sig param with the signature", func() {
params := url.Values{}
File renamed without changes.
@@ -5,7 +5,6 @@ type Response struct {
SimilarArtists SimilarArtists `json:"similarartists"`
TopTracks TopTracks `json:"toptracks"`
Album Album `json:"album"`
SimilarTracks SimilarTracks `json:"similartracks"`
Error int `json:"error"`
Message string `json:"message"`
Token string `json:"token"`
@@ -60,28 +59,6 @@ type TopTracks struct {
Attr Attr `json:"@attr"`
}
type SimilarTracks struct {
Track []SimilarTrack `json:"track"`
Attr SimilarAttr `json:"@attr"`
}
type SimilarTrack struct {
Name string `json:"name"`
MBID string `json:"mbid"`
Match float64 `json:"match"`
Artist SimilarTrackArtist `json:"artist"`
}
type SimilarTrackArtist struct {
Name string `json:"name"`
MBID string `json:"mbid"`
}
type SimilarAttr struct {
Artist string `json:"artist"`
Track string `json:"track"`
}
type Session struct {
Name string `json:"name"`
Key string `json:"key"`
File renamed without changes.
File renamed without changes.
@@ -118,133 +118,12 @@ func (l *listenBrainzAgent) IsAuthorized(ctx context.Context, userId string) boo
return err == nil && sk != ""
}
func (l *listenBrainzAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
if mbid == "" {
return "", agents.ErrNotFound
}
url, err := l.client.getArtistUrl(ctx, mbid)
if err != nil {
return "", err
}
return url, nil
}
func (l *listenBrainzAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
resp, err := l.client.getArtistTopSongs(ctx, mbid, count)
if err != nil {
return nil, err
}
if len(resp) == 0 {
return nil, agents.ErrNotFound
}
res := make([]agents.Song, len(resp))
for i, t := range resp {
mbid := ""
if len(t.ArtistMBIDs) > 0 {
mbid = t.ArtistMBIDs[0]
}
res[i] = agents.Song{
Album: t.ReleaseName,
AlbumMBID: t.ReleaseMBID,
Artist: t.ArtistName,
ArtistMBID: mbid,
Duration: t.DurationMs,
Name: t.RecordingName,
MBID: t.RecordingMbid,
}
}
return res, nil
}
func (l *listenBrainzAgent) GetSimilarArtists(ctx context.Context, id string, name string, mbid string, limit int) ([]agents.Artist, error) {
if mbid == "" {
return nil, agents.ErrNotFound
}
resp, err := l.client.getSimilarArtists(ctx, mbid, limit)
if err != nil {
return nil, err
}
if len(resp) == 0 {
return nil, agents.ErrNotFound
}
artists := make([]agents.Artist, len(resp))
for i, artist := range resp {
artists[i] = agents.Artist{
MBID: artist.MBID,
Name: artist.Name,
}
}
return artists, nil
}
func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id string, name string, artist string, mbid string, limit int) ([]agents.Song, error) {
if mbid == "" {
return nil, agents.ErrNotFound
}
resp, err := l.client.getSimilarRecordings(ctx, mbid, limit)
if err != nil {
return nil, err
}
if len(resp) == 0 {
return nil, agents.ErrNotFound
}
songs := make([]agents.Song, len(resp))
for i, song := range resp {
songs[i] = agents.Song{
Album: song.ReleaseName,
AlbumMBID: song.ReleaseMBID,
Artist: song.Artist,
MBID: song.MBID,
Name: song.Name,
}
}
return songs, nil
}
func (l *listenBrainzAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
return nil
}
func init() {
conf.AddHook(func() {
if conf.Server.ListenBrainz.Enabled {
scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil)
// See https://go.dev/doc/faq#nil_error
a := listenBrainzConstructor(ds)
if a != nil {
return a
}
return nil
})
agents.Register(listenBrainzAgentName, func(ds model.DataStore) agents.Interface {
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil)
// See https://go.dev/doc/faq#nil_error
a := listenBrainzConstructor(ds)
if a != nil {
return a
}
return nil
return listenBrainzConstructor(ds)
})
}
})
}
var (
_ agents.ArtistTopSongsRetriever = (*listenBrainzAgent)(nil)
_ agents.ArtistURLRetriever = (*listenBrainzAgent)(nil)
_ agents.ArtistSimilarRetriever = (*listenBrainzAgent)(nil)
_ agents.SimilarSongsByTrackRetriever = (*listenBrainzAgent)(nil)
)
+165
View File
@@ -0,0 +1,165 @@
package listenbrainz
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
. "github.com/onsi/gomega/gstruct"
)
var _ = Describe("listenBrainzAgent", func() {
var ds model.DataStore
var ctx context.Context
var agent *listenBrainzAgent
var httpClient *tests.FakeHttpClient
var track *model.MediaFile
BeforeEach(func() {
ds = &tests.MockDataStore{}
ctx = context.Background()
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
httpClient = &tests.FakeHttpClient{}
agent = listenBrainzConstructor(ds)
agent.client = newClient("http://localhost:8080", httpClient)
track = &model.MediaFile{
ID: "123",
Title: "Track Title",
Album: "Track Album",
Artist: "Track Artist",
TrackNumber: 1,
MbzRecordingID: "mbz-123",
MbzAlbumID: "mbz-456",
MbzReleaseGroupID: "mbz-789",
Duration: 142.2,
Participants: map[model.Role]model.ParticipantList{
model.RoleArtist: []model.Participant{
{Artist: model.Artist{ID: "ar-1", Name: "Artist 1", MbzArtistID: "mbz-111"}},
{Artist: model.Artist{ID: "ar-2", Name: "Artist 2", MbzArtistID: "mbz-222"}},
},
},
}
})
Describe("formatListen", func() {
It("constructs the listenInfo properly", func() {
lr := agent.formatListen(track)
Expect(lr).To(MatchAllFields(Fields{
"ListenedAt": Equal(0),
"TrackMetadata": MatchAllFields(Fields{
"ArtistName": Equal(track.Artist),
"TrackName": Equal(track.Title),
"ReleaseName": Equal(track.Album),
"AdditionalInfo": MatchAllFields(Fields{
"SubmissionClient": Equal(consts.AppName),
"SubmissionClientVersion": Equal(consts.Version),
"TrackNumber": Equal(track.TrackNumber),
"RecordingMBID": Equal(track.MbzRecordingID),
"ReleaseMBID": Equal(track.MbzAlbumID),
"ReleaseGroupMBID": Equal(track.MbzReleaseGroupID),
"ArtistNames": ConsistOf("Artist 1", "Artist 2"),
"ArtistMBIDs": ConsistOf("mbz-111", "mbz-222"),
"DurationMs": Equal(142200),
}),
}),
}))
})
})
Describe("NowPlaying", func() {
It("updates NowPlaying successfully", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
err := agent.NowPlaying(ctx, "user-1", track, 0)
Expect(err).ToNot(HaveOccurred())
})
It("returns ErrNotAuthorized if user is not linked", func() {
err := agent.NowPlaying(ctx, "user-2", track, 0)
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
})
})
Describe("Scrobble", func() {
var sc scrobbler.Scrobble
BeforeEach(func() {
sc = scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}
})
It("sends a Scrobble successfully", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
err := agent.Scrobble(ctx, "user-1", sc)
Expect(err).ToNot(HaveOccurred())
})
It("sets the Timestamp properly", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
err := agent.Scrobble(ctx, "user-1", sc)
Expect(err).ToNot(HaveOccurred())
decoder := json.NewDecoder(httpClient.SavedRequest.Body)
var lr listenBrainzRequestBody
err = decoder.Decode(&lr)
Expect(err).ToNot(HaveOccurred())
Expect(lr.Payload[0].ListenedAt).To(Equal(int(sc.TimeStamp.Unix())))
})
It("returns ErrNotAuthorized if user is not linked", func() {
err := agent.Scrobble(ctx, "user-2", sc)
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
})
It("returns ErrRetryLater on error 503", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 503, "error": "Cannot submit listens to queue, please try again later."}`)),
StatusCode: 503,
}
err := agent.Scrobble(ctx, "user-1", sc)
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
})
It("returns ErrRetryLater on error 500", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 500, "error": "Something went wrong. Please try again."}`)),
StatusCode: 500,
}
err := agent.Scrobble(ctx, "user-1", sc)
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
})
It("returns ErrRetryLater on http errors", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Bad Gateway`)),
StatusCode: 500,
}
err := agent.Scrobble(ctx, "user-1", sc)
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
})
It("returns ErrUnrecoverable on other errors", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 400, "error": "BadRequest: Invalid JSON document submitted."}`)),
StatusCode: 400,
}
err := agent.Scrobble(ctx, "user-1", sc)
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
})
})
})
@@ -60,7 +60,7 @@ func (s *Router) routes() http.Handler {
}
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{}
resp := map[string]interface{}{}
u, _ := request.UserFrom(r.Context())
key, err := s.sessionKeys.Get(r.Context(), u.ID)
if err != nil && !errors.Is(err, model.ErrNotFound) {
@@ -107,7 +107,7 @@ func (s *Router) link(w http.ResponseWriter, r *http.Request) {
return
}
_ = rest.RespondWithJSON(w, http.StatusOK, map[string]any{"status": resp.Valid, "user": resp.UserName})
_ = rest.RespondWithJSON(w, http.StatusOK, map[string]interface{}{"status": resp.Valid, "user": resp.UserName})
}
func (s *Router) unlink(w http.ResponseWriter, r *http.Request) {
@@ -37,7 +37,7 @@ var _ = Describe("ListenBrainz Auth Router", func() {
req = httptest.NewRequest("GET", "/listenbrainz/link", nil)
r.getLinkStatus(resp, req)
Expect(resp.Code).To(Equal(http.StatusOK))
var parsed map[string]any
var parsed map[string]interface{}
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
Expect(parsed["status"]).To(Equal(false))
})
@@ -47,7 +47,7 @@ var _ = Describe("ListenBrainz Auth Router", func() {
req = httptest.NewRequest("GET", "/listenbrainz/link", nil)
r.getLinkStatus(resp, req)
Expect(resp.Code).To(Equal(http.StatusOK))
var parsed map[string]any
var parsed map[string]interface{}
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
Expect(parsed["status"]).To(Equal(true))
})
@@ -80,7 +80,7 @@ var _ = Describe("ListenBrainz Auth Router", func() {
req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{"token": "tok-1"}`))
r.link(resp, req)
Expect(resp.Code).To(Equal(http.StatusOK))
var parsed map[string]any
var parsed map[string]interface{}
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
Expect(parsed["status"]).To(Equal(true))
Expect(parsed["user"]).To(Equal("ListenBrainzUser"))
+179
View File
@@ -0,0 +1,179 @@
package listenbrainz
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"path"
"github.com/navidrome/navidrome/log"
)
type listenBrainzError struct {
Code int
Message string
}
func (e *listenBrainzError) Error() string {
return fmt.Sprintf("ListenBrainz error(%d): %s", e.Code, e.Message)
}
type httpDoer interface {
Do(req *http.Request) (*http.Response, error)
}
func newClient(baseURL string, hc httpDoer) *client {
return &client{baseURL, hc}
}
type client struct {
baseURL string
hc httpDoer
}
type listenBrainzResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Error string `json:"error"`
Status string `json:"status"`
Valid bool `json:"valid"`
UserName string `json:"user_name"`
}
type listenBrainzRequest struct {
ApiKey string
Body listenBrainzRequestBody
}
type listenBrainzRequestBody struct {
ListenType listenType `json:"listen_type,omitempty"`
Payload []listenInfo `json:"payload,omitempty"`
}
type listenType string
const (
Single listenType = "single"
PlayingNow listenType = "playing_now"
)
type listenInfo struct {
ListenedAt int `json:"listened_at,omitempty"`
TrackMetadata trackMetadata `json:"track_metadata,omitempty"`
}
type trackMetadata struct {
ArtistName string `json:"artist_name,omitempty"`
TrackName string `json:"track_name,omitempty"`
ReleaseName string `json:"release_name,omitempty"`
AdditionalInfo additionalInfo `json:"additional_info,omitempty"`
}
type additionalInfo struct {
SubmissionClient string `json:"submission_client,omitempty"`
SubmissionClientVersion string `json:"submission_client_version,omitempty"`
TrackNumber int `json:"tracknumber,omitempty"`
ArtistNames []string `json:"artist_names,omitempty"`
ArtistMBIDs []string `json:"artist_mbids,omitempty"`
RecordingMBID string `json:"recording_mbid,omitempty"`
ReleaseMBID string `json:"release_mbid,omitempty"`
ReleaseGroupMBID string `json:"release_group_mbid,omitempty"`
DurationMs int `json:"duration_ms,omitempty"`
}
func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrainzResponse, error) {
r := &listenBrainzRequest{
ApiKey: apiKey,
}
response, err := c.makeRequest(ctx, http.MethodGet, "validate-token", r)
if err != nil {
return nil, err
}
return response, nil
}
func (c *client) updateNowPlaying(ctx context.Context, apiKey string, li listenInfo) error {
r := &listenBrainzRequest{
ApiKey: apiKey,
Body: listenBrainzRequestBody{
ListenType: PlayingNow,
Payload: []listenInfo{li},
},
}
resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", r)
if err != nil {
return err
}
if resp.Status != "ok" {
log.Warn(ctx, "ListenBrainz: NowPlaying was not accepted", "status", resp.Status)
}
return nil
}
func (c *client) scrobble(ctx context.Context, apiKey string, li listenInfo) error {
r := &listenBrainzRequest{
ApiKey: apiKey,
Body: listenBrainzRequestBody{
ListenType: Single,
Payload: []listenInfo{li},
},
}
resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", r)
if err != nil {
return err
}
if resp.Status != "ok" {
log.Warn(ctx, "ListenBrainz: Scrobble was not accepted", "status", resp.Status)
}
return nil
}
func (c *client) path(endpoint string) (string, error) {
u, err := url.Parse(c.baseURL)
if err != nil {
return "", err
}
u.Path = path.Join(u.Path, endpoint)
return u.String(), nil
}
func (c *client) makeRequest(ctx context.Context, method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) {
b, _ := json.Marshal(r.Body)
uri, err := c.path(endpoint)
if err != nil {
return nil, err
}
req, _ := http.NewRequestWithContext(ctx, method, uri, bytes.NewBuffer(b))
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
if r.ApiKey != "" {
req.Header.Add("Authorization", fmt.Sprintf("Token %s", r.ApiKey))
}
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz %s request", req.Method), "url", req.URL)
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var response listenBrainzResponse
jsonErr := decoder.Decode(&response)
if resp.StatusCode != 200 && jsonErr != nil {
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
if jsonErr != nil {
return nil, jsonErr
}
if response.Code != 0 && response.Code != 200 {
return &response, &listenBrainzError{Code: response.Code, Message: response.Error}
}
return &response, nil
}
+120
View File
@@ -0,0 +1,120 @@
package listenbrainz
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"os"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("client", func() {
var httpClient *tests.FakeHttpClient
var client *client
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client = newClient("BASE_URL/", httpClient)
})
Describe("listenBrainzResponse", func() {
It("parses a response properly", func() {
var response listenBrainzResponse
err := json.Unmarshal([]byte(`{"code": 200, "message": "Message", "user_name": "UserName", "valid": true, "status": "ok", "error": "Error"}`), &response)
Expect(err).ToNot(HaveOccurred())
Expect(response.Code).To(Equal(200))
Expect(response.Message).To(Equal("Message"))
Expect(response.UserName).To(Equal("UserName"))
Expect(response.Valid).To(BeTrue())
Expect(response.Status).To(Equal("ok"))
Expect(response.Error).To(Equal("Error"))
})
})
Describe("validateToken", func() {
BeforeEach(func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)),
StatusCode: 200,
}
})
It("formats the request properly", func() {
_, err := client.validateToken(context.Background(), "LB-TOKEN")
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/validate-token"))
Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("parses and returns the response", func() {
res, err := client.validateToken(context.Background(), "LB-TOKEN")
Expect(err).ToNot(HaveOccurred())
Expect(res.Valid).To(Equal(true))
Expect(res.UserName).To(Equal("ListenBrainzUser"))
})
})
Context("with listenInfo", func() {
var li listenInfo
BeforeEach(func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)),
StatusCode: 200,
}
li = listenInfo{
TrackMetadata: trackMetadata{
ArtistName: "Track Artist",
TrackName: "Track Title",
ReleaseName: "Track Album",
AdditionalInfo: additionalInfo{
TrackNumber: 1,
ArtistNames: []string{"Artist 1", "Artist 2"},
ArtistMBIDs: []string{"mbz-789", "mbz-012"},
RecordingMBID: "mbz-123",
ReleaseMBID: "mbz-456",
DurationMs: 142200,
},
},
}
})
Describe("updateNowPlaying", func() {
It("formats the request properly", func() {
Expect(client.updateNowPlaying(context.Background(), "LB-TOKEN", li)).To(Succeed())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens"))
Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
f, _ := os.ReadFile("tests/fixtures/listenbrainz.nowplaying.request.json")
Expect(body).To(MatchJSON(f))
})
})
Describe("scrobble", func() {
BeforeEach(func() {
li.ListenedAt = 1635000000
})
It("formats the request properly", func() {
Expect(client.scrobble(context.Background(), "LB-TOKEN", li)).To(Succeed())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens"))
Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
f, _ := os.ReadFile("tests/fixtures/listenbrainz.scrobble.request.json")
Expect(body).To(MatchJSON(f))
})
})
})
})
+116
View File
@@ -0,0 +1,116 @@
package spotify
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/navidrome/navidrome/log"
)
const apiBaseUrl = "https://api.spotify.com/v1/"
var (
ErrNotFound = errors.New("spotify: not found")
)
type httpDoer interface {
Do(req *http.Request) (*http.Response, error)
}
func newClient(id, secret string, hc httpDoer) *client {
return &client{id, secret, hc}
}
type client struct {
id string
secret string
hc httpDoer
}
func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
token, err := c.authorize(ctx)
if err != nil {
return nil, err
}
params := url.Values{}
params.Add("type", "artist")
params.Add("q", name)
params.Add("offset", "0")
params.Add("limit", strconv.Itoa(limit))
req, _ := http.NewRequestWithContext(ctx, "GET", apiBaseUrl+"search", nil)
req.URL.RawQuery = params.Encode()
req.Header.Add("Authorization", "Bearer "+token)
var results SearchResults
err = c.makeRequest(req, &results)
if err != nil {
return nil, err
}
if len(results.Artists.Items) == 0 {
return nil, ErrNotFound
}
return results.Artists.Items, err
}
func (c *client) authorize(ctx context.Context) (string, error) {
payload := url.Values{}
payload.Add("grant_type", "client_credentials")
encodePayload := payload.Encode()
req, _ := http.NewRequestWithContext(ctx, "POST", "https://accounts.spotify.com/api/token", strings.NewReader(encodePayload))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(encodePayload)))
auth := c.id + ":" + c.secret
req.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(auth)))
response := map[string]interface{}{}
err := c.makeRequest(req, &response)
if err != nil {
return "", err
}
if v, ok := response["access_token"]; ok {
return v.(string), nil
}
log.Error(ctx, "Invalid spotify response", "resp", response)
return "", errors.New("invalid response")
}
func (c *client) makeRequest(req *http.Request, response interface{}) error {
log.Trace(req.Context(), fmt.Sprintf("Sending Spotify %s request", req.Method), "url", req.URL)
resp, err := c.hc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode != 200 {
return c.parseError(data)
}
return json.Unmarshal(data, response)
}
func (c *client) parseError(data []byte) error {
var e Error
err := json.Unmarshal(data, &e)
if err != nil {
return err
}
return fmt.Errorf("spotify error(%s): %s", e.Code, e.Message)
}
+131
View File
@@ -0,0 +1,131 @@
package spotify
import (
"bytes"
"context"
"io"
"net/http"
"os"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("client", func() {
var httpClient *fakeHttpClient
var client *client
BeforeEach(func() {
httpClient = &fakeHttpClient{}
client = newClient("SPOTIFY_ID", "SPOTIFY_SECRET", httpClient)
})
Describe("ArtistImages", func() {
It("returns artist images from a successful request", func() {
f, _ := os.Open("tests/fixtures/spotify.search.artist.json")
httpClient.mock("https://api.spotify.com/v1/search", http.Response{Body: f, StatusCode: 200})
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
})
artists, err := client.searchArtists(context.TODO(), "U2", 10)
Expect(err).To(BeNil())
Expect(artists).To(HaveLen(20))
Expect(artists[0].Popularity).To(Equal(82))
images := artists[0].Images
Expect(images).To(HaveLen(3))
Expect(images[0].Width).To(Equal(640))
Expect(images[1].Width).To(Equal(320))
Expect(images[2].Width).To(Equal(160))
})
It("fails if artist was not found", func() {
httpClient.mock("https://api.spotify.com/v1/search", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{
"artists" : {
"href" : "https://api.spotify.com/v1/search?query=dasdasdas%2Cdna&type=artist&offset=0&limit=20",
"items" : [ ], "limit" : 20, "next" : null, "offset" : 0, "previous" : null, "total" : 0
}}`)),
})
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
})
_, err := client.searchArtists(context.TODO(), "U2", 10)
Expect(err).To(MatchError(ErrNotFound))
})
It("fails if not able to authorize", func() {
f, _ := os.Open("tests/fixtures/spotify.search.artist.json")
httpClient.mock("https://api.spotify.com/v1/search", http.Response{Body: f, StatusCode: 200})
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
StatusCode: 400,
Body: io.NopCloser(bytes.NewBufferString(`{"error":"invalid_client","error_description":"Invalid client"}`)),
})
_, err := client.searchArtists(context.TODO(), "U2", 10)
Expect(err).To(MatchError("spotify error(invalid_client): Invalid client"))
})
})
Describe("authorize", func() {
It("returns an access_token on successful authorization", func() {
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
})
token, err := client.authorize(context.TODO())
Expect(err).To(BeNil())
Expect(token).To(Equal("NEW_ACCESS_TOKEN"))
auth := httpClient.lastRequest.Header.Get("Authorization")
Expect(auth).To(Equal("Basic U1BPVElGWV9JRDpTUE9USUZZX1NFQ1JFVA=="))
})
It("fails on unsuccessful authorization", func() {
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
StatusCode: 400,
Body: io.NopCloser(bytes.NewBufferString(`{"error":"invalid_client","error_description":"Invalid client"}`)),
})
_, err := client.authorize(context.TODO())
Expect(err).To(MatchError("spotify error(invalid_client): Invalid client"))
})
It("fails on invalid JSON response", func() {
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{NOT_VALID}`)),
})
_, err := client.authorize(context.TODO())
Expect(err).To(MatchError("invalid character 'N' looking for beginning of object key string"))
})
})
})
type fakeHttpClient struct {
responses map[string]*http.Response
lastRequest *http.Request
}
func (c *fakeHttpClient) mock(url string, response http.Response) {
if c.responses == nil {
c.responses = make(map[string]*http.Response)
}
c.responses[url] = &response
}
func (c *fakeHttpClient) Do(req *http.Request) (*http.Response, error) {
c.lastRequest = req
u := req.URL
u.RawQuery = ""
if resp, ok := c.responses[u.String()]; ok {
return resp, nil
}
panic("URL not mocked: " + u.String())
}
+30
View File
@@ -0,0 +1,30 @@
package spotify
type SearchResults struct {
Artists ArtistsResult `json:"artists"`
}
type ArtistsResult struct {
HRef string `json:"href"`
Items []Artist `json:"items"`
}
type Artist struct {
Genres []string `json:"genres"`
HRef string `json:"href"`
ID string `json:"id"`
Popularity int `json:"popularity"`
Images []Image `json:"images"`
Name string `json:"name"`
}
type Image struct {
URL string `json:"url"`
Width int `json:"width"`
Height int `json:"height"`
}
type Error struct {
Code string `json:"error"`
Message string `json:"error_description"`
}
+48
View File
@@ -0,0 +1,48 @@
package spotify
import (
"encoding/json"
"os"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Responses", func() {
Describe("Search type=artist", func() {
It("parses the artist search result correctly ", func() {
var resp SearchResults
body, _ := os.ReadFile("tests/fixtures/spotify.search.artist.json")
err := json.Unmarshal(body, &resp)
Expect(err).To(BeNil())
Expect(resp.Artists.Items).To(HaveLen(20))
u2 := resp.Artists.Items[0]
Expect(u2.Name).To(Equal("U2"))
Expect(u2.Genres).To(ContainElements("irish rock", "permanent wave", "rock"))
Expect(u2.ID).To(Equal("51Blml2LZPmy7TTiAg47vQ"))
Expect(u2.HRef).To(Equal("https://api.spotify.com/v1/artists/51Blml2LZPmy7TTiAg47vQ"))
Expect(u2.Images[0].URL).To(Equal("https://i.scdn.co/image/e22d5c0c8139b8439440a69854ed66efae91112d"))
Expect(u2.Images[0].Width).To(Equal(640))
Expect(u2.Images[0].Height).To(Equal(640))
Expect(u2.Images[1].URL).To(Equal("https://i.scdn.co/image/40d6c5c14355cfc127b70da221233315497ec91d"))
Expect(u2.Images[1].Width).To(Equal(320))
Expect(u2.Images[1].Height).To(Equal(320))
Expect(u2.Images[2].URL).To(Equal("https://i.scdn.co/image/7293d6752ae8a64e34adee5086858e408185b534"))
Expect(u2.Images[2].Width).To(Equal(160))
Expect(u2.Images[2].Height).To(Equal(160))
})
})
Describe("Error", func() {
It("parses the error response correctly", func() {
var errorResp Error
body := []byte(`{"error":"invalid_client","error_description":"Invalid client"}`)
err := json.Unmarshal(body, &errorResp)
Expect(err).To(BeNil())
Expect(errorResp.Code).To(Equal("invalid_client"))
Expect(errorResp.Message).To(Equal("Invalid client"))
})
})
})
+96
View File
@@ -0,0 +1,96 @@
package spotify
import (
"context"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/cache"
"github.com/xrash/smetrics"
)
const spotifyAgentName = "spotify"
type spotifyAgent struct {
ds model.DataStore
id string
secret string
client *client
}
func spotifyConstructor(ds model.DataStore) agents.Interface {
if conf.Server.Spotify.ID == "" || conf.Server.Spotify.Secret == "" {
return nil
}
l := &spotifyAgent{
ds: ds,
id: conf.Server.Spotify.ID,
secret: conf.Server.Spotify.Secret,
}
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
l.client = newClient(l.id, l.secret, chc)
return l
}
func (s *spotifyAgent) AgentName() string {
return spotifyAgentName
}
func (s *spotifyAgent) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) {
a, err := s.searchArtist(ctx, name)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
log.Warn(ctx, "Artist not found in Spotify", "artist", name)
} else {
log.Error(ctx, "Error calling Spotify", "artist", name, err)
}
return nil, err
}
var res []agents.ExternalImage
for _, img := range a.Images {
res = append(res, agents.ExternalImage{
URL: img.URL,
Size: img.Width,
})
}
return res, nil
}
func (s *spotifyAgent) searchArtist(ctx context.Context, name string) (*Artist, error) {
artists, err := s.client.searchArtists(ctx, name, 40)
if err != nil || len(artists) == 0 {
return nil, model.ErrNotFound
}
name = strings.ToLower(name)
// Sort results, prioritizing artists with images, with similar names and with high popularity, in this order
sort.Slice(artists, func(i, j int) bool {
ai := fmt.Sprintf("%-5t-%03d-%04d", len(artists[i].Images) == 0, smetrics.WagnerFischer(name, strings.ToLower(artists[i].Name), 1, 1, 2), 1000-artists[i].Popularity)
aj := fmt.Sprintf("%-5t-%03d-%04d", len(artists[j].Images) == 0, smetrics.WagnerFischer(name, strings.ToLower(artists[j].Name), 1, 1, 2), 1000-artists[j].Popularity)
return ai < aj
})
// If the first one has the same name, that's the one
if strings.ToLower(artists[0].Name) != name {
return nil, model.ErrNotFound
}
return &artists[0], err
}
func init() {
conf.AddHook(func() {
agents.Register(spotifyAgentName, spotifyConstructor)
})
}
@@ -1,4 +1,4 @@
package stream
package spotify
import (
"testing"
@@ -9,9 +9,9 @@ import (
. "github.com/onsi/gomega"
)
func TestStream(t *testing.T) {
func TestSpotify(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Stream Suite")
RunSpecs(t, "Spotify Test Suite")
}
+10 -8
View File
@@ -10,11 +10,9 @@ import (
"strings"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/utils/str"
)
type Archiver interface {
@@ -24,13 +22,13 @@ type Archiver interface {
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
}
func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share) Archiver {
func NewArchiver(ms MediaStreamer, ds model.DataStore, shares Share) Archiver {
return &archiver{ds: ds, ms: ms, shares: shares}
}
type archiver struct {
ds model.DataStore
ms stream.MediaStreamer
ms MediaStreamer
shares Share
}
@@ -88,7 +86,7 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc
if isMultiDisc {
file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file)
}
return fmt.Sprintf("%s/%s", str.SanitizeFilename(mf.Album), file)
return fmt.Sprintf("%s/%s", sanitizeName(mf.Album), file)
}
func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error {
@@ -127,7 +125,7 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st
// Add M3U file if requested
if addM3U && len(zippedMfs) > 0 {
plsName := str.SanitizeFilename(name)
plsName := sanitizeName(name)
w, err := z.CreateHeader(&zip.FileHeader{
Name: plsName + ".m3u",
Modified: mfs[0].UpdatedAt,
@@ -157,7 +155,11 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int)
if format != "" && format != "raw" {
ext = format
}
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext)
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, sanitizeName(mf.Artist), sanitizeName(mf.Title), ext)
}
func sanitizeName(target string) string {
return strings.ReplaceAll(target, "/", "_")
}
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error {
@@ -174,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.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
r, err = a.ms.DoStream(ctx, &mf, format, bitrate, 0)
} else {
r, err = os.Open(path)
}
+8 -9
View File
@@ -9,7 +9,6 @@ import (
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -45,7 +44,7 @@ var _ = Describe("Archiver", func() {
}}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
out := new(bytes.Buffer)
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
@@ -74,7 +73,7 @@ var _ = Describe("Archiver", func() {
}}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out)
@@ -105,7 +104,7 @@ var _ = Describe("Archiver", func() {
}
sh.On("Load", mock.Anything, "1").Return(share, nil)
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipShare(context.Background(), "1", out)
@@ -137,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("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)
@@ -215,15 +214,15 @@ func (m *mockPlaylistRepository) GetWithTracks(id string, refreshSmartPlaylists,
type mockMediaStreamer struct {
mock.Mock
stream.MediaStreamer
core.MediaStreamer
}
func (m *mockMediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) {
args := m.Called(ctx, mf, req)
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)
if args.Error(1) != nil {
return nil, args.Error(1)
}
return &stream.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
return &core.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
}
type mockShare struct {
-120
View File
@@ -1,120 +0,0 @@
package artwork
import (
"bytes"
"encoding/binary"
)
// isAnimatedGIF checks for multiple image descriptor blocks (0x2C) in a GIF file.
// Animated GIFs use GIF89a and contain multiple image blocks.
func isAnimatedGIF(data []byte) bool {
// GIF header: "GIF87a" or "GIF89a"
if !bytes.HasPrefix(data, []byte("GIF")) {
return false
}
// Skip header (6 bytes) + logical screen descriptor (7 bytes)
pos := 13
if pos >= len(data) {
return false
}
// Skip Global Color Table if present (bit 7 of packed byte at offset 10)
if len(data) > 10 && data[10]&0x80 != 0 {
// GCT size = 3 * 2^(N+1) where N = bits 0-2 of packed byte
gctSize := 3 * (1 << ((data[10] & 0x07) + 1))
pos += gctSize
}
frameCount := 0
for pos < len(data) {
switch data[pos] {
case 0x2C: // Image Descriptor - marks a frame
frameCount++
if frameCount > 1 {
return true
}
pos++ // skip introducer
if pos+8 >= len(data) {
return false
}
pos += 8 // skip x, y, w, h (each 2 bytes)
packed := data[pos]
pos++ // skip packed byte
// Skip Local Color Table if present
if packed&0x80 != 0 {
lctSize := 3 * (1 << ((packed & 0x07) + 1))
pos += lctSize
}
// Skip LZW minimum code size
pos++
// Skip sub-blocks
pos = skipGIFSubBlocks(data, pos)
case 0x21: // Extension block
pos++ // skip introducer
if pos >= len(data) {
return false
}
pos++ // skip extension label
// Skip sub-blocks
pos = skipGIFSubBlocks(data, pos)
case 0x3B: // Trailer
return false
default:
// Unknown block, bail
return false
}
}
return false
}
// skipGIFSubBlocks advances past a sequence of GIF sub-blocks (terminated by a zero-length block).
func skipGIFSubBlocks(data []byte, pos int) int {
for pos < len(data) {
blockSize := int(data[pos])
pos++ // skip size byte
if blockSize == 0 {
break
}
pos += blockSize
}
return pos
}
// isAnimatedWebP checks for ANMF (animation frame) chunks in a WebP RIFF container.
func isAnimatedWebP(data []byte) bool {
// WebP header: "RIFF" + 4 bytes size + "WEBP"
if !bytes.HasPrefix(data, []byte("RIFF")) || len(data) < 12 {
return false
}
if !bytes.Equal(data[8:12], []byte("WEBP")) {
return false
}
// Scan for ANMF chunk identifier
return bytes.Contains(data[12:], []byte("ANMF"))
}
// isAnimatedPNG checks for the acTL (animation control) chunk in a PNG file.
// APNG files contain an acTL chunk that is not present in static PNGs.
func isAnimatedPNG(data []byte) bool {
// PNG signature: 8 bytes
pngSig := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
if !bytes.HasPrefix(data, pngSig) {
return false
}
// Scan chunks for "acTL" (animation control)
pos := uint64(8)
dataLen := uint64(len(data))
for pos+8 <= dataLen {
chunkLen := uint64(binary.BigEndian.Uint32(data[pos : pos+4]))
chunkType := string(data[pos+4 : pos+8])
if chunkType == "acTL" {
return true
}
// Move to next chunk: 4 (length) + 4 (type) + chunkLen (data) + 4 (CRC)
pos += 12 + chunkLen
}
return false
}
-161
View File
@@ -1,161 +0,0 @@
package artwork
import (
"bytes"
"encoding/binary"
"image"
"image/color"
"image/gif"
"image/png"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Animation detection", func() {
Describe("isAnimatedGIF", func() {
It("detects an animated GIF with multiple frames", func() {
Expect(isAnimatedGIF(createAnimatedGIF(2))).To(BeTrue())
})
It("detects an animated GIF with many frames", func() {
Expect(isAnimatedGIF(createAnimatedGIF(5))).To(BeTrue())
})
It("does not flag a static GIF (single frame)", func() {
Expect(isAnimatedGIF(createAnimatedGIF(1))).To(BeFalse())
})
It("returns false for non-GIF data", func() {
Expect(isAnimatedGIF(nil)).To(BeFalse())
Expect(isAnimatedGIF([]byte{0xFF, 0xD8})).To(BeFalse())
})
})
Describe("isAnimatedWebP", func() {
It("detects an animated WebP with ANMF chunk", func() {
Expect(isAnimatedWebP(createAnimatedWebPBytes())).To(BeTrue())
})
It("does not flag a static WebP (no ANMF chunk)", func() {
Expect(isAnimatedWebP(createStaticWebPBytes())).To(BeFalse())
})
It("returns false for non-WebP data", func() {
Expect(isAnimatedWebP(nil)).To(BeFalse())
Expect(isAnimatedWebP([]byte{0xFF, 0xD8})).To(BeFalse())
})
})
Describe("isAnimatedPNG", func() {
It("detects an APNG with acTL chunk", func() {
Expect(isAnimatedPNG(createAPNGBytes())).To(BeTrue())
})
It("does not flag a static PNG (no acTL chunk)", func() {
Expect(isAnimatedPNG(createStaticPNGBytes())).To(BeFalse())
})
It("returns false for non-PNG data", func() {
Expect(isAnimatedPNG(nil)).To(BeFalse())
Expect(isAnimatedPNG([]byte{0xFF, 0xD8})).To(BeFalse())
})
})
})
// createAnimatedGIF creates a minimal animated GIF with the given number of frames.
func createAnimatedGIF(frames int) []byte {
g := &gif.GIF{
LoopCount: 0,
}
for range frames {
img := image.NewPaletted(image.Rect(0, 0, 2, 2), color.Palette{color.Black, color.White})
g.Image = append(g.Image, img)
g.Delay = append(g.Delay, 10)
}
var buf bytes.Buffer
err := gif.EncodeAll(&buf, g)
if err != nil {
panic(err)
}
return buf.Bytes()
}
// writeUint32LE appends a little-endian uint32 to the buffer.
func writeUint32LE(buf *bytes.Buffer, v uint32) {
b := make([]byte, 4)
binary.LittleEndian.PutUint32(b, v)
buf.Write(b)
}
// writeUint32BE appends a big-endian uint32 to the buffer.
func writeUint32BE(buf *bytes.Buffer, v uint32) {
b := make([]byte, 4)
binary.BigEndian.PutUint32(b, v)
buf.Write(b)
}
// createAnimatedWebPBytes creates a minimal RIFF/WEBP container with an ANMF chunk.
func createAnimatedWebPBytes() []byte {
var buf bytes.Buffer
buf.WriteString("RIFF")
writeUint32LE(&buf, 100) // file size placeholder
buf.WriteString("WEBP")
// VP8X chunk (extended format, required for animation)
buf.WriteString("VP8X")
writeUint32LE(&buf, 10)
buf.Write(make([]byte, 10))
// ANIM chunk (animation parameters)
buf.WriteString("ANIM")
writeUint32LE(&buf, 6)
buf.Write(make([]byte, 6))
// ANMF chunk (animation frame)
buf.WriteString("ANMF")
writeUint32LE(&buf, 16)
buf.Write(make([]byte, 16))
return buf.Bytes()
}
// createStaticWebPBytes creates a minimal RIFF/WEBP container without ANMF chunks.
func createStaticWebPBytes() []byte {
var buf bytes.Buffer
buf.WriteString("RIFF")
writeUint32LE(&buf, 20) // file size
buf.WriteString("WEBP")
// VP8 chunk (simple lossy format)
buf.WriteString("VP8 ")
writeUint32LE(&buf, 4)
buf.Write(make([]byte, 4))
return buf.Bytes()
}
// createAPNGBytes creates a minimal PNG with an acTL chunk (making it APNG).
func createAPNGBytes() []byte {
// Start with a real PNG
staticPNG := createStaticPNGBytes()
// Insert an acTL chunk after the IHDR chunk.
// PNG structure: signature (8) + IHDR chunk (4 len + 4 type + 13 data + 4 crc = 25)
ihdrEnd := 8 + 25
var buf bytes.Buffer
buf.Write(staticPNG[:ihdrEnd])
// Write acTL chunk: length=8, type="acTL", data=num_frames(4)+num_plays(4), CRC=4
writeUint32BE(&buf, 8) // chunk data length
buf.WriteString("acTL")
writeUint32BE(&buf, 2) // num_frames
writeUint32BE(&buf, 0) // num_plays (0 = infinite)
writeUint32BE(&buf, 0) // CRC placeholder
buf.Write(staticPNG[ihdrEnd:])
return buf.Bytes()
}
// createStaticPNGBytes creates a minimal valid static PNG.
func createStaticPNGBytes() []byte {
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
var buf bytes.Buffer
err := png.Encode(&buf, img)
if err != nil {
panic(err)
}
return buf.Bytes()
}
-4
View File
@@ -122,10 +122,6 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
artReader, err = newMediafileArtworkReader(ctx, a, artID)
case model.KindPlaylistArtwork:
artReader, err = newPlaylistArtworkReader(ctx, a, artID)
case model.KindDiscArtwork:
artReader, err = newDiscArtworkReader(ctx, a, artID)
case model.KindRadioArtwork:
artReader, err = newRadioArtworkReader(ctx, a, artID)
default:
return nil, ErrUnavailable
}
+16 -316
View File
@@ -9,9 +9,7 @@ import (
"io"
"os"
"path/filepath"
"time"
_ "github.com/gen2brain/webp"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
@@ -27,7 +25,7 @@ var _ = Describe("Artwork", func() {
var ffmpeg *tests.MockFFmpeg
var folderRepo *fakeFolderRepo
ctx := log.NewContext(context.TODO())
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers, alSingleDisc model.Album
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers model.Album
var arMultipleCovers model.Artist
var mfWithEmbed, mfAnotherWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile
@@ -37,20 +35,14 @@ var _ = Describe("Artwork", func() {
conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*"
folderRepo = &fakeFolderRepo{}
libRepo := &tests.MockLibraryRepo{}
repoRoot, _ := os.Getwd()
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
ds = &tests.MockDataStore{
MockedTranscoding: &tests.MockTranscodingRepo{},
MockedFolder: folderRepo,
MockedLibrary: libRepo,
}
// Paths use forward slashes because the scanner stores fs.FS-relative paths in the DB.
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}
alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}}
alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "", 2: ""}}
alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}}
alExternalNotFound = model.Album{ID: "555", Name: "External not found", FolderIDs: []string{"f2"}}
alSingleDisc = model.Album{ID: "888", Name: "Single disc", FolderIDs: []string{"f1"}, Discs: model.Discs{1: ""}}
arMultipleCovers = model.Artist{ID: "777", Name: "All options"}
alMultipleCovers = model.Album{
ID: "666",
@@ -150,61 +142,13 @@ var _ = Describe("Artwork", func() {
Entry(nil, " embedded , front.* , cover.*,folder.*", "tests/fixtures/artist/an-album/test.mp3"),
)
})
Context("LastUpdated", func() {
// Regression test for #5377: LastUpdated feeds the HTTP Last-Modified header.
// It must return max(album.UpdatedAt, ImagesUpdatedAt) so browsers revalidate
// cached cover art when only the image file changes.
now := time.Now().Truncate(time.Second)
DescribeTable("returns the max of album.UpdatedAt and ImagesUpdatedAt",
func(albumUpdatedAt, imagesUpdatedAt, expected time.Time) {
album := model.Album{ID: "al1", UpdatedAt: albumUpdatedAt}
folderRepo.result = []model.Folder{{ImagesUpdatedAt: imagesUpdatedAt}}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album})
ar, err := newAlbumArtworkReader(ctx, aw, album.CoverArtID(), nil)
Expect(err).ToNot(HaveOccurred())
Expect(ar.LastUpdated()).To(Equal(expected))
},
Entry("album newer than images", now, now.Add(-1*time.Hour), now),
Entry("images newer than album", now.Add(-24*time.Hour), now.Add(-1*time.Hour), now.Add(-1*time.Hour)),
Entry("equal timestamps", now, now, now),
)
})
})
Describe("discArtworkReader", func() {
Context("LastUpdated", func() {
// Regression test for #5377: same bug as albumArtworkReader — disc covers
// must also revalidate when the image file changes, not only when media files do.
now := time.Now().Truncate(time.Second)
DescribeTable("returns the max of album.UpdatedAt and ImagesUpdatedAt",
func(albumUpdatedAt, imagesUpdatedAt, expected time.Time) {
album := model.Album{ID: "al1", UpdatedAt: albumUpdatedAt}
folderRepo.result = []model.Folder{{ImagesUpdatedAt: imagesUpdatedAt}}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album})
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "mf1", AlbumID: "al1", DiscNumber: 1, Path: "tests/fixtures/test.mp3"},
})
artID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("al1", 1), nil)
dr, err := newDiscArtworkReader(ctx, aw, artID)
Expect(err).ToNot(HaveOccurred())
Expect(dr.LastUpdated()).To(Equal(expected))
},
Entry("album newer than images", now, now.Add(-1*time.Hour), now),
Entry("images newer than album", now.Add(-24*time.Hour), now.Add(-1*time.Hour), now.Add(-1*time.Hour)),
Entry("equal timestamps", now, now, now),
)
})
})
Describe("artistArtworkReader", func() {
Context("Multiple covers", func() {
BeforeEach(func() {
repoRoot, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
folderRepo.result = []model.Folder{{
LibraryPath: testFileLibPath(repoRoot),
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"artist.png"},
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"artist.png"},
}}
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{
arMultipleCovers,
@@ -223,7 +167,7 @@ var _ = Describe("Artwork", func() {
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(filepath.ToSlash(path)).To(HaveSuffix(expected))
Expect(path).To(Equal(expected))
},
Entry(nil, " folder.* , artist.*,album/artist.*", "tests/fixtures/artist/artist.jpg"),
Entry(nil, "album/artist.*, folder.*,artist.*", "tests/fixtures/artist/an-album/artist.png"),
@@ -246,7 +190,6 @@ var _ = Describe("Artwork", func() {
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alOnlyEmbed,
alOnlyExternal,
alSingleDisc,
})
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
mfWithEmbed,
@@ -290,137 +233,8 @@ var _ = Describe("Artwork", func() {
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal("al-444_0"))
})
It("falls back to disc cover art when media file has a disc number on a multi-disc album", func() {
mfWithDisc := model.MediaFile{ID: "46", Path: "tests/fixtures/test.ogg", AlbumID: "444", DiscNumber: 2}
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfWithDisc)).To(Succeed())
aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfWithDisc.ID))
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred())
// Should fall back to disc art, which itself falls back to album art
Expect(path).To(Equal("dc-444:2_0"))
})
It("falls back to album cover art for single-disc albums even with a disc number", func() {
mfOnSingleDisc := model.MediaFile{ID: "47", Path: "tests/fixtures/test.ogg", AlbumID: "888", DiscNumber: 1}
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfOnSingleDisc)).To(Succeed())
aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfOnSingleDisc.ID))
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred())
// Single-disc album should skip disc art and go straight to album art
Expect(path).To(Equal("al-888_0"))
})
})
})
Describe("playlistArtworkReader", func() {
Describe("findPlaylistSidecarPath", func() {
It("discovers sidecar image next to playlist file", func() {
tmpDir := GinkgoT().TempDir()
plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u")
imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg")
Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath)
Expect(result).To(Equal(imgPath))
})
It("returns empty string when no sidecar image exists", func() {
tmpDir := GinkgoT().TempDir()
plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u")
Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath)
Expect(result).To(BeEmpty())
})
It("returns empty string when playlist has no path", func() {
result := findPlaylistSidecarPath(GinkgoT().Context(), "")
Expect(result).To(BeEmpty())
})
It("finds sidecar with different case base name", func() {
tmpDir := GinkgoT().TempDir()
plsPath := filepath.Join(tmpDir, "myplaylist.m3u")
imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg")
Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath)
Expect(result).To(Equal(imgPath))
})
})
Describe("fromPlaylistExternalImage", func() {
It("opens local path from ExternalImageURL", func() {
tmpDir := GinkgoT().TempDir()
imgPath := filepath.Join(tmpDir, "cover.jpg")
Expect(os.WriteFile(imgPath, []byte("external image data"), 0600)).To(Succeed())
reader := &playlistArtworkReader{
pl: model.Playlist{ExternalImageURL: imgPath},
}
r, path, err := reader.fromPlaylistExternalImage(ctx)()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
Expect(path).To(Equal(imgPath))
data, _ := io.ReadAll(r)
Expect(string(data)).To(Equal("external image data"))
r.Close()
})
It("returns nil when ExternalImageURL is empty", func() {
reader := &playlistArtworkReader{
pl: model.Playlist{ExternalImageURL: ""},
}
r, path, err := reader.fromPlaylistExternalImage(ctx)()
Expect(err).ToNot(HaveOccurred())
Expect(r).To(BeNil())
Expect(path).To(BeEmpty())
})
It("returns error when local file does not exist", func() {
reader := &playlistArtworkReader{
pl: model.Playlist{ExternalImageURL: "/non/existent/path/cover.jpg"},
}
r, _, err := reader.fromPlaylistExternalImage(ctx)()
Expect(err).To(HaveOccurred())
Expect(r).To(BeNil())
})
It("skips HTTP URL when EnableM3UExternalAlbumArt is false", func() {
conf.Server.EnableM3UExternalAlbumArt = false
reader := &playlistArtworkReader{
pl: model.Playlist{ExternalImageURL: "https://example.com/cover.jpg"},
}
r, path, err := reader.fromPlaylistExternalImage(ctx)()
Expect(err).ToNot(HaveOccurred())
Expect(r).To(BeNil())
Expect(path).To(BeEmpty())
})
It("still opens local path when EnableM3UExternalAlbumArt is false", func() {
conf.Server.EnableM3UExternalAlbumArt = false
tmpDir := GinkgoT().TempDir()
imgPath := filepath.Join(tmpDir, "cover.jpg")
Expect(os.WriteFile(imgPath, []byte("local image"), 0600)).To(Succeed())
reader := &playlistArtworkReader{
pl: model.Playlist{ExternalImageURL: imgPath},
}
r, path, err := reader.fromPlaylistExternalImage(ctx)()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
Expect(path).To(Equal(imgPath))
r.Close()
})
})
})
Describe("resizedArtworkReader", func() {
BeforeEach(func() {
folderRepo.result = []model.Folder{{
@@ -432,7 +246,7 @@ var _ = Describe("Artwork", func() {
})
})
When("Square is false", func() {
It("returns PNG if original image is a PNG", func() {
It("returns a PNG if original image is a PNG", func() {
conf.Server.CoverArtPriority = "front.png"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
Expect(err).ToNot(HaveOccurred())
@@ -443,7 +257,7 @@ var _ = Describe("Artwork", func() {
Expect(img.Bounds().Size().X).To(Equal(15))
Expect(img.Bounds().Size().Y).To(Equal(15))
})
It("returns JPEG if original image is not a PNG", func() {
It("returns a JPEG if original image is not a PNG", func() {
conf.Server.CoverArtPriority = "cover.jpg"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
Expect(err).ToNot(HaveOccurred())
@@ -459,18 +273,15 @@ var _ = Describe("Artwork", func() {
var alCover model.Album
DescribeTable("resize",
func(srcFormat string, expectedFormat string, landscape bool, size int) {
coverFileName := "cover." + srcFormat
dirName := createImage(srcFormat, landscape, size)
func(format string, landscape bool, size int) {
coverFileName := "cover." + format
dirName := createImage(format, landscape, size)
alCover = model.Album{
ID: "444",
Name: "Only external",
FolderIDs: []string{"tmp"},
}
folderRepo.result = []model.Folder{{ImageFiles: []string{coverFileName}}}
rootLibRepo := &tests.MockLibraryRepo{}
rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}})
ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo
folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{coverFileName}}}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alCover,
})
@@ -481,127 +292,16 @@ var _ = Describe("Artwork", func() {
img, format, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
Expect(format).To(Equal(expectedFormat))
Expect(format).To(Equal("png"))
Expect(img.Bounds().Size().X).To(Equal(size))
Expect(img.Bounds().Size().Y).To(Equal(size))
},
Entry("portrait png image", "png", "png", false, 200),
Entry("landscape png image", "png", "png", true, 200),
Entry("portrait jpg image", "jpg", "png", false, 200),
Entry("landscape jpg image", "jpg", "png", true, 200),
Entry("portrait png image", "png", false, 200),
Entry("landscape png image", "png", true, 200),
Entry("portrait jpg image", "jpg", false, 200),
Entry("landscape jpg image", "jpg", true, 200),
)
})
When("EnableWebPEncoding is true and square is false", func() {
BeforeEach(func() {
conf.Server.EnableWebPEncoding = true
})
It("returns WebP even if original image is a PNG", func() {
conf.Server.CoverArtPriority = "front.png"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
Expect(err).ToNot(HaveOccurred())
img, format, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
Expect(format).To(Equal("webp"))
Expect(img.Bounds().Size().X).To(Equal(15))
Expect(img.Bounds().Size().Y).To(Equal(15))
})
It("returns WebP if original image is not a PNG", func() {
conf.Server.CoverArtPriority = "cover.jpg"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
Expect(err).ToNot(HaveOccurred())
img, format, err := image.Decode(r)
Expect(format).To(Equal("webp"))
Expect(err).ToNot(HaveOccurred())
Expect(img.Bounds().Size().X).To(Equal(200))
Expect(img.Bounds().Size().Y).To(Equal(200))
})
})
When("EnableWebPEncoding is false and square is false", func() {
BeforeEach(func() {
conf.Server.EnableWebPEncoding = false
})
It("returns PNG if original image is a PNG", func() {
conf.Server.CoverArtPriority = "front.png"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
Expect(err).ToNot(HaveOccurred())
img, format, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
Expect(format).To(Equal("png"))
Expect(img.Bounds().Size().X).To(Equal(15))
Expect(img.Bounds().Size().Y).To(Equal(15))
})
It("returns JPEG if original image is a JPG", func() {
conf.Server.CoverArtPriority = "cover.jpg"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
Expect(err).ToNot(HaveOccurred())
img, format, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
Expect(format).To(Equal("jpeg"))
Expect(img.Bounds().Size().X).To(Equal(200))
Expect(img.Bounds().Size().Y).To(Equal(200))
})
})
When("EnableWebPEncoding is false and square is true", func() {
var alCover model.Album
BeforeEach(func() {
conf.Server.EnableWebPEncoding = false
})
It("returns PNG for square mode", func() {
dirName := createImage("png", false, 200)
alCover = model.Album{
ID: "444",
Name: "Only external",
FolderIDs: []string{"tmp"},
}
folderRepo.result = []model.Folder{{ImageFiles: []string{"cover.png"}}}
rootLibRepo := &tests.MockLibraryRepo{}
rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}})
ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{alCover})
conf.Server.CoverArtPriority = "cover.png"
r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), 200, true)
Expect(err).ToNot(HaveOccurred())
img, format, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
Expect(format).To(Equal("png"))
Expect(img.Bounds().Size().X).To(Equal(200))
Expect(img.Bounds().Size().Y).To(Equal(200))
})
})
When("Requested size is larger than original", func() {
It("clamps size to original dimensions", func() {
conf.Server.CoverArtPriority = "front.png"
// front.png is 16x16, requesting 99999 should return at original size
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 99999, false)
Expect(err).ToNot(HaveOccurred())
img, _, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
// Should be clamped to original size (16), not 99999
Expect(img.Bounds().Size().X).To(Equal(16))
Expect(img.Bounds().Size().Y).To(Equal(16))
})
It("clamps square size to original dimensions", func() {
conf.Server.CoverArtPriority = "front.png"
// front.png is 16x16, requesting 99999 with square should return 16x16 square
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 99999, true)
Expect(err).ToNot(HaveOccurred())
img, _, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
// Should be clamped to original size (16), not 99999
Expect(img.Bounds().Size().X).To(Equal(16))
Expect(img.Bounds().Size().Y).To(Equal(16))
})
})
})
})
Loaded 100 of 1365 files, more files were not shown because too many files have changed in this diff. Show more