mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Compare commits
349 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a00152397e | ||
|
|
ae0e0c89d9 | ||
|
|
13c48b38a0 | ||
|
|
7e16b6acb5 | ||
|
|
556f345a10 | ||
|
|
94eb6c522b | ||
|
|
2b9f326993 | ||
|
|
2307a64da7 | ||
|
|
bdea9ed6a1 | ||
|
|
57fc85f434 | ||
|
|
0fd9c6df2e | ||
|
|
d9dac44456 | ||
|
|
46b4dcd5f6 | ||
|
|
d5ba61adf8 | ||
|
|
a4c1fa6378 | ||
|
|
3e25ca3868 | ||
|
|
5c4f0298a6 | ||
|
|
0fe08bfa74 | ||
|
|
259c1a9484 | ||
|
|
e6680c904b | ||
|
|
5d1c1157b5 | ||
|
|
a756cad1dc | ||
|
|
fd930eefd7 | ||
|
|
1bd736dae9 | ||
|
|
0ab10e819f | ||
|
|
5d1c9530ab | ||
|
|
81a17f6bbb | ||
|
|
9824102efb | ||
|
|
ca09070a6c | ||
|
|
251cc71e2d | ||
|
|
3b3b9a62ca | ||
|
|
7e083e0795 | ||
|
|
4488349a3a | ||
|
|
44e63596a0 | ||
|
|
2954c052f5 | ||
|
|
64c8d3f4c5 | ||
|
|
3b7d3f4383 | ||
|
|
28eba567a7 | ||
|
|
155e293f4d | ||
|
|
e86d3266c4 | ||
|
|
15e011bd49 | ||
|
|
02c9fc3359 | ||
|
|
e53e60d39d | ||
|
|
0a6b5519cc | ||
|
|
52e47b896a | ||
|
|
aa84e645ba | ||
|
|
9dfd9ac849 | ||
|
|
1988a4162e | ||
|
|
c49e5855b9 | ||
|
|
85e9982b43 | ||
|
|
501c6eaf8f | ||
|
|
27209ed26a | ||
|
|
de6475bb49 | ||
|
|
1f3a7efa75 | ||
|
|
ab2f1b45de | ||
|
|
9b0bfc606b | ||
|
|
4570dec675 | ||
|
|
36a7be9eaf | ||
|
|
9e2c6adffd | ||
|
|
1de4e43d29 | ||
|
|
1044c173cb | ||
|
|
478845bc5d | ||
|
|
7834674381 | ||
|
|
c91721363b | ||
|
|
664217f3f7 | ||
|
|
991bd3ed21 | ||
|
|
d7baf6ee7f | ||
|
|
2018979bc3 | ||
|
|
e7c7cba873 | ||
|
|
93631cdee9 | ||
|
|
c87db92cee | ||
|
|
80c1e60259 | ||
|
|
23f3556371 | ||
|
|
c60637de24 | ||
|
|
220019a9f1 | ||
|
|
6109bf5192 | ||
|
|
4030bfe06f | ||
|
|
c5bb920b88 | ||
|
|
0f6a076dca | ||
|
|
420d2c8e5a | ||
|
|
9fe9cf3ff6 | ||
|
|
a293d12034 | ||
|
|
dc99994bdd | ||
|
|
049fc78177 | ||
|
|
2b041c02ad | ||
|
|
2588558946 | ||
|
|
f33ca75378 | ||
|
|
79e1af7cd6 | ||
|
|
ccee33f474 | ||
|
|
33e20d355e | ||
|
|
4c91936848 | ||
|
|
0a0f1779cb | ||
|
|
356b0716b6 | ||
|
|
8a19fa9991 | ||
|
|
221d301c42 | ||
|
|
4cca7bce4e | ||
|
|
d91b5e8f4d | ||
|
|
03608d3eef | ||
|
|
cb396f3dba | ||
|
|
400a079fcd | ||
|
|
03844a9a36 | ||
|
|
5cd1fcb492 | ||
|
|
a4c289b28c | ||
|
|
f7b60c7952 | ||
|
|
ba8d427890 | ||
|
|
3f7226d253 | ||
|
|
00b8fbd789 | ||
|
|
31d94acfe7 | ||
|
|
b5164c61ab | ||
|
|
a83ebd1c98 | ||
|
|
d2a54243a8 | ||
|
|
b013b71ba9 | ||
|
|
ad92b752be | ||
|
|
f39d75e7d2 | ||
|
|
693abe2f6b | ||
|
|
a0fe728098 | ||
|
|
8f05f7815e | ||
|
|
2f5b2b5135 | ||
|
|
e7c6e78dd0 | ||
|
|
9ae9134a91 | ||
|
|
cefa6e9619 | ||
|
|
ab8a58157a | ||
|
|
be06196168 | ||
|
|
36aea8a11f | ||
|
|
aa93911991 | ||
|
|
c42570446b | ||
|
|
a887521d7a | ||
|
|
69e7d163fc | ||
|
|
6b8fcc37c6 | ||
|
|
197d357f02 | ||
|
|
549b812633 | ||
|
|
c63346de04 | ||
|
|
ba3974ee59 | ||
|
|
8939f31d55 | ||
|
|
d79b812467 | ||
|
|
55331b5fd9 | ||
|
|
d042fc138c | ||
|
|
55e10b9c77 | ||
|
|
49a14d4583 | ||
|
|
a50b2a1e72 | ||
|
|
4ddb0774ec | ||
|
|
0790f66627 | ||
|
|
d0fbba14ff | ||
|
|
903e3f070f | ||
|
|
0312eb33f1 | ||
|
|
5ecbe31a06 | ||
|
|
d8bc41fbb1 | ||
|
|
51c48bcacd | ||
|
|
75e5bc4e81 | ||
|
|
053a0fd6c0 | ||
|
|
767744a301 | ||
|
|
844dffa2f1 | ||
|
|
d76b49c6d1 | ||
|
|
94894fd511 | ||
|
|
d7c3a50f86 | ||
|
|
d4b2499e1e | ||
|
|
e08d4bef16 | ||
|
|
09e1cf6ae7 | ||
|
|
957130ca38 | ||
|
|
a25306f2c1 | ||
|
|
7c5aa1fafa | ||
|
|
928741ef25 | ||
|
|
ae1e0ddb11 | ||
|
|
e1b3412999 | ||
|
|
3cd5d16b0a | ||
|
|
f102036dc6 | ||
|
|
d2db41691e | ||
|
|
1ce561cc8e | ||
|
|
12f28b9d97 | ||
|
|
627266ec82 | ||
|
|
11e4aaed1b | ||
|
|
f03ca44a8e | ||
|
|
eeb1bd5f41 | ||
|
|
668869b6c7 | ||
|
|
24ba655dc3 | ||
|
|
ed4c0ef432 | ||
|
|
c885766854 | ||
|
|
692f0f99f6 | ||
|
|
157c917ca5 | ||
|
|
435fb0b076 | ||
|
|
6fd044fb09 | ||
|
|
30df004d4d | ||
|
|
82f9f88c0f | ||
|
|
3d86d44fd9 | ||
|
|
acd69f6a4f | ||
|
|
c4fd8e3125 | ||
|
|
27a83547f7 | ||
|
|
d004f99f8f | ||
|
|
4e34d3ac1f | ||
|
|
3476be01f7 | ||
|
|
2471bb9cf6 | ||
|
|
d9a215e1e3 | ||
|
|
d134de1061 | ||
|
|
bd8032b327 | ||
|
|
582d1b3cd9 | ||
|
|
cdd3432788 | ||
|
|
5bc2bbb70e | ||
|
|
14343d91b0 | ||
|
|
fc36f1daa6 | ||
|
|
652c27690b | ||
|
|
2bb13e5ff1 | ||
|
|
d1c5e6a2f2 | ||
|
|
0c3cc86535 | ||
|
|
b59eb32961 | ||
|
|
23bf256a66 | ||
|
|
d02bf9a53d | ||
|
|
ec75808153 | ||
|
|
7ad2907719 | ||
|
|
76c01566a9 | ||
|
|
1cf3fd9161 | ||
|
|
54de0dbc52 | ||
|
|
6f5f58ae9d | ||
|
|
821f22a86f | ||
|
|
74aa4d6fa5 | ||
|
|
dc4607c657 | ||
|
|
ddab0da207 | ||
|
|
08a71320ea | ||
|
|
44a5482493 | ||
|
|
5fa8356b31 | ||
|
|
cad9cdc53e | ||
|
|
b774133cd1 | ||
|
|
a20d56c137 | ||
|
|
b64d8ad334 | ||
|
|
f00af7f983 | ||
|
|
875ffc2b78 | ||
|
|
885334c819 | ||
|
|
ff86b9f2b9 | ||
|
|
13d3d510f5 | ||
|
|
656009e5f8 | ||
|
|
06b3a1f33e | ||
|
|
0f4e8376cb | ||
|
|
199cde4109 | ||
|
|
897de02a84 | ||
|
|
7ee56fe3bf | ||
|
|
34c6f12aee | ||
|
|
eb9ebc3fba | ||
|
|
e05a7e230f | ||
|
|
62f9c3a458 | ||
|
|
fd09ca103f | ||
|
|
ed79a8897b | ||
|
|
302d99aa8b | ||
|
|
bee0305831 | ||
|
|
c280dd67a4 | ||
|
|
8319905d2c | ||
|
|
c80ef8ae41 | ||
|
|
0a4722802a | ||
|
|
a704e86ac1 | ||
|
|
408aa78ed5 | ||
|
|
29f98b889b | ||
|
|
1e37e680d7 | ||
|
|
6fb4cd277e | ||
|
|
e11206f0ee | ||
|
|
b4e03673ba | ||
|
|
01c839d9be | ||
|
|
2731e25fd2 | ||
|
|
4f3845bbe3 | ||
|
|
e8863ed147 | ||
|
|
19ea338bed | ||
|
|
338853468f | ||
|
|
4e720ee931 | ||
|
|
0c8f2a559c | ||
|
|
a1036e75a9 | ||
|
|
2829cec0ce | ||
|
|
ddff5db14a | ||
|
|
d7ec7355c9 | ||
|
|
c3a4585c83 | ||
|
|
2068e7d413 | ||
|
|
15526b25e5 | ||
|
|
948f6507c1 | ||
|
|
9bce7677f5 | ||
|
|
7b709899a1 | ||
|
|
ebbc31f1ab | ||
|
|
84ab652ca7 | ||
|
|
f13ca58c98 | ||
|
|
36252823ce | ||
|
|
7d5e13672d | ||
|
|
4c2bd7509c | ||
|
|
7b523d6b61 | ||
|
|
c9e58e3666 | ||
|
|
77367548f6 | ||
|
|
71f549afbf | ||
|
|
1afcf7775b | ||
|
|
a55c4f0410 | ||
|
|
5db585e1b1 | ||
|
|
63517e904c | ||
|
|
51026de80b | ||
|
|
fda35dd8ce | ||
|
|
4d4740b83b | ||
|
|
772d1f359b | ||
|
|
b455546fdf | ||
|
|
c6c1c16923 | ||
|
|
75dd28678f | ||
|
|
1c4a7e8556 | ||
|
|
b1b488be77 | ||
|
|
6fce30c133 | ||
|
|
6c7f8314e2 | ||
|
|
37aa54fe06 | ||
|
|
fae58bb390 | ||
|
|
f1e75c40dc | ||
|
|
66474fc9f4 | ||
|
|
fd620413b8 | ||
|
|
4ec6e7c56e | ||
|
|
03120bac32 | ||
|
|
0473c50b49 | ||
|
|
2de2484bca | ||
|
|
64e165aaef | ||
|
|
8e96dd0784 | ||
|
|
9bd91d2c04 | ||
|
|
c5447a637a | ||
|
|
b9247ba34e | ||
|
|
510acde3db | ||
|
|
13be8e6dfb | ||
|
|
9ab0c2dc67 | ||
|
|
032cfa2a4d | ||
|
|
84bf4fac04 | ||
|
|
8485371ad3 | ||
|
|
d45d306492 | ||
|
|
6d47a6ebd9 | ||
|
|
14efb13cd4 | ||
|
|
3adc4eb8aa | ||
|
|
7b9bc1c5ac | ||
|
|
03a45753e9 | ||
|
|
fd4a04339e | ||
|
|
9d95ef7b3f | ||
|
|
55966ba5ec | ||
|
|
5c3568f758 | ||
|
|
735c0d9103 | ||
|
|
fc9817552d | ||
|
|
0c1b65d3e6 | ||
|
|
47b448c64f | ||
|
|
834fa494e4 | ||
|
|
5d34640065 | ||
|
|
9ed309ac81 | ||
|
|
8c80be56da | ||
|
|
cde5992c46 | ||
|
|
017676c457 | ||
|
|
2d7b716834 | ||
|
|
c7ac0e4414 | ||
|
|
c9409d306a | ||
|
|
ebbe62bbbd | ||
|
|
42c85a18e2 | ||
|
|
7ccf44b8ed | ||
|
|
603cccde11 | ||
|
|
6ed6524752 | ||
|
|
a081569ed4 | ||
|
|
e923c02c6a | ||
|
|
51ca2dee65 | ||
|
|
6b961bd99d | ||
|
|
396eee48c6 |
@ -13,15 +13,5 @@ RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/shar
|
|||||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||||
&& apt-get -y install --no-install-recommends ffmpeg
|
&& apt-get -y install --no-install-recommends ffmpeg
|
||||||
|
|
||||||
# Install TagLib from cross-taglib releases
|
|
||||||
ARG CROSS_TAGLIB_VERSION="2.1.1-1"
|
|
||||||
ARG TARGETARCH
|
|
||||||
RUN DOWNLOAD_ARCH="linux-${TARGETARCH}" \
|
|
||||||
&& wget -q "https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/taglib-${DOWNLOAD_ARCH}.tar.gz" -O /tmp/cross-taglib.tar.gz \
|
|
||||||
&& tar -xzf /tmp/cross-taglib.tar.gz -C /usr --strip-components=1 \
|
|
||||||
&& mv /usr/include/taglib/* /usr/include/ \
|
|
||||||
&& rmdir /usr/include/taglib \
|
|
||||||
&& rm /tmp/cross-taglib.tar.gz /usr/provenance.json
|
|
||||||
|
|
||||||
# [Optional] Uncomment this line to install global node packages.
|
# [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
|
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
|
||||||
|
|||||||
@ -4,11 +4,10 @@
|
|||||||
"dockerfile": "Dockerfile",
|
"dockerfile": "Dockerfile",
|
||||||
"args": {
|
"args": {
|
||||||
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
|
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
|
||||||
"VARIANT": "1.25",
|
"VARIANT": "1.26",
|
||||||
// Options
|
// Options
|
||||||
"INSTALL_NODE": "true",
|
"INSTALL_NODE": "true",
|
||||||
"NODE_VERSION": "v24",
|
"NODE_VERSION": "v24"
|
||||||
"CROSS_TAGLIB_VERSION": "2.1.1-1"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"workspaceMount": "",
|
"workspaceMount": "",
|
||||||
|
|||||||
@ -15,4 +15,5 @@ dist
|
|||||||
binaries
|
binaries
|
||||||
cache
|
cache
|
||||||
music
|
music
|
||||||
|
music.old
|
||||||
!Dockerfile
|
!Dockerfile
|
||||||
23
.github/actions/download-taglib/action.yml
vendored
23
.github/actions/download-taglib/action.yml
vendored
@ -1,23 +0,0 @@
|
|||||||
name: 'Download TagLib'
|
|
||||||
description: 'Downloads and extracts the TagLib library, adding it to PKG_CONFIG_PATH'
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: 'Version of TagLib to download'
|
|
||||||
required: true
|
|
||||||
platform:
|
|
||||||
description: 'Platform to download TagLib for'
|
|
||||||
default: 'linux-amd64'
|
|
||||||
runs:
|
|
||||||
using: 'composite'
|
|
||||||
steps:
|
|
||||||
- name: Download TagLib
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
mkdir -p /tmp/taglib
|
|
||||||
cd /tmp
|
|
||||||
FILE=taglib-${{ inputs.platform }}.tar.gz
|
|
||||||
wget https://github.com/navidrome/cross-taglib/releases/download/v${{ inputs.version }}/${FILE}
|
|
||||||
tar -xzf ${FILE} -C taglib
|
|
||||||
PKG_CONFIG_PREFIX=/tmp/taglib
|
|
||||||
echo "PKG_CONFIG_PREFIX=${PKG_CONFIG_PREFIX}" >> $GITHUB_ENV
|
|
||||||
echo "PKG_CONFIG_PATH=${PKG_CONFIG_PATH}:${PKG_CONFIG_PREFIX}/lib/pkgconfig" >> $GITHUB_ENV
|
|
||||||
137
.github/workflows/pipeline.yml
vendored
137
.github/workflows/pipeline.yml
vendored
@ -14,7 +14,6 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CROSS_TAGLIB_VERSION: "2.1.1-1"
|
|
||||||
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
|
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@ -65,10 +64,9 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download TagLib
|
- uses: actions/setup-go@v6
|
||||||
uses: ./.github/actions/download-taglib
|
|
||||||
with:
|
with:
|
||||||
version: ${{ env.CROSS_TAGLIB_VERSION }}
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v9
|
uses: golangci/golangci-lint-action@v9
|
||||||
@ -88,6 +86,16 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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:
|
go:
|
||||||
name: Test Go code
|
name: Test Go code
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -95,18 +103,95 @@ jobs:
|
|||||||
- name: Check out code into the Go module directory
|
- name: Check out code into the Go module directory
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download TagLib
|
- uses: actions/setup-go@v6
|
||||||
uses: ./.github/actions/download-taglib
|
|
||||||
with:
|
with:
|
||||||
version: ${{ env.CROSS_TAGLIB_VERSION }}
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: Download dependencies
|
- name: Download dependencies
|
||||||
run: go mod download
|
run: go mod download
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
|
run: go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v
|
||||||
|
|
||||||
|
- name: Test ndpgen
|
||||||
run: |
|
run: |
|
||||||
pkg-config --define-prefix --cflags --libs taglib # for debugging
|
cd plugins/cmd/ndpgen
|
||||||
go test -shuffle=on -tags netgo -race ./... -v
|
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@v4
|
||||||
|
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
|
||||||
|
|
||||||
js:
|
js:
|
||||||
name: Test JS code
|
name: Test JS code
|
||||||
@ -172,10 +257,10 @@ jobs:
|
|||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
needs: [js, go, go-lint, i18n-lint, git-version, check-push-enabled]
|
needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled]
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]
|
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 ]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
IS_LINUX: ${{ startsWith(matrix.platform, 'linux/') && 'true' || 'false' }}
|
IS_LINUX: ${{ startsWith(matrix.platform, 'linux/') && 'true' || 'false' }}
|
||||||
@ -203,7 +288,7 @@ jobs:
|
|||||||
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
|
||||||
- name: Build Binaries
|
- name: Build Binaries
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
@ -214,10 +299,9 @@ jobs:
|
|||||||
build-args: |
|
build-args: |
|
||||||
GIT_SHA=${{ env.GIT_SHA }}
|
GIT_SHA=${{ env.GIT_SHA }}
|
||||||
GIT_TAG=${{ env.GIT_TAG }}
|
GIT_TAG=${{ env.GIT_TAG }}
|
||||||
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
|
|
||||||
|
|
||||||
- name: Upload Binaries
|
- name: Upload Binaries
|
||||||
uses: actions/upload-artifact@v5
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: navidrome-${{ env.PLATFORM }}
|
name: navidrome-${{ env.PLATFORM }}
|
||||||
path: ./output
|
path: ./output
|
||||||
@ -226,7 +310,7 @@ jobs:
|
|||||||
- name: Build and push image by digest
|
- name: Build and push image by digest
|
||||||
id: push-image
|
id: push-image
|
||||||
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
@ -235,7 +319,6 @@ jobs:
|
|||||||
build-args: |
|
build-args: |
|
||||||
GIT_SHA=${{ env.GIT_SHA }}
|
GIT_SHA=${{ env.GIT_SHA }}
|
||||||
GIT_TAG=${{ env.GIT_TAG }}
|
GIT_TAG=${{ env.GIT_TAG }}
|
||||||
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
|
|
||||||
outputs: |
|
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=${{ 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
|
type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true
|
||||||
@ -248,7 +331,7 @@ jobs:
|
|||||||
touch "/tmp/digests/${digest#sha256:}"
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
uses: actions/upload-artifact@v5
|
uses: actions/upload-artifact@v7
|
||||||
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
||||||
with:
|
with:
|
||||||
name: digests-${{ env.PLATFORM }}
|
name: digests-${{ env.PLATFORM }}
|
||||||
@ -270,7 +353,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@v6
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
@ -304,7 +387,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@v6
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
@ -320,7 +403,7 @@ jobs:
|
|||||||
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
|
||||||
- name: Create manifest list and push to Docker Hub
|
- name: Create manifest list and push to Docker Hub
|
||||||
uses: nick-fields/retry@v3
|
uses: nick-fields/retry@v4
|
||||||
with:
|
with:
|
||||||
timeout_minutes: 5
|
timeout_minutes: 5
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
@ -356,7 +439,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: actions/download-artifact@v6
|
- uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
path: ./binaries
|
path: ./binaries
|
||||||
pattern: navidrome-windows*
|
pattern: navidrome-windows*
|
||||||
@ -375,7 +458,7 @@ jobs:
|
|||||||
du -h binaries/msi/*.msi
|
du -h binaries/msi/*.msi
|
||||||
|
|
||||||
- name: Upload MSI files
|
- name: Upload MSI files
|
||||||
uses: actions/upload-artifact@v5
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: navidrome-windows-installers
|
name: navidrome-windows-installers
|
||||||
path: binaries/msi/*.msi
|
path: binaries/msi/*.msi
|
||||||
@ -393,7 +476,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
fetch-tags: true
|
fetch-tags: true
|
||||||
|
|
||||||
- uses: actions/download-artifact@v6
|
- uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
path: ./binaries
|
path: ./binaries
|
||||||
pattern: navidrome-*
|
pattern: navidrome-*
|
||||||
@ -406,7 +489,7 @@ jobs:
|
|||||||
run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV
|
run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v7
|
||||||
with:
|
with:
|
||||||
version: '~> v2'
|
version: '~> v2'
|
||||||
args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}"
|
args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}"
|
||||||
@ -419,7 +502,7 @@ jobs:
|
|||||||
rm ./dist/*.tar.gz ./dist/*.zip
|
rm ./dist/*.tar.gz ./dist/*.zip
|
||||||
|
|
||||||
- name: Upload all-packages artifact
|
- name: Upload all-packages artifact
|
||||||
uses: actions/upload-artifact@v5
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: packages
|
name: packages
|
||||||
path: dist/navidrome_0*
|
path: dist/navidrome_0*
|
||||||
@ -442,13 +525,13 @@ jobs:
|
|||||||
item: ${{ fromJson(needs.release.outputs.package_list) }}
|
item: ${{ fromJson(needs.release.outputs.package_list) }}
|
||||||
steps:
|
steps:
|
||||||
- name: Download all-packages artifact
|
- name: Download all-packages artifact
|
||||||
uses: actions/download-artifact@v6
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: packages
|
name: packages
|
||||||
path: ./dist
|
path: ./dist
|
||||||
|
|
||||||
- name: Upload all-packages artifact
|
- name: Upload all-packages artifact
|
||||||
uses: actions/upload-artifact@v5
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: navidrome_linux_${{ matrix.item }}
|
name: navidrome_linux_${{ matrix.item }}
|
||||||
path: dist/navidrome_0*_linux_${{ matrix.item }}
|
path: dist/navidrome_0*_linux_${{ matrix.item }}
|
||||||
|
|||||||
138
.github/workflows/push-translations.sh
vendored
Executable file
138
.github/workflows/push-translations.sh
vendored
Executable file
@ -0,0 +1,138 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
I18N_DIR=resources/i18n
|
||||||
|
|
||||||
|
# Normalize JSON for deterministic comparison:
|
||||||
|
# remove empty/null attributes, sort keys alphabetically
|
||||||
|
process_json() {
|
||||||
|
jq 'walk(if type == "object" then with_entries(select(.value != null and .value != "" and .value != [] and .value != {})) | to_entries | sort_by(.key) | from_entries else . end)' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get list of all languages configured in the POEditor project
|
||||||
|
get_language_list() {
|
||||||
|
curl -s -X POST https://api.poeditor.com/v2/languages/list \
|
||||||
|
-d api_token="${POEDITOR_APIKEY}" \
|
||||||
|
-d id="${POEDITOR_PROJECTID}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract language name from the language list JSON given a language code
|
||||||
|
get_language_name() {
|
||||||
|
lang_code="$1"
|
||||||
|
lang_list="$2"
|
||||||
|
echo "$lang_list" | jq -r ".result.languages[] | select(.code == \"$lang_code\") | .name"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract language code from a file path (e.g., "resources/i18n/fr.json" -> "fr")
|
||||||
|
get_lang_code() {
|
||||||
|
filepath="$1"
|
||||||
|
filename=$(basename "$filepath")
|
||||||
|
echo "${filename%.*}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Export the current translation for a language from POEditor (v2 API)
|
||||||
|
export_language() {
|
||||||
|
lang_code="$1"
|
||||||
|
response=$(curl -s -X POST https://api.poeditor.com/v2/projects/export \
|
||||||
|
-d api_token="${POEDITOR_APIKEY}" \
|
||||||
|
-d id="${POEDITOR_PROJECTID}" \
|
||||||
|
-d language="$lang_code" \
|
||||||
|
-d type="key_value_json")
|
||||||
|
|
||||||
|
url=$(echo "$response" | jq -r '.result.url')
|
||||||
|
if [ -z "$url" ] || [ "$url" = "null" ]; then
|
||||||
|
echo "Failed to export $lang_code: $response" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "$url"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Flatten nested JSON to POEditor languages/update format.
|
||||||
|
# POEditor uses term + context pairs, where:
|
||||||
|
# term = the leaf key name
|
||||||
|
# context = the parent path as "key1"."key2"."key3" (empty for root keys)
|
||||||
|
flatten_to_poeditor() {
|
||||||
|
jq -c '[paths(scalars) as $p |
|
||||||
|
{
|
||||||
|
"term": ($p | last | tostring),
|
||||||
|
"context": (if ($p | length) > 1 then ($p[:-1] | map("\"" + tostring + "\"") | join(".")) else "" end),
|
||||||
|
"translation": {"content": getpath($p)}
|
||||||
|
}
|
||||||
|
]' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update translations for a language in POEditor via languages/update API
|
||||||
|
update_language() {
|
||||||
|
lang_code="$1"
|
||||||
|
file="$2"
|
||||||
|
|
||||||
|
flatten_to_poeditor "$file" > /tmp/poeditor_data.json
|
||||||
|
response=$(curl -s -X POST https://api.poeditor.com/v2/languages/update \
|
||||||
|
-d api_token="${POEDITOR_APIKEY}" \
|
||||||
|
-d id="${POEDITOR_PROJECTID}" \
|
||||||
|
-d language="$lang_code" \
|
||||||
|
--data-urlencode data@/tmp/poeditor_data.json)
|
||||||
|
rm -f /tmp/poeditor_data.json
|
||||||
|
|
||||||
|
status=$(echo "$response" | jq -r '.response.status')
|
||||||
|
if [ "$status" != "success" ]; then
|
||||||
|
echo "Failed to update $lang_code: $response" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
parsed=$(echo "$response" | jq -r '.result.translations.parsed')
|
||||||
|
added=$(echo "$response" | jq -r '.result.translations.added')
|
||||||
|
updated=$(echo "$response" | jq -r '.result.translations.updated')
|
||||||
|
echo " Translations - parsed: $parsed, added: $added, updated: $updated"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Main ---
|
||||||
|
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
echo "Usage: $0 <file1> [file2] ..."
|
||||||
|
echo "No files specified. Nothing to do."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
lang_list=$(get_language_list)
|
||||||
|
upload_count=0
|
||||||
|
|
||||||
|
for file in "$@"; do
|
||||||
|
if [ ! -f "$file" ]; then
|
||||||
|
echo "Warning: File not found: $file, skipping"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
lang_code=$(get_lang_code "$file")
|
||||||
|
lang_name=$(get_language_name "$lang_code" "$lang_list")
|
||||||
|
|
||||||
|
if [ -z "$lang_name" ]; then
|
||||||
|
echo "Warning: Language code '$lang_code' not found in POEditor, skipping $file"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Processing $lang_name ($lang_code)..."
|
||||||
|
|
||||||
|
# Export current state from POEditor
|
||||||
|
url=$(export_language "$lang_code")
|
||||||
|
curl -sSL "$url" -o poeditor_export.json
|
||||||
|
|
||||||
|
# Normalize both files for comparison
|
||||||
|
process_json "$file" > local_normalized.json
|
||||||
|
process_json poeditor_export.json > remote_normalized.json
|
||||||
|
|
||||||
|
# Compare normalized versions
|
||||||
|
if diff -q local_normalized.json remote_normalized.json > /dev/null 2>&1; then
|
||||||
|
echo " No differences, skipping"
|
||||||
|
else
|
||||||
|
echo " Differences found, updating POEditor..."
|
||||||
|
update_language "$lang_code" "$file"
|
||||||
|
upload_count=$((upload_count + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f poeditor_export.json local_normalized.json remote_normalized.json
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done. Updated $upload_count translation(s) in POEditor."
|
||||||
32
.github/workflows/push-translations.yml
vendored
Normal file
32
.github/workflows/push-translations.yml
vendored
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
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
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@ -12,7 +12,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: dessant/lock-threads@v5
|
- uses: dessant/lock-threads@v6
|
||||||
with:
|
with:
|
||||||
process-only: 'issues, prs'
|
process-only: 'issues, prs'
|
||||||
issue-inactive-days: 120
|
issue-inactive-days: 120
|
||||||
|
|||||||
2
.github/workflows/update-translations.yml
vendored
2
.github/workflows/update-translations.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
|||||||
git status --porcelain
|
git status --porcelain
|
||||||
git diff
|
git diff
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
uses: peter-evans/create-pull-request@v7
|
uses: peter-evans/create-pull-request@v8
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.PAT }}
|
token: ${{ secrets.PAT }}
|
||||||
author: "navidrome-bot <navidrome-bot@navidrome.org>"
|
author: "navidrome-bot <navidrome-bot@navidrome.org>"
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@ -17,14 +17,17 @@ master.zip
|
|||||||
testDB
|
testDB
|
||||||
cache/*
|
cache/*
|
||||||
*.swp
|
*.swp
|
||||||
|
coverage.out
|
||||||
dist
|
dist
|
||||||
music
|
music
|
||||||
|
music.old
|
||||||
*.db*
|
*.db*
|
||||||
.gitinfo
|
.gitinfo
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
!contrib/docker-compose.yml
|
!contrib/docker-compose.yml
|
||||||
binaries
|
binaries
|
||||||
navidrome-*
|
navidrome-*
|
||||||
|
/ndpgen
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
.github/prompts
|
.github/prompts
|
||||||
.github/instructions
|
.github/instructions
|
||||||
@ -32,4 +35,7 @@ AGENTS.md
|
|||||||
*.exe
|
*.exe
|
||||||
*.test
|
*.test
|
||||||
*.wasm
|
*.wasm
|
||||||
openspec/
|
*.ndp
|
||||||
|
openspec/
|
||||||
|
go.work*
|
||||||
|
.worktrees/
|
||||||
@ -2,6 +2,7 @@ version: "2"
|
|||||||
run:
|
run:
|
||||||
build-tags:
|
build-tags:
|
||||||
- netgo
|
- netgo
|
||||||
|
- sqlite_fts5
|
||||||
linters:
|
linters:
|
||||||
enable:
|
enable:
|
||||||
- asasalint
|
- asasalint
|
||||||
@ -39,6 +40,11 @@ linters:
|
|||||||
enable:
|
enable:
|
||||||
- nilness
|
- nilness
|
||||||
exclusions:
|
exclusions:
|
||||||
|
rules:
|
||||||
|
- linters:
|
||||||
|
- gosec
|
||||||
|
path: _test\.go
|
||||||
|
text: "G703"
|
||||||
generated: lax
|
generated: lax
|
||||||
presets:
|
presets:
|
||||||
- comments
|
- comments
|
||||||
@ -49,6 +55,7 @@ linters:
|
|||||||
- third_party$
|
- third_party$
|
||||||
- builtin$
|
- builtin$
|
||||||
- examples$
|
- examples$
|
||||||
|
- node_modules
|
||||||
formatters:
|
formatters:
|
||||||
exclusions:
|
exclusions:
|
||||||
generated: lax
|
generated: lax
|
||||||
@ -56,3 +63,4 @@ formatters:
|
|||||||
- third_party$
|
- third_party$
|
||||||
- builtin$
|
- builtin$
|
||||||
- examples$
|
- examples$
|
||||||
|
- node_modules
|
||||||
|
|||||||
@ -38,7 +38,7 @@ Before submitting a pull request, ensure that you go through the following:
|
|||||||
### Commit Conventions
|
### Commit Conventions
|
||||||
Each commit message must adhere to the following format:
|
Each commit message must adhere to the following format:
|
||||||
```
|
```
|
||||||
<type>(scope): <description> - <issue number>
|
<type>(scope): <description>
|
||||||
|
|
||||||
[optional body]
|
[optional body]
|
||||||
```
|
```
|
||||||
|
|||||||
92
Dockerfile
92
Dockerfile
@ -2,10 +2,10 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcros
|
|||||||
|
|
||||||
########################################################################################################################
|
########################################################################################################################
|
||||||
### Build xx (original image: tonistiigi/xx)
|
### Build xx (original image: tonistiigi/xx)
|
||||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.19 AS xx-build
|
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS xx-build
|
||||||
|
|
||||||
# v1.5.0
|
# v1.9.0
|
||||||
ENV XX_VERSION=b4e4c451c778822e6742bfc9d9a91d7c7d885c8a
|
ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50
|
||||||
|
|
||||||
RUN apk add -U --no-cache git
|
RUN apk add -U --no-cache git
|
||||||
RUN git clone https://github.com/tonistiigi/xx && \
|
RUN git clone https://github.com/tonistiigi/xx && \
|
||||||
@ -24,26 +24,6 @@ RUN cd /out && \
|
|||||||
FROM scratch AS xx
|
FROM scratch AS xx
|
||||||
COPY --from=xx-build /out/ /usr/bin/
|
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
|
### Build Navidrome UI
|
||||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/node:lts-alpine AS ui
|
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/node:lts-alpine AS ui
|
||||||
@ -62,8 +42,47 @@ FROM scratch AS ui-bundle
|
|||||||
COPY --from=ui /build /build
|
COPY --from=ui /build /build
|
||||||
|
|
||||||
########################################################################################################################
|
########################################################################################################################
|
||||||
### Build Navidrome binary
|
### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen)
|
||||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.25-bookworm AS base
|
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-alpine AS build-alpine
|
||||||
|
COPY --from=xx / /
|
||||||
|
|
||||||
|
ARG TARGETPLATFORM
|
||||||
|
|
||||||
|
RUN apk add --no-cache clang lld file git
|
||||||
|
RUN xx-apk add --no-cache gcc musl-dev zlib-dev
|
||||||
|
RUN xx-verify --setup
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
RUN --mount=type=bind,source=. \
|
||||||
|
--mount=type=cache,target=/root/.cache \
|
||||||
|
--mount=type=cache,target=/go/pkg/mod \
|
||||||
|
go mod download
|
||||||
|
|
||||||
|
ARG GIT_SHA
|
||||||
|
ARG GIT_TAG
|
||||||
|
|
||||||
|
RUN --mount=type=bind,source=. \
|
||||||
|
--mount=from=ui,source=/build,target=./ui/build,ro \
|
||||||
|
--mount=type=cache,target=/root/.cache \
|
||||||
|
--mount=type=cache,target=/go/pkg/mod <<EOT
|
||||||
|
set -e
|
||||||
|
xx-go --wrap
|
||||||
|
export CGO_ENABLED=1
|
||||||
|
# -latomic is required on 32-bit arm (arm/v6, arm/v7) so SQLite's 64-bit atomics resolve.
|
||||||
|
go build -tags=netgo,sqlite_fts5 -ldflags="-w -s \
|
||||||
|
-linkmode=external -extldflags '-latomic' \
|
||||||
|
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
|
||||||
|
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
|
||||||
|
-o /out/navidrome .
|
||||||
|
# Fail the build if the binary is accidentally statically linked: dlopen (and
|
||||||
|
# therefore native libwebp detection) only works with a dynamic interpreter.
|
||||||
|
file /out/navidrome | grep -q "dynamically linked" || { echo "ERROR: /out/navidrome is not dynamically linked"; file /out/navidrome; exit 1; }
|
||||||
|
EOT
|
||||||
|
|
||||||
|
########################################################################################################################
|
||||||
|
### Build Navidrome binary for standalone distribution (static glibc, cross-compiled)
|
||||||
|
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-trixie AS base
|
||||||
RUN apt-get update && apt-get install -y clang lld
|
RUN apt-get update && apt-get install -y clang lld
|
||||||
COPY --from=xx / /
|
COPY --from=xx / /
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
@ -88,13 +107,11 @@ RUN --mount=type=bind,source=. \
|
|||||||
--mount=from=ui,source=/build,target=./ui/build,ro \
|
--mount=from=ui,source=/build,target=./ui/build,ro \
|
||||||
--mount=from=osxcross,src=/osxcross/SDK,target=/xx-sdk,ro \
|
--mount=from=osxcross,src=/osxcross/SDK,target=/xx-sdk,ro \
|
||||||
--mount=type=cache,target=/root/.cache \
|
--mount=type=cache,target=/root/.cache \
|
||||||
--mount=type=cache,target=/go/pkg/mod \
|
--mount=type=cache,target=/go/pkg/mod <<EOT
|
||||||
--mount=from=taglib-build,target=/taglib,src=/taglib,ro <<EOT
|
|
||||||
|
|
||||||
# Setup CGO cross-compilation environment
|
# Setup CGO cross-compilation environment
|
||||||
xx-go --wrap
|
xx-go --wrap
|
||||||
export CGO_ENABLED=1
|
export CGO_ENABLED=1
|
||||||
export PKG_CONFIG_PATH=/taglib/lib/pkgconfig
|
|
||||||
cat $(go env GOENV)
|
cat $(go env GOENV)
|
||||||
|
|
||||||
# Only Darwin (macOS) requires clang (default), Windows requires gcc, everything else can use any compiler.
|
# Only Darwin (macOS) requires clang (default), Windows requires gcc, everything else can use any compiler.
|
||||||
@ -108,7 +125,7 @@ RUN --mount=type=bind,source=. \
|
|||||||
export EXT=".exe"
|
export EXT=".exe"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
go build -tags=netgo -ldflags="${LD_EXTRA} -w -s \
|
go build -tags=netgo,sqlite_fts5 -ldflags="${LD_EXTRA} -w -s \
|
||||||
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
|
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
|
||||||
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
|
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
|
||||||
-o /out/navidrome${EXT} .
|
-o /out/navidrome${EXT} .
|
||||||
@ -122,25 +139,32 @@ COPY --from=build /out /
|
|||||||
|
|
||||||
########################################################################################################################
|
########################################################################################################################
|
||||||
### Build Final Image
|
### Build Final Image
|
||||||
FROM public.ecr.aws/docker/library/alpine:3.19 AS final
|
FROM public.ecr.aws/docker/library/alpine:3.20 AS final
|
||||||
LABEL maintainer="deluan@navidrome.org"
|
LABEL maintainer="deluan@navidrome.org"
|
||||||
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
|
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
|
||||||
|
|
||||||
# Install ffmpeg and mpv
|
# Install runtime dependencies
|
||||||
RUN apk add -U --no-cache ffmpeg mpv sqlite
|
# - libwebp + symlinks: enables native WebP encoding via purego/dlopen
|
||||||
|
RUN apk add -U --no-cache ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \
|
||||||
|
for lib in libwebp libwebpdemux libwebpmux; do \
|
||||||
|
target=$(ls /usr/lib/$lib.so.* 2>/dev/null | head -1) && \
|
||||||
|
[ -n "$target" ] && ln -sf "$target" /usr/lib/$lib.so; \
|
||||||
|
done
|
||||||
|
|
||||||
# Copy navidrome binary
|
# Copy navidrome binary (musl build for Docker, enables native libwebp)
|
||||||
COPY --from=build /out/navidrome /app/
|
COPY --from=build-alpine /out/navidrome /app/
|
||||||
|
|
||||||
VOLUME ["/data", "/music"]
|
VOLUME ["/data", "/music"]
|
||||||
ENV ND_MUSICFOLDER=/music
|
ENV ND_MUSICFOLDER=/music
|
||||||
ENV ND_DATAFOLDER=/data
|
ENV ND_DATAFOLDER=/data
|
||||||
ENV ND_CONFIGFILE=/data/navidrome.toml
|
ENV ND_CONFIGFILE=/data/navidrome.toml
|
||||||
ENV ND_PORT=4533
|
ENV ND_PORT=4533
|
||||||
|
ENV ND_ENABLEWEBPENCODING=true
|
||||||
RUN touch /.nddockerenv
|
RUN touch /.nddockerenv
|
||||||
|
|
||||||
EXPOSE ${ND_PORT}
|
EXPOSE ${ND_PORT}
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
ENV PATH="/app:${PATH}"
|
||||||
|
|
||||||
ENTRYPOINT ["/app/navidrome"]
|
ENTRYPOINT ["/app/navidrome"]
|
||||||
|
|
||||||
|
|||||||
108
Makefile
108
Makefile
@ -1,6 +1,12 @@
|
|||||||
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
|
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
|
||||||
NODE_VERSION=$(shell cat .nvmrc)
|
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)","")
|
ifneq ("$(wildcard .git/HEAD)","")
|
||||||
GIT_SHA=$(shell git rev-parse --short HEAD)
|
GIT_SHA=$(shell git rev-parse --short HEAD)
|
||||||
GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)-SNAPSHOT
|
GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)-SNAPSHOT
|
||||||
@ -9,14 +15,12 @@ GIT_SHA=source_archive
|
|||||||
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT
|
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT
|
||||||
endif
|
endif
|
||||||
|
|
||||||
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
|
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
|
||||||
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
|
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
|
||||||
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
||||||
DOCKER_TAG ?= deluan/navidrome:develop
|
DOCKER_TAG ?= deluan/navidrome:develop
|
||||||
|
|
||||||
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
|
GOLANGCI_LINT_VERSION ?= v2.12.0
|
||||||
CROSS_TAGLIB_VERSION ?= 2.1.1-1
|
|
||||||
GOLANGCI_LINT_VERSION ?= v2.6.2
|
|
||||||
|
|
||||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||||
|
|
||||||
@ -26,11 +30,11 @@ setup: check_env download-deps install-golangci-lint setup-git ##@1_Run_First In
|
|||||||
.PHONY: setup
|
.PHONY: setup
|
||||||
|
|
||||||
dev: check_env ##@Development Start Navidrome in development mode, with hot-reload for both frontend and backend
|
dev: check_env ##@Development Start Navidrome in development mode, with hot-reload for both frontend and backend
|
||||||
ND_ENABLEINSIGHTSCOLLECTOR="false" npx foreman -j Procfile.dev -p 4533 start
|
npx foreman -j Procfile.dev -p 4533 start
|
||||||
.PHONY: dev
|
.PHONY: dev
|
||||||
|
|
||||||
server: check_go_env buildjs ##@Development Start the backend in development mode
|
server: check_go_env buildjs ##@Development Start the backend in development mode
|
||||||
@ND_ENABLEINSIGHTSCOLLECTOR="false" go tool reflex -d none -c reflex.conf
|
go tool reflex -d none -c reflex.conf
|
||||||
.PHONY: server
|
.PHONY: server
|
||||||
|
|
||||||
stop: ##@Development Stop development servers (UI and backend)
|
stop: ##@Development Stop development servers (UI and backend)
|
||||||
@ -42,19 +46,23 @@ stop: ##@Development Stop development servers (UI and backend)
|
|||||||
.PHONY: stop
|
.PHONY: stop
|
||||||
|
|
||||||
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
|
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
|
||||||
go tool ginkgo watch -tags=netgo -notify ./...
|
go tool ginkgo watch -tags=$(GO_BUILD_TAGS) -notify ./...
|
||||||
.PHONY: watch
|
.PHONY: watch
|
||||||
|
|
||||||
PKG ?= ./...
|
PKG ?= ./...
|
||||||
test: ##@Development Run Go tests. Use PKG variable to specify packages to test, e.g. make test PKG=./server
|
test: ##@Development Run Go tests. Use PKG variable to specify packages to test, e.g. make test PKG=./server
|
||||||
go test -tags netgo $(PKG)
|
go test -tags $(GO_BUILD_TAGS) $(PKG)
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
|
|
||||||
testall: test-race test-i18n test-js ##@Development Run Go and JS tests
|
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
|
||||||
.PHONY: testall
|
.PHONY: testall
|
||||||
|
|
||||||
test-race: ##@Development Run Go tests with race detector
|
test-race: ##@Development Run Go tests with race detector
|
||||||
go test -tags netgo -race -shuffle=on $(PKG)
|
go test -tags $(GO_BUILD_TAGS) -race -shuffle=on $(PKG)
|
||||||
.PHONY: test-race
|
.PHONY: test-race
|
||||||
|
|
||||||
test-js: ##@Development Run JS tests
|
test-js: ##@Development Run JS tests
|
||||||
@ -67,8 +75,8 @@ test-i18n: ##@Development Validate all translations files
|
|||||||
|
|
||||||
install-golangci-lint: ##@Development Install golangci-lint if not present
|
install-golangci-lint: ##@Development Install golangci-lint if not present
|
||||||
@INSTALL=false; \
|
@INSTALL=false; \
|
||||||
if PATH=$$PATH:./bin which golangci-lint > /dev/null 2>&1; then \
|
if PATH=./bin:$$PATH 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); \
|
CURRENT_VERSION=$$(PATH=./bin:$$PATH 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//'); \
|
REQUIRED_VERSION=$$(echo "$(GOLANGCI_LINT_VERSION)" | sed 's/^v//'); \
|
||||||
if [ "$$CURRENT_VERSION" != "$$REQUIRED_VERSION" ]; then \
|
if [ "$$CURRENT_VERSION" != "$$REQUIRED_VERSION" ]; then \
|
||||||
echo "Found golangci-lint $$CURRENT_VERSION, but $$REQUIRED_VERSION is required. Reinstalling..."; \
|
echo "Found golangci-lint $$CURRENT_VERSION, but $$REQUIRED_VERSION is required. Reinstalling..."; \
|
||||||
@ -85,7 +93,7 @@ install-golangci-lint: ##@Development Install golangci-lint if not present
|
|||||||
.PHONY: install-golangci-lint
|
.PHONY: install-golangci-lint
|
||||||
|
|
||||||
lint: install-golangci-lint ##@Development Lint Go code
|
lint: install-golangci-lint ##@Development Lint Go code
|
||||||
PATH=$$PATH:./bin golangci-lint run -v --timeout 5m
|
PATH=./bin:$$PATH golangci-lint run --timeout 5m
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
|
|
||||||
lintall: lint ##@Development Lint Go and JS code
|
lintall: lint ##@Development Lint Go and JS code
|
||||||
@ -100,9 +108,18 @@ format: ##@Development Format code
|
|||||||
.PHONY: format
|
.PHONY: format
|
||||||
|
|
||||||
wire: check_go_env ##@Development Update Dependency Injection
|
wire: check_go_env ##@Development Update Dependency Injection
|
||||||
go tool wire gen -tags=netgo ./...
|
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
|
||||||
.PHONY: wire
|
.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
|
snapshots: ##@Development Update (GoLang) Snapshot tests
|
||||||
UPDATE_SNAPSHOTS=true go tool ginkgo ./server/subsonic/responses/...
|
UPDATE_SNAPSHOTS=true go tool ginkgo ./server/subsonic/responses/...
|
||||||
.PHONY: snapshots
|
.PHONY: snapshots
|
||||||
@ -127,14 +144,14 @@ setup-git: ##@Development Setup Git hooks (pre-commit and pre-push)
|
|||||||
.PHONY: setup-git
|
.PHONY: setup-git
|
||||||
|
|
||||||
build: check_go_env buildjs ##@Build Build the project
|
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=netgo
|
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)
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
|
|
||||||
buildall: deprecated build
|
buildall: deprecated build
|
||||||
.PHONY: buildall
|
.PHONY: buildall
|
||||||
|
|
||||||
debug-build: check_go_env buildjs ##@Build Build the project (with remote debug on)
|
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=netgo
|
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)
|
||||||
.PHONY: debug-build
|
.PHONY: debug-build
|
||||||
|
|
||||||
buildjs: check_node_env ui/build/index.html ##@Build Build only frontend
|
buildjs: check_node_env ui/build/index.html ##@Build Build only frontend
|
||||||
@ -159,7 +176,6 @@ docker-build: ##@Cross_Compilation Cross-compile for any supported platform (che
|
|||||||
--platform $(PLATFORMS) \
|
--platform $(PLATFORMS) \
|
||||||
--build-arg GIT_TAG=${GIT_TAG} \
|
--build-arg GIT_TAG=${GIT_TAG} \
|
||||||
--build-arg GIT_SHA=${GIT_SHA} \
|
--build-arg GIT_SHA=${GIT_SHA} \
|
||||||
--build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \
|
|
||||||
--output "./binaries" --target binary .
|
--output "./binaries" --target binary .
|
||||||
.PHONY: docker-build
|
.PHONY: docker-build
|
||||||
|
|
||||||
@ -171,7 +187,6 @@ docker-image: ##@Cross_Compilation Build Docker image, tagged as `deluan/navidro
|
|||||||
--platform $(IMAGE_PLATFORMS) \
|
--platform $(IMAGE_PLATFORMS) \
|
||||||
--build-arg GIT_TAG=${GIT_TAG} \
|
--build-arg GIT_TAG=${GIT_TAG} \
|
||||||
--build-arg GIT_SHA=${GIT_SHA} \
|
--build-arg GIT_SHA=${GIT_SHA} \
|
||||||
--build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \
|
|
||||||
--tag $(DOCKER_TAG) .
|
--tag $(DOCKER_TAG) .
|
||||||
.PHONY: docker-image
|
.PHONY: docker-image
|
||||||
|
|
||||||
@ -184,8 +199,8 @@ docker-msi: ##@Cross_Compilation Build MSI installer for Windows
|
|||||||
@du -h binaries/msi/*.msi
|
@du -h binaries/msi/*.msi
|
||||||
.PHONY: docker-msi
|
.PHONY: docker-msi
|
||||||
|
|
||||||
run-docker: ##@Development Run a Navidrome Docker image. Usage: make run-docker tag=<tag>
|
docker-run: ##@Development Run a Navidrome Docker image. Usage: make docker-run tag=<tag>
|
||||||
@if [ -z "$(tag)" ]; then echo "Usage: make run-docker tag=<tag>"; exit 1; fi
|
@if [ -z "$(tag)" ]; then echo "Usage: make docker-run tag=<tag>"; exit 1; fi
|
||||||
@TAG_DIR="tmp/$$(echo '$(tag)' | tr '/:' '_')"; mkdir -p "$$TAG_DIR"; \
|
@TAG_DIR="tmp/$$(echo '$(tag)' | tr '/:' '_')"; mkdir -p "$$TAG_DIR"; \
|
||||||
VOLUMES="-v $(PWD)/$$TAG_DIR:/data"; \
|
VOLUMES="-v $(PWD)/$$TAG_DIR:/data"; \
|
||||||
if [ -f navidrome.toml ]; then \
|
if [ -f navidrome.toml ]; then \
|
||||||
@ -196,7 +211,7 @@ run-docker: ##@Development Run a Navidrome Docker image. Usage: make run-docker
|
|||||||
fi; \
|
fi; \
|
||||||
fi; \
|
fi; \
|
||||||
echo "Running: docker run --rm -p 4533:4533 $$VOLUMES $(tag)"; docker run --rm -p 4533:4533 $$VOLUMES $(tag)
|
echo "Running: docker run --rm -p 4533:4533 $$VOLUMES $(tag)"; docker run --rm -p 4533:4533 $$VOLUMES $(tag)
|
||||||
.PHONY: run-docker
|
.PHONY: docker-run
|
||||||
|
|
||||||
package: docker-build ##@Cross_Compilation Create binaries and packages for ALL supported platforms
|
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
|
@if [ -z `which goreleaser` ]; then echo "Please install goreleaser first: https://goreleaser.com/install/"; exit 1; fi
|
||||||
@ -215,6 +230,39 @@ get-music: ##@Development Download some free music from Navidrome's demo instanc
|
|||||||
.PHONY: get-music
|
.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
|
#### Miscellaneous
|
||||||
|
|
||||||
@ -266,24 +314,6 @@ deprecated:
|
|||||||
@echo "WARNING: This target is deprecated and will be removed in future releases. Use 'make build' instead."
|
@echo "WARNING: This target is deprecated and will be removed in future releases. Use 'make build' instead."
|
||||||
.PHONY: deprecated
|
.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
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
HELP_FUN = \
|
HELP_FUN = \
|
||||||
|
|||||||
@ -29,20 +29,19 @@ type httpDoer interface {
|
|||||||
|
|
||||||
type client struct {
|
type client struct {
|
||||||
httpDoer httpDoer
|
httpDoer httpDoer
|
||||||
language string
|
|
||||||
jwt jwtToken
|
jwt jwtToken
|
||||||
}
|
}
|
||||||
|
|
||||||
func newClient(hc httpDoer, language string) *client {
|
func newClient(hc httpDoer) *client {
|
||||||
return &client{
|
return &client{
|
||||||
httpDoer: hc,
|
httpDoer: hc,
|
||||||
language: language,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
|
func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Add("q", name)
|
params.Add("q", name)
|
||||||
|
params.Add("order", "RANKING")
|
||||||
params.Add("limit", strconv.Itoa(limit))
|
params.Add("limit", strconv.Itoa(limit))
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", apiBaseURL+"/search/artist", nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", apiBaseURL+"/search/artist", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -128,7 +127,7 @@ const pipeAPIURL = "https://pipe.deezer.com/api"
|
|||||||
|
|
||||||
var strictPolicy = bluemonday.StrictPolicy()
|
var strictPolicy = bluemonday.StrictPolicy()
|
||||||
|
|
||||||
func (c *client) getArtistBio(ctx context.Context, artistID int) (string, error) {
|
func (c *client) getArtistBio(ctx context.Context, artistID int, lang string) (string, error) {
|
||||||
jwt, err := c.getJWT(ctx)
|
jwt, err := c.getJWT(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("deezer: failed to get JWT: %w", err)
|
return "", fmt.Errorf("deezer: failed to get JWT: %w", err)
|
||||||
@ -159,10 +158,10 @@ func (c *client) getArtistBio(ctx context.Context, artistID int) (string, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Accept-Language", c.language)
|
req.Header.Set("Accept-Language", lang)
|
||||||
req.Header.Set("Authorization", "Bearer "+jwt)
|
req.Header.Set("Authorization", "Bearer "+jwt)
|
||||||
|
|
||||||
log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", c.language)
|
log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", lang)
|
||||||
resp, err := c.httpDoer.Do(req)
|
resp, err := c.httpDoer.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@ -10,7 +10,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -65,7 +65,7 @@ func (c *client) getJWT(ctx context.Context) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type authResponse struct {
|
type authResponse struct {
|
||||||
JWT string `json:"jwt"`
|
JWT string `json:"jwt"` //nolint:gosec
|
||||||
}
|
}
|
||||||
|
|
||||||
var result authResponse
|
var result authResponse
|
||||||
@ -84,8 +84,8 @@ func (c *client) getJWT(ctx context.Context) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate TTL with a 1-minute buffer for clock skew and network delays
|
// Calculate TTL with a 1-minute buffer for clock skew and network delays
|
||||||
expiresAt := token.Expiration()
|
expiresAt, ok := token.Expiration()
|
||||||
if expiresAt.IsZero() {
|
if !ok || expiresAt.IsZero() {
|
||||||
return "", errors.New("deezer: JWT token has no expiration time")
|
return "", errors.New("deezer: JWT token has no expiration time")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -9,7 +9,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
@ -21,7 +21,7 @@ var _ = Describe("JWT Authentication", func() {
|
|||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
httpClient = &fakeHttpClient{}
|
httpClient = &fakeHttpClient{}
|
||||||
client = newClient(httpClient, "en")
|
client = newClient(httpClient)
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -179,7 +179,8 @@ var _ = Describe("JWT Authentication", func() {
|
|||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
|
|
||||||
// Verify token has no expiration
|
// Verify token has no expiration
|
||||||
Expect(testToken.Expiration().IsZero()).To(BeTrue())
|
_, hasExp := testToken.Expiration()
|
||||||
|
Expect(hasExp).To(BeFalse())
|
||||||
|
|
||||||
testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
|
testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
@ -252,7 +253,7 @@ var _ = Describe("JWT Authentication", func() {
|
|||||||
|
|
||||||
// Writer goroutine
|
// Writer goroutine
|
||||||
wg.Go(func() {
|
wg.Go(func() {
|
||||||
for i := 0; i < 100; i++ {
|
for i := range 100 {
|
||||||
cache.set(fmt.Sprintf("token-%d", i), 1*time.Hour)
|
cache.set(fmt.Sprintf("token-%d", i), 1*time.Hour)
|
||||||
time.Sleep(1 * time.Millisecond)
|
time.Sleep(1 * time.Millisecond)
|
||||||
}
|
}
|
||||||
@ -260,7 +261,7 @@ var _ = Describe("JWT Authentication", func() {
|
|||||||
|
|
||||||
// Reader goroutine
|
// Reader goroutine
|
||||||
wg.Go(func() {
|
wg.Go(func() {
|
||||||
for i := 0; i < 100; i++ {
|
for range 100 {
|
||||||
cache.get()
|
cache.get()
|
||||||
time.Sleep(1 * time.Millisecond)
|
time.Sleep(1 * time.Millisecond)
|
||||||
}
|
}
|
||||||
@ -18,7 +18,7 @@ var _ = Describe("client", func() {
|
|||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
httpClient = &fakeHttpClient{}
|
httpClient = &fakeHttpClient{}
|
||||||
client = newClient(httpClient, "en")
|
client = newClient(httpClient)
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("ArtistImages", func() {
|
Describe("ArtistImages", func() {
|
||||||
@ -45,6 +45,28 @@ var _ = Describe("client", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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() {
|
Describe("ArtistBio", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
// Mock the JWT token endpoint with a valid JWT that expires in 5 minutes
|
// Mock the JWT token endpoint with a valid JWT that expires in 5 minutes
|
||||||
@ -56,40 +78,33 @@ var _ = Describe("client", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("returns artist bio from a successful request", func() {
|
It("returns artist bio from a successful request", func() {
|
||||||
f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
|
f, err := os.Open("tests/fixtures/deezer.artist.bio.en.json")
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
|
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
|
||||||
|
|
||||||
bio, err := client.getArtistBio(GinkgoT().Context(), 27)
|
bio, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
|
Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
|
||||||
Expect(bio).ToNot(ContainSubstring("<p>"))
|
Expect(bio).ToNot(ContainSubstring("<p>"))
|
||||||
Expect(bio).ToNot(ContainSubstring("</p>"))
|
Expect(bio).ToNot(ContainSubstring("</p>"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("uses the configured language", func() {
|
It("uses the provided language", func() {
|
||||||
client = newClient(httpClient, "fr")
|
f, err := os.Open("tests/fixtures/deezer.artist.bio.fr.json")
|
||||||
// Mock JWT token for the new client instance with a valid JWT
|
|
||||||
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))),
|
|
||||||
})
|
|
||||||
f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
|
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
|
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
|
||||||
|
|
||||||
_, err = client.getArtistBio(GinkgoT().Context(), 27)
|
_, err = client.getArtistBio(GinkgoT().Context(), 27, "fr")
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
Expect(httpClient.lastRequest.Header.Get("Accept-Language")).To(Equal("fr"))
|
Expect(httpClient.lastRequest.Header.Get("Accept-Language")).To(Equal("fr"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("includes the JWT token in the request", func() {
|
It("includes the JWT token in the request", func() {
|
||||||
f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
|
f, err := os.Open("tests/fixtures/deezer.artist.bio.en.json")
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
|
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
|
||||||
|
|
||||||
_, err = client.getArtistBio(GinkgoT().Context(), 27)
|
_, err = client.getArtistBio(GinkgoT().Context(), 27, "en")
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
// Verify that the Authorization header has the Bearer token format
|
// Verify that the Authorization header has the Bearer token format
|
||||||
authHeader := httpClient.lastRequest.Header.Get("Authorization")
|
authHeader := httpClient.lastRequest.Header.Get("Authorization")
|
||||||
@ -120,7 +135,7 @@ var _ = Describe("client", func() {
|
|||||||
Body: io.NopCloser(bytes.NewBufferString(errorResponse)),
|
Body: io.NopCloser(bytes.NewBufferString(errorResponse)),
|
||||||
})
|
})
|
||||||
|
|
||||||
_, err := client.getArtistBio(GinkgoT().Context(), 999)
|
_, err := client.getArtistBio(GinkgoT().Context(), 999, "en")
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
Expect(err.Error()).To(ContainSubstring("GraphQL error"))
|
Expect(err.Error()).To(ContainSubstring("GraphQL error"))
|
||||||
Expect(err.Error()).To(ContainSubstring("Artist not found"))
|
Expect(err.Error()).To(ContainSubstring("Artist not found"))
|
||||||
@ -142,7 +157,7 @@ var _ = Describe("client", func() {
|
|||||||
Body: io.NopCloser(bytes.NewBufferString(emptyBioResponse)),
|
Body: io.NopCloser(bytes.NewBufferString(emptyBioResponse)),
|
||||||
})
|
})
|
||||||
|
|
||||||
_, err := client.getArtistBio(GinkgoT().Context(), 27)
|
_, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
|
||||||
Expect(err).To(MatchError("deezer: biography not found"))
|
Expect(err).To(MatchError("deezer: biography not found"))
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -152,7 +167,7 @@ var _ = Describe("client", func() {
|
|||||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
|
Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
|
||||||
})
|
})
|
||||||
|
|
||||||
_, err := client.getArtistBio(GinkgoT().Context(), 27)
|
_, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
Expect(err.Error()).To(ContainSubstring("failed to get JWT"))
|
Expect(err.Error()).To(ContainSubstring("failed to get JWT"))
|
||||||
})
|
})
|
||||||
@ -165,7 +180,7 @@ var _ = Describe("client", func() {
|
|||||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, expiredJWT))),
|
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, expiredJWT))),
|
||||||
})
|
})
|
||||||
|
|
||||||
_, err := client.getArtistBio(GinkgoT().Context(), 27)
|
_, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
|
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
|
||||||
})
|
})
|
||||||
@ -3,6 +3,7 @@ package deezer
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@ -25,15 +26,19 @@ const deezerArtistSearchLimit = 50
|
|||||||
type deezerAgent struct {
|
type deezerAgent struct {
|
||||||
dataStore model.DataStore
|
dataStore model.DataStore
|
||||||
client *client
|
client *client
|
||||||
|
languages []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func deezerConstructor(dataStore model.DataStore) agents.Interface {
|
func deezerConstructor(dataStore model.DataStore) agents.Interface {
|
||||||
agent := &deezerAgent{dataStore: dataStore}
|
agent := &deezerAgent{
|
||||||
|
dataStore: dataStore,
|
||||||
|
languages: conf.Server.Deezer.Languages,
|
||||||
|
}
|
||||||
httpClient := &http.Client{
|
httpClient := &http.Client{
|
||||||
Timeout: consts.DefaultHttpClientTimeOut,
|
Timeout: consts.DefaultHttpClientTimeOut,
|
||||||
}
|
}
|
||||||
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
|
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
|
||||||
agent.client = newClient(cachedHttpClient, conf.Server.Deezer.Language)
|
agent.client = newClient(cachedHttpClient)
|
||||||
return agent
|
return agent
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,10 +87,20 @@ func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, e
|
|||||||
return nil, err
|
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 the first one has the same name, that's the one
|
||||||
if !strings.EqualFold(artists[0].Name, name) {
|
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
|
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
|
return &artists[0], err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,7 +139,9 @@ func (s *deezerAgent) GetArtistTopSongs(ctx context.Context, _, artistName, _ st
|
|||||||
|
|
||||||
res := slice.Map(tracks, func(r Track) agents.Song {
|
res := slice.Map(tracks, func(r Track) agents.Song {
|
||||||
return agents.Song{
|
return agents.Song{
|
||||||
Name: r.Title,
|
Name: r.Title,
|
||||||
|
Album: r.Album.Title,
|
||||||
|
Duration: uint32(r.Duration * 1000), // Convert seconds to milliseconds
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return res, nil
|
return res, nil
|
||||||
@ -136,7 +153,14 @@ func (s *deezerAgent) GetArtistBiography(ctx context.Context, _, name, _ string)
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.client.getArtistBio(ctx, artist.ID)
|
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() {
|
func init() {
|
||||||
171
adapters/deezer/deezer_test.go
Normal file
171
adapters/deezer/deezer_test.go
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package taglib
|
package gotaglib
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@ -96,7 +96,7 @@ var _ = Describe("Extractor", func() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
e = &extractor{}
|
e = &extractor{fs: os.DirFS(".")}
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("ReplayGain", func() {
|
Describe("ReplayGain", func() {
|
||||||
@ -151,11 +151,7 @@ var _ = Describe("Extractor", func() {
|
|||||||
unsSylt := makeLyrics("xxx", "unspecified SYLT")
|
unsSylt := makeLyrics("xxx", "unspecified SYLT")
|
||||||
unsUslt := makeLyrics("xxx", "unspecified")
|
unsUslt := makeLyrics("xxx", "unspecified")
|
||||||
|
|
||||||
// Why is the order inconsistent between runs? Nobody knows
|
Expect(lyrics).To(ConsistOf(engSylt, engUslt, unsSylt, unsUslt))
|
||||||
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) {
|
DescribeTable("format-specific lyrics", func(file string, isId3 bool) {
|
||||||
301
adapters/gotaglib/gotaglib.go
Normal file
301
adapters/gotaglib/gotaglib.go
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
// 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())
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package taglib
|
package gotaglib
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
@ -9,9 +9,9 @@ import (
|
|||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTagLib(t *testing.T) {
|
func TestGoTagLib(t *testing.T) {
|
||||||
tests.Init(t, true)
|
tests.Init(t, true)
|
||||||
log.SetLevel(log.LevelFatal)
|
log.SetLevel(log.LevelFatal)
|
||||||
RegisterFailHandler(Fail)
|
RegisterFailHandler(Fail)
|
||||||
RunSpecs(t, "TagLib Suite")
|
RunSpecs(t, "GoTagLib Suite")
|
||||||
}
|
}
|
||||||
@ -1,10 +1,11 @@
|
|||||||
package taglib
|
package gotaglib
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
"github.com/navidrome/navidrome/utils"
|
"github.com/navidrome/navidrome/utils"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
@ -14,7 +15,7 @@ var _ = Describe("Extractor", func() {
|
|||||||
var e *extractor
|
var e *extractor
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
e = &extractor{}
|
e = &extractor{fs: os.DirFS(".")}
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("Parse", func() {
|
Describe("Parse", func() {
|
||||||
@ -80,12 +81,11 @@ var _ = Describe("Extractor", func() {
|
|||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
|
Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
|
||||||
|
|
||||||
// TabLib 1.12 returns 18, previous versions return 39.
|
// TagLib 1.12 returns 18, previous versions return 39.
|
||||||
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
|
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
|
||||||
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
|
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
|
||||||
Expect(m.AudioProperties.Channels).To(BeElementOf(2))
|
Expect(m.AudioProperties.Channels).To(BeElementOf(2))
|
||||||
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
|
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
|
||||||
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
|
|
||||||
Expect(m.HasPicture).To(BeTrue())
|
Expect(m.HasPicture).To(BeTrue())
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -106,7 +106,7 @@ var _ = Describe("Extractor", func() {
|
|||||||
|
|
||||||
Expect(m.Tags).To(Or(
|
Expect(m.Tags).To(Or(
|
||||||
HaveKeyWithValue("replaygain_album_gain", []string{albumGain}),
|
HaveKeyWithValue("replaygain_album_gain", []string{albumGain}),
|
||||||
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{albumGain}),
|
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_gain", []string{albumGain}),
|
||||||
))
|
))
|
||||||
|
|
||||||
Expect(m.Tags).To(Or(
|
Expect(m.Tags).To(Or(
|
||||||
@ -128,6 +128,17 @@ var _ = Describe("Extractor", func() {
|
|||||||
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
|
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
|
||||||
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
|
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
|
||||||
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"}))
|
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(HaveKeyWithValue("bpm", []string{"123"}))
|
||||||
Expect(m.Tags).To(Or(
|
Expect(m.Tags).To(Or(
|
||||||
@ -174,6 +185,9 @@ 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 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),
|
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
|
// 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
|
// 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),
|
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),
|
||||||
@ -200,6 +214,9 @@ var _ = Describe("Extractor", func() {
|
|||||||
// Only run permission tests if we are not root
|
// Only run permission tests if we are not root
|
||||||
RegularUserContext("when run without root privileges", func() {
|
RegularUserContext("when run without root privileges", func() {
|
||||||
BeforeEach(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")
|
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
|
||||||
|
|
||||||
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
|
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
|
||||||
@ -212,20 +229,25 @@ var _ = Describe("Extractor", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("correctly handle unreadable file due to insufficient read permission", func() {
|
It("correctly handle unreadable file due to insufficient read permission", func() {
|
||||||
_, err := e.extractMetadata(accessForbiddenFile)
|
// Strip leading slash for DirFS rooted at "/"
|
||||||
|
_, err := e.extractMetadata(accessForbiddenFile[1:])
|
||||||
Expect(err).To(MatchError(os.ErrPermission))
|
Expect(err).To(MatchError(os.ErrPermission))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("skips the file if it cannot be read", func() {
|
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{
|
files := []string{
|
||||||
"tests/fixtures/test.mp3",
|
cwd[1:] + "/tests/fixtures/test.mp3",
|
||||||
"tests/fixtures/test.ogg",
|
cwd[1:] + "/tests/fixtures/test.ogg",
|
||||||
accessForbiddenFile,
|
accessForbiddenFile[1:],
|
||||||
}
|
}
|
||||||
mds, err := e.Parse(files...)
|
mds, err := e.Parse(files...)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
Expect(mds).To(HaveLen(2))
|
Expect(mds).To(HaveLen(2))
|
||||||
Expect(mds).ToNot(HaveKey(accessForbiddenFile))
|
Expect(mds).ToNot(HaveKey(accessForbiddenFile[1:]))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -26,17 +26,23 @@ const (
|
|||||||
sessionKeyProperty = "LastFMSessionKey"
|
sessionKeyProperty = "LastFMSessionKey"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ignoredBiographies = []string{
|
var ignoredContent = []string{
|
||||||
// Unknown Artist
|
// Empty Artist/Album
|
||||||
`<a href="https://www.last.fm/music/`,
|
`<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 {
|
type lastfmAgent struct {
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
sessionKeys *agents.SessionKeys
|
sessionKeys *agents.SessionKeys
|
||||||
apiKey string
|
apiKey string
|
||||||
secret string
|
secret string
|
||||||
lang string
|
languages []string
|
||||||
client *client
|
client *client
|
||||||
httpClient httpDoer
|
httpClient httpDoer
|
||||||
getInfoMutex sync.Mutex
|
getInfoMutex sync.Mutex
|
||||||
@ -48,7 +54,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
|
|||||||
}
|
}
|
||||||
l := &lastfmAgent{
|
l := &lastfmAgent{
|
||||||
ds: ds,
|
ds: ds,
|
||||||
lang: conf.Server.LastFM.Language,
|
languages: conf.Server.LastFM.Languages,
|
||||||
apiKey: conf.Server.LastFM.ApiKey,
|
apiKey: conf.Server.LastFM.ApiKey,
|
||||||
secret: conf.Server.LastFM.Secret,
|
secret: conf.Server.LastFM.Secret,
|
||||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||||
@ -58,7 +64,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
|
|||||||
}
|
}
|
||||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||||
l.httpClient = chc
|
l.httpClient = chc
|
||||||
l.client = newClient(l.apiKey, l.secret, l.lang, chc)
|
l.client = newClient(l.apiKey, l.secret, chc)
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,22 +74,47 @@ func (l *lastfmAgent) AgentName() string {
|
|||||||
|
|
||||||
var imageRegex = regexp.MustCompile(`u\/(\d+)`)
|
var imageRegex = regexp.MustCompile(`u\/(\d+)`)
|
||||||
|
|
||||||
func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
|
// isValidContent checks if content is non-empty and not in the ignored list
|
||||||
a, err := l.callAlbumGetInfo(ctx, name, artist, mbid)
|
func isValidContent(content string) bool {
|
||||||
if err != nil {
|
content = strings.TrimSpace(content)
|
||||||
return nil, err
|
if content == "" {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
for _, ign := range ignoredContent {
|
||||||
|
if strings.HasPrefix(content, ign) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return &agents.AlbumInfo{
|
func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
|
||||||
Name: a.Name,
|
var a *Album
|
||||||
MBID: a.MBID,
|
var resp agents.AlbumInfo
|
||||||
Description: a.Description.Summary,
|
for _, lang := range l.languages {
|
||||||
URL: a.URL,
|
var err error
|
||||||
}, nil
|
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)
|
||||||
|
}
|
||||||
|
// This condition should not be hit (languages default to ["en"]), but just in case
|
||||||
|
if a == nil {
|
||||||
|
return nil, agents.ErrNotFound
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
|
func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
|
||||||
a, err := l.callAlbumGetInfo(ctx, name, artist, mbid)
|
a, err := l.callAlbumGetInfo(ctx, name, artist, mbid, l.languages[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -118,7 +149,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) {
|
func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
|
||||||
a, err := l.callArtistGetInfo(ctx, name)
|
a, err := l.callArtistGetInfo(ctx, name, l.languages[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@ -129,7 +160,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) {
|
func (l *lastfmAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
||||||
a, err := l.callArtistGetInfo(ctx, name)
|
a, err := l.callArtistGetInfo(ctx, name, l.languages[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@ -140,20 +171,17 @@ func (l *lastfmAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l *lastfmAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
func (l *lastfmAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
||||||
a, err := l.callArtistGetInfo(ctx, name)
|
for _, lang := range l.languages {
|
||||||
if err != nil {
|
a, err := l.callArtistGetInfo(ctx, name, lang)
|
||||||
return "", err
|
if err != nil {
|
||||||
}
|
return "", err
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
return a.Bio.Summary, nil
|
return "", agents.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *lastfmAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
|
func (l *lastfmAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
|
||||||
@ -192,6 +220,26 @@ func (l *lastfmAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbi
|
|||||||
return res, nil
|
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 (
|
var (
|
||||||
artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
|
artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
|
||||||
artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name
|
artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name
|
||||||
@ -199,7 +247,7 @@ var (
|
|||||||
|
|
||||||
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
|
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
|
||||||
log.Debug(ctx, "Getting artist images from Last.fm", "name", name)
|
log.Debug(ctx, "Getting artist images from Last.fm", "name", name)
|
||||||
a, err := l.callArtistGetInfo(ctx, name)
|
a, err := l.callArtistGetInfo(ctx, name, l.languages[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get artist info: %w", err)
|
return nil, fmt.Errorf("get artist info: %w", err)
|
||||||
}
|
}
|
||||||
@ -239,14 +287,14 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
|
|||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string) (*Album, error) {
|
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string, lang string) (*Album, error) {
|
||||||
a, err := l.client.albumGetInfo(ctx, name, artist, mbid)
|
a, err := l.client.albumGetInfo(ctx, name, artist, mbid, lang)
|
||||||
var lfErr *lastFMError
|
var lfErr *lastFMError
|
||||||
isLastFMError := errors.As(err, &lfErr)
|
isLastFMError := errors.As(err, &lfErr)
|
||||||
|
|
||||||
if mbid != "" && (isLastFMError && lfErr.Code == 6) {
|
if mbid != "" && (isLastFMError && lfErr.Code == 6) {
|
||||||
log.Debug(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid)
|
log.Debug(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid)
|
||||||
return l.callAlbumGetInfo(ctx, name, artist, "")
|
return l.callAlbumGetInfo(ctx, name, artist, "", lang)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -260,11 +308,11 @@ func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid s
|
|||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string) (*Artist, error) {
|
func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) {
|
||||||
l.getInfoMutex.Lock()
|
l.getInfoMutex.Lock()
|
||||||
defer l.getInfoMutex.Unlock()
|
defer l.getInfoMutex.Unlock()
|
||||||
|
|
||||||
a, err := l.client.artistGetInfo(ctx, name)
|
a, err := l.client.artistGetInfo(ctx, name, lang)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, err)
|
log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -290,6 +338,15 @@ func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName str
|
|||||||
return t.Track, nil
|
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
|
||||||
|
}
|
||||||
|
return s.Track, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile, role model.Role, displayName string) string {
|
func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile, role model.Role, displayName string) string {
|
||||||
if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[role]) > 0 {
|
if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[role]) > 0 {
|
||||||
return track.Participants[role][0].Name
|
return track.Participants[role][0].Name
|
||||||
@ -359,6 +416,10 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
|
|||||||
return err == nil && sk != ""
|
return err == nil && sk != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *lastfmAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
conf.AddHook(func() {
|
conf.AddHook(func() {
|
||||||
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
|
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
|
||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@ -38,12 +39,12 @@ var _ = Describe("lastfmAgent", func() {
|
|||||||
})
|
})
|
||||||
Describe("lastFMConstructor", func() {
|
Describe("lastFMConstructor", func() {
|
||||||
When("Agent is properly configured", func() {
|
When("Agent is properly configured", func() {
|
||||||
It("uses configured api key and language", func() {
|
It("uses configured api key and languages", func() {
|
||||||
conf.Server.LastFM.Language = "pt"
|
conf.Server.LastFM.Languages = []string{"pt", "en"}
|
||||||
agent := lastFMConstructor(ds)
|
agent := lastFMConstructor(ds)
|
||||||
Expect(agent.apiKey).To(Equal("123"))
|
Expect(agent.apiKey).To(Equal("123"))
|
||||||
Expect(agent.secret).To(Equal("secret"))
|
Expect(agent.secret).To(Equal("secret"))
|
||||||
Expect(agent.lang).To(Equal("pt"))
|
Expect(agent.languages).To(Equal([]string{"pt", "en"}))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
When("Agent is disabled", func() {
|
When("Agent is disabled", func() {
|
||||||
@ -71,7 +72,7 @@ var _ = Describe("lastfmAgent", func() {
|
|||||||
var httpClient *tests.FakeHttpClient
|
var httpClient *tests.FakeHttpClient
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
httpClient = &tests.FakeHttpClient{}
|
httpClient = &tests.FakeHttpClient{}
|
||||||
client := newClient("API_KEY", "SECRET", "pt", httpClient)
|
client := newClient("API_KEY", "SECRET", httpClient)
|
||||||
agent = lastFMConstructor(ds)
|
agent = lastFMConstructor(ds)
|
||||||
agent.client = client
|
agent.client = client
|
||||||
})
|
})
|
||||||
@ -79,7 +80,7 @@ var _ = Describe("lastfmAgent", func() {
|
|||||||
It("returns the biography", func() {
|
It("returns the biography", func() {
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
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. <a href=\"https://www.last.fm/music/U2\">Read more on Last.fm</a>"))
|
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(httpClient.RequestCount).To(Equal(1))
|
Expect(httpClient.RequestCount).To(Equal(1))
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
||||||
})
|
})
|
||||||
@ -101,12 +102,129 @@ 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() {
|
Describe("GetSimilarArtists", func() {
|
||||||
var agent *lastfmAgent
|
var agent *lastfmAgent
|
||||||
var httpClient *tests.FakeHttpClient
|
var httpClient *tests.FakeHttpClient
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
httpClient = &tests.FakeHttpClient{}
|
httpClient = &tests.FakeHttpClient{}
|
||||||
client := newClient("API_KEY", "SECRET", "pt", httpClient)
|
client := newClient("API_KEY", "SECRET", httpClient)
|
||||||
agent = lastFMConstructor(ds)
|
agent = lastFMConstructor(ds)
|
||||||
agent.client = client
|
agent.client = client
|
||||||
})
|
})
|
||||||
@ -144,7 +262,7 @@ var _ = Describe("lastfmAgent", func() {
|
|||||||
var httpClient *tests.FakeHttpClient
|
var httpClient *tests.FakeHttpClient
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
httpClient = &tests.FakeHttpClient{}
|
httpClient = &tests.FakeHttpClient{}
|
||||||
client := newClient("API_KEY", "SECRET", "pt", httpClient)
|
client := newClient("API_KEY", "SECRET", httpClient)
|
||||||
agent = lastFMConstructor(ds)
|
agent = lastFMConstructor(ds)
|
||||||
agent.client = client
|
agent.client = client
|
||||||
})
|
})
|
||||||
@ -177,6 +295,54 @@ 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() {
|
Describe("Scrobbling", func() {
|
||||||
var agent *lastfmAgent
|
var agent *lastfmAgent
|
||||||
var httpClient *tests.FakeHttpClient
|
var httpClient *tests.FakeHttpClient
|
||||||
@ -184,7 +350,7 @@ var _ = Describe("lastfmAgent", func() {
|
|||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
|
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
|
||||||
httpClient = &tests.FakeHttpClient{}
|
httpClient = &tests.FakeHttpClient{}
|
||||||
client := newClient("API_KEY", "SECRET", "en", httpClient)
|
client := newClient("API_KEY", "SECRET", httpClient)
|
||||||
agent = lastFMConstructor(ds)
|
agent = lastFMConstructor(ds)
|
||||||
agent.client = client
|
agent.client = client
|
||||||
track = &model.MediaFile{
|
track = &model.MediaFile{
|
||||||
@ -217,7 +383,8 @@ var _ = Describe("lastfmAgent", func() {
|
|||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
|
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
|
||||||
sentParams := httpClient.SavedRequest.URL.Query()
|
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
|
||||||
|
sentParams, _ := url.ParseQuery(string(body))
|
||||||
Expect(sentParams.Get("method")).To(Equal("track.updateNowPlaying"))
|
Expect(sentParams.Get("method")).To(Equal("track.updateNowPlaying"))
|
||||||
Expect(sentParams.Get("sk")).To(Equal("SK-1"))
|
Expect(sentParams.Get("sk")).To(Equal("SK-1"))
|
||||||
Expect(sentParams.Get("track")).To(Equal(track.Title))
|
Expect(sentParams.Get("track")).To(Equal(track.Title))
|
||||||
@ -245,7 +412,8 @@ var _ = Describe("lastfmAgent", func() {
|
|||||||
err := agent.NowPlaying(ctx, "user-1", track, 0)
|
err := agent.NowPlaying(ctx, "user-1", track, 0)
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
sentParams := httpClient.SavedRequest.URL.Query()
|
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
|
||||||
|
sentParams, _ := url.ParseQuery(string(body))
|
||||||
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
|
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
|
||||||
Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
|
Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
|
||||||
})
|
})
|
||||||
@ -261,7 +429,8 @@ var _ = Describe("lastfmAgent", func() {
|
|||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
|
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
|
||||||
sentParams := httpClient.SavedRequest.URL.Query()
|
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
|
||||||
|
sentParams, _ := url.ParseQuery(string(body))
|
||||||
Expect(sentParams.Get("method")).To(Equal("track.scrobble"))
|
Expect(sentParams.Get("method")).To(Equal("track.scrobble"))
|
||||||
Expect(sentParams.Get("sk")).To(Equal("SK-1"))
|
Expect(sentParams.Get("sk")).To(Equal("SK-1"))
|
||||||
Expect(sentParams.Get("track")).To(Equal(track.Title))
|
Expect(sentParams.Get("track")).To(Equal(track.Title))
|
||||||
@ -286,7 +455,8 @@ var _ = Describe("lastfmAgent", func() {
|
|||||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts})
|
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts})
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
sentParams := httpClient.SavedRequest.URL.Query()
|
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
|
||||||
|
sentParams, _ := url.ParseQuery(string(body))
|
||||||
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
|
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
|
||||||
Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
|
Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
|
||||||
})
|
})
|
||||||
@ -354,7 +524,7 @@ var _ = Describe("lastfmAgent", func() {
|
|||||||
var httpClient *tests.FakeHttpClient
|
var httpClient *tests.FakeHttpClient
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
httpClient = &tests.FakeHttpClient{}
|
httpClient = &tests.FakeHttpClient{}
|
||||||
client := newClient("API_KEY", "SECRET", "pt", httpClient)
|
client := newClient("API_KEY", "SECRET", httpClient)
|
||||||
agent = lastFMConstructor(ds)
|
agent = lastFMConstructor(ds)
|
||||||
agent.client = client
|
agent.client = client
|
||||||
})
|
})
|
||||||
@ -365,7 +535,7 @@ var _ = Describe("lastfmAgent", func() {
|
|||||||
Expect(agent.GetAlbumInfo(ctx, "Believe", "Cher", "03c91c40-49a6-44a7-90e7-a700edf97a62")).To(Equal(&agents.AlbumInfo{
|
Expect(agent.GetAlbumInfo(ctx, "Believe", "Cher", "03c91c40-49a6-44a7-90e7-a700edf97a62")).To(Equal(&agents.AlbumInfo{
|
||||||
Name: "Believe",
|
Name: "Believe",
|
||||||
MBID: "03c91c40-49a6-44a7-90e7-a700edf97a62",
|
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 <a href=\"https://www.last.fm/music/Cher/Believe\">Read more on Last.fm</a>.",
|
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",
|
||||||
URL: "https://www.last.fm/music/Cher/Believe",
|
URL: "https://www.last.fm/music/Cher/Believe",
|
||||||
}))
|
}))
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
Expect(httpClient.RequestCount).To(Equal(1))
|
||||||
@ -424,7 +594,7 @@ var _ = Describe("lastfmAgent", func() {
|
|||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
apiClient = &tests.FakeHttpClient{}
|
apiClient = &tests.FakeHttpClient{}
|
||||||
httpClient = &tests.FakeHttpClient{}
|
httpClient = &tests.FakeHttpClient{}
|
||||||
client := newClient("API_KEY", "SECRET", "pt", apiClient)
|
client := newClient("API_KEY", "SECRET", apiClient)
|
||||||
agent = lastFMConstructor(ds)
|
agent = lastFMConstructor(ds)
|
||||||
agent.client = client
|
agent.client = client
|
||||||
agent.httpClient = httpClient
|
agent.httpClient = httpClient
|
||||||
@ -485,3 +655,31 @@ var _ = Describe("lastfmAgent", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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{
|
hc := &http.Client{
|
||||||
Timeout: consts.DefaultHttpClientTimeOut,
|
Timeout: consts.DefaultHttpClientTimeOut,
|
||||||
}
|
}
|
||||||
r.client = newClient(r.apiKey, r.secret, "en", hc)
|
r.client = newClient(r.apiKey, r.secret, hc)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,7 +65,7 @@ func (s *Router) routes() http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
|
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
resp := map[string]interface{}{
|
resp := map[string]any{
|
||||||
"apiKey": s.apiKey,
|
"apiKey": s.apiKey,
|
||||||
}
|
}
|
||||||
u, _ := request.UserFrom(r.Context())
|
u, _ := request.UserFrom(r.Context())
|
||||||
@ -110,7 +110,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
_, _ = w.Write([]byte("An error occurred while authorizing with Last.fm. \n\nRequest ID: " + middleware.GetReqID(ctx)))
|
_, _ = w.Write([]byte("An error occurred while authorizing with Last.fm. \n\nRequest ID: " + middleware.GetReqID(ctx))) //nolint:gosec
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,24 +34,23 @@ type httpDoer interface {
|
|||||||
Do(req *http.Request) (*http.Response, error)
|
Do(req *http.Request) (*http.Response, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newClient(apiKey string, secret string, lang string, hc httpDoer) *client {
|
func newClient(apiKey string, secret string, hc httpDoer) *client {
|
||||||
return &client{apiKey, secret, lang, hc}
|
return &client{apiKey, secret, hc}
|
||||||
}
|
}
|
||||||
|
|
||||||
type client struct {
|
type client struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
secret string
|
secret string
|
||||||
lang string
|
|
||||||
hc httpDoer
|
hc httpDoer
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string) (*Album, error) {
|
func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string, lang string) (*Album, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Add("method", "album.getInfo")
|
params.Add("method", "album.getInfo")
|
||||||
params.Add("album", name)
|
params.Add("album", name)
|
||||||
params.Add("artist", artist)
|
params.Add("artist", artist)
|
||||||
params.Add("mbid", mbid)
|
params.Add("mbid", mbid)
|
||||||
params.Add("lang", c.lang)
|
params.Add("lang", lang)
|
||||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -59,11 +58,11 @@ func (c *client) albumGetInfo(ctx context.Context, name string, artist string, m
|
|||||||
return &response.Album, nil
|
return &response.Album, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *client) artistGetInfo(ctx context.Context, name string) (*Artist, error) {
|
func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Add("method", "artist.getInfo")
|
params.Add("method", "artist.getInfo")
|
||||||
params.Add("artist", name)
|
params.Add("artist", name)
|
||||||
params.Add("lang", c.lang)
|
params.Add("lang", lang)
|
||||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -95,6 +94,19 @@ func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int)
|
|||||||
return &response.TopTracks, nil
|
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) {
|
func (c *client) GetToken(ctx context.Context) (string, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Add("method", "auth.getToken")
|
params.Add("method", "auth.getToken")
|
||||||
@ -185,8 +197,15 @@ func (c *client) makeRequest(ctx context.Context, method string, params url.Valu
|
|||||||
c.sign(params)
|
c.sign(params)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, _ := http.NewRequestWithContext(ctx, method, apiBaseUrl, nil)
|
var req *http.Request
|
||||||
req.URL.RawQuery = params.Encode()
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
log.Trace(ctx, fmt.Sprintf("Sending Last.fm %s request", req.Method), "url", req.URL)
|
log.Trace(ctx, fmt.Sprintf("Sending Last.fm %s request", req.Method), "url", req.URL)
|
||||||
resp, err := c.hc.Do(req)
|
resp, err := c.hc.Do(req)
|
||||||
@ -22,7 +22,7 @@ var _ = Describe("client", func() {
|
|||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
httpClient = &tests.FakeHttpClient{}
|
httpClient = &tests.FakeHttpClient{}
|
||||||
client = newClient("API_KEY", "SECRET", "pt", httpClient)
|
client = newClient("API_KEY", "SECRET", httpClient)
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("albumGetInfo", func() {
|
Describe("albumGetInfo", func() {
|
||||||
@ -30,7 +30,7 @@ var _ = Describe("client", func() {
|
|||||||
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
|
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||||
|
|
||||||
album, err := client.albumGetInfo(context.Background(), "Believe", "U2", "mbid-1234")
|
album, err := client.albumGetInfo(context.Background(), "Believe", "U2", "mbid-1234", "pt")
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
Expect(album.Name).To(Equal("Believe"))
|
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"))
|
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")
|
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||||
|
|
||||||
artist, err := client.artistGetInfo(context.Background(), "U2")
|
artist, err := client.artistGetInfo(context.Background(), "U2", "pt")
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
Expect(artist.Name).To(Equal("U2"))
|
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"))
|
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,
|
StatusCode: 500,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := client.artistGetInfo(context.Background(), "U2")
|
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
|
||||||
Expect(err).To(MatchError("last.fm http status: (500)"))
|
Expect(err).To(MatchError("last.fm http status: (500)"))
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -64,7 +64,7 @@ var _ = Describe("client", func() {
|
|||||||
StatusCode: 400,
|
StatusCode: 400,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := client.artistGetInfo(context.Background(), "U2")
|
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
|
||||||
Expect(err).To(MatchError(&lastFMError{Code: 3, Message: "Invalid Method - No method with that name in this package"}))
|
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,
|
StatusCode: 200,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := client.artistGetInfo(context.Background(), "U2")
|
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
|
||||||
Expect(err).To(MatchError(&lastFMError{Code: 6, Message: "The artist you supplied could not be found"}))
|
Expect(err).To(MatchError(&lastFMError{Code: 6, Message: "The artist you supplied could not be found"}))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("fails if HttpClient.Do() returns error", func() {
|
It("fails if HttpClient.Do() returns error", func() {
|
||||||
httpClient.Err = errors.New("generic error")
|
httpClient.Err = errors.New("generic error")
|
||||||
|
|
||||||
_, err := client.artistGetInfo(context.Background(), "U2")
|
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
|
||||||
Expect(err).To(MatchError("generic error"))
|
Expect(err).To(MatchError("generic error"))
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -91,7 +91,7 @@ var _ = Describe("client", func() {
|
|||||||
StatusCode: 200,
|
StatusCode: 200,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := client.artistGetInfo(context.Background(), "U2")
|
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
|
||||||
Expect(err).To(MatchError("invalid character '<' looking for beginning of value"))
|
Expect(err).To(MatchError("invalid character '<' looking for beginning of value"))
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -121,6 +121,30 @@ 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() {
|
Describe("GetToken", func() {
|
||||||
It("returns a token when the request is successful", func() {
|
It("returns a token when the request is successful", func() {
|
||||||
httpClient.Res = http.Response{
|
httpClient.Res = http.Response{
|
||||||
@ -154,6 +178,74 @@ 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() {
|
Describe("sign", func() {
|
||||||
It("adds an api_sig param with the signature", func() {
|
It("adds an api_sig param with the signature", func() {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
@ -5,6 +5,7 @@ type Response struct {
|
|||||||
SimilarArtists SimilarArtists `json:"similarartists"`
|
SimilarArtists SimilarArtists `json:"similarartists"`
|
||||||
TopTracks TopTracks `json:"toptracks"`
|
TopTracks TopTracks `json:"toptracks"`
|
||||||
Album Album `json:"album"`
|
Album Album `json:"album"`
|
||||||
|
SimilarTracks SimilarTracks `json:"similartracks"`
|
||||||
Error int `json:"error"`
|
Error int `json:"error"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
@ -59,6 +60,28 @@ type TopTracks struct {
|
|||||||
Attr Attr `json:"@attr"`
|
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 {
|
type Session struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
@ -118,12 +118,133 @@ func (l *listenBrainzAgent) IsAuthorized(ctx context.Context, userId string) boo
|
|||||||
return err == nil && sk != ""
|
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() {
|
func init() {
|
||||||
conf.AddHook(func() {
|
conf.AddHook(func() {
|
||||||
if conf.Server.ListenBrainz.Enabled {
|
if conf.Server.ListenBrainz.Enabled {
|
||||||
scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
|
scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
|
||||||
return listenBrainzConstructor(ds)
|
// 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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ agents.ArtistTopSongsRetriever = (*listenBrainzAgent)(nil)
|
||||||
|
_ agents.ArtistURLRetriever = (*listenBrainzAgent)(nil)
|
||||||
|
_ agents.ArtistSimilarRetriever = (*listenBrainzAgent)(nil)
|
||||||
|
_ agents.SimilarSongsByTrackRetriever = (*listenBrainzAgent)(nil)
|
||||||
|
)
|
||||||
443
adapters/listenbrainz/agent_test.go
Normal file
443
adapters/listenbrainz/agent_test.go
Normal file
@ -0,0 +1,443 @@
|
|||||||
|
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: "a‐ha",
|
||||||
|
ArtistMBID: "",
|
||||||
|
Album: "Hunting High and Low",
|
||||||
|
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
||||||
|
Duration: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "",
|
||||||
|
Name: "Wake Me Up Before You Go‐Go",
|
||||||
|
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: "a‐ha",
|
||||||
|
ArtistMBID: "",
|
||||||
|
Album: "Hunting High and Low",
|
||||||
|
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
||||||
|
Duration: 0,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -60,7 +60,7 @@ func (s *Router) routes() http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
|
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
resp := map[string]interface{}{}
|
resp := map[string]any{}
|
||||||
u, _ := request.UserFrom(r.Context())
|
u, _ := request.UserFrom(r.Context())
|
||||||
key, err := s.sessionKeys.Get(r.Context(), u.ID)
|
key, err := s.sessionKeys.Get(r.Context(), u.ID)
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
@ -107,7 +107,7 @@ func (s *Router) link(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = rest.RespondWithJSON(w, http.StatusOK, map[string]interface{}{"status": resp.Valid, "user": resp.UserName})
|
_ = rest.RespondWithJSON(w, http.StatusOK, map[string]any{"status": resp.Valid, "user": resp.UserName})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Router) unlink(w http.ResponseWriter, r *http.Request) {
|
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)
|
req = httptest.NewRequest("GET", "/listenbrainz/link", nil)
|
||||||
r.getLinkStatus(resp, req)
|
r.getLinkStatus(resp, req)
|
||||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||||
var parsed map[string]interface{}
|
var parsed map[string]any
|
||||||
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
|
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
|
||||||
Expect(parsed["status"]).To(Equal(false))
|
Expect(parsed["status"]).To(Equal(false))
|
||||||
})
|
})
|
||||||
@ -47,7 +47,7 @@ var _ = Describe("ListenBrainz Auth Router", func() {
|
|||||||
req = httptest.NewRequest("GET", "/listenbrainz/link", nil)
|
req = httptest.NewRequest("GET", "/listenbrainz/link", nil)
|
||||||
r.getLinkStatus(resp, req)
|
r.getLinkStatus(resp, req)
|
||||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||||
var parsed map[string]interface{}
|
var parsed map[string]any
|
||||||
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
|
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
|
||||||
Expect(parsed["status"]).To(Equal(true))
|
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"}`))
|
req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{"token": "tok-1"}`))
|
||||||
r.link(resp, req)
|
r.link(resp, req)
|
||||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||||
var parsed map[string]interface{}
|
var parsed map[string]any
|
||||||
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
|
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
|
||||||
Expect(parsed["status"]).To(Equal(true))
|
Expect(parsed["status"]).To(Equal(true))
|
||||||
Expect(parsed["user"]).To(Equal("ListenBrainzUser"))
|
Expect(parsed["user"]).To(Equal("ListenBrainzUser"))
|
||||||
378
adapters/listenbrainz/client.go
Normal file
378
adapters/listenbrainz/client.go
Normal file
@ -0,0 +1,378 @@
|
|||||||
|
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
adapters/listenbrainz/client_test.go
Normal file
464
adapters/listenbrainz/client_test.go
Normal file
@ -0,0 +1,464 @@
|
|||||||
|
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: "a‐ha",
|
||||||
|
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 Go‐Go",
|
||||||
|
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: "a‐ha",
|
||||||
|
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: "a‐ha",
|
||||||
|
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 Go‐Go",
|
||||||
|
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: "a‐ha",
|
||||||
|
ReleaseName: "Hunting High and Low",
|
||||||
|
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
||||||
|
Score: 124,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,9 +0,0 @@
|
|||||||
//go:build !windows
|
|
||||||
|
|
||||||
package taglib
|
|
||||||
|
|
||||||
import "C"
|
|
||||||
|
|
||||||
func getFilename(s string) *C.char {
|
|
||||||
return C.CString(s)
|
|
||||||
}
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package taglib
|
|
||||||
|
|
||||||
// From https://github.com/orofarne/gowchar
|
|
||||||
|
|
||||||
/*
|
|
||||||
#include <wchar.h>
|
|
||||||
|
|
||||||
const size_t SIZEOF_WCHAR_T = sizeof(wchar_t);
|
|
||||||
|
|
||||||
void gowchar_set (wchar_t *arr, int pos, wchar_t val)
|
|
||||||
{
|
|
||||||
arr[pos] = val;
|
|
||||||
}
|
|
||||||
|
|
||||||
wchar_t gowchar_get (wchar_t *arr, int pos)
|
|
||||||
{
|
|
||||||
return arr[pos];
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
import "C"
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"unicode/utf16"
|
|
||||||
"unicode/utf8"
|
|
||||||
)
|
|
||||||
|
|
||||||
var SIZEOF_WCHAR_T C.size_t = C.size_t(C.SIZEOF_WCHAR_T)
|
|
||||||
|
|
||||||
func getFilename(s string) *C.wchar_t {
|
|
||||||
wstr, _ := StringToWcharT(s)
|
|
||||||
return wstr
|
|
||||||
}
|
|
||||||
|
|
||||||
func StringToWcharT(s string) (*C.wchar_t, C.size_t) {
|
|
||||||
switch SIZEOF_WCHAR_T {
|
|
||||||
case 2:
|
|
||||||
return stringToWchar2(s) // Windows
|
|
||||||
case 4:
|
|
||||||
return stringToWchar4(s) // Unix
|
|
||||||
default:
|
|
||||||
panic(fmt.Sprintf("Invalid sizeof(wchar_t) = %v", SIZEOF_WCHAR_T))
|
|
||||||
}
|
|
||||||
panic("?!!")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Windows
|
|
||||||
func stringToWchar2(s string) (*C.wchar_t, C.size_t) {
|
|
||||||
var slen int
|
|
||||||
s1 := s
|
|
||||||
for len(s1) > 0 {
|
|
||||||
r, size := utf8.DecodeRuneInString(s1)
|
|
||||||
if er, _ := utf16.EncodeRune(r); er == '\uFFFD' {
|
|
||||||
slen += 1
|
|
||||||
} else {
|
|
||||||
slen += 2
|
|
||||||
}
|
|
||||||
s1 = s1[size:]
|
|
||||||
}
|
|
||||||
slen++ // \0
|
|
||||||
res := C.malloc(C.size_t(slen) * SIZEOF_WCHAR_T)
|
|
||||||
var i int
|
|
||||||
for len(s) > 0 {
|
|
||||||
r, size := utf8.DecodeRuneInString(s)
|
|
||||||
if r1, r2 := utf16.EncodeRune(r); r1 != '\uFFFD' {
|
|
||||||
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r1))
|
|
||||||
i++
|
|
||||||
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r2))
|
|
||||||
i++
|
|
||||||
} else {
|
|
||||||
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r))
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
s = s[size:]
|
|
||||||
}
|
|
||||||
C.gowchar_set((*C.wchar_t)(res), C.int(slen-1), C.wchar_t(0)) // \0
|
|
||||||
return (*C.wchar_t)(res), C.size_t(slen)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unix
|
|
||||||
func stringToWchar4(s string) (*C.wchar_t, C.size_t) {
|
|
||||||
slen := utf8.RuneCountInString(s)
|
|
||||||
slen++ // \0
|
|
||||||
res := C.malloc(C.size_t(slen) * SIZEOF_WCHAR_T)
|
|
||||||
var i int
|
|
||||||
for len(s) > 0 {
|
|
||||||
r, size := utf8.DecodeRuneInString(s)
|
|
||||||
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r))
|
|
||||||
s = s[size:]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
C.gowchar_set((*C.wchar_t)(res), C.int(slen-1), C.wchar_t(0)) // \0
|
|
||||||
return (*C.wchar_t)(res), C.size_t(slen)
|
|
||||||
}
|
|
||||||
@ -1,178 +0,0 @@
|
|||||||
package taglib
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io/fs"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/core/storage/local"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
"github.com/navidrome/navidrome/model/metadata"
|
|
||||||
)
|
|
||||||
|
|
||||||
type extractor struct {
|
|
||||||
baseDir string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
|
|
||||||
results := make(map[string]metadata.Info)
|
|
||||||
for _, path := range files {
|
|
||||||
props, err := e.extractMetadata(path)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
results[path] = *props
|
|
||||||
}
|
|
||||||
return results, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e extractor) Version() string {
|
|
||||||
return Version()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
|
|
||||||
fullPath := filepath.Join(e.baseDir, filePath)
|
|
||||||
tags, err := Read(fullPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn("extractor: Error reading metadata from file. Skipping", "filePath", fullPath, err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse audio properties
|
|
||||||
ap := metadata.AudioProperties{}
|
|
||||||
ap.BitRate = parseProp(tags, "__bitrate")
|
|
||||||
ap.Channels = parseProp(tags, "__channels")
|
|
||||||
ap.SampleRate = parseProp(tags, "__samplerate")
|
|
||||||
ap.BitDepth = parseProp(tags, "__bitspersample")
|
|
||||||
length := parseProp(tags, "__lengthinmilliseconds")
|
|
||||||
ap.Duration = (time.Millisecond * time.Duration(length)).Round(time.Millisecond * 10)
|
|
||||||
|
|
||||||
// Extract basic tags
|
|
||||||
parseBasicTag(tags, "__title", "title")
|
|
||||||
parseBasicTag(tags, "__artist", "artist")
|
|
||||||
parseBasicTag(tags, "__album", "album")
|
|
||||||
parseBasicTag(tags, "__comment", "comment")
|
|
||||||
parseBasicTag(tags, "__genre", "genre")
|
|
||||||
parseBasicTag(tags, "__year", "year")
|
|
||||||
parseBasicTag(tags, "__track", "tracknumber")
|
|
||||||
|
|
||||||
// Parse track/disc totals
|
|
||||||
parseTuple := func(prop string) {
|
|
||||||
tagName := prop + "number"
|
|
||||||
tagTotal := prop + "total"
|
|
||||||
if value, ok := tags[tagName]; ok && len(value) > 0 {
|
|
||||||
parts := strings.Split(value[0], "/")
|
|
||||||
tags[tagName] = []string{parts[0]}
|
|
||||||
if len(parts) == 2 {
|
|
||||||
tags[tagTotal] = []string{parts[1]}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parseTuple("track")
|
|
||||||
parseTuple("disc")
|
|
||||||
|
|
||||||
// Adjust some ID3 tags
|
|
||||||
parseLyrics(tags)
|
|
||||||
parseTIPL(tags)
|
|
||||||
delete(tags, "tmcl") // TMCL is already parsed by TagLib
|
|
||||||
|
|
||||||
return &metadata.Info{
|
|
||||||
Tags: tags,
|
|
||||||
AudioProperties: ap,
|
|
||||||
HasPicture: tags["has_picture"] != nil && len(tags["has_picture"]) > 0 && tags["has_picture"][0] == "true",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseLyrics make sure lyrics tags have language
|
|
||||||
func parseLyrics(tags map[string][]string) {
|
|
||||||
lyrics := tags["lyrics"]
|
|
||||||
if len(lyrics) > 0 {
|
|
||||||
tags["lyrics:xxx"] = lyrics
|
|
||||||
delete(tags, "lyrics")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// These are the only roles we support, based on Picard's tag map:
|
|
||||||
// https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html
|
|
||||||
var tiplMapping = map[string]string{
|
|
||||||
"arranger": "arranger",
|
|
||||||
"engineer": "engineer",
|
|
||||||
"producer": "producer",
|
|
||||||
"mix": "mixer",
|
|
||||||
"DJ-mix": "djmixer",
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseProp parses a property from the tags map and sets it to the target integer.
|
|
||||||
// It also deletes the property from the tags map after parsing.
|
|
||||||
func parseProp(tags map[string][]string, prop string) int {
|
|
||||||
if value, ok := tags[prop]; ok && len(value) > 0 {
|
|
||||||
v, _ := strconv.Atoi(value[0])
|
|
||||||
delete(tags, prop)
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseBasicTag checks if a basic tag (like __title, __artist, etc.) exists in the tags map.
|
|
||||||
// If it does, it moves the value to a more appropriate tag name (like title, artist, etc.),
|
|
||||||
// and deletes the basic tag from the map. If the target tag already exists, it ignores the basic tag.
|
|
||||||
func parseBasicTag(tags map[string][]string, basicName string, tagName string) {
|
|
||||||
basicValue := tags[basicName]
|
|
||||||
if len(basicValue) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
delete(tags, basicName)
|
|
||||||
if len(tags[tagName]) == 0 {
|
|
||||||
tags[tagName] = basicValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format:
|
|
||||||
//
|
|
||||||
// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson".
|
|
||||||
//
|
|
||||||
// and breaks it down into a map of roles and names, e.g.:
|
|
||||||
//
|
|
||||||
// {"arranger": ["Andrew Powell"], "engineer": ["Chris Blair", "Pat Stapley"], "producer": ["Eric Woolfson"]}.
|
|
||||||
func parseTIPL(tags map[string][]string) {
|
|
||||||
tipl := tags["tipl"]
|
|
||||||
if len(tipl) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
addRole := func(currentRole string, currentValue []string) {
|
|
||||||
if currentRole != "" && len(currentValue) > 0 {
|
|
||||||
role := tiplMapping[currentRole]
|
|
||||||
tags[role] = append(tags[role], strings.Join(currentValue, " "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var currentRole string
|
|
||||||
var currentValue []string
|
|
||||||
for _, part := range strings.Split(tipl[0], " ") {
|
|
||||||
if _, ok := tiplMapping[part]; ok {
|
|
||||||
addRole(currentRole, currentValue)
|
|
||||||
currentRole = part
|
|
||||||
currentValue = nil
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
currentValue = append(currentValue, part)
|
|
||||||
}
|
|
||||||
addRole(currentRole, currentValue)
|
|
||||||
delete(tags, "tipl")
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ local.Extractor = (*extractor)(nil)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
local.RegisterExtractor("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,299 +0,0 @@
|
|||||||
#include <stdlib.h>
|
|
||||||
#include <string.h>
|
|
||||||
|
|
||||||
#define TAGLIB_STATIC
|
|
||||||
#include <apeproperties.h>
|
|
||||||
#include <apetag.h>
|
|
||||||
#include <aifffile.h>
|
|
||||||
#include <asffile.h>
|
|
||||||
#include <dsffile.h>
|
|
||||||
#include <fileref.h>
|
|
||||||
#include <flacfile.h>
|
|
||||||
#include <id3v2tag.h>
|
|
||||||
#include <unsynchronizedlyricsframe.h>
|
|
||||||
#include <synchronizedlyricsframe.h>
|
|
||||||
#include <mp4file.h>
|
|
||||||
#include <mpegfile.h>
|
|
||||||
#include <opusfile.h>
|
|
||||||
#include <tpropertymap.h>
|
|
||||||
#include <vorbisfile.h>
|
|
||||||
#include <wavfile.h>
|
|
||||||
#include <wavfile.h>
|
|
||||||
#include <wavpackfile.h>
|
|
||||||
|
|
||||||
#include "taglib_wrapper.h"
|
|
||||||
|
|
||||||
char has_cover(const TagLib::FileRef f);
|
|
||||||
|
|
||||||
static char TAGLIB_VERSION[16];
|
|
||||||
|
|
||||||
char* taglib_version() {
|
|
||||||
snprintf((char *)TAGLIB_VERSION, 16, "%d.%d.%d", TAGLIB_MAJOR_VERSION, TAGLIB_MINOR_VERSION, TAGLIB_PATCH_VERSION);
|
|
||||||
return (char *)TAGLIB_VERSION;
|
|
||||||
}
|
|
||||||
|
|
||||||
int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
|
|
||||||
TagLib::FileRef f(filename, true, TagLib::AudioProperties::Fast);
|
|
||||||
|
|
||||||
if (f.isNull()) {
|
|
||||||
return TAGLIB_ERR_PARSE;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!f.audioProperties()) {
|
|
||||||
return TAGLIB_ERR_AUDIO_PROPS;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add audio properties to the tags
|
|
||||||
const TagLib::AudioProperties *props(f.audioProperties());
|
|
||||||
goPutInt(id, (char *)"__lengthinmilliseconds", props->lengthInMilliseconds());
|
|
||||||
goPutInt(id, (char *)"__bitrate", props->bitrate());
|
|
||||||
goPutInt(id, (char *)"__channels", props->channels());
|
|
||||||
goPutInt(id, (char *)"__samplerate", props->sampleRate());
|
|
||||||
|
|
||||||
// Extract bits per sample for supported formats
|
|
||||||
int bitsPerSample = 0;
|
|
||||||
if (const auto* apeProperties{ dynamic_cast<const TagLib::APE::Properties*>(props) })
|
|
||||||
bitsPerSample = apeProperties->bitsPerSample();
|
|
||||||
else if (const auto* asfProperties{ dynamic_cast<const TagLib::ASF::Properties*>(props) })
|
|
||||||
bitsPerSample = asfProperties->bitsPerSample();
|
|
||||||
else if (const auto* flacProperties{ dynamic_cast<const TagLib::FLAC::Properties*>(props) })
|
|
||||||
bitsPerSample = flacProperties->bitsPerSample();
|
|
||||||
else if (const auto* mp4Properties{ dynamic_cast<const TagLib::MP4::Properties*>(props) })
|
|
||||||
bitsPerSample = mp4Properties->bitsPerSample();
|
|
||||||
else if (const auto* wavePackProperties{ dynamic_cast<const TagLib::WavPack::Properties*>(props) })
|
|
||||||
bitsPerSample = wavePackProperties->bitsPerSample();
|
|
||||||
else if (const auto* aiffProperties{ dynamic_cast<const TagLib::RIFF::AIFF::Properties*>(props) })
|
|
||||||
bitsPerSample = aiffProperties->bitsPerSample();
|
|
||||||
else if (const auto* wavProperties{ dynamic_cast<const TagLib::RIFF::WAV::Properties*>(props) })
|
|
||||||
bitsPerSample = wavProperties->bitsPerSample();
|
|
||||||
else if (const auto* dsfProperties{ dynamic_cast<const TagLib::DSF::Properties*>(props) })
|
|
||||||
bitsPerSample = dsfProperties->bitsPerSample();
|
|
||||||
|
|
||||||
if (bitsPerSample > 0) {
|
|
||||||
goPutInt(id, (char *)"__bitspersample", bitsPerSample);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send all properties to the Go map
|
|
||||||
TagLib::PropertyMap tags = f.file()->properties();
|
|
||||||
|
|
||||||
// Make sure at least the basic properties are extracted
|
|
||||||
TagLib::Tag *basic = f.file()->tag();
|
|
||||||
if (!basic->isEmpty()) {
|
|
||||||
if (!basic->title().isEmpty()) {
|
|
||||||
tags.insert("__title", basic->title());
|
|
||||||
}
|
|
||||||
if (!basic->artist().isEmpty()) {
|
|
||||||
tags.insert("__artist", basic->artist());
|
|
||||||
}
|
|
||||||
if (!basic->album().isEmpty()) {
|
|
||||||
tags.insert("__album", basic->album());
|
|
||||||
}
|
|
||||||
if (!basic->comment().isEmpty()) {
|
|
||||||
tags.insert("__comment", basic->comment());
|
|
||||||
}
|
|
||||||
if (!basic->genre().isEmpty()) {
|
|
||||||
tags.insert("__genre", basic->genre());
|
|
||||||
}
|
|
||||||
if (basic->year() > 0) {
|
|
||||||
tags.insert("__year", TagLib::String::number(basic->year()));
|
|
||||||
}
|
|
||||||
if (basic->track() > 0) {
|
|
||||||
tags.insert("__track", TagLib::String::number(basic->track()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TagLib::ID3v2::Tag *id3Tags = NULL;
|
|
||||||
|
|
||||||
// Get some extended/non-standard ID3-only tags (ex: iTunes extended frames)
|
|
||||||
TagLib::MPEG::File *mp3File(dynamic_cast<TagLib::MPEG::File *>(f.file()));
|
|
||||||
if (mp3File != NULL) {
|
|
||||||
id3Tags = mp3File->ID3v2Tag();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id3Tags == NULL) {
|
|
||||||
TagLib::RIFF::WAV::File *wavFile(dynamic_cast<TagLib::RIFF::WAV::File *>(f.file()));
|
|
||||||
if (wavFile != NULL && wavFile->hasID3v2Tag()) {
|
|
||||||
id3Tags = wavFile->ID3v2Tag();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id3Tags == NULL) {
|
|
||||||
TagLib::RIFF::AIFF::File *aiffFile(dynamic_cast<TagLib::RIFF::AIFF::File *>(f.file()));
|
|
||||||
if (aiffFile && aiffFile->hasID3v2Tag()) {
|
|
||||||
id3Tags = aiffFile->tag();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Yes, it is possible to have ID3v2 tags in FLAC. However, that can cause problems
|
|
||||||
// with many players, so they will not be parsed
|
|
||||||
|
|
||||||
if (id3Tags != NULL) {
|
|
||||||
const auto &frames = id3Tags->frameListMap();
|
|
||||||
|
|
||||||
for (const auto &kv: frames) {
|
|
||||||
if (kv.first == "USLT") {
|
|
||||||
for (const auto &tag: kv.second) {
|
|
||||||
TagLib::ID3v2::UnsynchronizedLyricsFrame *frame = dynamic_cast<TagLib::ID3v2::UnsynchronizedLyricsFrame *>(tag);
|
|
||||||
if (frame == NULL) continue;
|
|
||||||
|
|
||||||
tags.erase("LYRICS");
|
|
||||||
|
|
||||||
const auto bv = frame->language();
|
|
||||||
char language[4] = {'x', 'x', 'x', '\0'};
|
|
||||||
if (bv.size() == 3) {
|
|
||||||
strncpy(language, bv.data(), 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
char *val = const_cast<char*>(frame->text().toCString(true));
|
|
||||||
|
|
||||||
goPutLyrics(id, language, val);
|
|
||||||
}
|
|
||||||
} else if (kv.first == "SYLT") {
|
|
||||||
for (const auto &tag: kv.second) {
|
|
||||||
TagLib::ID3v2::SynchronizedLyricsFrame *frame = dynamic_cast<TagLib::ID3v2::SynchronizedLyricsFrame *>(tag);
|
|
||||||
if (frame == NULL) continue;
|
|
||||||
|
|
||||||
const auto bv = frame->language();
|
|
||||||
char language[4] = {'x', 'x', 'x', '\0'};
|
|
||||||
if (bv.size() == 3) {
|
|
||||||
strncpy(language, bv.data(), 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
const auto format = frame->timestampFormat();
|
|
||||||
if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMilliseconds) {
|
|
||||||
|
|
||||||
for (const auto &line: frame->synchedText()) {
|
|
||||||
char *text = const_cast<char*>(line.text.toCString(true));
|
|
||||||
goPutLyricLine(id, language, text, line.time);
|
|
||||||
}
|
|
||||||
} else if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMpegFrames) {
|
|
||||||
const int sampleRate = props->sampleRate();
|
|
||||||
|
|
||||||
if (sampleRate != 0) {
|
|
||||||
for (const auto &line: frame->synchedText()) {
|
|
||||||
const int timeInMs = (line.time * 1000) / sampleRate;
|
|
||||||
char *text = const_cast<char*>(line.text.toCString(true));
|
|
||||||
goPutLyricLine(id, language, text, timeInMs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (kv.first == "TIPL"){
|
|
||||||
if (!kv.second.isEmpty()) {
|
|
||||||
tags.insert(kv.first, kv.second.front()->toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// M4A may have some iTunes specific tags not captured by the PropertyMap interface
|
|
||||||
TagLib::MP4::File *m4afile(dynamic_cast<TagLib::MP4::File *>(f.file()));
|
|
||||||
if (m4afile != NULL) {
|
|
||||||
const auto itemListMap = m4afile->tag()->itemMap();
|
|
||||||
for (const auto item: itemListMap) {
|
|
||||||
char *key = const_cast<char*>(item.first.toCString(true));
|
|
||||||
for (const auto value: item.second.toStringList()) {
|
|
||||||
char *val = const_cast<char*>(value.toCString(true));
|
|
||||||
goPutM4AStr(id, key, val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WMA/ASF files may have additional tags not captured by the PropertyMap interface
|
|
||||||
TagLib::ASF::File *asfFile(dynamic_cast<TagLib::ASF::File *>(f.file()));
|
|
||||||
if (asfFile != NULL) {
|
|
||||||
const TagLib::ASF::Tag *asfTags{asfFile->tag()};
|
|
||||||
const auto itemListMap = asfTags->attributeListMap();
|
|
||||||
for (const auto item : itemListMap) {
|
|
||||||
char *key = const_cast<char*>(item.first.toCString(true));
|
|
||||||
|
|
||||||
for (auto j = item.second.begin();
|
|
||||||
j != item.second.end(); ++j) {
|
|
||||||
|
|
||||||
char *val = const_cast<char*>(j->toString().toCString(true));
|
|
||||||
goPutStr(id, key, val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send all collected tags to the Go map
|
|
||||||
for (TagLib::PropertyMap::ConstIterator i = tags.begin(); i != tags.end();
|
|
||||||
++i) {
|
|
||||||
char *key = const_cast<char*>(i->first.toCString(true));
|
|
||||||
for (TagLib::StringList::ConstIterator j = i->second.begin();
|
|
||||||
j != i->second.end(); ++j) {
|
|
||||||
char *val = const_cast<char*>((*j).toCString(true));
|
|
||||||
goPutStr(id, key, val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cover art has to be handled separately
|
|
||||||
if (has_cover(f)) {
|
|
||||||
goPutStr(id, (char *)"has_picture", (char *)"true");
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect if the file has cover art. Returns 1 if the file has cover art, 0 otherwise.
|
|
||||||
char has_cover(const TagLib::FileRef f) {
|
|
||||||
char hasCover = 0;
|
|
||||||
// ----- MP3
|
|
||||||
if (TagLib::MPEG::File * mp3File{dynamic_cast<TagLib::MPEG::File *>(f.file())}) {
|
|
||||||
if (mp3File->ID3v2Tag()) {
|
|
||||||
const auto &frameListMap{mp3File->ID3v2Tag()->frameListMap()};
|
|
||||||
hasCover = !frameListMap["APIC"].isEmpty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// ----- FLAC
|
|
||||||
else if (TagLib::FLAC::File * flacFile{dynamic_cast<TagLib::FLAC::File *>(f.file())}) {
|
|
||||||
hasCover = !flacFile->pictureList().isEmpty();
|
|
||||||
}
|
|
||||||
// ----- MP4
|
|
||||||
else if (TagLib::MP4::File * mp4File{dynamic_cast<TagLib::MP4::File *>(f.file())}) {
|
|
||||||
auto &coverItem{mp4File->tag()->itemMap()["covr"]};
|
|
||||||
TagLib::MP4::CoverArtList coverArtList{coverItem.toCoverArtList()};
|
|
||||||
hasCover = !coverArtList.isEmpty();
|
|
||||||
}
|
|
||||||
// ----- Ogg
|
|
||||||
else if (TagLib::Ogg::Vorbis::File * vorbisFile{dynamic_cast<TagLib::Ogg::Vorbis::File *>(f.file())}) {
|
|
||||||
hasCover = !vorbisFile->tag()->pictureList().isEmpty();
|
|
||||||
}
|
|
||||||
// ----- Opus
|
|
||||||
else if (TagLib::Ogg::Opus::File * opusFile{dynamic_cast<TagLib::Ogg::Opus::File *>(f.file())}) {
|
|
||||||
hasCover = !opusFile->tag()->pictureList().isEmpty();
|
|
||||||
}
|
|
||||||
// ----- WAV
|
|
||||||
else if (TagLib::RIFF::WAV::File * wavFile{ dynamic_cast<TagLib::RIFF::WAV::File*>(f.file()) }) {
|
|
||||||
if (wavFile->hasID3v2Tag()) {
|
|
||||||
const auto& frameListMap{ wavFile->ID3v2Tag()->frameListMap() };
|
|
||||||
hasCover = !frameListMap["APIC"].isEmpty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// ----- AIFF
|
|
||||||
else if (TagLib::RIFF::AIFF::File * aiffFile{ dynamic_cast<TagLib::RIFF::AIFF::File *>(f.file())}) {
|
|
||||||
if (aiffFile->hasID3v2Tag()) {
|
|
||||||
const auto& frameListMap{ aiffFile->tag()->frameListMap() };
|
|
||||||
hasCover = !frameListMap["APIC"].isEmpty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// ----- WMA
|
|
||||||
else if (TagLib::ASF::File * asfFile{dynamic_cast<TagLib::ASF::File *>(f.file())}) {
|
|
||||||
const TagLib::ASF::Tag *tag{ asfFile->tag() };
|
|
||||||
hasCover = tag && tag->attributeListMap().contains("WM/Picture");
|
|
||||||
}
|
|
||||||
// ----- DSF
|
|
||||||
else if (TagLib::DSF::File * dsffile{ dynamic_cast<TagLib::DSF::File *>(f.file())}) {
|
|
||||||
const TagLib::ID3v2::Tag *tag { dsffile->tag() };
|
|
||||||
hasCover = tag && !tag->frameListMap()["APIC"].isEmpty();
|
|
||||||
}
|
|
||||||
// ----- WAVPAK (APE tag)
|
|
||||||
else if (TagLib::WavPack::File * wvFile{dynamic_cast<TagLib::WavPack::File *>(f.file())}) {
|
|
||||||
if (wvFile->hasAPETag()) {
|
|
||||||
// This is the particular string that Picard uses
|
|
||||||
hasCover = !wvFile->APETag()->itemListMap()["COVER ART (FRONT)"].isEmpty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasCover;
|
|
||||||
}
|
|
||||||
@ -1,157 +0,0 @@
|
|||||||
package taglib
|
|
||||||
|
|
||||||
/*
|
|
||||||
#cgo !windows pkg-config: --define-prefix taglib
|
|
||||||
#cgo windows pkg-config: taglib
|
|
||||||
#cgo illumos LDFLAGS: -lstdc++ -lsendfile
|
|
||||||
#cgo linux darwin CXXFLAGS: -std=c++11
|
|
||||||
#cgo darwin LDFLAGS: -L/opt/homebrew/opt/taglib/lib
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <string.h>
|
|
||||||
#include "taglib_wrapper.h"
|
|
||||||
*/
|
|
||||||
import "C"
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"runtime/debug"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"unsafe"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
const iTunesKeyPrefix = "----:com.apple.itunes:"
|
|
||||||
|
|
||||||
func Version() string {
|
|
||||||
return C.GoString(C.taglib_version())
|
|
||||||
}
|
|
||||||
|
|
||||||
func Read(filename string) (tags map[string][]string, err error) {
|
|
||||||
// Do not crash on failures in the C code/library
|
|
||||||
debug.SetPanicOnFault(true)
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
log.Error("extractor: recovered from panic when reading tags", "file", filename, "error", r)
|
|
||||||
err = fmt.Errorf("extractor: recovered from panic: %s", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
fp := getFilename(filename)
|
|
||||||
defer C.free(unsafe.Pointer(fp))
|
|
||||||
id, m, release := newMap()
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
log.Trace("extractor: reading tags", "filename", filename, "map_id", id)
|
|
||||||
res := C.taglib_read(fp, C.ulong(id))
|
|
||||||
switch res {
|
|
||||||
case C.TAGLIB_ERR_PARSE:
|
|
||||||
// Check additional case whether the file is unreadable due to permission
|
|
||||||
file, fileErr := os.OpenFile(filename, os.O_RDONLY, 0600)
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
if os.IsPermission(fileErr) {
|
|
||||||
return nil, fmt.Errorf("navidrome does not have permission: %w", fileErr)
|
|
||||||
} else if fileErr != nil {
|
|
||||||
return nil, fmt.Errorf("cannot parse file media file: %w", fileErr)
|
|
||||||
} else {
|
|
||||||
return nil, fmt.Errorf("cannot parse file media file")
|
|
||||||
}
|
|
||||||
case C.TAGLIB_ERR_AUDIO_PROPS:
|
|
||||||
return nil, fmt.Errorf("can't get audio properties from file")
|
|
||||||
}
|
|
||||||
if log.IsGreaterOrEqualTo(log.LevelDebug) {
|
|
||||||
j, _ := json.Marshal(m)
|
|
||||||
log.Trace("extractor: read tags", "tags", string(j), "filename", filename, "id", id)
|
|
||||||
} else {
|
|
||||||
log.Trace("extractor: read tags", "tags", m, "filename", filename, "id", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type tagMap map[string][]string
|
|
||||||
|
|
||||||
var allMaps sync.Map
|
|
||||||
var mapsNextID atomic.Uint32
|
|
||||||
|
|
||||||
func newMap() (uint32, tagMap, func()) {
|
|
||||||
id := mapsNextID.Add(1)
|
|
||||||
|
|
||||||
m := tagMap{}
|
|
||||||
allMaps.Store(id, m)
|
|
||||||
|
|
||||||
return id, m, func() {
|
|
||||||
allMaps.Delete(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func doPutTag(id C.ulong, key string, val *C.char) {
|
|
||||||
if key == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
r, _ := allMaps.Load(uint32(id))
|
|
||||||
m := r.(tagMap)
|
|
||||||
k := strings.ToLower(key)
|
|
||||||
v := strings.TrimSpace(C.GoString(val))
|
|
||||||
m[k] = append(m[k], v)
|
|
||||||
}
|
|
||||||
|
|
||||||
//export goPutM4AStr
|
|
||||||
func goPutM4AStr(id C.ulong, key *C.char, val *C.char) {
|
|
||||||
k := C.GoString(key)
|
|
||||||
|
|
||||||
// Special for M4A, do not catch keys that have no actual name
|
|
||||||
k = strings.TrimPrefix(k, iTunesKeyPrefix)
|
|
||||||
doPutTag(id, k, val)
|
|
||||||
}
|
|
||||||
|
|
||||||
//export goPutStr
|
|
||||||
func goPutStr(id C.ulong, key *C.char, val *C.char) {
|
|
||||||
doPutTag(id, C.GoString(key), val)
|
|
||||||
}
|
|
||||||
|
|
||||||
//export goPutInt
|
|
||||||
func goPutInt(id C.ulong, key *C.char, val C.int) {
|
|
||||||
valStr := strconv.Itoa(int(val))
|
|
||||||
vp := C.CString(valStr)
|
|
||||||
defer C.free(unsafe.Pointer(vp))
|
|
||||||
goPutStr(id, key, vp)
|
|
||||||
}
|
|
||||||
|
|
||||||
//export goPutLyrics
|
|
||||||
func goPutLyrics(id C.ulong, lang *C.char, val *C.char) {
|
|
||||||
doPutTag(id, "lyrics:"+C.GoString(lang), val)
|
|
||||||
}
|
|
||||||
|
|
||||||
//export goPutLyricLine
|
|
||||||
func goPutLyricLine(id C.ulong, lang *C.char, text *C.char, time C.int) {
|
|
||||||
language := C.GoString(lang)
|
|
||||||
line := C.GoString(text)
|
|
||||||
timeGo := int64(time)
|
|
||||||
|
|
||||||
ms := timeGo % 1000
|
|
||||||
timeGo /= 1000
|
|
||||||
sec := timeGo % 60
|
|
||||||
timeGo /= 60
|
|
||||||
minimum := timeGo % 60
|
|
||||||
formattedLine := fmt.Sprintf("[%02d:%02d.%02d]%s\n", minimum, sec, ms/10, line)
|
|
||||||
|
|
||||||
key := "lyrics:" + language
|
|
||||||
|
|
||||||
r, _ := allMaps.Load(uint32(id))
|
|
||||||
m := r.(tagMap)
|
|
||||||
k := strings.ToLower(key)
|
|
||||||
existing, ok := m[k]
|
|
||||||
if ok {
|
|
||||||
existing[0] += formattedLine
|
|
||||||
} else {
|
|
||||||
m[k] = []string{formattedLine}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
#define TAGLIB_ERR_PARSE -1
|
|
||||||
#define TAGLIB_ERR_AUDIO_PROPS -2
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
extern "C" {
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#ifdef WIN32
|
|
||||||
#define FILENAME_CHAR_T wchar_t
|
|
||||||
#else
|
|
||||||
#define FILENAME_CHAR_T char
|
|
||||||
#endif
|
|
||||||
|
|
||||||
extern void goPutM4AStr(unsigned long id, char *key, char *val);
|
|
||||||
extern void goPutStr(unsigned long id, char *key, char *val);
|
|
||||||
extern void goPutInt(unsigned long id, char *key, int val);
|
|
||||||
extern void goPutLyrics(unsigned long id, char *lang, char *val);
|
|
||||||
extern void goPutLyricLine(unsigned long id, char *lang, char *text, int time);
|
|
||||||
int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id);
|
|
||||||
char* taglib_version();
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
224
cmd/pls.go
224
cmd/pls.go
@ -7,11 +7,19 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
|
"github.com/navidrome/navidrome/core"
|
||||||
|
"github.com/navidrome/navidrome/core/playlists"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"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/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -20,6 +28,7 @@ var (
|
|||||||
outputFile string
|
outputFile string
|
||||||
userID string
|
userID string
|
||||||
outputFormat string
|
outputFormat string
|
||||||
|
syncFlag bool
|
||||||
)
|
)
|
||||||
|
|
||||||
type displayPlaylist struct {
|
type displayPlaylist struct {
|
||||||
@ -41,6 +50,15 @@ func init() {
|
|||||||
listCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID")
|
listCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID")
|
||||||
listCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]")
|
listCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]")
|
||||||
plsCmd.AddCommand(listCommand)
|
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 (
|
var (
|
||||||
@ -60,72 +78,165 @@ var (
|
|||||||
runList(cmd.Context())
|
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func runExporter(ctx context.Context) {
|
func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.Playlists {
|
||||||
ds, ctx := getAdminContext(ctx)
|
options := model.QueryOptions{Sort: sort}
|
||||||
playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true, false)
|
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)
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
log.Fatal("Error retrieving playlist", "name", playlistID, err)
|
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||||
}
|
}
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": playlistID}})
|
playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error retrieving playlist", "name", playlistID, err)
|
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||||
}
|
}
|
||||||
if len(playlists) > 0 {
|
if len(playlists) > 0 {
|
||||||
playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true, false)
|
playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error retrieving playlist", "name", playlistID, err)
|
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if playlist == nil {
|
if playlist == nil {
|
||||||
log.Fatal("Playlist not found", "name", playlistID)
|
log.Fatal("Playlist not found", "name", nameOrID)
|
||||||
}
|
}
|
||||||
|
return playlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func runExporter(ctx context.Context) {
|
||||||
|
ds, ctx := getAdminContext(ctx)
|
||||||
|
playlist := findPlaylist(ctx, ds, playlistID)
|
||||||
pls := playlist.ToM3U8()
|
pls := playlist.ToM3U8()
|
||||||
if outputFile == "-" || outputFile == "" {
|
if outputFile == "-" || outputFile == "" {
|
||||||
println(pls)
|
println(pls)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
err := os.WriteFile(outputFile, []byte(pls), 0600)
|
||||||
err = os.WriteFile(outputFile, []byte(pls), 0600)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error writing to the output file", "file", outputFile, err)
|
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(ctx context.Context) {
|
||||||
if outputFormat != "csv" && outputFormat != "json" {
|
if outputFormat != "csv" && outputFormat != "json" {
|
||||||
log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat)
|
log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat)
|
||||||
}
|
}
|
||||||
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
options := model.QueryOptions{Sort: "owner_name"}
|
allPls := fetchPlaylists(ctx, ds, "owner_name")
|
||||||
|
|
||||||
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}
|
|
||||||
}
|
|
||||||
|
|
||||||
playlists, err := ds.Playlist(ctx).GetAll(options)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Failed to retrieve playlists", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if outputFormat == "csv" {
|
if outputFormat == "csv" {
|
||||||
w := csv.NewWriter(os.Stdout)
|
w := csv.NewWriter(os.Stdout)
|
||||||
_ = w.Write([]string{"playlist id", "playlist name", "owner id", "owner name", "public"})
|
_ = w.Write([]string{"playlist id", "playlist name", "owner id", "owner name", "public"})
|
||||||
for _, playlist := range playlists {
|
for _, playlist := range allPls {
|
||||||
_ = w.Write([]string{playlist.ID, playlist.Name, playlist.OwnerID, playlist.OwnerName, strconv.FormatBool(playlist.Public)})
|
_ = w.Write([]string{playlist.ID, playlist.Name, playlist.OwnerID, playlist.OwnerName, strconv.FormatBool(playlist.Public)})
|
||||||
}
|
}
|
||||||
w.Flush()
|
w.Flush()
|
||||||
} else {
|
} else {
|
||||||
display := make(displayPlaylists, len(playlists))
|
display := make(displayPlaylists, len(allPls))
|
||||||
for idx, playlist := range playlists {
|
for idx, playlist := range allPls {
|
||||||
display[idx].Id = playlist.ID
|
display[idx].Id = playlist.ID
|
||||||
display[idx].Name = playlist.Name
|
display[idx].Name = playlist.Name
|
||||||
display[idx].OwnerId = playlist.OwnerID
|
display[idx].OwnerId = playlist.OwnerID
|
||||||
@ -137,3 +248,62 @@ func runList(ctx context.Context) {
|
|||||||
fmt.Printf("%s\n", j)
|
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
cmd/plugin.go
716
cmd/plugin.go
@ -1,716 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
@ -1,193 +0,0 @@
|
|||||||
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())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
19
cmd/root.go
19
cmd/root.go
@ -9,7 +9,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
_ "github.com/navidrome/navidrome/adapters/taglib"
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
@ -22,6 +21,12 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"golang.org/x/sync/errgroup"
|
"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 (
|
var (
|
||||||
@ -189,7 +194,8 @@ func runInitialScan(ctx context.Context) func() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
scanNeeded := conf.Server.Scanner.ScanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
|
scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup
|
||||||
|
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
|
||||||
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
|
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
|
||||||
if scanNeeded {
|
if scanNeeded {
|
||||||
s := CreateScanner(ctx)
|
s := CreateScanner(ctx)
|
||||||
@ -330,16 +336,13 @@ func startPlaybackServer(ctx context.Context) func() error {
|
|||||||
// startPluginManager starts the plugin manager, if configured.
|
// startPluginManager starts the plugin manager, if configured.
|
||||||
func startPluginManager(ctx context.Context) func() error {
|
func startPluginManager(ctx context.Context) func() error {
|
||||||
return func() error {
|
return func() error {
|
||||||
|
manager := GetPluginManager(ctx)
|
||||||
if !conf.Server.Plugins.Enabled {
|
if !conf.Server.Plugins.Enabled {
|
||||||
log.Debug("Plugins are DISABLED")
|
log.Debug("Plugin system is DISABLED")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
log.Info(ctx, "Starting plugin manager")
|
log.Info(ctx, "Starting plugin manager")
|
||||||
// Get the manager instance and scan for plugins
|
return manager.Start(ctx)
|
||||||
manager := GetPluginManager(ctx)
|
|
||||||
manager.ScanPlugins()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
49
cmd/scan.go
49
cmd/scan.go
@ -1,11 +1,15 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"encoding/gob"
|
"encoding/gob"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
|
"github.com/navidrome/navidrome/core/playlists"
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
@ -19,12 +23,14 @@ var (
|
|||||||
fullScan bool
|
fullScan bool
|
||||||
subprocess bool
|
subprocess bool
|
||||||
targets []string
|
targets []string
|
||||||
|
targetFile string
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
scanCmd.Flags().BoolVarP(&fullScan, "full", "f", false, "check all subfolders, ignoring timestamps")
|
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().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().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)
|
rootCmd.AddCommand(scanCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,12 +75,19 @@ func runScanner(ctx context.Context) {
|
|||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
defer db.Db().Close()
|
defer db.Db().Close()
|
||||||
ds := persistence.New(sqlDB)
|
ds := persistence.New(sqlDB)
|
||||||
pls := core.NewPlaylists(ds)
|
pls := playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
|
|
||||||
// Parse targets if provided
|
// Parse targets from command line or file
|
||||||
var scanTargets []model.ScanTarget
|
var scanTargets []model.ScanTarget
|
||||||
if len(targets) > 0 {
|
var err error
|
||||||
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)
|
scanTargets, err = model.ParseTargets(targets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Failed to parse targets", err)
|
log.Fatal(ctx, "Failed to parse targets", err)
|
||||||
@ -94,3 +107,31 @@ func runScanner(ctx context.Context) {
|
|||||||
trackScanInteractively(ctx, progress)
|
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
cmd/scan_test.go
Normal file
89
cmd/scan_test.go
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
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))
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -248,6 +248,7 @@ ExecStart={{.Path|cmdEscape}}{{range .Arguments}} {{.|cmd}}{{end}}
|
|||||||
TimeoutStopSec=20
|
TimeoutStopSec=20
|
||||||
RestartSec=120
|
RestartSec=120
|
||||||
EnvironmentFile=-/etc/sysconfig/{{.Name}}
|
EnvironmentFile=-/etc/sysconfig/{{.Name}}
|
||||||
|
Environment="ND_SYSTEMD_PRIORITY_LOGGING=1"
|
||||||
|
|
||||||
DevicePolicy=closed
|
DevicePolicy=closed
|
||||||
NoNewPrivileges=yes
|
NoNewPrivileges=yes
|
||||||
|
|||||||
109
cmd/wire_gen.go
109
cmd/wire_gen.go
@ -1,6 +1,6 @@
|
|||||||
// Code generated by Wire. DO NOT EDIT.
|
// Code generated by Wire. DO NOT EDIT.
|
||||||
|
|
||||||
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo"
|
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo sqlite_fts5"
|
||||||
//go:build !wireinject
|
//go:build !wireinject
|
||||||
// +build !wireinject
|
// +build !wireinject
|
||||||
|
|
||||||
@ -9,16 +9,21 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"github.com/google/wire"
|
"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"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"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/artwork"
|
||||||
"github.com/navidrome/navidrome/core/external"
|
"github.com/navidrome/navidrome/core/external"
|
||||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
"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/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playback"
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
|
"github.com/navidrome/navidrome/core/playlists"
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
"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/db"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/persistence"
|
"github.com/navidrome/navidrome/persistence"
|
||||||
@ -32,7 +37,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "github.com/navidrome/navidrome/adapters/taglib"
|
_ "github.com/navidrome/navidrome/adapters/deezer"
|
||||||
|
_ "github.com/navidrome/navidrome/adapters/gotaglib"
|
||||||
|
_ "github.com/navidrome/navidrome/adapters/lastfm"
|
||||||
|
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Injectors from wire_injectors.go:
|
// Injectors from wire_injectors.go:
|
||||||
@ -47,9 +55,7 @@ func CreateServer() *server.Server {
|
|||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
broker := events.GetBroker()
|
broker := events.GetBroker()
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
insights := metrics.GetInstance(dataStore)
|
||||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
|
||||||
insights := metrics.GetInstance(dataStore, manager)
|
|
||||||
serverServer := server.New(dataStore, broker, insights)
|
serverServer := server.New(dataStore, broker, insights)
|
||||||
return serverServer
|
return serverServer
|
||||||
}
|
}
|
||||||
@ -58,22 +64,25 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
|||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
share := core.NewShare(dataStore)
|
share := core.NewShare(dataStore)
|
||||||
playlists := core.NewPlaylists(dataStore)
|
imageUploadService := core.NewImageUploadService()
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
insights := metrics.GetInstance(dataStore)
|
||||||
insights := metrics.GetInstance(dataStore, manager)
|
|
||||||
fileCache := artwork.GetImageCache()
|
fileCache := artwork.GetImageCache()
|
||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
|
broker := events.GetBroker()
|
||||||
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
|
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
provider := external.NewProvider(dataStore, agentsAgents)
|
matcherMatcher := matcher.New(dataStore)
|
||||||
|
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
broker := events.GetBroker()
|
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
|
||||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||||
library := core.NewLibrary(dataStore, modelScanner, watcher, broker)
|
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
|
||||||
|
user := core.NewUser(dataStore, manager)
|
||||||
maintenance := core.NewMaintenance(dataStore)
|
maintenance := core.NewMaintenance(dataStore)
|
||||||
router := nativeapi.New(dataStore, share, playlists, insights, library, maintenance)
|
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, imageUploadService)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,23 +91,28 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
|||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
fileCache := artwork.GetImageCache()
|
fileCache := artwork.GetImageCache()
|
||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
|
broker := events.GetBroker()
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
provider := external.NewProvider(dataStore, agentsAgents)
|
matcherMatcher := matcher.New(dataStore)
|
||||||
|
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
transcodingCache := core.GetTranscodingCache()
|
transcodingCache := stream.GetTranscodingCache()
|
||||||
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||||
share := core.NewShare(dataStore)
|
share := core.NewShare(dataStore)
|
||||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||||
players := core.NewPlayers(dataStore)
|
players := core.NewPlayers(dataStore)
|
||||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
broker := events.GetBroker()
|
imageUploadService := core.NewImageUploadService()
|
||||||
playlists := core.NewPlaylists(dataStore)
|
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||||
playbackServer := playback.GetInstance(dataStore)
|
playbackServer := playback.GetInstance(dataStore)
|
||||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
|
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)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,13 +121,15 @@ func CreatePublicRouter() *public.Router {
|
|||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
fileCache := artwork.GetImageCache()
|
fileCache := artwork.GetImageCache()
|
||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
|
broker := events.GetBroker()
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
provider := external.NewProvider(dataStore, agentsAgents)
|
matcherMatcher := matcher.New(dataStore)
|
||||||
|
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
transcodingCache := core.GetTranscodingCache()
|
transcodingCache := stream.GetTranscodingCache()
|
||||||
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||||
share := core.NewShare(dataStore)
|
share := core.NewShare(dataStore)
|
||||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||||
router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
|
router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
|
||||||
@ -137,9 +153,7 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
|
|||||||
func CreateInsights() metrics.Insights {
|
func CreateInsights() metrics.Insights {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
insights := metrics.GetInstance(dataStore)
|
||||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
|
||||||
insights := metrics.GetInstance(dataStore, manager)
|
|
||||||
return insights
|
return insights
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -155,15 +169,17 @@ func CreateScanner(ctx context.Context) model.Scanner {
|
|||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
fileCache := artwork.GetImageCache()
|
fileCache := artwork.GetImageCache()
|
||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
|
broker := events.GetBroker()
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
provider := external.NewProvider(dataStore, agentsAgents)
|
matcherMatcher := matcher.New(dataStore)
|
||||||
|
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
broker := events.GetBroker()
|
imageUploadService := core.NewImageUploadService()
|
||||||
playlists := core.NewPlaylists(dataStore)
|
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||||
return modelScanner
|
return modelScanner
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -172,15 +188,17 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
|||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
fileCache := artwork.GetImageCache()
|
fileCache := artwork.GetImageCache()
|
||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
|
broker := events.GetBroker()
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
provider := external.NewProvider(dataStore, agentsAgents)
|
matcherMatcher := matcher.New(dataStore)
|
||||||
|
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
broker := events.GetBroker()
|
imageUploadService := core.NewImageUploadService()
|
||||||
playlists := core.NewPlaylists(dataStore)
|
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||||
return watcher
|
return watcher
|
||||||
}
|
}
|
||||||
@ -192,19 +210,20 @@ func GetPlaybackServer() playback.PlaybackServer {
|
|||||||
return playbackServer
|
return playbackServer
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPluginManager() plugins.Manager {
|
func getPluginManager() *plugins.Manager {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
|
broker := events.GetBroker()
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||||
return manager
|
return manager
|
||||||
}
|
}
|
||||||
|
|
||||||
// wire_injectors.go:
|
// 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, 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.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, 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)))
|
||||||
|
|
||||||
func GetPluginManager(ctx context.Context) plugins.Manager {
|
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||||
manager := getPluginManager()
|
manager := getPluginManager()
|
||||||
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
|
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
|
||||||
return manager
|
return manager
|
||||||
|
|||||||
@ -6,14 +6,16 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/google/wire"
|
"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"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"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/artwork"
|
||||||
|
"github.com/navidrome/navidrome/core/lyrics"
|
||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playback"
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
|
"github.com/navidrome/navidrome/core/sonic"
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/persistence"
|
"github.com/navidrome/navidrome/persistence"
|
||||||
@ -39,12 +41,17 @@ var allProviders = wire.NewSet(
|
|||||||
events.GetBroker,
|
events.GetBroker,
|
||||||
scanner.New,
|
scanner.New,
|
||||||
scanner.GetWatcher,
|
scanner.GetWatcher,
|
||||||
plugins.GetManager,
|
|
||||||
metrics.GetPrometheusInstance,
|
metrics.GetPrometheusInstance,
|
||||||
db.Db,
|
db.Db,
|
||||||
wire.Bind(new(agents.PluginLoader), new(plugins.Manager)),
|
plugins.GetManager,
|
||||||
wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)),
|
sonic.New,
|
||||||
wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)),
|
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)),
|
wire.Bind(new(core.Watcher), new(scanner.Watcher)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -120,13 +127,13 @@ func GetPlaybackServer() playback.PlaybackServer {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPluginManager() plugins.Manager {
|
func getPluginManager() *plugins.Manager {
|
||||||
panic(wire.Build(
|
panic(wire.Build(
|
||||||
allProviders,
|
allProviders,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetPluginManager(ctx context.Context) plugins.Manager {
|
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||||
manager := getPluginManager()
|
manager := getPluginManager()
|
||||||
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
|
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
|
||||||
return manager
|
return manager
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
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
conf/buildtags/doc.go
Normal file
6
conf/buildtags/doc.go
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
// Package buildtags provides compile-time enforcement of required build tags.
|
||||||
|
//
|
||||||
|
// Each file in this package is guarded by a build constraint and exports a variable
|
||||||
|
// that main.go references. If a required tag is missing during compilation, the build
|
||||||
|
// fails with an "undefined" error, directing the developer to use `make build`.
|
||||||
|
package buildtags
|
||||||
@ -2,10 +2,6 @@
|
|||||||
|
|
||||||
package buildtags
|
package buildtags
|
||||||
|
|
||||||
// NOTICE: This file was created to force the inclusion of the `netgo` tag when compiling the project.
|
// The `netgo` tag is required when compiling the project. See https://github.com/navidrome/navidrome/issues/700
|
||||||
// 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
|
var NETGO = true
|
||||||
|
|||||||
8
conf/buildtags/sqlite_fts5.go
Normal file
8
conf/buildtags/sqlite_fts5.go
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
//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
|
||||||
@ -1,21 +1,24 @@
|
|||||||
package conf
|
package conf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/bmatcuk/doublestar/v4"
|
"github.com/bmatcuk/doublestar/v4"
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
"github.com/go-viper/encoding/ini"
|
"github.com/go-viper/encoding/ini"
|
||||||
"github.com/kr/pretty"
|
"github.com/kr/pretty"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/scheduler"
|
||||||
"github.com/navidrome/navidrome/utils/run"
|
"github.com/navidrome/navidrome/utils/run"
|
||||||
"github.com/robfig/cron/v3"
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -24,6 +27,7 @@ type configOptions struct {
|
|||||||
Address string
|
Address string
|
||||||
Port int
|
Port int
|
||||||
UnixSocketPerm string
|
UnixSocketPerm string
|
||||||
|
EnforceNonRootUser bool
|
||||||
MusicFolder string
|
MusicFolder string
|
||||||
DataFolder string
|
DataFolder string
|
||||||
CacheFolder string
|
CacheFolder string
|
||||||
@ -44,6 +48,7 @@ type configOptions struct {
|
|||||||
EnableTranscodingCancellation bool
|
EnableTranscodingCancellation bool
|
||||||
EnableDownloads bool
|
EnableDownloads bool
|
||||||
EnableExternalServices bool
|
EnableExternalServices bool
|
||||||
|
EnableM3UExternalAlbumArt bool
|
||||||
EnableInsightsCollector bool
|
EnableInsightsCollector bool
|
||||||
EnableMediaFileCoverArt bool
|
EnableMediaFileCoverArt bool
|
||||||
TranscodingCacheSize string
|
TranscodingCacheSize string
|
||||||
@ -56,7 +61,8 @@ type configOptions struct {
|
|||||||
SmartPlaylistRefreshDelay time.Duration
|
SmartPlaylistRefreshDelay time.Duration
|
||||||
AutoTranscodeDownload bool
|
AutoTranscodeDownload bool
|
||||||
DefaultDownsamplingFormat string
|
DefaultDownsamplingFormat string
|
||||||
SearchFullString bool
|
Search searchOptions `json:",omitzero"`
|
||||||
|
Matcher matcherOptions `json:",omitzero"`
|
||||||
RecentlyAddedByModTime bool
|
RecentlyAddedByModTime bool
|
||||||
PreferSortTags bool
|
PreferSortTags bool
|
||||||
IgnoredArticles string
|
IgnoredArticles string
|
||||||
@ -65,13 +71,18 @@ type configOptions struct {
|
|||||||
MPVPath string
|
MPVPath string
|
||||||
MPVCmdTemplate string
|
MPVCmdTemplate string
|
||||||
CoverArtPriority string
|
CoverArtPriority string
|
||||||
CoverJpegQuality int
|
CoverArtQuality int
|
||||||
|
EnableWebPEncoding bool
|
||||||
ArtistArtPriority string
|
ArtistArtPriority string
|
||||||
|
ArtistImageFolder string
|
||||||
|
DiscArtPriority string
|
||||||
LyricsPriority string
|
LyricsPriority string
|
||||||
EnableGravatar bool
|
EnableGravatar bool
|
||||||
EnableFavourites bool
|
EnableFavourites bool
|
||||||
EnableStarRating bool
|
EnableStarRating bool
|
||||||
EnableUserEditing bool
|
EnableUserEditing bool
|
||||||
|
EnableArtworkUpload bool
|
||||||
|
MaxImageUploadSize string
|
||||||
EnableSharing bool
|
EnableSharing bool
|
||||||
ShareURL string
|
ShareURL string
|
||||||
DefaultShareExpiration time.Duration
|
DefaultShareExpiration time.Duration
|
||||||
@ -79,9 +90,12 @@ type configOptions struct {
|
|||||||
DefaultTheme string
|
DefaultTheme string
|
||||||
DefaultLanguage string
|
DefaultLanguage string
|
||||||
DefaultUIVolume int
|
DefaultUIVolume int
|
||||||
|
UISearchDebounceMs int
|
||||||
|
UICoverArtSize int
|
||||||
EnableReplayGain bool
|
EnableReplayGain bool
|
||||||
EnableCoverAnimation bool
|
EnableCoverAnimation bool
|
||||||
EnableNowPlaying bool
|
EnableNowPlaying bool
|
||||||
|
UIPlaybackReportInterval time.Duration
|
||||||
GATrackingID string
|
GATrackingID string
|
||||||
EnableLogRedacting bool
|
EnableLogRedacting bool
|
||||||
AuthRequestLimit int
|
AuthRequestLimit int
|
||||||
@ -89,8 +103,7 @@ type configOptions struct {
|
|||||||
PasswordEncryptionKey string
|
PasswordEncryptionKey string
|
||||||
ExtAuth extAuthOptions
|
ExtAuth extAuthOptions
|
||||||
Plugins pluginsOptions
|
Plugins pluginsOptions
|
||||||
PluginConfig map[string]map[string]string
|
HTTPHeaders httpHeaderOptions `json:",omitzero"`
|
||||||
HTTPSecurityHeaders secureOptions `json:",omitzero"`
|
|
||||||
Prometheus prometheusOptions `json:",omitzero"`
|
Prometheus prometheusOptions `json:",omitzero"`
|
||||||
Scanner scannerOptions `json:",omitzero"`
|
Scanner scannerOptions `json:",omitzero"`
|
||||||
Jukebox jukeboxOptions `json:",omitzero"`
|
Jukebox jukeboxOptions `json:",omitzero"`
|
||||||
@ -99,7 +112,6 @@ type configOptions struct {
|
|||||||
Inspect inspectOptions `json:",omitzero"`
|
Inspect inspectOptions `json:",omitzero"`
|
||||||
Subsonic subsonicOptions `json:",omitzero"`
|
Subsonic subsonicOptions `json:",omitzero"`
|
||||||
LastFM lastfmOptions `json:",omitzero"`
|
LastFM lastfmOptions `json:",omitzero"`
|
||||||
Spotify spotifyOptions `json:",omitzero"`
|
|
||||||
Deezer deezerOptions `json:",omitzero"`
|
Deezer deezerOptions `json:",omitzero"`
|
||||||
ListenBrainz listenBrainzOptions `json:",omitzero"`
|
ListenBrainz listenBrainzOptions `json:",omitzero"`
|
||||||
EnableScrobbleHistory bool
|
EnableScrobbleHistory bool
|
||||||
@ -134,6 +146,7 @@ type configOptions struct {
|
|||||||
DevExternalArtistFetchMultiplier float64
|
DevExternalArtistFetchMultiplier float64
|
||||||
DevOptimizeDB bool
|
DevOptimizeDB bool
|
||||||
DevPreserveUnicodeInExternalCalls bool
|
DevPreserveUnicodeInExternalCalls bool
|
||||||
|
DevEnableMediaFileProbe bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type scannerOptions struct {
|
type scannerOptions struct {
|
||||||
@ -151,9 +164,12 @@ type scannerOptions struct {
|
|||||||
|
|
||||||
type subsonicOptions struct {
|
type subsonicOptions struct {
|
||||||
AppendSubtitle bool
|
AppendSubtitle bool
|
||||||
|
AppendAlbumVersion bool
|
||||||
ArtistParticipations bool
|
ArtistParticipations bool
|
||||||
DefaultReportRealPath bool
|
DefaultReportRealPath bool
|
||||||
|
EnableAverageRating bool
|
||||||
LegacyClients string
|
LegacyClients string
|
||||||
|
MinimalClients string
|
||||||
}
|
}
|
||||||
|
|
||||||
type TagConf struct {
|
type TagConf struct {
|
||||||
@ -167,35 +183,38 @@ type TagConf struct {
|
|||||||
|
|
||||||
type lastfmOptions struct {
|
type lastfmOptions struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
ApiKey string
|
ApiKey string //nolint:gosec
|
||||||
Secret string
|
Secret string //nolint:gosec
|
||||||
Language string
|
Language string
|
||||||
ScrobbleFirstArtistOnly bool
|
ScrobbleFirstArtistOnly bool
|
||||||
}
|
|
||||||
|
|
||||||
type spotifyOptions struct {
|
// Computed values
|
||||||
ID string
|
Languages []string // Computed from Language, split by comma
|
||||||
Secret string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type deezerOptions struct {
|
type deezerOptions struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
Language string
|
Language string
|
||||||
|
|
||||||
|
// Computed values
|
||||||
|
Languages []string // Computed from Language, split by comma
|
||||||
}
|
}
|
||||||
|
|
||||||
type listenBrainzOptions struct {
|
type listenBrainzOptions struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
BaseURL string
|
BaseURL string
|
||||||
|
ArtistAlgorithm string
|
||||||
|
TrackAlgorithm string
|
||||||
}
|
}
|
||||||
|
|
||||||
type secureOptions struct {
|
type httpHeaderOptions struct {
|
||||||
CustomFrameOptionsValue string
|
FrameOptions string
|
||||||
}
|
}
|
||||||
|
|
||||||
type prometheusOptions struct {
|
type prometheusOptions struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
MetricsPath string
|
MetricsPath string
|
||||||
Password string
|
Password string //nolint:gosec
|
||||||
}
|
}
|
||||||
|
|
||||||
type AudioDeviceDefinition []string
|
type AudioDeviceDefinition []string
|
||||||
@ -226,14 +245,40 @@ type inspectOptions struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type pluginsOptions struct {
|
type pluginsOptions struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
Folder string
|
Folder string
|
||||||
CacheSize string
|
CacheSize string
|
||||||
|
AutoReload bool
|
||||||
|
LogLevel string
|
||||||
}
|
}
|
||||||
|
|
||||||
type extAuthOptions struct {
|
type extAuthOptions struct {
|
||||||
TrustedSources string
|
TrustedSources string
|
||||||
UserHeader 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
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -245,29 +290,35 @@ func LoadFromFile(confFile string) {
|
|||||||
viper.SetConfigFile(confFile)
|
viper.SetConfigFile(confFile)
|
||||||
err := viper.ReadInConfig()
|
err := viper.ReadInConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error reading config file:", err)
|
logFatal("Error reading config file:", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
Load(true)
|
Load(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load(noConfigDump bool) {
|
func Load(noConfigDump bool) {
|
||||||
parseIniFileConfiguration()
|
parseIniFileConfiguration()
|
||||||
|
remapEnvVarKeysFromConfig()
|
||||||
|
|
||||||
// Map deprecated options to their new names for backwards compatibility
|
// Map deprecated options to their new names for backwards compatibility
|
||||||
mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
||||||
mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
||||||
|
mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||||
|
mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality")
|
||||||
|
mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
|
||||||
|
|
||||||
err := viper.Unmarshal(&Server)
|
err := viper.Unmarshal(&Server)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
|
logFatal("Error parsing config:", err)
|
||||||
os.Exit(1)
|
}
|
||||||
|
|
||||||
|
// Validate non-root user early, before any filesystem operations
|
||||||
|
if err := validateEnforceNonRootUser(); err != nil {
|
||||||
|
logFatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = os.MkdirAll(Server.DataFolder, os.ModePerm)
|
err = os.MkdirAll(Server.DataFolder, os.ModePerm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", err)
|
logFatal("Error creating data path:", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if Server.CacheFolder == "" {
|
if Server.CacheFolder == "" {
|
||||||
@ -275,8 +326,12 @@ func Load(noConfigDump bool) {
|
|||||||
}
|
}
|
||||||
err = os.MkdirAll(Server.CacheFolder, os.ModePerm)
|
err = os.MkdirAll(Server.CacheFolder, os.ModePerm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating cache path:", err)
|
logFatal("Error creating cache path:", err)
|
||||||
os.Exit(1)
|
}
|
||||||
|
|
||||||
|
err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm)
|
||||||
|
if err != nil {
|
||||||
|
logFatal("Error creating artwork path:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if Server.Plugins.Enabled {
|
if Server.Plugins.Enabled {
|
||||||
@ -285,8 +340,7 @@ func Load(noConfigDump bool) {
|
|||||||
}
|
}
|
||||||
err = os.MkdirAll(Server.Plugins.Folder, 0700)
|
err = os.MkdirAll(Server.Plugins.Folder, 0700)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating plugins path:", err)
|
logFatal("Error creating plugins path:", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -298,8 +352,7 @@ func Load(noConfigDump bool) {
|
|||||||
if Server.Backup.Path != "" {
|
if Server.Backup.Path != "" {
|
||||||
err = os.MkdirAll(Server.Backup.Path, os.ModePerm)
|
err = os.MkdirAll(Server.Backup.Path, os.ModePerm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating backup path:", err)
|
logFatal("Error creating backup path:", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -307,10 +360,15 @@ func Load(noConfigDump bool) {
|
|||||||
if Server.LogFile != "" {
|
if Server.LogFile != "" {
|
||||||
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = fmt.Fprintf(os.Stderr, "FATAL: Error opening log file %s: %s\n", Server.LogFile, err.Error())
|
logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error()))
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
log.SetOutput(out)
|
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)
|
log.SetLevelString(Server.LogLevel)
|
||||||
@ -323,16 +381,19 @@ func Load(noConfigDump bool) {
|
|||||||
validateBackupSchedule,
|
validateBackupSchedule,
|
||||||
validatePlaylistsPath,
|
validatePlaylistsPath,
|
||||||
validatePurgeMissingOption,
|
validatePurgeMissingOption,
|
||||||
|
validateMaxImageUploadSize,
|
||||||
|
validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Exit(1)
|
logFatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Server.Search.Backend = normalizeSearchBackend(Server.Search.Backend)
|
||||||
|
|
||||||
if Server.BaseURL != "" {
|
if Server.BaseURL != "" {
|
||||||
u, err := url.Parse(Server.BaseURL)
|
u, err := url.Parse(Server.BaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Invalid BaseURL:", err)
|
logFatal("Invalid BaseURL:", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
Server.BasePath = u.Path
|
Server.BasePath = u.Path
|
||||||
u.Path = ""
|
u.Path = ""
|
||||||
@ -344,6 +405,8 @@ func Load(noConfigDump bool) {
|
|||||||
// Log configuration source
|
// Log configuration source
|
||||||
if Server.ConfigFile != "" {
|
if Server.ConfigFile != "" {
|
||||||
log.Info("Loaded configuration", "file", 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 {
|
} 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.")
|
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.")
|
||||||
}
|
}
|
||||||
@ -361,14 +424,36 @@ func Load(noConfigDump bool) {
|
|||||||
disableExternalServices()
|
disableExternalServices()
|
||||||
}
|
}
|
||||||
|
|
||||||
if Server.Scanner.Extractor != consts.DefaultScannerExtractor {
|
// Make sure we don't have empty PIDs
|
||||||
log.Warn(fmt.Sprintf("Extractor '%s' is not implemented, using 'taglib'", Server.Scanner.Extractor))
|
Server.PID.Album = cmp.Or(Server.PID.Album, consts.DefaultAlbumPID)
|
||||||
Server.Scanner.Extractor = consts.DefaultScannerExtractor
|
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
|
||||||
}
|
}
|
||||||
logDeprecatedOptions("Scanner.GenreSeparators")
|
|
||||||
logDeprecatedOptions("Scanner.GroupAlbumReleases")
|
|
||||||
logDeprecatedOptions("DevEnableBufferedScrobble") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
|
|
||||||
logDeprecatedOptions("ReverseProxyWhitelist", "ReverseProxyUserHeader")
|
|
||||||
|
|
||||||
// Call init hooks
|
// Call init hooks
|
||||||
for _, hook := range hooks {
|
for _, hook := range hooks {
|
||||||
@ -376,15 +461,67 @@ func Load(noConfigDump bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func logDeprecatedOptions(options ...string) {
|
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) {
|
||||||
for _, option := range options {
|
for _, option := range options {
|
||||||
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_"))
|
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_"))
|
||||||
if os.Getenv(envVar) != "" {
|
logWarning := func(option string) {
|
||||||
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", envVar))
|
log.Warn(fmt.Sprintf("Option '%s' is not available anymore and will be ignored. Please remove it from your config", option))
|
||||||
}
|
}
|
||||||
if viper.InConfig(option) {
|
if viper.InConfig(option) {
|
||||||
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", option))
|
logWarning(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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -402,21 +539,18 @@ func mapDeprecatedOption(legacyName, newName string) {
|
|||||||
func parseIniFileConfiguration() {
|
func parseIniFileConfiguration() {
|
||||||
cfgFile := viper.ConfigFileUsed()
|
cfgFile := viper.ConfigFileUsed()
|
||||||
if strings.ToLower(filepath.Ext(cfgFile)) == ".ini" {
|
if strings.ToLower(filepath.Ext(cfgFile)) == ".ini" {
|
||||||
var iniConfig map[string]interface{}
|
var iniConfig map[string]any
|
||||||
err := viper.Unmarshal(&iniConfig)
|
err := viper.Unmarshal(&iniConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
|
logFatal("Error parsing config:", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
cfg, ok := iniConfig["default"].(map[string]any)
|
cfg, ok := iniConfig["default"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config: missing [default] section:", iniConfig)
|
logFatal("Error parsing config: missing [default] section:", iniConfig)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
err = viper.MergeConfigMap(cfg)
|
err = viper.MergeConfigMap(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
|
logFatal("Error parsing config:", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -424,8 +558,8 @@ func parseIniFileConfiguration() {
|
|||||||
func disableExternalServices() {
|
func disableExternalServices() {
|
||||||
log.Info("All external integrations are DISABLED!")
|
log.Info("All external integrations are DISABLED!")
|
||||||
Server.EnableInsightsCollector = false
|
Server.EnableInsightsCollector = false
|
||||||
|
Server.EnableM3UExternalAlbumArt = false
|
||||||
Server.LastFM.Enabled = false
|
Server.LastFM.Enabled = false
|
||||||
Server.Spotify.ID = ""
|
|
||||||
Server.Deezer.Enabled = false
|
Server.Deezer.Enabled = false
|
||||||
Server.ListenBrainz.Enabled = false
|
Server.ListenBrainz.Enabled = false
|
||||||
Server.Agents = ""
|
Server.Agents = ""
|
||||||
@ -435,34 +569,61 @@ func disableExternalServices() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validatePlaylistsPath() error {
|
func validatePlaylistsPath() error {
|
||||||
for _, path := range strings.Split(Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
for path := range strings.SplitSeq(Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
||||||
_, err := doublestar.Match(path, "")
|
_, err := doublestar.Match(path, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Invalid PlaylistsPath", "path", path, err)
|
return fmt.Errorf("invalid PlaylistsPath %q: %w", path, err)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validatePurgeMissingOption() error {
|
// parseLanguages parses a comma-separated language string into a slice.
|
||||||
allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull}
|
// It trims whitespace from each entry and ensures at least [DefaultInfoLanguage] is returned.
|
||||||
valid := false
|
func parseLanguages(lang string) []string {
|
||||||
for _, v := range allowedValues {
|
var languages []string
|
||||||
if v == Server.Scanner.PurgeMissing {
|
for l := range strings.SplitSeq(lang, ",") {
|
||||||
valid = true
|
l = strings.TrimSpace(l)
|
||||||
break
|
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)
|
||||||
if !valid {
|
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
|
Server.Scanner.PurgeMissing = consts.PurgeMissingNever
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
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 {
|
func validateScanSchedule() error {
|
||||||
if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" {
|
if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" {
|
||||||
Server.Scanner.Schedule = ""
|
Server.Scanner.Schedule = ""
|
||||||
@ -484,17 +645,58 @@ func validateBackupSchedule() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateSchedule(schedule, field string) (string, error) {
|
func validateSchedule(schedule, field string) (string, error) {
|
||||||
if _, err := time.ParseDuration(schedule); err == nil {
|
_, err := scheduler.ParseCrontab(schedule)
|
||||||
schedule = "@every " + schedule
|
|
||||||
}
|
|
||||||
c := cron.New()
|
|
||||||
id, err := c.AddFunc(schedule, func() {})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(fmt.Sprintf("Invalid %s. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", field), "schedule", schedule, err)
|
return schedule, fmt.Errorf("invalid %s %q (see https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format): %w", field, schedule, err)
|
||||||
} else {
|
|
||||||
c.Remove(id)
|
|
||||||
}
|
}
|
||||||
return schedule, err
|
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, ".")
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddHook is used to register initialization code that should run as soon as the config is loaded
|
// AddHook is used to register initialization code that should run as soon as the config is loaded
|
||||||
@ -502,6 +704,16 @@ func AddHook(hook func()) {
|
|||||||
hooks = append(hooks, hook)
|
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() {
|
func setViperDefaults() {
|
||||||
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
|
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
|
||||||
viper.SetDefault("cachefolder", "")
|
viper.SetDefault("cachefolder", "")
|
||||||
@ -511,6 +723,7 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("address", "0.0.0.0")
|
viper.SetDefault("address", "0.0.0.0")
|
||||||
viper.SetDefault("port", 4533)
|
viper.SetDefault("port", 4533)
|
||||||
viper.SetDefault("unixsocketperm", "0660")
|
viper.SetDefault("unixsocketperm", "0660")
|
||||||
|
viper.SetDefault("enforcenonrootuser", false)
|
||||||
viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout)
|
viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout)
|
||||||
viper.SetDefault("baseurl", "")
|
viper.SetDefault("baseurl", "")
|
||||||
viper.SetDefault("tlscert", "")
|
viper.SetDefault("tlscert", "")
|
||||||
@ -530,19 +743,27 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("smartPlaylistRefreshDelay", 5*time.Second)
|
viper.SetDefault("smartPlaylistRefreshDelay", 5*time.Second)
|
||||||
viper.SetDefault("enabledownloads", true)
|
viper.SetDefault("enabledownloads", true)
|
||||||
viper.SetDefault("enableexternalservices", true)
|
viper.SetDefault("enableexternalservices", true)
|
||||||
|
viper.SetDefault("enablem3uexternalalbumart", false)
|
||||||
viper.SetDefault("enablemediafilecoverart", true)
|
viper.SetDefault("enablemediafilecoverart", true)
|
||||||
viper.SetDefault("autotranscodedownload", false)
|
viper.SetDefault("autotranscodedownload", false)
|
||||||
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
|
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
|
||||||
viper.SetDefault("searchfullstring", false)
|
viper.SetDefault("search.fullstring", false)
|
||||||
|
viper.SetDefault("search.backend", "fts")
|
||||||
|
viper.SetDefault("matcher.preferstarred", true)
|
||||||
|
viper.SetDefault("matcher.fuzzythreshold", 85)
|
||||||
viper.SetDefault("recentlyaddedbymodtime", false)
|
viper.SetDefault("recentlyaddedbymodtime", false)
|
||||||
viper.SetDefault("prefersorttags", false)
|
viper.SetDefault("prefersorttags", false)
|
||||||
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
|
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("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("ffmpegpath", "")
|
||||||
|
viper.SetDefault("mpvpath", "")
|
||||||
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display %f --input-ipc-server=%s")
|
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("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
|
||||||
viper.SetDefault("coverjpegquality", 75)
|
viper.SetDefault("coverartquality", 75)
|
||||||
|
viper.SetDefault("enablewebpencoding", false)
|
||||||
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
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("lyricspriority", ".lrc,.txt,embedded")
|
||||||
viper.SetDefault("enablegravatar", false)
|
viper.SetDefault("enablegravatar", false)
|
||||||
viper.SetDefault("enablefavourites", true)
|
viper.SetDefault("enablefavourites", true)
|
||||||
@ -551,9 +772,14 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("defaulttheme", "Dark")
|
viper.SetDefault("defaulttheme", "Dark")
|
||||||
viper.SetDefault("defaultlanguage", "")
|
viper.SetDefault("defaultlanguage", "")
|
||||||
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
|
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
|
||||||
|
viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs)
|
||||||
|
viper.SetDefault("uicoverartsize", consts.DefaultUICoverArtSize)
|
||||||
viper.SetDefault("enablereplaygain", true)
|
viper.SetDefault("enablereplaygain", true)
|
||||||
viper.SetDefault("enablecoveranimation", true)
|
viper.SetDefault("enablecoveranimation", true)
|
||||||
viper.SetDefault("enablenowplaying", 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("enablesharing", false)
|
||||||
viper.SetDefault("shareurl", "")
|
viper.SetDefault("shareurl", "")
|
||||||
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
|
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
|
||||||
@ -566,6 +792,7 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("passwordencryptionkey", "")
|
viper.SetDefault("passwordencryptionkey", "")
|
||||||
viper.SetDefault("extauth.userheader", "Remote-User")
|
viper.SetDefault("extauth.userheader", "Remote-User")
|
||||||
viper.SetDefault("extauth.trustedsources", "")
|
viper.SetDefault("extauth.trustedsources", "")
|
||||||
|
viper.SetDefault("extauth.logouturl", "")
|
||||||
viper.SetDefault("prometheus.enabled", false)
|
viper.SetDefault("prometheus.enabled", false)
|
||||||
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
|
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
|
||||||
viper.SetDefault("prometheus.password", "")
|
viper.SetDefault("prometheus.password", "")
|
||||||
@ -584,23 +811,26 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("scanner.followsymlinks", true)
|
viper.SetDefault("scanner.followsymlinks", true)
|
||||||
viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever)
|
viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever)
|
||||||
viper.SetDefault("subsonic.appendsubtitle", true)
|
viper.SetDefault("subsonic.appendsubtitle", true)
|
||||||
|
viper.SetDefault("subsonic.appendalbumversion", true)
|
||||||
viper.SetDefault("subsonic.artistparticipations", false)
|
viper.SetDefault("subsonic.artistparticipations", false)
|
||||||
viper.SetDefault("subsonic.defaultreportrealpath", false)
|
viper.SetDefault("subsonic.defaultreportrealpath", false)
|
||||||
|
viper.SetDefault("subsonic.enableaveragerating", true)
|
||||||
viper.SetDefault("subsonic.legacyclients", "DSub")
|
viper.SetDefault("subsonic.legacyclients", "DSub")
|
||||||
viper.SetDefault("agents", "lastfm,spotify,deezer")
|
viper.SetDefault("subsonic.minimalclients", "SubMusic")
|
||||||
|
viper.SetDefault("agents", "deezer,lastfm,listenbrainz")
|
||||||
viper.SetDefault("lastfm.enabled", true)
|
viper.SetDefault("lastfm.enabled", true)
|
||||||
viper.SetDefault("lastfm.language", "en")
|
viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage)
|
||||||
viper.SetDefault("lastfm.apikey", "")
|
viper.SetDefault("lastfm.apikey", "")
|
||||||
viper.SetDefault("lastfm.secret", "")
|
viper.SetDefault("lastfm.secret", "")
|
||||||
viper.SetDefault("lastfm.scrobblefirstartistonly", false)
|
viper.SetDefault("lastfm.scrobblefirstartistonly", false)
|
||||||
viper.SetDefault("spotify.id", "")
|
|
||||||
viper.SetDefault("spotify.secret", "")
|
|
||||||
viper.SetDefault("deezer.enabled", true)
|
viper.SetDefault("deezer.enabled", true)
|
||||||
viper.SetDefault("deezer.language", "en")
|
viper.SetDefault("deezer.language", consts.DefaultInfoLanguage)
|
||||||
viper.SetDefault("listenbrainz.enabled", true)
|
viper.SetDefault("listenbrainz.enabled", true)
|
||||||
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
|
viper.SetDefault("listenbrainz.baseurl", consts.DefaultListenBrainzBaseURL)
|
||||||
|
viper.SetDefault("listenbrainz.artistalgorithm", consts.DefaultListenBrainzArtistAlgorithm)
|
||||||
|
viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm)
|
||||||
viper.SetDefault("enablescrobblehistory", true)
|
viper.SetDefault("enablescrobblehistory", true)
|
||||||
viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")
|
viper.SetDefault("httpheaders.frameoptions", "DENY")
|
||||||
viper.SetDefault("backup.path", "")
|
viper.SetDefault("backup.path", "")
|
||||||
viper.SetDefault("backup.schedule", "")
|
viper.SetDefault("backup.schedule", "")
|
||||||
viper.SetDefault("backup.count", 0)
|
viper.SetDefault("backup.count", 0)
|
||||||
@ -611,8 +841,10 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit)
|
viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit)
|
||||||
viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout)
|
viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||||
viper.SetDefault("plugins.folder", "")
|
viper.SetDefault("plugins.folder", "")
|
||||||
viper.SetDefault("plugins.enabled", false)
|
viper.SetDefault("plugins.enabled", true)
|
||||||
viper.SetDefault("plugins.cachesize", "100MB")
|
viper.SetDefault("plugins.cachesize", "200MB")
|
||||||
|
viper.SetDefault("plugins.autoreload", false)
|
||||||
|
viper.SetDefault("plugins.loglevel", "")
|
||||||
|
|
||||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||||
viper.SetDefault("devlogsourceline", false)
|
viper.SetDefault("devlogsourceline", false)
|
||||||
@ -626,7 +858,7 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("devuishowconfig", true)
|
viper.SetDefault("devuishowconfig", true)
|
||||||
viper.SetDefault("devneweventstream", true)
|
viper.SetDefault("devneweventstream", true)
|
||||||
viper.SetDefault("devoffsetoptimize", 50000)
|
viper.SetDefault("devoffsetoptimize", 50000)
|
||||||
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/3))
|
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2))
|
||||||
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
||||||
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||||
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
|
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
|
||||||
@ -641,6 +873,7 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
|
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
|
||||||
viper.SetDefault("devoptimizedb", true)
|
viper.SetDefault("devoptimizedb", true)
|
||||||
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
|
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
|
||||||
|
viper.SetDefault("devenablemediafileprobe", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@ -677,8 +910,7 @@ func InitConfig(cfgFile string, loadEnvVars bool) {
|
|||||||
|
|
||||||
err := viper.ReadInConfig()
|
err := viper.ReadInConfig()
|
||||||
if viper.ConfigFileUsed() != "" && err != nil {
|
if viper.ConfigFileUsed() != "" && err != nil {
|
||||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Navidrome could not open config file: ", err)
|
logFatal("Navidrome could not open config file:", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -690,7 +922,7 @@ func getConfigFile(cfgFile string) string {
|
|||||||
}
|
}
|
||||||
cfgFile = os.Getenv("ND_CONFIGFILE")
|
cfgFile = os.Getenv("ND_CONFIGFILE")
|
||||||
if cfgFile != "" {
|
if cfgFile != "" {
|
||||||
if _, err := os.Stat(cfgFile); err == nil {
|
if _, err := os.Stat(cfgFile); err == nil { //nolint:gosec
|
||||||
return cfgFile
|
return cfgFile
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package conf_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@ -24,6 +25,272 @@ var _ = Describe("Configuration", func() {
|
|||||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||||
viper.SetDefault("loglevel", "error")
|
viper.SetDefault("loglevel", "error")
|
||||||
conf.ResetConf()
|
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",
|
DescribeTable("should load configuration from",
|
||||||
|
|||||||
@ -5,3 +5,30 @@ func ResetConf() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var SetViperDefaults = setViperDefaults
|
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
conf/testdata/cfg_nd_conflict.toml
vendored
Normal file
2
conf/testdata/cfg_nd_conflict.toml
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
ND_ADDRESS = "127.0.0.1"
|
||||||
|
Address = "0.0.0.0"
|
||||||
3
conf/testdata/cfg_nd_keys.toml
vendored
Normal file
3
conf/testdata/cfg_nd_keys.toml
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
ND_ADDRESS = "127.0.0.1"
|
||||||
|
ND_PORT = 4531
|
||||||
|
ND_SCANNER_SCHEDULE = "@every 1h"
|
||||||
@ -56,6 +56,8 @@ const (
|
|||||||
|
|
||||||
ServerReadHeaderTimeout = 3 * time.Second
|
ServerReadHeaderTimeout = 3 * time.Second
|
||||||
|
|
||||||
|
DefaultInfoLanguage = "en"
|
||||||
|
|
||||||
ArtistInfoTimeToLive = 24 * time.Hour
|
ArtistInfoTimeToLive = 24 * time.Hour
|
||||||
AlbumInfoTimeToLive = 7 * 24 * time.Hour
|
AlbumInfoTimeToLive = 7 * 24 * time.Hour
|
||||||
UpdateLastAccessFrequency = time.Minute
|
UpdateLastAccessFrequency = time.Minute
|
||||||
@ -63,20 +65,31 @@ const (
|
|||||||
|
|
||||||
I18nFolder = "i18n"
|
I18nFolder = "i18n"
|
||||||
ScanIgnoreFile = ".ndignore"
|
ScanIgnoreFile = ".ndignore"
|
||||||
|
ArtworkFolder = "artwork"
|
||||||
|
|
||||||
PlaceholderArtistArt = "artist-placeholder.webp"
|
PlaceholderArtistArt = "artist-placeholder.webp"
|
||||||
PlaceholderAlbumArt = "album-placeholder.webp"
|
PlaceholderAlbumArt = "album-placeholder.webp"
|
||||||
PlaceholderAvatar = "logo-192x192.png"
|
PlaceholderAvatar = "logo-192x192.png"
|
||||||
UICoverArtSize = 300
|
DefaultUIVolume = 100
|
||||||
DefaultUIVolume = 100
|
DefaultUISearchDebounceMs = 200
|
||||||
|
DefaultUIPlaybackReportInterval = time.Minute
|
||||||
|
|
||||||
DefaultHttpClientTimeOut = 10 * time.Second
|
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"
|
DefaultScannerExtractor = "taglib"
|
||||||
DefaultWatcherWait = 5 * time.Second
|
DefaultWatcherWait = 5 * time.Second
|
||||||
Zwsp = string('\u200b')
|
Zwsp = string('\u200b')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultUICoverArtSize = 300
|
||||||
|
DefaultMaxImageUploadSize = "10MB"
|
||||||
|
)
|
||||||
|
|
||||||
// Prometheus options
|
// Prometheus options
|
||||||
const (
|
const (
|
||||||
PrometheusDefaultPath = "/metrics"
|
PrometheusDefaultPath = "/metrics"
|
||||||
@ -95,6 +108,13 @@ const (
|
|||||||
DefaultCacheCleanUpInterval = 10 * time.Minute
|
DefaultCacheCleanUpInterval = 10 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Entity types
|
||||||
|
const (
|
||||||
|
EntityArtist = "artist"
|
||||||
|
EntityPlaylist = "playlist"
|
||||||
|
EntityRadio = "radio"
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
AlbumPlayCountModeAbsolute = "absolute"
|
AlbumPlayCountModeAbsolute = "absolute"
|
||||||
AlbumPlayCountModeNormalized = "normalized"
|
AlbumPlayCountModeNormalized = "normalized"
|
||||||
@ -147,9 +167,17 @@ var (
|
|||||||
DefaultBitRate: 256,
|
DefaultBitRate: 256,
|
||||||
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
|
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f 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 (
|
var (
|
||||||
VariousArtists = "Various Artists"
|
VariousArtists = "Various Artists"
|
||||||
// TODO This will be dynamic when using disambiguation
|
// TODO This will be dynamic when using disambiguation
|
||||||
|
|||||||
4
context7.json
Normal file
4
context7.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"url": "https://context7.com/navidrome/navidrome",
|
||||||
|
"public_key": "pk_WqzhKScNKWQ84J4n0oG0J"
|
||||||
|
}
|
||||||
@ -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.
|
2) Implement one or more of the `*Retriever()` interfaces. That's where the agent's logic resides.
|
||||||
3) Register itself (in its `init()` function).
|
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 `"lastfm,spotify"`). 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 `"deezer,lastfm"`). The order dictates the priority of the agents
|
||||||
|
|
||||||
For a simple Agent example, look at the [local_agent](local_agent.go) agent source code.
|
For a simple Agent example, look at the [local_agent](local_agent.go) agent source code.
|
||||||
|
|||||||
@ -22,6 +22,8 @@ type PluginLoader interface {
|
|||||||
LoadMediaAgent(name string) (Interface, bool)
|
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 {
|
type Agents struct {
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
pluginLoader PluginLoader
|
pluginLoader PluginLoader
|
||||||
@ -64,6 +66,7 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent {
|
|||||||
if a.pluginLoader != nil {
|
if a.pluginLoader != nil {
|
||||||
availablePlugins = a.pluginLoader.PluginNames("MetadataAgent")
|
availablePlugins = a.pluginLoader.PluginNames("MetadataAgent")
|
||||||
}
|
}
|
||||||
|
log.Trace("Available MetadataAgent plugins", "plugins", availablePlugins)
|
||||||
|
|
||||||
configuredAgents := strings.Split(conf.Server.Agents, ",")
|
configuredAgents := strings.Split(conf.Server.Agents, ",")
|
||||||
|
|
||||||
@ -128,26 +131,14 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str
|
|||||||
case consts.VariousArtistsID:
|
case consts.VariousArtistsID:
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
start := time.Now()
|
|
||||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
return callAgentMethod(ctx, a, "GetArtistMBID", func(ag Interface) (string, error) {
|
||||||
ag := a.getAgent(enabledAgent)
|
|
||||||
if ag == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if utils.IsCtxDone(ctx) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
retriever, ok := ag.(ArtistMBIDRetriever)
|
retriever, ok := ag.(ArtistMBIDRetriever)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
return "", ErrNotFound
|
||||||
}
|
}
|
||||||
mbid, err := retriever.GetArtistMBID(ctx, id, name)
|
return 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) {
|
func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
||||||
@ -157,26 +148,14 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin
|
|||||||
case consts.VariousArtistsID:
|
case consts.VariousArtistsID:
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
start := time.Now()
|
|
||||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
return callAgentMethod(ctx, a, "GetArtistURL", func(ag Interface) (string, error) {
|
||||||
ag := a.getAgent(enabledAgent)
|
|
||||||
if ag == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if utils.IsCtxDone(ctx) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
retriever, ok := ag.(ArtistURLRetriever)
|
retriever, ok := ag.(ArtistURLRetriever)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
return "", ErrNotFound
|
||||||
}
|
}
|
||||||
url, err := retriever.GetArtistURL(ctx, id, name, mbid)
|
return 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) {
|
func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
||||||
@ -186,26 +165,14 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string)
|
|||||||
case consts.VariousArtistsID:
|
case consts.VariousArtistsID:
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
start := time.Now()
|
|
||||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
return callAgentMethod(ctx, a, "GetArtistBiography", func(ag Interface) (string, error) {
|
||||||
ag := a.getAgent(enabledAgent)
|
|
||||||
if ag == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if utils.IsCtxDone(ctx) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
retriever, ok := ag.(ArtistBiographyRetriever)
|
retriever, ok := ag.(ArtistBiographyRetriever)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
return "", ErrNotFound
|
||||||
}
|
}
|
||||||
bio, err := retriever.GetArtistBiography(ctx, id, name, mbid)
|
return 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
|
// GetSimilarArtists returns similar artists by id, name, and/or mbid. Because some artists returned from an enabled
|
||||||
@ -253,26 +220,14 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]
|
|||||||
case consts.VariousArtistsID:
|
case consts.VariousArtistsID:
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
start := time.Now()
|
|
||||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
return callAgentSliceMethod(ctx, a, "GetArtistImages", func(ag Interface) ([]ExternalImage, error) {
|
||||||
ag := a.getAgent(enabledAgent)
|
|
||||||
if ag == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if utils.IsCtxDone(ctx) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
retriever, ok := ag.(ArtistImageRetriever)
|
retriever, ok := ag.(ArtistImageRetriever)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
images, err := retriever.GetArtistImages(ctx, id, name, mbid)
|
return 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
|
// GetArtistTopSongs returns top songs by id, name, and/or mbid. Because some songs returned from an enabled
|
||||||
@ -287,77 +242,127 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str
|
|||||||
|
|
||||||
overLimit := int(float64(count) * conf.Server.DevExternalArtistFetchMultiplier)
|
overLimit := int(float64(count) * conf.Server.DevExternalArtistFetchMultiplier)
|
||||||
|
|
||||||
start := time.Now()
|
return callAgentSliceMethod(ctx, a, "GetArtistTopSongs", func(ag Interface) ([]Song, error) {
|
||||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
|
||||||
ag := a.getAgent(enabledAgent)
|
|
||||||
if ag == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if utils.IsCtxDone(ctx) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
retriever, ok := ag.(ArtistTopSongsRetriever)
|
retriever, ok := ag.(ArtistTopSongsRetriever)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
songs, err := retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit)
|
return 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) {
|
func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) {
|
||||||
if name == consts.UnknownAlbum {
|
if name == consts.UnknownAlbum {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
start := time.Now()
|
|
||||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
return callAgentMethod(ctx, a, "GetAlbumInfo", func(ag Interface) (*AlbumInfo, error) {
|
||||||
ag := a.getAgent(enabledAgent)
|
|
||||||
if ag == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if utils.IsCtxDone(ctx) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
retriever, ok := ag.(AlbumInfoRetriever)
|
retriever, ok := ag.(AlbumInfoRetriever)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
album, err := retriever.GetAlbumInfo(ctx, name, artist, mbid)
|
return 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) {
|
func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]ExternalImage, error) {
|
||||||
if name == consts.UnknownAlbum {
|
if name == consts.UnknownAlbum {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return callAgentSliceMethod(ctx, a, "GetAlbumImages", func(ag Interface) ([]ExternalImage, error) {
|
||||||
|
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()
|
start := time.Now()
|
||||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
for _, enabledAgent := range agents.getEnabledAgentNames() {
|
||||||
ag := a.getAgent(enabledAgent)
|
ag := agents.getAgent(enabledAgent)
|
||||||
if ag == nil {
|
if ag == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if utils.IsCtxDone(ctx) {
|
if utils.IsCtxDone(ctx) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
retriever, ok := ag.(AlbumImageRetriever)
|
result, err := fn(ag)
|
||||||
if !ok {
|
if err != nil {
|
||||||
|
log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
images, err := retriever.GetAlbumImages(ctx, name, artist, mbid)
|
|
||||||
if len(images) > 0 && err == nil {
|
if result != zero {
|
||||||
log.Debug(ctx, "Got Album Images", "agent", ag.AgentName(), "album", name, "artist", artist,
|
log.Debug(ctx, "Got result", "method", methodName, "agent", ag.AgentName(), "elapsed", time.Since(start))
|
||||||
"mbid", mbid, "elapsed", time.Since(start))
|
return result, nil
|
||||||
return images, 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
@ -372,3 +377,6 @@ var _ ArtistImageRetriever = (*Agents)(nil)
|
|||||||
var _ ArtistTopSongsRetriever = (*Agents)(nil)
|
var _ ArtistTopSongsRetriever = (*Agents)(nil)
|
||||||
var _ AlbumInfoRetriever = (*Agents)(nil)
|
var _ AlbumInfoRetriever = (*Agents)(nil)
|
||||||
var _ AlbumImageRetriever = (*Agents)(nil)
|
var _ AlbumImageRetriever = (*Agents)(nil)
|
||||||
|
var _ SimilarSongsByTrackRetriever = (*Agents)(nil)
|
||||||
|
var _ SimilarSongsByAlbumRetriever = (*Agents)(nil)
|
||||||
|
var _ SimilarSongsByArtistRetriever = (*Agents)(nil)
|
||||||
|
|||||||
@ -295,11 +295,77 @@ var _ = Describe("Agents", func() {
|
|||||||
Expect(mock.Args).To(BeEmpty())
|
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 {
|
type mockAgent struct {
|
||||||
Args []interface{}
|
Args []any
|
||||||
Err error
|
Err error
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -308,7 +374,7 @@ func (a *mockAgent) AgentName() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *mockAgent) GetArtistMBID(_ context.Context, id string, name string) (string, error) {
|
func (a *mockAgent) GetArtistMBID(_ context.Context, id string, name string) (string, error) {
|
||||||
a.Args = []interface{}{id, name}
|
a.Args = []any{id, name}
|
||||||
if a.Err != nil {
|
if a.Err != nil {
|
||||||
return "", a.Err
|
return "", a.Err
|
||||||
}
|
}
|
||||||
@ -316,7 +382,7 @@ func (a *mockAgent) GetArtistMBID(_ context.Context, id string, name string) (st
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (string, error) {
|
func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (string, error) {
|
||||||
a.Args = []interface{}{id, name, mbid}
|
a.Args = []any{id, name, mbid}
|
||||||
if a.Err != nil {
|
if a.Err != nil {
|
||||||
return "", a.Err
|
return "", a.Err
|
||||||
}
|
}
|
||||||
@ -324,7 +390,7 @@ func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string) (string, error) {
|
func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string) (string, error) {
|
||||||
a.Args = []interface{}{id, name, mbid}
|
a.Args = []any{id, name, mbid}
|
||||||
if a.Err != nil {
|
if a.Err != nil {
|
||||||
return "", a.Err
|
return "", a.Err
|
||||||
}
|
}
|
||||||
@ -332,7 +398,7 @@ func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *mockAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) {
|
func (a *mockAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) {
|
||||||
a.Args = []interface{}{id, name, mbid}
|
a.Args = []any{id, name, mbid}
|
||||||
if a.Err != nil {
|
if a.Err != nil {
|
||||||
return nil, a.Err
|
return nil, a.Err
|
||||||
}
|
}
|
||||||
@ -343,7 +409,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) {
|
func (a *mockAgent) GetSimilarArtists(_ context.Context, id, name, mbid string, limit int) ([]Artist, error) {
|
||||||
a.Args = []interface{}{id, name, mbid, limit}
|
a.Args = []any{id, name, mbid, limit}
|
||||||
if a.Err != nil {
|
if a.Err != nil {
|
||||||
return nil, a.Err
|
return nil, a.Err
|
||||||
}
|
}
|
||||||
@ -354,7 +420,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) {
|
func (a *mockAgent) GetArtistTopSongs(_ context.Context, id, artistName, mbid string, count int) ([]Song, error) {
|
||||||
a.Args = []interface{}{id, artistName, mbid, count}
|
a.Args = []any{id, artistName, mbid, count}
|
||||||
if a.Err != nil {
|
if a.Err != nil {
|
||||||
return nil, a.Err
|
return nil, a.Err
|
||||||
}
|
}
|
||||||
@ -365,7 +431,7 @@ func (a *mockAgent) GetArtistTopSongs(_ context.Context, id, artistName, mbid st
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) {
|
func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) {
|
||||||
a.Args = []interface{}{name, artist, mbid}
|
a.Args = []any{name, artist, mbid}
|
||||||
if a.Err != nil {
|
if a.Err != nil {
|
||||||
return nil, a.Err
|
return nil, a.Err
|
||||||
}
|
}
|
||||||
@ -377,6 +443,39 @@ func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string)
|
|||||||
}, nil
|
}, 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 {
|
type emptyAgent struct {
|
||||||
Interface
|
Interface
|
||||||
}
|
}
|
||||||
@ -389,12 +488,12 @@ type testImageAgent struct {
|
|||||||
Name string
|
Name string
|
||||||
Images []ExternalImage
|
Images []ExternalImage
|
||||||
Err error
|
Err error
|
||||||
Args []interface{}
|
Args []any
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *testImageAgent) AgentName() string { return t.Name }
|
func (t *testImageAgent) AgentName() string { return t.Name }
|
||||||
|
|
||||||
func (t *testImageAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) {
|
func (t *testImageAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) {
|
||||||
t.Args = []interface{}{id, name, mbid}
|
t.Args = []any{id, name, mbid}
|
||||||
return t.Images, t.Err
|
return t.Images, t.Err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ type AlbumInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Artist struct {
|
type Artist struct {
|
||||||
|
ID string
|
||||||
Name string
|
Name string
|
||||||
MBID string
|
MBID string
|
||||||
}
|
}
|
||||||
@ -32,8 +33,15 @@ type ExternalImage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Song struct {
|
type Song struct {
|
||||||
Name string
|
ID string
|
||||||
MBID string
|
Name string
|
||||||
|
MBID string
|
||||||
|
ISRC string
|
||||||
|
Artist string
|
||||||
|
ArtistMBID string
|
||||||
|
Album string
|
||||||
|
AlbumMBID string
|
||||||
|
Duration uint32 // Duration in milliseconds, 0 means unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -74,6 +82,41 @@ type ArtistTopSongsRetriever interface {
|
|||||||
GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error)
|
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
|
var Map map[string]Constructor
|
||||||
|
|
||||||
func Register(name string, init Constructor) {
|
func Register(name string, init Constructor) {
|
||||||
|
|||||||
@ -1,165 +0,0 @@
|
|||||||
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))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,179 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,120 +0,0 @@
|
|||||||
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))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,116 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@ -1,131 +0,0 @@
|
|||||||
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())
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
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"`
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
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"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -10,9 +10,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
|
"github.com/navidrome/navidrome/core/stream"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
"github.com/navidrome/navidrome/utils/slice"
|
||||||
|
"github.com/navidrome/navidrome/utils/str"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Archiver interface {
|
type Archiver interface {
|
||||||
@ -22,13 +24,13 @@ type Archiver interface {
|
|||||||
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewArchiver(ms MediaStreamer, ds model.DataStore, shares Share) Archiver {
|
func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share) Archiver {
|
||||||
return &archiver{ds: ds, ms: ms, shares: shares}
|
return &archiver{ds: ds, ms: ms, shares: shares}
|
||||||
}
|
}
|
||||||
|
|
||||||
type archiver struct {
|
type archiver struct {
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
ms MediaStreamer
|
ms stream.MediaStreamer
|
||||||
shares Share
|
shares Share
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,7 +88,7 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc
|
|||||||
if isMultiDisc {
|
if isMultiDisc {
|
||||||
file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file)
|
file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s/%s", sanitizeName(mf.Album), file)
|
return fmt.Sprintf("%s/%s", str.SanitizeFilename(mf.Album), file)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error {
|
func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error {
|
||||||
@ -125,7 +127,7 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st
|
|||||||
|
|
||||||
// Add M3U file if requested
|
// Add M3U file if requested
|
||||||
if addM3U && len(zippedMfs) > 0 {
|
if addM3U && len(zippedMfs) > 0 {
|
||||||
plsName := sanitizeName(name)
|
plsName := str.SanitizeFilename(name)
|
||||||
w, err := z.CreateHeader(&zip.FileHeader{
|
w, err := z.CreateHeader(&zip.FileHeader{
|
||||||
Name: plsName + ".m3u",
|
Name: plsName + ".m3u",
|
||||||
Modified: mfs[0].UpdatedAt,
|
Modified: mfs[0].UpdatedAt,
|
||||||
@ -155,11 +157,7 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int)
|
|||||||
if format != "" && format != "raw" {
|
if format != "" && format != "raw" {
|
||||||
ext = format
|
ext = format
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, sanitizeName(mf.Artist), sanitizeName(mf.Title), ext)
|
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(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 {
|
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error {
|
||||||
@ -176,7 +174,7 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
|||||||
|
|
||||||
var r io.ReadCloser
|
var r io.ReadCloser
|
||||||
if format != "raw" && format != "" {
|
if format != "raw" && format != "" {
|
||||||
r, err = a.ms.DoStream(ctx, &mf, format, bitrate, 0)
|
r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
|
||||||
} else {
|
} else {
|
||||||
r, err = os.Open(path)
|
r, err = os.Open(path)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
|
"github.com/navidrome/navidrome/core/stream"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
@ -44,7 +45,7 @@ var _ = Describe("Archiver", func() {
|
|||||||
}}).Return(mfs, nil)
|
}}).Return(mfs, nil)
|
||||||
|
|
||||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
|
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
out := new(bytes.Buffer)
|
||||||
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
|
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
|
||||||
@ -73,7 +74,7 @@ var _ = Describe("Archiver", func() {
|
|||||||
}}).Return(mfs, nil)
|
}}).Return(mfs, nil)
|
||||||
|
|
||||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
out := new(bytes.Buffer)
|
||||||
err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out)
|
err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out)
|
||||||
@ -104,7 +105,7 @@ var _ = Describe("Archiver", func() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sh.On("Load", mock.Anything, "1").Return(share, nil)
|
sh.On("Load", mock.Anything, "1").Return(share, nil)
|
||||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
out := new(bytes.Buffer)
|
||||||
err := arch.ZipShare(context.Background(), "1", out)
|
err := arch.ZipShare(context.Background(), "1", out)
|
||||||
@ -136,7 +137,7 @@ var _ = Describe("Archiver", func() {
|
|||||||
plRepo := &mockPlaylistRepository{}
|
plRepo := &mockPlaylistRepository{}
|
||||||
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
|
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
|
||||||
ds.On("Playlist", mock.Anything).Return(plRepo)
|
ds.On("Playlist", mock.Anything).Return(plRepo)
|
||||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
out := new(bytes.Buffer)
|
||||||
err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)
|
err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)
|
||||||
@ -214,15 +215,15 @@ func (m *mockPlaylistRepository) GetWithTracks(id string, refreshSmartPlaylists,
|
|||||||
|
|
||||||
type mockMediaStreamer struct {
|
type mockMediaStreamer struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
core.MediaStreamer
|
stream.MediaStreamer
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*core.Stream, error) {
|
func (m *mockMediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) {
|
||||||
args := m.Called(ctx, mf, reqFormat, reqBitRate, reqOffset)
|
args := m.Called(ctx, mf, req)
|
||||||
if args.Error(1) != nil {
|
if args.Error(1) != nil {
|
||||||
return nil, args.Error(1)
|
return nil, args.Error(1)
|
||||||
}
|
}
|
||||||
return &core.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
|
return &stream.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type mockShare struct {
|
type mockShare struct {
|
||||||
|
|||||||
120
core/artwork/animation.go
Normal file
120
core/artwork/animation.go
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
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
core/artwork/animation_test.go
Normal file
161
core/artwork/animation_test.go
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
@ -122,6 +122,10 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
|
|||||||
artReader, err = newMediafileArtworkReader(ctx, a, artID)
|
artReader, err = newMediafileArtworkReader(ctx, a, artID)
|
||||||
case model.KindPlaylistArtwork:
|
case model.KindPlaylistArtwork:
|
||||||
artReader, err = newPlaylistArtworkReader(ctx, a, artID)
|
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:
|
default:
|
||||||
return nil, ErrUnavailable
|
return nil, ErrUnavailable
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,9 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/gen2brain/webp"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
@ -25,7 +27,7 @@ var _ = Describe("Artwork", func() {
|
|||||||
var ffmpeg *tests.MockFFmpeg
|
var ffmpeg *tests.MockFFmpeg
|
||||||
var folderRepo *fakeFolderRepo
|
var folderRepo *fakeFolderRepo
|
||||||
ctx := log.NewContext(context.TODO())
|
ctx := log.NewContext(context.TODO())
|
||||||
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers model.Album
|
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers, alSingleDisc model.Album
|
||||||
var arMultipleCovers model.Artist
|
var arMultipleCovers model.Artist
|
||||||
var mfWithEmbed, mfAnotherWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile
|
var mfWithEmbed, mfAnotherWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile
|
||||||
|
|
||||||
@ -35,14 +37,20 @@ var _ = Describe("Artwork", func() {
|
|||||||
conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*"
|
conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*"
|
||||||
|
|
||||||
folderRepo = &fakeFolderRepo{}
|
folderRepo = &fakeFolderRepo{}
|
||||||
|
libRepo := &tests.MockLibraryRepo{}
|
||||||
|
repoRoot, _ := os.Getwd()
|
||||||
|
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
|
||||||
ds = &tests.MockDataStore{
|
ds = &tests.MockDataStore{
|
||||||
MockedTranscoding: &tests.MockTranscodingRepo{},
|
MockedTranscoding: &tests.MockTranscodingRepo{},
|
||||||
MockedFolder: folderRepo,
|
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"}}
|
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"}}
|
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"}}
|
alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "", 2: ""}}
|
||||||
alExternalNotFound = model.Album{ID: "555", Name: "External not found", FolderIDs: []string{"f2"}}
|
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"}
|
arMultipleCovers = model.Artist{ID: "777", Name: "All options"}
|
||||||
alMultipleCovers = model.Album{
|
alMultipleCovers = model.Album{
|
||||||
ID: "666",
|
ID: "666",
|
||||||
@ -142,13 +150,61 @@ var _ = Describe("Artwork", func() {
|
|||||||
Entry(nil, " embedded , front.* , cover.*,folder.*", "tests/fixtures/artist/an-album/test.mp3"),
|
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() {
|
Describe("artistArtworkReader", func() {
|
||||||
Context("Multiple covers", func() {
|
Context("Multiple covers", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
repoRoot, err := os.Getwd()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
folderRepo.result = []model.Folder{{
|
folderRepo.result = []model.Folder{{
|
||||||
Path: "tests/fixtures/artist/an-album",
|
LibraryPath: testFileLibPath(repoRoot),
|
||||||
ImageFiles: []string{"artist.png"},
|
Path: "tests/fixtures/artist/an-album",
|
||||||
|
ImageFiles: []string{"artist.png"},
|
||||||
}}
|
}}
|
||||||
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{
|
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{
|
||||||
arMultipleCovers,
|
arMultipleCovers,
|
||||||
@ -167,7 +223,7 @@ var _ = Describe("Artwork", func() {
|
|||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
_, path, err := aw.Reader(ctx)
|
_, path, err := aw.Reader(ctx)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(path).To(Equal(expected))
|
Expect(filepath.ToSlash(path)).To(HaveSuffix(expected))
|
||||||
},
|
},
|
||||||
Entry(nil, " folder.* , artist.*,album/artist.*", "tests/fixtures/artist/artist.jpg"),
|
Entry(nil, " folder.* , artist.*,album/artist.*", "tests/fixtures/artist/artist.jpg"),
|
||||||
Entry(nil, "album/artist.*, folder.*,artist.*", "tests/fixtures/artist/an-album/artist.png"),
|
Entry(nil, "album/artist.*, folder.*,artist.*", "tests/fixtures/artist/an-album/artist.png"),
|
||||||
@ -190,6 +246,7 @@ var _ = Describe("Artwork", func() {
|
|||||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||||
alOnlyEmbed,
|
alOnlyEmbed,
|
||||||
alOnlyExternal,
|
alOnlyExternal,
|
||||||
|
alSingleDisc,
|
||||||
})
|
})
|
||||||
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||||
mfWithEmbed,
|
mfWithEmbed,
|
||||||
@ -233,8 +290,137 @@ var _ = Describe("Artwork", func() {
|
|||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(path).To(Equal("al-444_0"))
|
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() {
|
Describe("resizedArtworkReader", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
folderRepo.result = []model.Folder{{
|
folderRepo.result = []model.Folder{{
|
||||||
@ -246,7 +432,7 @@ var _ = Describe("Artwork", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
When("Square is false", func() {
|
When("Square is false", func() {
|
||||||
It("returns a PNG if original image is a PNG", func() {
|
It("returns PNG if original image is a PNG", func() {
|
||||||
conf.Server.CoverArtPriority = "front.png"
|
conf.Server.CoverArtPriority = "front.png"
|
||||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
@ -257,7 +443,7 @@ var _ = Describe("Artwork", func() {
|
|||||||
Expect(img.Bounds().Size().X).To(Equal(15))
|
Expect(img.Bounds().Size().X).To(Equal(15))
|
||||||
Expect(img.Bounds().Size().Y).To(Equal(15))
|
Expect(img.Bounds().Size().Y).To(Equal(15))
|
||||||
})
|
})
|
||||||
It("returns a JPEG if original image is not a PNG", func() {
|
It("returns JPEG if original image is not a PNG", func() {
|
||||||
conf.Server.CoverArtPriority = "cover.jpg"
|
conf.Server.CoverArtPriority = "cover.jpg"
|
||||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
|
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
@ -273,15 +459,18 @@ var _ = Describe("Artwork", func() {
|
|||||||
var alCover model.Album
|
var alCover model.Album
|
||||||
|
|
||||||
DescribeTable("resize",
|
DescribeTable("resize",
|
||||||
func(format string, landscape bool, size int) {
|
func(srcFormat string, expectedFormat string, landscape bool, size int) {
|
||||||
coverFileName := "cover." + format
|
coverFileName := "cover." + srcFormat
|
||||||
dirName := createImage(format, landscape, size)
|
dirName := createImage(srcFormat, landscape, size)
|
||||||
alCover = model.Album{
|
alCover = model.Album{
|
||||||
ID: "444",
|
ID: "444",
|
||||||
Name: "Only external",
|
Name: "Only external",
|
||||||
FolderIDs: []string{"tmp"},
|
FolderIDs: []string{"tmp"},
|
||||||
}
|
}
|
||||||
folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{coverFileName}}}
|
folderRepo.result = []model.Folder{{ImageFiles: []string{coverFileName}}}
|
||||||
|
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{
|
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||||
alCover,
|
alCover,
|
||||||
})
|
})
|
||||||
@ -292,16 +481,127 @@ var _ = Describe("Artwork", func() {
|
|||||||
|
|
||||||
img, format, err := image.Decode(r)
|
img, format, err := image.Decode(r)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(format).To(Equal("png"))
|
Expect(format).To(Equal(expectedFormat))
|
||||||
Expect(img.Bounds().Size().X).To(Equal(size))
|
Expect(img.Bounds().Size().X).To(Equal(size))
|
||||||
Expect(img.Bounds().Size().Y).To(Equal(size))
|
Expect(img.Bounds().Size().Y).To(Equal(size))
|
||||||
},
|
},
|
||||||
Entry("portrait png image", "png", false, 200),
|
Entry("portrait png image", "png", "png", false, 200),
|
||||||
Entry("landscape png image", "png", true, 200),
|
Entry("landscape png image", "png", "png", true, 200),
|
||||||
Entry("portrait jpg image", "jpg", false, 200),
|
Entry("portrait jpg image", "jpg", "png", false, 200),
|
||||||
Entry("landscape jpg image", "jpg", true, 200),
|
Entry("landscape jpg image", "jpg", "png", 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))
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,17 @@
|
|||||||
package artwork
|
package artwork
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/core/storage"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/model/metadata"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
@ -15,3 +23,49 @@ func TestArtwork(t *testing.T) {
|
|||||||
RegisterFailHandler(Fail)
|
RegisterFailHandler(Fail)
|
||||||
RunSpecs(t, "Artwork Suite")
|
RunSpecs(t, "Artwork Suite")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// osDirFS wraps os.DirFS as a storage.MusicFS for integration tests.
|
||||||
|
// ReadTags is not used by albumArtworkReader, so it is left as a stub.
|
||||||
|
type osDirFS struct{ fs.FS }
|
||||||
|
|
||||||
|
func (o osDirFS) ReadTags(...string) (map[string]metadata.Info, error) { return nil, nil }
|
||||||
|
|
||||||
|
// testFileScheme is the URL scheme registered to expose a tempdir as a
|
||||||
|
// storage.MusicFS for artwork integration tests.
|
||||||
|
const testFileScheme = "testfile"
|
||||||
|
|
||||||
|
// testFileLibPath builds a `testfile://` library URL for the given absolute
|
||||||
|
// filesystem path. On Windows, the native path (e.g. `C:\foo`) has no leading
|
||||||
|
// slash after ToSlash, which makes url.Parse treat the drive letter as a
|
||||||
|
// host. We prepend a `/` so parsing yields `u.Path == /C:/foo`, and the
|
||||||
|
// registered constructor below strips that leading slash back off.
|
||||||
|
func testFileLibPath(absPath string) string {
|
||||||
|
p := filepath.ToSlash(absPath)
|
||||||
|
if !strings.HasPrefix(p, "/") {
|
||||||
|
p = "/" + p
|
||||||
|
}
|
||||||
|
return testFileScheme + "://" + p
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Register the testfile storage scheme (os.DirFS-backed MusicFS). Used by
|
||||||
|
// integration tests that need real files but not the taglib extractor.
|
||||||
|
storage.Register(testFileScheme, func(u url.URL) storage.Storage {
|
||||||
|
root := u.Path
|
||||||
|
// Undo the leading slash added by testFileLibPath on Windows so that
|
||||||
|
// os.Stat / os.DirFS receive a native path like `C:\foo`.
|
||||||
|
if runtime.GOOS == "windows" && len(root) >= 3 && root[0] == '/' && root[2] == ':' {
|
||||||
|
root = root[1:]
|
||||||
|
}
|
||||||
|
return &osDirStorage{root: filepath.FromSlash(root)}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type osDirStorage struct{ root string }
|
||||||
|
|
||||||
|
func (s *osDirStorage) FS() (storage.MusicFS, error) {
|
||||||
|
if _, err := os.Stat(s.root); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return osDirFS{os.DirFS(s.root)}, nil
|
||||||
|
}
|
||||||
|
|||||||
37
core/artwork/benchmark_decode_test.go
Normal file
37
core/artwork/benchmark_decode_test.go
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
package artwork
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BenchmarkImageDecode(b *testing.B) {
|
||||||
|
sizes := []int{300, 1000, 3000}
|
||||||
|
formats := []struct {
|
||||||
|
name string
|
||||||
|
gen func(tb testing.TB, w, h int) []byte
|
||||||
|
}{
|
||||||
|
{"jpeg", func(tb testing.TB, w, h int) []byte { return generateJPEG(tb, w, h, 75) }},
|
||||||
|
{"png", func(tb testing.TB, w, h int) []byte { return generatePNG(tb, w, h) }},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, format := range formats {
|
||||||
|
for _, size := range sizes {
|
||||||
|
data := format.gen(b, size, size)
|
||||||
|
b.Run(fmt.Sprintf("%s/%dx%d", format.name, size, size), func(b *testing.B) {
|
||||||
|
b.SetBytes(int64(len(data)))
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_, _, err := image.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
189
core/artwork/benchmark_e2e_test.go
Normal file
189
core/artwork/benchmark_e2e_test.go
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
package artwork
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"image/jpeg"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
|
"github.com/navidrome/navidrome/utils/cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupE2EBenchmark creates an artwork instance with a real album cover image on disk,
|
||||||
|
// backed by either a real file cache or disabled cache depending on cacheSize.
|
||||||
|
// Note: This benchmarks artwork.Get() directly (not the full HTTP handler), which covers
|
||||||
|
// the critical path (source selection, decode, resize, encode, cache). This is a deliberate
|
||||||
|
// spec deviation — the full HTTP round-trip benchmark requires significant infrastructure
|
||||||
|
// (DB, scanner, fake filesystem) and can be added later if HTTP overhead proves significant.
|
||||||
|
//
|
||||||
|
// Depends on fakeFolderRepo defined in reader_artist_test.go (same package, compiled together).
|
||||||
|
func setupE2EBenchmark(b *testing.B, cacheSize string) (Artwork, model.ArtworkID, func()) {
|
||||||
|
b.Helper()
|
||||||
|
cleanup := configtest.SetupConfig()
|
||||||
|
b.Cleanup(cleanup)
|
||||||
|
|
||||||
|
tmpDir, err := os.MkdirTemp("", "artwork-bench-*")
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a realistic cover image on disk
|
||||||
|
coverPath := filepath.Join(tmpDir, "cover.jpg")
|
||||||
|
coverImg := generateGradientImage(1000, 1000)
|
||||||
|
f, err := os.Create(coverPath)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := jpeg.Encode(f, coverImg, &jpeg.Options{Quality: 90}); err != nil {
|
||||||
|
f.Close()
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
// Configure cache
|
||||||
|
conf.Server.ImageCacheSize = cacheSize
|
||||||
|
conf.Server.CacheFolder = tmpDir
|
||||||
|
conf.Server.CoverArtQuality = 75
|
||||||
|
conf.Server.CoverArtPriority = "cover.*"
|
||||||
|
|
||||||
|
// Set up mock data store with album pointing to our cover.
|
||||||
|
// Set UpdatedAt so CoverArtID().LastUpdate is consistent across calls.
|
||||||
|
album := model.Album{
|
||||||
|
ID: "bench-album-1",
|
||||||
|
Name: "Benchmark Album",
|
||||||
|
FolderIDs: []string{"f1"},
|
||||||
|
UpdatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
folderRepo := &fakeFolderRepo{
|
||||||
|
result: []model.Folder{{
|
||||||
|
Path: tmpDir,
|
||||||
|
ImageFiles: []string{"cover.jpg"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
ds := &tests.MockDataStore{
|
||||||
|
MockedTranscoding: &tests.MockTranscodingRepo{},
|
||||||
|
MockedFolder: folderRepo,
|
||||||
|
}
|
||||||
|
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{album})
|
||||||
|
|
||||||
|
artID := album.CoverArtID()
|
||||||
|
|
||||||
|
imgCache := cache.NewFileCache("BenchImage", cacheSize, "bench-images", 0,
|
||||||
|
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
|
||||||
|
r, _, err := arg.(artworkReader).Reader(ctx)
|
||||||
|
return r, err
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait for cache init if enabled
|
||||||
|
if cacheSize != "0" {
|
||||||
|
for !imgCache.Available(context.Background()) && !imgCache.Disabled(context.Background()) {
|
||||||
|
runtime.Gosched() // Yield to allow background init goroutine to run
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ffmpeg := tests.NewMockFFmpeg("fallback content")
|
||||||
|
aw := NewArtwork(ds, imgCache, ffmpeg, nil)
|
||||||
|
|
||||||
|
cleanupAll := func() {
|
||||||
|
os.RemoveAll(tmpDir)
|
||||||
|
}
|
||||||
|
return aw, artID, cleanupAll
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkArtworkGetE2E(b *testing.B) {
|
||||||
|
cacheConfigs := []struct {
|
||||||
|
name string
|
||||||
|
cacheSize string
|
||||||
|
}{
|
||||||
|
{"no_cache", "0"},
|
||||||
|
{"with_cache", "100MB"},
|
||||||
|
}
|
||||||
|
sizes := []int{0, 300}
|
||||||
|
|
||||||
|
for _, cc := range cacheConfigs {
|
||||||
|
for _, size := range sizes {
|
||||||
|
b.Run(fmt.Sprintf("%s/size_%d", cc.name, size), func(b *testing.B) {
|
||||||
|
aw, artID, cleanup := setupE2EBenchmark(b, cc.cacheSize)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Warm the cache on first call if cache is enabled
|
||||||
|
if cc.cacheSize != "0" {
|
||||||
|
r, _, err := aw.Get(context.Background(), artID, size, size > 0)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = io.ReadAll(r)
|
||||||
|
r.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
r, _, err := aw.Get(context.Background(), artID, size, size > 0)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = io.ReadAll(r)
|
||||||
|
r.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkArtworkGetE2EConcurrent(b *testing.B) {
|
||||||
|
cacheConfigs := []struct {
|
||||||
|
name string
|
||||||
|
cacheSize string
|
||||||
|
}{
|
||||||
|
{"no_cache", "0"},
|
||||||
|
{"with_cache", "100MB"},
|
||||||
|
}
|
||||||
|
concurrencyLevels := []int{10, 50}
|
||||||
|
|
||||||
|
for _, cc := range cacheConfigs {
|
||||||
|
for _, n := range concurrencyLevels {
|
||||||
|
b.Run(fmt.Sprintf("%s/goroutines_%d", cc.name, n), func(b *testing.B) {
|
||||||
|
aw, artID, cleanup := setupE2EBenchmark(b, cc.cacheSize)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Warm cache
|
||||||
|
if cc.cacheSize != "0" {
|
||||||
|
r, _, _ := aw.Get(context.Background(), artID, 300, true)
|
||||||
|
if r != nil {
|
||||||
|
_, _ = io.ReadAll(r)
|
||||||
|
r.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(n)
|
||||||
|
for g := 0; g < n; g++ {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
r, _, err := aw.Get(context.Background(), artID, 300, true)
|
||||||
|
if err != nil {
|
||||||
|
b.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.ReadAll(r)
|
||||||
|
r.Close()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
core/artwork/benchmark_encode_test.go
Normal file
40
core/artwork/benchmark_encode_test.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package artwork
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BenchmarkImageEncode(b *testing.B) {
|
||||||
|
img := generateGradientImage(300, 300)
|
||||||
|
|
||||||
|
jpegQualities := []int{60, 75, 90}
|
||||||
|
for _, q := range jpegQualities {
|
||||||
|
b.Run(fmt.Sprintf("jpeg/q%d/300x300", q), func(b *testing.B) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
buf.Reset()
|
||||||
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: q}); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.ReportMetric(float64(buf.Len()), "bytes")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Run("png/300x300", func(b *testing.B) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
buf.Reset()
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.ReportMetric(float64(buf.Len()), "bytes")
|
||||||
|
})
|
||||||
|
}
|
||||||
47
core/artwork/benchmark_helpers_test.go
Normal file
47
core/artwork/benchmark_helpers_test.go
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
package artwork
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// generateJPEG creates a JPEG image of the given dimensions with a gradient pattern.
|
||||||
|
// The gradient ensures the image has realistic entropy (not trivially compressible).
|
||||||
|
func generateJPEG(t testing.TB, width, height, quality int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
img := generateGradientImage(width, height)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// generatePNG creates a PNG image of the given dimensions with a gradient pattern.
|
||||||
|
func generatePNG(t testing.TB, width, height int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
img := generateGradientImage(width, height)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateGradientImage creates an RGBA image with a diagonal gradient pattern.
|
||||||
|
func generateGradientImage(width, height int) *image.RGBA {
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
|
for y := 0; y < height; y++ {
|
||||||
|
for x := 0; x < width; x++ {
|
||||||
|
r := uint8((x * 255) / width)
|
||||||
|
g := uint8((y * 255) / height)
|
||||||
|
b := uint8(((x + y) * 255) / (width + height))
|
||||||
|
img.Set(x, y, color.RGBA{R: r, G: g, B: b, A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
50
core/artwork/benchmark_pipeline_test.go
Normal file
50
core/artwork/benchmark_pipeline_test.go
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package artwork
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BenchmarkResizeFullPipeline(b *testing.B) {
|
||||||
|
cleanup := configtest.SetupConfig()
|
||||||
|
b.Cleanup(cleanup)
|
||||||
|
conf.Server.CoverArtQuality = 75
|
||||||
|
|
||||||
|
sourceSizes := []int{1000, 3000}
|
||||||
|
targetSize := 300
|
||||||
|
|
||||||
|
for _, srcSize := range sourceSizes {
|
||||||
|
jpegData := generateJPEG(b, srcSize, srcSize, 90)
|
||||||
|
|
||||||
|
b.Run(fmt.Sprintf("jpeg/%dx%d_to_%d", srcSize, srcSize, targetSize), func(b *testing.B) {
|
||||||
|
b.SetBytes(int64(len(jpegData)))
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
result, _, err := resizeStaticImage(jpegData, targetSize, false)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
b.Fatal("expected non-nil resized image")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run(fmt.Sprintf("jpeg/%dx%d_to_%d_square", srcSize, srcSize, targetSize), func(b *testing.B) {
|
||||||
|
b.SetBytes(int64(len(jpegData)))
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
result, _, err := resizeStaticImage(jpegData, targetSize, true)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
b.Fatal("expected non-nil resized image")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
38
core/artwork/benchmark_tag_test.go
Normal file
38
core/artwork/benchmark_tag_test.go
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
package artwork
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.senan.xyz/taglib"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BenchmarkTagExtraction(b *testing.B) {
|
||||||
|
// Ensure working directory is the project root (tests.Init not called with -run='^$')
|
||||||
|
_, file, _, ok := runtime.Caller(0)
|
||||||
|
if !ok {
|
||||||
|
b.Fatal("runtime.Caller failed")
|
||||||
|
}
|
||||||
|
appPath, _ := filepath.Abs(filepath.Join(filepath.Dir(file), "..", ".."))
|
||||||
|
|
||||||
|
// Use existing test fixture with embedded artwork
|
||||||
|
testFile := filepath.Join(appPath, "tests/fixtures/artist/an-album/test.mp3")
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
f, err := taglib.OpenReadOnly(testFile, taglib.WithReadStyle(taglib.ReadStyleFast))
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
images := f.Properties().Images
|
||||||
|
if len(images) == 0 {
|
||||||
|
b.Fatal("no images found in test file")
|
||||||
|
}
|
||||||
|
data, err := f.Image(0)
|
||||||
|
if err != nil || len(data) == 0 {
|
||||||
|
b.Fatal("failed to extract image data")
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,7 +10,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/consts"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
"github.com/navidrome/navidrome/model/request"
|
||||||
@ -24,7 +23,7 @@ type CacheWarmer interface {
|
|||||||
|
|
||||||
// NewCacheWarmer creates a new CacheWarmer instance. The CacheWarmer will pre-cache Artwork images in the background
|
// NewCacheWarmer creates a new CacheWarmer instance. The CacheWarmer will pre-cache Artwork images in the background
|
||||||
// to speed up the response time when the image is requested by the UI. The cache is pre-populated with the original
|
// to speed up the response time when the image is requested by the UI. The cache is pre-populated with the original
|
||||||
// image size, as well as the size defined in the UICoverArtSize constant.
|
// image size, as well as the size defined by the UICoverArtSize config option.
|
||||||
func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
||||||
// If image cache is disabled, return a NOOP implementation
|
// If image cache is disabled, return a NOOP implementation
|
||||||
if conf.Server.ImageCacheSize == "0" || !conf.Server.EnableArtworkPrecache {
|
if conf.Server.ImageCacheSize == "0" || !conf.Server.EnableArtworkPrecache {
|
||||||
@ -38,10 +37,11 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a := &cacheWarmer{
|
a := &cacheWarmer{
|
||||||
artwork: artwork,
|
artwork: artwork,
|
||||||
cache: cache,
|
cache: cache,
|
||||||
buffer: make(map[model.ArtworkID]struct{}),
|
buffer: make(map[model.ArtworkID]struct{}),
|
||||||
wakeSignal: make(chan struct{}, 1),
|
wakeSignal: make(chan struct{}, 1),
|
||||||
|
coverArtSize: conf.Server.UICoverArtSize,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a context with a fake admin user, to be able to pre-cache Playlist CoverArts
|
// Create a context with a fake admin user, to be able to pre-cache Playlist CoverArts
|
||||||
@ -51,11 +51,12 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type cacheWarmer struct {
|
type cacheWarmer struct {
|
||||||
artwork Artwork
|
artwork Artwork
|
||||||
buffer map[model.ArtworkID]struct{}
|
buffer map[model.ArtworkID]struct{}
|
||||||
mutex sync.Mutex
|
mutex sync.Mutex
|
||||||
cache cache.FileCache
|
cache cache.FileCache
|
||||||
wakeSignal chan struct{}
|
wakeSignal chan struct{}
|
||||||
|
coverArtSize int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *cacheWarmer) PreCache(artID model.ArtworkID) {
|
func (a *cacheWarmer) PreCache(artID model.ArtworkID) {
|
||||||
@ -132,7 +133,7 @@ func (a *cacheWarmer) waitSignal(ctx context.Context, timeout time.Duration) {
|
|||||||
func (a *cacheWarmer) processBatch(ctx context.Context, batch []model.ArtworkID) {
|
func (a *cacheWarmer) processBatch(ctx context.Context, batch []model.ArtworkID) {
|
||||||
log.Trace(ctx, "PreCaching a new batch of artwork", "batchSize", len(batch))
|
log.Trace(ctx, "PreCaching a new batch of artwork", "batchSize", len(batch))
|
||||||
input := pl.FromSlice(ctx, batch)
|
input := pl.FromSlice(ctx, batch)
|
||||||
errs := pl.Sink(ctx, 2, input, a.doCacheImage)
|
errs := pl.Sink(ctx, 4, input, a.doCacheImage)
|
||||||
for err := range errs {
|
for err := range errs {
|
||||||
log.Debug(ctx, "Error warming cache", err)
|
log.Debug(ctx, "Error warming cache", err)
|
||||||
}
|
}
|
||||||
@ -142,16 +143,14 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro
|
|||||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize, true)
|
size := a.coverArtSize
|
||||||
|
r, _, err := a.artwork.Get(ctx, id, size, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("caching id='%s': %w", id, err)
|
return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err)
|
||||||
}
|
}
|
||||||
defer r.Close()
|
|
||||||
_, err = io.Copy(io.Discard, r)
|
_, err = io.Copy(io.Discard, r)
|
||||||
if err != nil {
|
r.Close()
|
||||||
return err
|
return err
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NoopCacheWarmer() CacheWarmer {
|
func NoopCacheWarmer() CacheWarmer {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -143,7 +144,7 @@ var _ = Describe("CacheWarmer", func() {
|
|||||||
|
|
||||||
It("processes items in batches", func() {
|
It("processes items in batches", func() {
|
||||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||||
for i := 0; i < 5; i++ {
|
for i := range 5 {
|
||||||
cw.PreCache(model.MustParseArtworkID(fmt.Sprintf("al-%d", i)))
|
cw.PreCache(model.MustParseArtworkID(fmt.Sprintf("al-%d", i)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,20 +174,42 @@ var _ = Describe("CacheWarmer", func() {
|
|||||||
return len(cw.buffer)
|
return len(cw.buffer)
|
||||||
}).Should(Equal(0))
|
}).Should(Equal(0))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("pre-caches UICoverArtSize", func() {
|
||||||
|
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||||
|
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||||
|
|
||||||
|
Eventually(func() []int {
|
||||||
|
return aw.getCachedSizes()
|
||||||
|
}).Should(ContainElements(conf.Server.UICoverArtSize))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
type mockArtwork struct {
|
type mockArtwork struct {
|
||||||
err error
|
err error
|
||||||
|
mu sync.Mutex
|
||||||
|
cachedSizes []int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) {
|
func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) {
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
return nil, time.Time{}, m.err
|
return nil, time.Time{}, m.err
|
||||||
}
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.cachedSizes = append(m.cachedSizes, size)
|
||||||
|
m.mu.Unlock()
|
||||||
return io.NopCloser(strings.NewReader("test")), time.Now(), nil
|
return io.NopCloser(strings.NewReader("test")), time.Now(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockArtwork) getCachedSizes() []int {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
result := make([]int, len(m.cachedSizes))
|
||||||
|
copy(result, m.cachedSizes)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) {
|
func (m *mockArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) {
|
||||||
return m.Get(ctx, model.ArtworkID{}, size, square)
|
return m.Get(ctx, model.ArtworkID{}, size, square)
|
||||||
}
|
}
|
||||||
|
|||||||
352
core/artwork/e2e/album_test.go
Normal file
352
core/artwork/e2e/album_test.go
Normal file
@ -0,0 +1,352 @@
|
|||||||
|
package artworke2e_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultCoverPriority = "cover.*, folder.*, front.*, embedded, external"
|
||||||
|
defaultDiscPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("Album artwork resolution", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
setupHarness()
|
||||||
|
})
|
||||||
|
|
||||||
|
When("an album has a single folder with cover.jpg at the album root", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// ├── 01 - Track.mp3
|
||||||
|
// └── cover.jpg ← matched by cover.*
|
||||||
|
It("returns the album-root cover", func() {
|
||||||
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
|
"Artist/Album/cover.jpg": imageFile("album-root"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Bug 2 variant: cover.* basenames tie across album-root and per-disc folders;
|
||||||
|
// compareImageFiles' lexicographic full-path tiebreaker ranks disc-subfolder
|
||||||
|
// files first.
|
||||||
|
When("a multi-disc album has a cover.jpg at the album root and per-disc covers", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// ├── CD1/
|
||||||
|
// │ ├── 01 - Track.mp3
|
||||||
|
// │ └── cover.jpg ← currently wins (bug)
|
||||||
|
// ├── CD2/
|
||||||
|
// │ ├── 01 - Track.mp3
|
||||||
|
// │ └── cover.jpg
|
||||||
|
// └── cover.jpg ← should win (album-root fallback)
|
||||||
|
It("prefers the album-root cover over per-disc covers", func() {
|
||||||
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
||||||
|
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
||||||
|
"Artist/Album/cover.jpg": imageFile("album-root"),
|
||||||
|
"Artist/Album/CD1/cover.jpg": imageFile("disc1"),
|
||||||
|
"Artist/Album/CD2/cover.jpg": imageFile("disc2"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
Expect(al.FolderIDs).To(HaveLen(2),
|
||||||
|
"sanity check: scanner should treat the two disc subfolders as one multi-disc album")
|
||||||
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Bug 2: folder.jpg basenames tie across album-root and per-disc folders;
|
||||||
|
// the lexicographic full-path tiebreaker in compareImageFiles ranks
|
||||||
|
// "Artist/Album/CD1/folder.jpg" ahead of "Artist/Album/folder.jpg".
|
||||||
|
When("a multi-disc album has folder.jpg at the album root AND in each disc subfolder", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// ├── CD1/
|
||||||
|
// │ ├── 01 - Track.mp3
|
||||||
|
// │ └── folder.jpg ← currently wins (bug)
|
||||||
|
// ├── CD2/
|
||||||
|
// │ ├── 01 - Track.mp3
|
||||||
|
// │ └── folder.jpg
|
||||||
|
// └── folder.jpg ← should win (album-root fallback)
|
||||||
|
It("prefers the album-root folder.jpg over per-disc folder.jpg", func() {
|
||||||
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
||||||
|
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
||||||
|
"Artist/Album/folder.jpg": imageFile("album-root"),
|
||||||
|
"Artist/Album/CD1/folder.jpg": imageFile("disc1"),
|
||||||
|
"Artist/Album/CD2/folder.jpg": imageFile("disc2"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Bug 1: commonParentFolder's `len(folders) < 2` guard skips the parent-folder
|
||||||
|
// lookup whenever an album lives entirely under a single subfolder, so an
|
||||||
|
// album-root cover is never considered.
|
||||||
|
When("an album lives entirely under a single disc subfolder with cover.jpg at the parent", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// ├── disc1/
|
||||||
|
// │ └── 01 - Track.mp3
|
||||||
|
// └── cover.jpg ← should win (parent-folder fallback, currently ignored — bug)
|
||||||
|
It("uses the parent-folder cover for single-disc-subfolder albums", func() {
|
||||||
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
|
"Artist/Album/cover.jpg": imageFile("album-root"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("CoverArtPriority puts embedded first and the album has both embedded and external art", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// ├── 01 - Track.mp3 ← has embedded picture (wins via "embedded")
|
||||||
|
// └── cover.jpg
|
||||||
|
It("returns the embedded image", func() {
|
||||||
|
conf.Server.CoverArtPriority = "embedded, cover.*, folder.*, front.*, external"
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
|
||||||
|
"Artist/Album/cover.jpg": imageFile("external"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
// Swap in real MP3 bytes so libFS.Open returns a taglib-readable stream.
|
||||||
|
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("CoverArtPriority lists external first but no external file is present", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// └── 01 - Track.mp3 ← has embedded picture (falls through to "embedded")
|
||||||
|
It("falls through to embedded artwork", func() {
|
||||||
|
conf.Server.CoverArtPriority = "external, embedded"
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("the only cover file uses uppercase extension and a different case in its name", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// ├── 01 - Track.mp3
|
||||||
|
// └── Cover.JPG ← matched case-insensitively by cover.*
|
||||||
|
It("matches case-insensitively against cover.*", func() {
|
||||||
|
conf.Server.CoverArtPriority = "cover.*, folder.*"
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
|
"Artist/Album/Cover.JPG": imageFile("case-insensitive"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("case-insensitive")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("two cover files have basenames that tie under the natural-sort tiebreaker", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// ├── 01 - Track.mp3
|
||||||
|
// ├── cover.jpg ← wins (no numeric suffix)
|
||||||
|
// └── cover.1.jpg
|
||||||
|
It("prefers the file without a numeric suffix", func() {
|
||||||
|
conf.Server.CoverArtPriority = "cover.*"
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
|
"Artist/Album/cover.jpg": imageFile("primary"),
|
||||||
|
"Artist/Album/cover.1.jpg": imageFile("secondary"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("the album has no cover and CoverArtPriority lists only file patterns", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// └── 01 - Track.mp3 (no image files — returns ErrUnavailable)
|
||||||
|
It("returns ErrUnavailable", func() {
|
||||||
|
conf.Server.CoverArtPriority = "cover.*, folder.*"
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
_, err := readArtworkOrErr(model.NewArtworkID(model.KindAlbumArtwork, al.ID, &al.UpdatedAt))
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Doc scenarios from:
|
||||||
|
// https://www.navidrome.org/docs/usage/library/artwork/#albums
|
||||||
|
// Default CoverArtPriority is "cover.*, folder.*, front.*, embedded, external".
|
||||||
|
When("only folder.jpg is present (cover.* and front.* missing)", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// ├── 01 - Track.mp3
|
||||||
|
// └── folder.jpg ← matched by folder.*
|
||||||
|
It("falls through to folder.jpg", func() {
|
||||||
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
|
"Artist/Album/folder.jpg": imageFile("folder"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("only front.jpg is present (cover.* and folder.* missing)", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// ├── 01 - Track.mp3
|
||||||
|
// └── front.jpg ← matched by front.*
|
||||||
|
It("falls through to front.jpg", func() {
|
||||||
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
|
"Artist/Album/front.jpg": imageFile("front"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("front")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("cover.*, folder.*, and front.* all exist in the same folder", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// ├── 01 - Track.mp3
|
||||||
|
// ├── cover.jpg ← wins (cover.* is first in priority)
|
||||||
|
// ├── folder.jpg
|
||||||
|
// └── front.jpg
|
||||||
|
It("prefers cover.* (first in CoverArtPriority)", func() {
|
||||||
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
|
"Artist/Album/cover.jpg": imageFile("cover"),
|
||||||
|
"Artist/Album/folder.jpg": imageFile("folder"),
|
||||||
|
"Artist/Album/front.jpg": imageFile("front"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("only folder.* and front.* exist (priority order check)", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// ├── 01 - Track.mp3
|
||||||
|
// ├── folder.jpg ← wins (folder.* comes before front.*)
|
||||||
|
// └── front.jpg
|
||||||
|
It("prefers folder.* over front.*", func() {
|
||||||
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
|
"Artist/Album/folder.jpg": imageFile("folder"),
|
||||||
|
"Artist/Album/front.jpg": imageFile("front"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("three cover files tie by basename and differ only by numeric suffix", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// ├── 01 - Track.mp3
|
||||||
|
// ├── cover.jpg ← wins (no numeric suffix)
|
||||||
|
// ├── cover.1.jpg
|
||||||
|
// └── cover.2.jpg
|
||||||
|
It("selects the unsuffixed file first regardless of numeric-suffix order", func() {
|
||||||
|
conf.Server.CoverArtPriority = "cover.*"
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
|
"Artist/Album/cover.2.jpg": imageFile("second"),
|
||||||
|
"Artist/Album/cover.jpg": imageFile("primary"),
|
||||||
|
"Artist/Album/cover.1.jpg": imageFile("first"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("CoverArtPriority contains an unknown pattern before a matching one", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// ├── 01 - Track.mp3
|
||||||
|
// └── cover.jpg ← wins (unknown "bogus.*" is skipped)
|
||||||
|
It("skips the unknown pattern and falls through to the matching one", func() {
|
||||||
|
conf.Server.CoverArtPriority = "bogus.*, cover.*"
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
|
"Artist/Album/cover.jpg": imageFile("cover"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("embedded is first in CoverArtPriority but the track has no embedded art", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// ├── 01 - Track.mp3 (no embedded picture)
|
||||||
|
// └── cover.jpg ← wins (embedded skipped, falls through)
|
||||||
|
It("falls through to the next priority entry", func() {
|
||||||
|
conf.Server.CoverArtPriority = "embedded, cover.*"
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
|
"Artist/Album/cover.jpg": imageFile("cover"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
167
core/artwork/e2e/artist_test.go
Normal file
167
core/artwork/e2e/artist_test.go
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
package artworke2e_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"github.com/Masterminds/squirrel"
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/consts"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Doc reference:
|
||||||
|
// https://www.navidrome.org/docs/usage/library/artwork/#artists
|
||||||
|
// Default ArtistArtPriority is "artist.*, album/artist.*, external".
|
||||||
|
var _ = Describe("Artist artwork resolution", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
setupHarness()
|
||||||
|
})
|
||||||
|
|
||||||
|
When("the artist folder contains an artist.jpg", func() {
|
||||||
|
// Artist/
|
||||||
|
// ├── artist.jpg ← matched by artist.*
|
||||||
|
// └── Album/
|
||||||
|
// └── 01 - Track.mp3
|
||||||
|
It("returns the artist.* image from the artist folder", func() {
|
||||||
|
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||||
|
"Artist/artist.jpg": imageFile("artist-folder"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
ar := soleArtist()
|
||||||
|
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||||
|
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("artist.* only exists inside an album folder", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// ├── 01 - Track.mp3
|
||||||
|
// └── artist.jpg ← matched by album/artist.*
|
||||||
|
It("falls through to album/artist.* and returns that image", func() {
|
||||||
|
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||||
|
"Artist/Album/artist.jpg": imageFile("album-artist"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
ar := soleArtist()
|
||||||
|
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||||
|
Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("both the artist folder and an album folder have an artist.* image", func() {
|
||||||
|
// Artist/
|
||||||
|
// ├── artist.jpg ← wins (artist.* before album/artist.*)
|
||||||
|
// └── Album/
|
||||||
|
// ├── 01 - Track.mp3
|
||||||
|
// └── artist.jpg
|
||||||
|
It("prefers the artist-folder image (artist.* comes before album/artist.*)", func() {
|
||||||
|
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||||
|
"Artist/artist.jpg": imageFile("artist-folder"),
|
||||||
|
"Artist/Album/artist.jpg": imageFile("album-artist"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
ar := soleArtist()
|
||||||
|
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||||
|
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("an artist has an uploaded image and a matching artist.* file", func() {
|
||||||
|
// <DataFolder>/
|
||||||
|
// └── artwork/
|
||||||
|
// └── artist/
|
||||||
|
// └── <id>_upload.jpg ← wins (uploaded image beats the priority chain)
|
||||||
|
// Library:
|
||||||
|
// Artist/
|
||||||
|
// ├── artist.jpg (ignored — uploaded image comes first)
|
||||||
|
// └── Album/
|
||||||
|
// └── 01 - Track.mp3
|
||||||
|
It("prefers the uploaded image over any priority-chain match", func() {
|
||||||
|
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||||
|
"Artist/artist.jpg": imageFile("artist-folder"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
ar := soleArtist()
|
||||||
|
|
||||||
|
uploaded := ar.ID + "_upload.jpg"
|
||||||
|
writeUploadedImage(consts.EntityArtist, uploaded, imageBytes("artist-uploaded"))
|
||||||
|
ar.UploadedImage = uploaded
|
||||||
|
Expect(ds.Artist(ctx).Put(&ar)).To(Succeed())
|
||||||
|
|
||||||
|
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||||
|
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-uploaded")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("ArtistArtPriority uses album/<arbitrary pattern> (not just album/artist.*)", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// ├── 01 - Track.mp3
|
||||||
|
// └── artist.jpg ← matched by album/artist.*
|
||||||
|
It("resolves the pattern against the artist's album image files", func() {
|
||||||
|
conf.Server.ArtistArtPriority = "album/artist.*, external"
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||||
|
"Artist/Album/artist.jpg": imageFile("album-artist"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
ar := soleArtist()
|
||||||
|
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||||
|
Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("ArtistArtPriority starts with image-folder and ArtistImageFolder has a name-matching image", func() {
|
||||||
|
// <ArtistImageFolder>/
|
||||||
|
// └── Artist.jpg ← matched by artist name (image-folder source)
|
||||||
|
// Library:
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// └── 01 - Track.mp3 (no artist.* present in library)
|
||||||
|
It("returns the image from the configured artist image folder", func() {
|
||||||
|
imgFolder := GinkgoT().TempDir()
|
||||||
|
Expect(os.WriteFile(filepath.Join(imgFolder, "Artist.jpg"), imageBytes("image-folder"), 0600)).To(Succeed())
|
||||||
|
conf.Server.ArtistImageFolder = imgFolder
|
||||||
|
conf.Server.ArtistArtPriority = "image-folder, artist.*, album/artist.*"
|
||||||
|
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
ar := soleArtist()
|
||||||
|
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
|
||||||
|
Expect(readArtwork(artID)).To(Equal(imageBytes("image-folder")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
func soleArtist() model.Artist {
|
||||||
|
GinkgoHelper()
|
||||||
|
artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||||
|
Filters: squirrel.Eq{"artist.name": "Artist"},
|
||||||
|
})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
if len(artists) == 0 {
|
||||||
|
Fail("sole artist not found")
|
||||||
|
return model.Artist{}
|
||||||
|
}
|
||||||
|
return artists[0]
|
||||||
|
}
|
||||||
276
core/artwork/e2e/disc_test.go
Normal file
276
core/artwork/e2e/disc_test.go
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
package artworke2e_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("Disc artwork resolution", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
setupHarness()
|
||||||
|
})
|
||||||
|
|
||||||
|
When("the album is single-disc with a disc1.jpg in the only folder", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// ├── 01 - Track.mp3
|
||||||
|
// └── disc1.jpg ← matched by disc*.*
|
||||||
|
It("returns the disc1.jpg image (matched as disc*.*)", func() {
|
||||||
|
conf.Server.DiscArtPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, embedded"
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
|
"Artist/Album/disc1.jpg": imageFile("disc1-image"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||||
|
Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-image")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("the album has no per-disc image and no album cover", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// └── 01 - Track.mp3 (no disc or album art — returns ErrUnavailable)
|
||||||
|
It("returns ErrUnavailable for the disc lookup", func() {
|
||||||
|
conf.Server.DiscArtPriority = "disc*.*, cd*.*"
|
||||||
|
conf.Server.CoverArtPriority = "cover.*, folder.*"
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||||
|
_, err := readArtworkOrErr(discID)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("the album has no per-disc image but has an album cover", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// ├── 01 - Track.mp3
|
||||||
|
// └── cover.jpg ← album-level fallback (no disc art present)
|
||||||
|
It("falls back to the album cover", func() {
|
||||||
|
conf.Server.DiscArtPriority = "disc*.*, cd*.*"
|
||||||
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
|
"Artist/Album/cover.jpg": imageFile("album-cover"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||||
|
Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("multiple disc images exist in the same folder (disc1 vs disc10)", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// ├── 01 - Track.mp3
|
||||||
|
// ├── disc1.jpg ← matches request for disc 1
|
||||||
|
// └── disc10.jpg
|
||||||
|
It("matches the requested disc number, not a higher-numbered one", func() {
|
||||||
|
conf.Server.DiscArtPriority = "disc*.*"
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
|
"Artist/Album/disc1.jpg": imageFile("disc-one"),
|
||||||
|
"Artist/Album/disc10.jpg": imageFile("disc-ten"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||||
|
Expect(readArtwork(discID)).To(Equal(imageBytes("disc-one")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("a multi-disc album has per-disc covers", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// ├── CD1/
|
||||||
|
// │ ├── 01 - Track.mp3
|
||||||
|
// │ └── disc1.jpg ← matches request for disc 1
|
||||||
|
// └── CD2/
|
||||||
|
// ├── 01 - Track.mp3
|
||||||
|
// └── disc2.jpg ← matches request for disc 2
|
||||||
|
It("returns the requested disc's image", func() {
|
||||||
|
conf.Server.DiscArtPriority = "disc*.*"
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||||
|
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||||
|
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
|
||||||
|
"Artist/Album/CD2/disc2.jpg": imageFile("disc-2"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt)
|
||||||
|
Expect(readArtwork(discID)).To(Equal(imageBytes("disc-2")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Doc scenarios from:
|
||||||
|
// https://www.navidrome.org/docs/usage/library/artwork/#disc-cover-art
|
||||||
|
// Default DiscArtPriority is "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded".
|
||||||
|
When("a disc subfolder has a cd2.png image", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// ├── CD1/
|
||||||
|
// │ ├── 01 - Track.mp3
|
||||||
|
// │ └── disc1.jpg
|
||||||
|
// └── CD2/
|
||||||
|
// ├── 01 - Track.mp3
|
||||||
|
// └── cd2.png ← matched by cd*.* for disc 2
|
||||||
|
It("matches via the cd*.* pattern", func() {
|
||||||
|
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||||
|
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||||
|
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
|
||||||
|
"Artist/Album/CD2/cd2.png": imageFile("cd-2"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt)
|
||||||
|
Expect(readArtwork(discID)).To(Equal(imageBytes("cd-2")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("a disc subfolder has cover.jpg but no disc*.*/cd*.* image", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// ├── CD1/
|
||||||
|
// │ ├── 01 - Track.mp3
|
||||||
|
// │ └── cover.jpg ← matched by cover.* inside disc folder
|
||||||
|
// └── CD2/
|
||||||
|
// ├── 01 - Track.mp3
|
||||||
|
// └── cover.jpg
|
||||||
|
It("falls through to cover.* inside the disc folder", func() {
|
||||||
|
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||||
|
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||||
|
"Artist/Album/CD1/cover.jpg": imageFile("disc1-cover"),
|
||||||
|
"Artist/Album/CD2/cover.jpg": imageFile("disc2-cover"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||||
|
Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-cover")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("DiscArtPriority is the empty string", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// ├── CD1/
|
||||||
|
// │ ├── 01 - Track.mp3
|
||||||
|
// │ └── disc1.jpg (ignored — DiscArtPriority is empty)
|
||||||
|
// ├── CD2/
|
||||||
|
// │ ├── 01 - Track.mp3
|
||||||
|
// │ └── cd2.png (ignored — DiscArtPriority is empty)
|
||||||
|
// └── cover.jpg ← used for every disc (album-level fallback)
|
||||||
|
It("skips every disc-level source and returns the album cover", func() {
|
||||||
|
conf.Server.DiscArtPriority = ""
|
||||||
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||||
|
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||||
|
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
|
||||||
|
"Artist/Album/CD2/cd2.png": imageFile("cd-2"),
|
||||||
|
"Artist/Album/cover.jpg": imageFile("album-cover"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
for _, n := range []int{1, 2} {
|
||||||
|
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, n), &al.UpdatedAt)
|
||||||
|
Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover")),
|
||||||
|
"disc %d should use the album cover when DiscArtPriority is empty", n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("the documented multi-disc layout is used (disc1.jpg + cd2.png + album-root cover.jpg)", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// ├── disc1/
|
||||||
|
// │ ├── disc1.jpg ← matched by disc*.* for disc 1
|
||||||
|
// │ ├── 01 - Track.mp3
|
||||||
|
// │ └── 02 - Track.mp3
|
||||||
|
// ├── disc2/
|
||||||
|
// │ ├── cd2.png ← matched by cd*.* for disc 2
|
||||||
|
// │ ├── 01 - Track.mp3
|
||||||
|
// │ └── 02 - Track.mp3
|
||||||
|
// └── cover.jpg (album-level fallback, unused here)
|
||||||
|
It("matches the per-disc image for each disc", func() {
|
||||||
|
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||||
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/disc1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||||
|
"Artist/Album/disc1/02 - Track.mp3": trackFile(2, "T2", map[string]any{"disc": "1"}),
|
||||||
|
"Artist/Album/disc2/01 - Track.mp3": trackFile(1, "T3", map[string]any{"disc": "2"}),
|
||||||
|
"Artist/Album/disc2/02 - Track.mp3": trackFile(2, "T4", map[string]any{"disc": "2"}),
|
||||||
|
"Artist/Album/disc1/disc1.jpg": imageFile("disc-1"),
|
||||||
|
"Artist/Album/disc2/cd2.png": imageFile("cd-2"),
|
||||||
|
"Artist/Album/cover.jpg": imageFile("album-root"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
disc1ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||||
|
disc2ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt)
|
||||||
|
Expect(readArtwork(disc1ID)).To(Equal(imageBytes("disc-1")))
|
||||||
|
Expect(readArtwork(disc2ID)).To(Equal(imageBytes("cd-2")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("discsubtitle keyword matches an image whose stem equals the disc's subtitle", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks")
|
||||||
|
// └── Bonus Tracks.jpg ← matched by "discsubtitle" keyword
|
||||||
|
It("selects the subtitle-named image", func() {
|
||||||
|
conf.Server.DiscArtPriority = "discsubtitle"
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}),
|
||||||
|
"Artist/Album/Bonus Tracks.jpg": imageFile("bonus-tracks"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||||
|
Expect(readArtwork(discID)).To(Equal(imageBytes("bonus-tracks")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("discsubtitle is set but no image filename matches the subtitle", func() {
|
||||||
|
// Artist/
|
||||||
|
// └── Album/
|
||||||
|
// ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks")
|
||||||
|
// └── cover.jpg ← wins (discsubtitle has no match, falls through)
|
||||||
|
It("falls through to the next priority entry", func() {
|
||||||
|
conf.Server.DiscArtPriority = "discsubtitle, cover.*"
|
||||||
|
setLayout(fstest.MapFS{
|
||||||
|
"Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}),
|
||||||
|
"Artist/Album/cover.jpg": imageFile("cover"),
|
||||||
|
})
|
||||||
|
scan()
|
||||||
|
|
||||||
|
al := firstAlbum()
|
||||||
|
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
|
||||||
|
Expect(readArtwork(discID)).To(Equal(imageBytes("cover")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
184
core/artwork/e2e/helpers_test.go
Normal file
184
core/artwork/e2e/helpers_test.go
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
package artworke2e_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"errors"
|
||||||
|
"hash/fnv"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/consts"
|
||||||
|
"github.com/navidrome/navidrome/core/external"
|
||||||
|
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/resources"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
"go.senan.xyz/taglib"
|
||||||
|
)
|
||||||
|
|
||||||
|
// realMP3WithEmbeddedArt is the bytes of the canonical test fixture that
|
||||||
|
// contains a valid MP3 stream with an embedded picture. Used in the
|
||||||
|
// embedded-art e2e scenarios where FakeFS's JSON-encoded tag data isn't
|
||||||
|
// readable by taglib. Swap this into fakeFS.MapFS *after* scanning so the
|
||||||
|
// scanner still populates EmbedArtPath via the JSON-tagged track, and the
|
||||||
|
// artwork reader gets real bytes when it calls libFS.Open.
|
||||||
|
//
|
||||||
|
//go:embed testdata/embedded_art.mp3
|
||||||
|
var realMP3WithEmbeddedArt []byte
|
||||||
|
|
||||||
|
// embeddedArtBytes is the exact image payload that the artwork reader will
|
||||||
|
// extract from realMP3WithEmbeddedArt. Computed once via taglib so tests can
|
||||||
|
// assert byte-for-byte equality — if this ever differs it means the reader
|
||||||
|
// pulled from a different source.
|
||||||
|
var embeddedArtBytes = extractEmbeddedArt(realMP3WithEmbeddedArt)
|
||||||
|
|
||||||
|
func extractEmbeddedArt(mp3 []byte) []byte {
|
||||||
|
tf, err := taglib.OpenStream(bytes.NewReader(mp3))
|
||||||
|
if err != nil {
|
||||||
|
panic("embedded-art fixture: taglib.OpenStream failed: " + err.Error())
|
||||||
|
}
|
||||||
|
defer tf.Close()
|
||||||
|
images := tf.Properties().Images
|
||||||
|
if len(images) == 0 {
|
||||||
|
panic("embedded-art fixture has no embedded images")
|
||||||
|
}
|
||||||
|
data, err := tf.Image(0)
|
||||||
|
if err != nil || len(data) == 0 {
|
||||||
|
panic("embedded-art fixture: could not read image 0")
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// replaceWithRealMP3 swaps the FakeFS entry at the given library-relative
|
||||||
|
// path so libFS.Open returns an MP3 stream taglib can parse.
|
||||||
|
func replaceWithRealMP3(relPath string) {
|
||||||
|
GinkgoHelper()
|
||||||
|
fakeFS.MapFS[relPath] = &fstest.MapFile{Data: realMP3WithEmbeddedArt}
|
||||||
|
}
|
||||||
|
|
||||||
|
// placeholderBytes returns the bundled album-placeholder image bytes — the
|
||||||
|
// same stream the artwork reader emits when every source falls through.
|
||||||
|
func placeholderBytes() []byte {
|
||||||
|
GinkgoHelper()
|
||||||
|
r, err := resources.FS().Open(consts.PlaceholderAlbumArt)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
defer r.Close()
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeUploadedImage drops `filename` into <DataFolder>/artwork/<entity>/ with
|
||||||
|
// the given bytes, matching the on-disk layout expected by
|
||||||
|
// model.UploadedImagePath.
|
||||||
|
func writeUploadedImage(entity, filename string, data []byte) {
|
||||||
|
GinkgoHelper()
|
||||||
|
dir := filepath.Dir(model.UploadedImagePath(entity, filename))
|
||||||
|
Expect(os.MkdirAll(dir, 0755)).To(Succeed())
|
||||||
|
Expect(os.WriteFile(filepath.Join(dir, filename), data, 0600)).To(Succeed())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newNoopFFmpeg() *tests.MockFFmpeg {
|
||||||
|
ff := tests.NewMockFFmpeg("")
|
||||||
|
ff.Error = errors.New("noop")
|
||||||
|
return ff
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackFile builds a FakeFS MP3 entry with optional tag overrides.
|
||||||
|
func trackFile(num int, title string, extra ...map[string]any) *fstest.MapFile {
|
||||||
|
tags := storagetest.Track(num, title)
|
||||||
|
for _, e := range extra {
|
||||||
|
maps.Copy(tags, e)
|
||||||
|
}
|
||||||
|
return storagetest.MP3(tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageFile builds a label-keyed image entry. The bytes are deterministic
|
||||||
|
// per-label so tests can assert which file won.
|
||||||
|
func imageFile(label string) *fstest.MapFile {
|
||||||
|
return &fstest.MapFile{Data: []byte("image:" + label)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// realPNG builds a minimal 2x2 PNG with a color derived from label. Needed by
|
||||||
|
// tests that feed the bytes into image.Decode (e.g. playlist tiled covers).
|
||||||
|
func realPNG(label string) *fstest.MapFile {
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
|
||||||
|
// Derive a deterministic color per label.
|
||||||
|
h := fnv.New32a()
|
||||||
|
_, _ = h.Write([]byte(label))
|
||||||
|
sum := h.Sum32()
|
||||||
|
c := color.RGBA{R: byte(sum), G: byte(sum >> 8), B: byte(sum >> 16), A: 255}
|
||||||
|
for y := range 2 {
|
||||||
|
for x := range 2 {
|
||||||
|
img.Set(x, y, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
Expect(png.Encode(&buf, img)).To(Succeed())
|
||||||
|
return &fstest.MapFile{Data: buf.Bytes()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageBytes returns the bytes that imageFile(label) writes.
|
||||||
|
func imageBytes(label string) []byte { return imageFile(label).Data }
|
||||||
|
|
||||||
|
// setLayout populates fakeFS with the given map. Call after setupHarness.
|
||||||
|
// All paths must be forward-slash and relative (no leading "/").
|
||||||
|
func setLayout(files fstest.MapFS) {
|
||||||
|
GinkgoHelper()
|
||||||
|
fakeFS.SetFiles(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readArtwork(artID model.ArtworkID) []byte {
|
||||||
|
GinkgoHelper()
|
||||||
|
r, _, err := aw.Get(ctx, artID, 0, false)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
defer r.Close()
|
||||||
|
b, err := io.ReadAll(r)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func readArtworkOrErr(artID model.ArtworkID) ([]byte, error) {
|
||||||
|
r, _, err := aw.Get(ctx, artID, 0, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
return io.ReadAll(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// noopProvider implements external.Provider with not-found returns so the
|
||||||
|
// "external" priority entry never produces a result.
|
||||||
|
type noopProvider struct{}
|
||||||
|
|
||||||
|
func (n *noopProvider) UpdateAlbumInfo(context.Context, string) (*model.Album, error) {
|
||||||
|
return nil, model.ErrNotFound
|
||||||
|
}
|
||||||
|
func (n *noopProvider) UpdateArtistInfo(context.Context, string, int, bool) (*model.Artist, error) {
|
||||||
|
return nil, model.ErrNotFound
|
||||||
|
}
|
||||||
|
func (n *noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (n *noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (n *noopProvider) ArtistImage(context.Context, string) (*url.URL, error) {
|
||||||
|
return nil, model.ErrNotFound
|
||||||
|
}
|
||||||
|
func (n *noopProvider) AlbumImage(context.Context, string) (*url.URL, error) {
|
||||||
|
return nil, model.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ external.Provider = (*noopProvider)(nil)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user