Compare commits
158 Commits
Author | SHA1 | Date |
---|---|---|
Deluan Quintão | 67865512c8 | |
Deluan | b2ecc1d16f | |
Deluan | bcaa180fc7 | |
Deluan | aeed5a7099 | |
Deluan | 3977ef6e0f | |
Deluan | 653b4d97f9 | |
Guilherme Souza | 98218d045e | |
Deluan | a9feeac793 | |
Deluan | 1c0551f4f7 | |
Deluan | 15c9a0ded3 | |
Deluan Quintão | 5d41165b5b | |
Deluan | 0a763b91d5 | |
Deluan | 4d28d534cc | |
Deluan | a7a4fb522c | |
Deluan | 7f52ff72dc | |
Deluan | 8ed07333ed | |
Rob Emery | 52235c291d | |
Fynn Petersen-Frey | de0a08915c | |
Deluan | 45c4583f1b | |
Deluan | 478c709a64 | |
Deluan | 477bcaee58 | |
Deluan | 081ef85db6 | |
Deluan | 6f2643e55e | |
Deluan | 9ee63b39cb | |
Deluan | c556088820 | |
Deluan | 78f554721a | |
Deluan | 2c8c87a980 | |
Deluan | 3463d0c208 | |
Deluan | 0ae2944073 | |
Deluan | 30ae468dc1 | |
Deluan | ec68d69d56 | |
Deluan | 955a9b43af | |
Deluan | 56809419c2 | |
Deluan | 3a2a5e961b | |
Deluan | f3bb022238 | |
Deluan | 472324e280 | |
Deluan | ed83c22632 | |
edthu | 2fdc1677f7 | |
Deluan | 80e68dfbcd | |
Deluan | a9c745839b | |
Deluan | bb96d455f8 | |
Deluan | c0885b55db | |
Deluan | 00cbe4c357 | |
Valeri Sokolov | 2b49c7ff76 | |
Deluan | 09d1fd0658 | |
Deluan | 747069b229 | |
Deluan | 885cd345ab | |
Deluan Quintão | c4b05dac28 | |
Deluan Quintão | 6408dda948 | |
Deluan | 677d9947f3 | |
Deluan | a0290587b9 | |
Deluan | eb93136b3f | |
Deluan | 62cc8a2d4b | |
Deluan | dd4374cec6 | |
Deluan | 86567f5406 | |
Matthias Schmidt | ff8dca5abe | |
Matthias Schmidt | b3d70e9264 | |
Ludovic Fernandez | 4d29184998 | |
Deluan | 2470471b2b | |
Deluan | 544ae90ec1 | |
Deluan | aef49cb8d6 | |
Deluan | 7c5eec715d | |
Kendall Garner | a4c2232041 | |
Deluan | 8f11b991d2 | |
Deluan | d4a9a9e555 | |
Deluan | a8955f24e0 | |
Deluan | 2c06a4234e | |
Deluan | 7ab7b5df5e | |
Deluan | 3d9fff36f7 | |
Deluan | 31fcab07d2 | |
Deluan | de90152a71 | |
Deluan | 27875ba2dd | |
Deluan | 28f7ef43c1 | |
Deluan | 92a98cd558 | |
Deluan | 5d50558610 | |
vvdveen | 8bff1ad512 | |
crazygolem | 1e96b858a9 | |
Deluan | aafd5a952c | |
Deluan Quintão | d9cd5efd67 | |
Deluan | affa9c3478 | |
Anna Smith | 651a8fdaf9 | |
Deluan | f7fc17c0f7 | |
Deluan | f5df948eb1 | |
crazygolem | 18143fa5a1 | |
Tim | 8f9ed1b994 | |
dependabot[bot] | cf66594b6d | |
Deluan | ca005f6457 | |
Deluan | 6dcfe4d455 | |
Deluan | 7871d69adb | |
Deluan | 78182f40d6 | |
Deluan | 9aeaaa6610 | |
dependabot[bot] | 068c1e9a23 | |
Jonathan | bcec15dc13 | |
dependabot[bot] | cf6603e3ec | |
dependabot[bot] | 88d6757121 | |
Andrew Katsikas | c2f932c21c | |
Deluan | d968f7f530 | |
dependabot[bot] | 5fc78f120c | |
dependabot[bot] | 52dfa97262 | |
dependabot[bot] | c1eef058a4 | |
Deluan | 7f551a7932 | |
oftenoccur | bcb71b85c0 | |
Deluan | 8720bd154f | |
Cyrille | 699be19bb9 | |
looklose | 22cc9e0cd5 | |
dependabot[bot] | 6e36abdd62 | |
dependabot[bot] | e98c7374a9 | |
Deluan Quintão | de7f553526 | |
dependabot[bot] | 9cc0cc2e93 | |
dependabot[bot] | 24298605d4 | |
Deluan | 4865d04ec6 | |
dependabot[bot] | 81770351de | |
dependabot[bot] | b6bbba754a | |
deluan | 4f6121fae1 | |
Kendall Garner | f12dfb485a | |
Deluan | e81bf5125f | |
dependabot[bot] | a47acb6674 | |
dependabot[bot] | 4a15677474 | |
Deluan | 859cdda0bd | |
Deluan | 87ecd118bb | |
Deluan | 5abe156777 | |
Deluan | fa72aaa462 | |
Deluan | 6eb13c9f79 | |
Deluan | b67d1c0830 | |
Deluan | effd588406 | |
Deluan | 6f4c55dbde | |
Deluan | 176329343a | |
Deluan | 97c7e5daaf | |
Deluan | 166eb37787 | |
Deluan | f7a4387d0e | |
Deluan | 71e5b271fb | |
Deluan | d51148ea4c | |
Deluan | 7cb8cc115e | |
Deluan | 69d91189c2 | |
Deluan | 88063fc189 | |
Deluan | 912e144b71 | |
Deluan | 87484fe7a9 | |
Deluan | 58f64355c2 | |
Deluan Quintão | 7167e5ac87 | |
Deluan | d8e1748928 | |
Deluan | 9a051967f6 | |
Deluan | 0b2cf30096 | |
Deluan | 6d253225de | |
Caio Cotts | bf2bcb1279 | |
Deluan Quintão | ac4ceab143 | |
Deluan | 6226741517 | |
Deluan | 79a4d8f6ad | |
Deluan Quintão | 61257f89d2 | |
Deluan | 1f71e56741 | |
Kendall Garner | 3a9b3452a2 | |
Deluan | 5125558f52 | |
Deluan | 5f9b6b632d | |
Deluan | fa7cc40d23 | |
caiocotts | 58218e6dc4 | |
Deluan | 67c82f524b | |
Deluan | fb7fd21984 | |
Deluan | a6fc84a2e1 | |
Deluan | 1e5e8be192 |
|
@ -4,10 +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.21",
|
"VARIANT": "1.22",
|
||||||
// Options
|
// Options
|
||||||
"INSTALL_NODE": "true",
|
"INSTALL_NODE": "true",
|
||||||
"NODE_VERSION": "v18"
|
"NODE_VERSION": "v20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"workspaceMount": "",
|
"workspaceMount": "",
|
||||||
|
|
|
@ -16,11 +16,13 @@ RUN chmod +x /navidrome
|
||||||
|
|
||||||
#####################################################
|
#####################################################
|
||||||
### Build Final Image
|
### Build Final Image
|
||||||
FROM alpine as release
|
FROM alpine:3.18
|
||||||
LABEL maintainer="deluan@navidrome.org"
|
LABEL maintainer="deluan@navidrome.org"
|
||||||
|
|
||||||
# Install ffmpeg and output build config
|
# Install ffmpeg and mpv
|
||||||
RUN apk add --no-cache ffmpeg
|
RUN apk add -U --no-cache ffmpeg mpv
|
||||||
|
|
||||||
|
# Show ffmpeg build info, for troubleshooting purposes
|
||||||
RUN ffmpeg -buildconf
|
RUN ffmpeg -buildconf
|
||||||
|
|
||||||
COPY --from=copy-binary /navidrome /app/
|
COPY --from=copy-binary /navidrome /app/
|
||||||
|
|
|
@ -8,33 +8,28 @@ on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
go-lint:
|
go-lint:
|
||||||
name: Lint Go code
|
name: Lint Go code
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container: deluan/ci-goreleaser:1.22.3-1
|
||||||
steps:
|
steps:
|
||||||
- name: Update ubuntu repo
|
- uses: actions/checkout@v4
|
||||||
run: sudo apt-get update
|
|
||||||
|
|
||||||
- name: Install taglib
|
- name: Config workspace folder as trusted
|
||||||
run: sudo apt-get install libtag1-dev
|
run: git config --global --add safe.directory $GITHUB_WORKSPACE; git describe --dirty --always --tags
|
||||||
|
|
||||||
- name: Set up Go 1.21
|
|
||||||
uses: actions/setup-go@v3
|
|
||||||
with:
|
|
||||||
go-version: 1.21.x
|
|
||||||
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v3
|
uses: golangci/golangci-lint-action@v6
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
problem-matchers: true
|
||||||
args: --timeout 2m
|
args: --timeout 2m
|
||||||
|
|
||||||
- name: Install goimports
|
- name: Install goimports
|
||||||
run: go install golang.org/x/tools/cmd/goimports
|
run: go install golang.org/x/tools/cmd/goimports@latest
|
||||||
|
|
||||||
- run: goimports -w `find . -name '*.go' | grep -v '_gen.go$'`
|
- run: goimports -w `find . -name '*.go' | grep -v '_gen.go$'`
|
||||||
- run: go mod tidy
|
- run: go mod tidy
|
||||||
|
@ -47,26 +42,15 @@ jobs:
|
||||||
fi
|
fi
|
||||||
|
|
||||||
go:
|
go:
|
||||||
name: Test with Go ${{ matrix.go_version }}
|
name: Test Go code
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
container: deluan/ci-goreleaser:1.22.3-1
|
||||||
matrix:
|
|
||||||
go_version: [1.21.x, 1.20.x]
|
|
||||||
steps:
|
steps:
|
||||||
- name: Update ubuntu repo
|
|
||||||
run: sudo apt-get update
|
|
||||||
|
|
||||||
- name: Install taglib
|
|
||||||
run: sudo apt-get install libtag1-dev ffmpeg
|
|
||||||
|
|
||||||
- name: Check out code into the Go module directory
|
- name: Check out code into the Go module directory
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Go ${{ matrix.go_version }}
|
- name: Config workspace folder as trusted
|
||||||
uses: actions/setup-go@v3
|
run: git config --global --add safe.directory $GITHUB_WORKSPACE; git describe --dirty --always --tags
|
||||||
with:
|
|
||||||
go-version: ${{ matrix.go_version }}
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Download dependencies
|
- name: Download dependencies
|
||||||
if: steps.cache-go.outputs.cache-hit != 'true'
|
if: steps.cache-go.outputs.cache-hit != 'true'
|
||||||
|
@ -83,10 +67,10 @@ jobs:
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 20
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: "**/package-lock.json"
|
cache-dependency-path: "**/package-lock.json"
|
||||||
|
|
||||||
|
@ -110,7 +94,7 @@ jobs:
|
||||||
cd ui
|
cd ui
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: js-bundle
|
name: js-bundle
|
||||||
path: ui/build
|
path: ui/build
|
||||||
|
@ -120,41 +104,34 @@ jobs:
|
||||||
name: Build binaries
|
name: Build binaries
|
||||||
needs: [js, go, go-lint]
|
needs: [js, go, go-lint]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container: deluan/ci-goreleaser:1.22.3-1
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: actions/download-artifact@v3
|
- name: Config workspace folder as trusted
|
||||||
|
run: git config --global --add safe.directory $GITHUB_WORKSPACE; git describe --dirty --always --tags
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: js-bundle
|
name: js-bundle
|
||||||
path: ui/build
|
path: ui/build
|
||||||
|
|
||||||
- name: Config /github/workspace folder as trusted
|
|
||||||
uses: docker://deluan/ci-goreleaser:1.21.5-1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
args: /bin/bash -c "git config --global --add safe.directory /github/workspace; git describe --dirty --always --tags"
|
|
||||||
|
|
||||||
- name: Run GoReleaser - SNAPSHOT
|
- name: Run GoReleaser - SNAPSHOT
|
||||||
if: startsWith(github.ref, 'refs/tags/') != true
|
if: startsWith(github.ref, 'refs/tags/') != true
|
||||||
uses: docker://deluan/ci-goreleaser:1.21.5-1
|
run: goreleaser release --clean --skip=publish --snapshot
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
|
||||||
args: goreleaser release --clean --skip=publish --snapshot
|
|
||||||
|
|
||||||
- name: Run GoReleaser - RELEASE
|
- name: Run GoReleaser - RELEASE
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
uses: docker://deluan/ci-goreleaser:1.21.5-1
|
run: goreleaser release --clean
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
|
||||||
args: goreleaser release --clean
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: binaries
|
name: binaries
|
||||||
path: |
|
path: |
|
||||||
|
@ -172,18 +149,18 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
id: qemu
|
id: qemu
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
if: env.DOCKER_IMAGE != ''
|
if: env.DOCKER_IMAGE != ''
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
if: env.DOCKER_IMAGE != ''
|
if: env.DOCKER_IMAGE != ''
|
||||||
|
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
if: env.DOCKER_IMAGE != ''
|
if: env.DOCKER_IMAGE != ''
|
||||||
|
|
||||||
- uses: actions/download-artifact@v3
|
- uses: actions/download-artifact@v4
|
||||||
if: env.DOCKER_IMAGE != ''
|
if: env.DOCKER_IMAGE != ''
|
||||||
with:
|
with:
|
||||||
name: binaries
|
name: binaries
|
||||||
|
@ -191,14 +168,14 @@ jobs:
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: env.DOCKER_IMAGE != ''
|
if: env.DOCKER_IMAGE != ''
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
if: env.DOCKER_IMAGE != ''
|
if: env.DOCKER_IMAGE != ''
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
@ -207,7 +184,7 @@ jobs:
|
||||||
- name: Extract metadata for Docker
|
- name: Extract metadata for Docker
|
||||||
if: env.DOCKER_IMAGE != ''
|
if: env.DOCKER_IMAGE != ''
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v4
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
labels: |
|
labels: |
|
||||||
maintainer=deluan
|
maintainer=deluan
|
||||||
|
@ -221,7 +198,7 @@ jobs:
|
||||||
|
|
||||||
- name: Build and Push
|
- name: Build and Push
|
||||||
if: env.DOCKER_IMAGE != ''
|
if: env.DOCKER_IMAGE != ''
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: .github/workflows/pipeline.dockerfile
|
file: .github/workflows/pipeline.dockerfile
|
||||||
|
|
|
@ -12,8 +12,9 @@ jobs:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: dessant/lock-threads@v4.0.0
|
- uses: dessant/lock-threads@v5
|
||||||
with:
|
with:
|
||||||
|
process-only: 'issues, prs'
|
||||||
issue-inactive-days: 120
|
issue-inactive-days: 120
|
||||||
pr-inactive-days: 120
|
pr-inactive-days: 120
|
||||||
log-output: true
|
log-output: true
|
||||||
|
@ -27,7 +28,7 @@ jobs:
|
||||||
This pull request has been automatically locked since there
|
This pull request has been automatically locked since there
|
||||||
has not been any recent activity after it was closed.
|
has not been any recent activity after it was closed.
|
||||||
Please open a new issue for related bugs.
|
Please open a new issue for related bugs.
|
||||||
- uses: actions/stale@v7
|
- uses: actions/stale@v9
|
||||||
with:
|
with:
|
||||||
operations-per-run: 999
|
operations-per-run: 999
|
||||||
days-before-issue-stale: 180
|
days-before-issue-stale: 180
|
||||||
|
|
|
@ -8,7 +8,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ github.repository_owner == 'navidrome' }}
|
if: ${{ github.repository_owner == 'navidrome' }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- name: Get updated translations
|
- name: Get updated translations
|
||||||
env:
|
env:
|
||||||
POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
|
POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
|
||||||
|
@ -20,7 +20,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@v4
|
uses: peter-evans/create-pull-request@v6
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.PAT }}
|
token: ${{ secrets.PAT }}
|
||||||
commit-message: Update translations
|
commit-message: Update translations
|
||||||
|
|
|
@ -1,6 +1,3 @@
|
||||||
run:
|
|
||||||
go: "1.20"
|
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
enable:
|
enable:
|
||||||
- asasalint
|
- asasalint
|
||||||
|
@ -28,8 +25,12 @@ linters:
|
||||||
- unused
|
- unused
|
||||||
- whitespace
|
- whitespace
|
||||||
|
|
||||||
issues:
|
linters-settings:
|
||||||
exclude-rules:
|
govet:
|
||||||
- linters:
|
enable:
|
||||||
- gosec
|
- nilness
|
||||||
text: "(G501|G401|G505):"
|
gosec:
|
||||||
|
excludes:
|
||||||
|
- G501
|
||||||
|
- G401
|
||||||
|
- G505
|
||||||
|
|
17
Makefile
17
Makefile
|
@ -9,7 +9,7 @@ GIT_SHA=source_archive
|
||||||
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))
|
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))
|
||||||
endif
|
endif
|
||||||
|
|
||||||
CI_RELEASER_VERSION=1.21.5-1 ## https://github.com/navidrome/ci-goreleaser
|
CI_RELEASER_VERSION=1.22.3-1 ## https://github.com/navidrome/ci-goreleaser
|
||||||
|
|
||||||
setup: check_env download-deps setup-git ##@1_Run_First Install dependencies and prepare development environment
|
setup: check_env download-deps setup-git ##@1_Run_First Install dependencies and prepare development environment
|
||||||
@echo Downloading Node dependencies...
|
@echo Downloading Node dependencies...
|
||||||
|
@ -61,12 +61,12 @@ snapshots: ##@Development Update (GoLang) Snapshot tests
|
||||||
|
|
||||||
migration-sql: ##@Development Create an empty SQL migration file
|
migration-sql: ##@Development Create an empty SQL migration file
|
||||||
@if [ -z "${name}" ]; then echo "Usage: make migration-sql name=name_of_migration_file"; exit 1; fi
|
@if [ -z "${name}" ]; then echo "Usage: make migration-sql name=name_of_migration_file"; exit 1; fi
|
||||||
go run github.com/pressly/goose/v3/cmd/goose@latest -dir db/migration create ${name} sql
|
go run github.com/pressly/goose/v3/cmd/goose@latest -dir db/migrations create ${name} sql
|
||||||
.PHONY: migration
|
.PHONY: migration
|
||||||
|
|
||||||
migration-go: ##@Development Create an empty Go migration file
|
migration-go: ##@Development Create an empty Go migration file
|
||||||
@if [ -z "${name}" ]; then echo "Usage: make migration-go name=name_of_migration_file"; exit 1; fi
|
@if [ -z "${name}" ]; then echo "Usage: make migration-go name=name_of_migration_file"; exit 1; fi
|
||||||
go run github.com/pressly/goose/v3/cmd/goose@latest -dir db/migration create ${name}
|
go run github.com/pressly/goose/v3/cmd/goose@latest -dir db/migrations create ${name}
|
||||||
.PHONY: migration
|
.PHONY: migration
|
||||||
|
|
||||||
setup-dev: setup
|
setup-dev: setup
|
||||||
|
@ -94,6 +94,7 @@ buildjs: check_node_env ##@Build Build only frontend
|
||||||
.PHONY: buildjs
|
.PHONY: buildjs
|
||||||
|
|
||||||
all: warning-noui-build ##@Cross_Compilation Build binaries for all supported platforms. It does not build the frontend
|
all: warning-noui-build ##@Cross_Compilation Build binaries for all supported platforms. It does not build the frontend
|
||||||
|
@echo "Building binaries for all platforms using builder ${CI_RELEASER_VERSION}"
|
||||||
docker run -t -v $(PWD):/workspace -w /workspace deluan/ci-goreleaser:$(CI_RELEASER_VERSION) \
|
docker run -t -v $(PWD):/workspace -w /workspace deluan/ci-goreleaser:$(CI_RELEASER_VERSION) \
|
||||||
goreleaser release --clean --skip=publish --snapshot
|
goreleaser release --clean --skip=publish --snapshot
|
||||||
.PHONY: all
|
.PHONY: all
|
||||||
|
@ -105,11 +106,17 @@ single: warning-noui-build ##@Cross_Compilation Build binaries for a single supp
|
||||||
grep -- "- id: navidrome_" .goreleaser.yml | sed 's/- id: navidrome_//g'; \
|
grep -- "- id: navidrome_" .goreleaser.yml | sed 's/- id: navidrome_//g'; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
fi
|
fi
|
||||||
@echo "Building binaries for ${GOOS}/${GOARCH}"
|
@echo "Building binaries for ${GOOS}/${GOARCH} using builder ${CI_RELEASER_VERSION}"
|
||||||
docker run -t -v $(PWD):/workspace -e GOOS -e GOARCH -w /workspace deluan/ci-goreleaser:$(CI_RELEASER_VERSION) \
|
docker run -t -v $(PWD):/workspace -e GOOS -e GOARCH -w /workspace deluan/ci-goreleaser:$(CI_RELEASER_VERSION) \
|
||||||
goreleaser build --clean --snapshot --single-target --id navidrome_${GOOS}_${GOARCH}
|
goreleaser build --clean --snapshot -p 2 --single-target --id navidrome_${GOOS}_${GOARCH}
|
||||||
.PHONY: single
|
.PHONY: single
|
||||||
|
|
||||||
|
docker: buildjs ##@Build Build Docker linux/amd64 image (tagged as `deluan/navidrome:develop`)
|
||||||
|
GOOS=linux GOARCH=amd64 make single
|
||||||
|
@echo "Building Docker image"
|
||||||
|
docker build . --platform linux/amd64 -t deluan/navidrome:develop -f .github/workflows/pipeline.dockerfile
|
||||||
|
.PHONY: docker
|
||||||
|
|
||||||
warning-noui-build:
|
warning-noui-build:
|
||||||
@echo "WARNING: This command does not build the frontend, it uses the latest built with 'make buildjs'"
|
@echo "WARNING: This command does not build the frontend, it uses the latest built with 'make buildjs'"
|
||||||
.PHONY: warning-noui-build
|
.PHONY: warning-noui-build
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
[![Downloads](https://img.shields.io/github/downloads/navidrome/navidrome/total?logo=github&style=flat-square)](https://github.com/navidrome/navidrome/releases/latest)
|
[![Downloads](https://img.shields.io/github/downloads/navidrome/navidrome/total?logo=github&style=flat-square)](https://github.com/navidrome/navidrome/releases/latest)
|
||||||
[![Docker Pulls](https://img.shields.io/docker/pulls/deluan/navidrome?logo=docker&label=pulls&style=flat-square)](https://hub.docker.com/r/deluan/navidrome)
|
[![Docker Pulls](https://img.shields.io/docker/pulls/deluan/navidrome?logo=docker&label=pulls&style=flat-square)](https://hub.docker.com/r/deluan/navidrome)
|
||||||
[![Dev Chat](https://img.shields.io/discord/671335427726114836?logo=discord&label=discord&style=flat-square)](https://discord.gg/xh7j7yF)
|
[![Dev Chat](https://img.shields.io/discord/671335427726114836?logo=discord&label=discord&style=flat-square)](https://discord.gg/xh7j7yF)
|
||||||
[![Subreddit](https://img.shields.io/reddit/subreddit-subscribers/navidrome?logo=reddit&label=/r/navidrome&style=flat-square)](https://www.reddit.com/r/navidrome/)
|
[![Subreddit](https://img.shields.io/badge/%2Fr%2Fnavidrome-%2B3000-red?logo=reddit)](https://www.reddit.com/r/navidrome/)
|
||||||
[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v2.0-ff69b4.svg?style=flat-square)](CODE_OF_CONDUCT.md)
|
[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v2.0-ff69b4.svg?style=flat-square)](CODE_OF_CONDUCT.md)
|
||||||
|
|
||||||
Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your
|
Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your
|
||||||
|
|
75
cmd/root.go
75
cmd/root.go
|
@ -2,31 +2,28 @@ package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/playback"
|
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/resources"
|
"github.com/navidrome/navidrome/resources"
|
||||||
"github.com/navidrome/navidrome/scheduler"
|
"github.com/navidrome/navidrome/scheduler"
|
||||||
"github.com/navidrome/navidrome/server/backgrounds"
|
"github.com/navidrome/navidrome/server/backgrounds"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"golang.org/x/sync/errgroup"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
)
|
)
|
||||||
|
|
||||||
var interrupted = errors.New("service was interrupted")
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
cfgFile string
|
cfgFile string
|
||||||
noBanner bool
|
noBanner bool
|
||||||
|
@ -42,10 +39,14 @@ Complete documentation is available at https://www.navidrome.org/docs`,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
runNavidrome()
|
runNavidrome()
|
||||||
},
|
},
|
||||||
|
PostRun: func(cmd *cobra.Command, args []string) {
|
||||||
|
postRun()
|
||||||
|
},
|
||||||
Version: consts.Version,
|
Version: consts.Version,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Execute runs the root cobra command, which will start the Navidrome server by calling the runNavidrome function.
|
||||||
func Execute() {
|
func Execute() {
|
||||||
rootCmd.SetVersionTemplate(`{{println .Version}}`)
|
rootCmd.SetVersionTemplate(`{{println .Version}}`)
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
@ -61,30 +62,42 @@ func preRun() {
|
||||||
conf.Load()
|
conf.Load()
|
||||||
}
|
}
|
||||||
|
|
||||||
func runNavidrome() {
|
func postRun() {
|
||||||
db.Init()
|
log.Info("Navidrome stopped, bye.")
|
||||||
defer func() {
|
}
|
||||||
if err := db.Close(); err != nil {
|
|
||||||
log.Error("Error closing DB", err)
|
|
||||||
}
|
|
||||||
log.Info("Navidrome stopped, bye.")
|
|
||||||
}()
|
|
||||||
|
|
||||||
g, ctx := errgroup.WithContext(context.Background())
|
// runNavidrome is the main entry point for the Navidrome server. It starts all the services and blocks.
|
||||||
|
// If any of the services returns an error, it will log it and exit. If the process receives a signal to exit,
|
||||||
|
// it will cancel the context and exit gracefully.
|
||||||
|
func runNavidrome() {
|
||||||
|
defer db.Init()()
|
||||||
|
|
||||||
|
ctx, cancel := mainContext()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
g, ctx := errgroup.WithContext(ctx)
|
||||||
g.Go(startServer(ctx))
|
g.Go(startServer(ctx))
|
||||||
g.Go(startSignaler(ctx))
|
g.Go(startSignaller(ctx))
|
||||||
g.Go(startScheduler(ctx))
|
g.Go(startScheduler(ctx))
|
||||||
|
g.Go(startPlaybackServer(ctx))
|
||||||
g.Go(schedulePeriodicScan(ctx))
|
g.Go(schedulePeriodicScan(ctx))
|
||||||
|
|
||||||
if conf.Server.Jukebox.Enabled {
|
if err := g.Wait(); err != nil {
|
||||||
g.Go(startPlaybackServer(ctx))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := g.Wait(); err != nil && !errors.Is(err, interrupted) {
|
|
||||||
log.Error("Fatal error in Navidrome. Aborting", err)
|
log.Error("Fatal error in Navidrome. Aborting", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mainContext returns a context that is cancelled when the process receives a signal to exit.
|
||||||
|
func mainContext() (context.Context, context.CancelFunc) {
|
||||||
|
return signal.NotifyContext(context.Background(),
|
||||||
|
os.Interrupt,
|
||||||
|
syscall.SIGHUP,
|
||||||
|
syscall.SIGTERM,
|
||||||
|
syscall.SIGABRT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startServer starts the Navidrome web server, adding all the necessary routers.
|
||||||
func startServer(ctx context.Context) func() error {
|
func startServer(ctx context.Context) func() error {
|
||||||
return func() error {
|
return func() error {
|
||||||
a := CreateServer(conf.Server.MusicFolder)
|
a := CreateServer(conf.Server.MusicFolder)
|
||||||
|
@ -112,6 +125,7 @@ func startServer(ctx context.Context) func() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// schedulePeriodicScan schedules a periodic scan of the music library, if configured.
|
||||||
func schedulePeriodicScan(ctx context.Context) func() error {
|
func schedulePeriodicScan(ctx context.Context) func() error {
|
||||||
return func() error {
|
return func() error {
|
||||||
schedule := conf.Server.ScanSchedule
|
schedule := conf.Server.ScanSchedule
|
||||||
|
@ -141,22 +155,26 @@ func schedulePeriodicScan(ctx context.Context) func() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startScheduler starts the Navidrome scheduler, which is used to run periodic tasks.
|
||||||
func startScheduler(ctx context.Context) func() error {
|
func startScheduler(ctx context.Context) func() error {
|
||||||
log.Info(ctx, "Starting scheduler")
|
|
||||||
schedulerInstance := scheduler.GetInstance()
|
|
||||||
|
|
||||||
return func() error {
|
return func() error {
|
||||||
|
log.Info(ctx, "Starting scheduler")
|
||||||
|
schedulerInstance := scheduler.GetInstance()
|
||||||
schedulerInstance.Run(ctx)
|
schedulerInstance.Run(ctx)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startPlaybackServer starts the Navidrome playback server, if configured.
|
||||||
|
// It is responsible for the Jukebox functionality
|
||||||
func startPlaybackServer(ctx context.Context) func() error {
|
func startPlaybackServer(ctx context.Context) func() error {
|
||||||
log.Info(ctx, "Starting playback server")
|
|
||||||
|
|
||||||
playbackInstance := playback.GetInstance()
|
|
||||||
|
|
||||||
return func() error {
|
return func() error {
|
||||||
|
if !conf.Server.Jukebox.Enabled {
|
||||||
|
log.Debug("Jukebox is DISABLED")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Info(ctx, "Starting Jukebox service")
|
||||||
|
playbackInstance := GetPlaybackServer()
|
||||||
return playbackInstance.Run(ctx)
|
return playbackInstance.Run(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -192,6 +210,7 @@ func init() {
|
||||||
rootCmd.Flags().Bool("enabletranscodingconfig", viper.GetBool("enabletranscodingconfig"), "enables transcoding configuration in the UI")
|
rootCmd.Flags().Bool("enabletranscodingconfig", viper.GetBool("enabletranscodingconfig"), "enables transcoding configuration in the UI")
|
||||||
rootCmd.Flags().String("transcodingcachesize", viper.GetString("transcodingcachesize"), "size of transcoding cache")
|
rootCmd.Flags().String("transcodingcachesize", viper.GetString("transcodingcachesize"), "size of transcoding cache")
|
||||||
rootCmd.Flags().String("imagecachesize", viper.GetString("imagecachesize"), "size of image (art work) cache. set to 0 to disable cache")
|
rootCmd.Flags().String("imagecachesize", viper.GetString("imagecachesize"), "size of image (art work) cache. set to 0 to disable cache")
|
||||||
|
rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized")
|
||||||
rootCmd.Flags().Bool("autoimportplaylists", viper.GetBool("autoimportplaylists"), "enable/disable .m3u playlist auto-import`")
|
rootCmd.Flags().Bool("autoimportplaylists", viper.GetBool("autoimportplaylists"), "enable/disable .m3u playlist auto-import`")
|
||||||
|
|
||||||
rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint`")
|
rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint`")
|
||||||
|
|
|
@ -1,27 +0,0 @@
|
||||||
//go:build windows || plan9
|
|
||||||
|
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
func startSignaler(ctx context.Context) func() error {
|
|
||||||
log.Info(ctx, "Starting signaler")
|
|
||||||
|
|
||||||
return func() error {
|
|
||||||
var sigChan = make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigChan, os.Interrupt)
|
|
||||||
select {
|
|
||||||
case sig := <-sigChan:
|
|
||||||
log.Info(ctx, "Received termination signal", "signal", sig)
|
|
||||||
return interrupted
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
//go:build windows || plan9
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Windows and Plan9 don't support SIGUSR1, so we don't need to start a signaler
|
||||||
|
func startSignaller(ctx context.Context) func() error {
|
||||||
|
return func() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,28 +14,17 @@ import (
|
||||||
|
|
||||||
const triggerScanSignal = syscall.SIGUSR1
|
const triggerScanSignal = syscall.SIGUSR1
|
||||||
|
|
||||||
func startSignaler(ctx context.Context) func() error {
|
func startSignaller(ctx context.Context) func() error {
|
||||||
log.Info(ctx, "Starting signaler")
|
log.Info(ctx, "Starting signaler")
|
||||||
scanner := GetScanner()
|
scanner := GetScanner()
|
||||||
|
|
||||||
return func() error {
|
return func() error {
|
||||||
var sigChan = make(chan os.Signal, 1)
|
var sigChan = make(chan os.Signal, 1)
|
||||||
signal.Notify(
|
signal.Notify(sigChan, triggerScanSignal)
|
||||||
sigChan,
|
|
||||||
os.Interrupt,
|
|
||||||
triggerScanSignal,
|
|
||||||
syscall.SIGHUP,
|
|
||||||
syscall.SIGTERM,
|
|
||||||
syscall.SIGABRT,
|
|
||||||
)
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case sig := <-sigChan:
|
case sig := <-sigChan:
|
||||||
if sig != triggerScanSignal {
|
|
||||||
log.Info(ctx, "Received termination signal", "signal", sig)
|
|
||||||
return interrupted
|
|
||||||
}
|
|
||||||
log.Info(ctx, "Received signal, triggering a new scan", "signal", sig)
|
log.Info(ctx, "Received signal, triggering a new scan", "signal", sig)
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
err := scanner.RescanAll(ctx, false)
|
err := scanner.RescanAll(ctx, false)
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by Wire. DO NOT EDIT.
|
// Code generated by Wire. DO NOT EDIT.
|
||||||
|
|
||||||
//go:generate go run github.com/google/wire/cmd/wire
|
//go:generate go run -mod=mod github.com/google/wire/cmd/wire
|
||||||
//go:build !wireinject
|
//go:build !wireinject
|
||||||
// +build !wireinject
|
// +build !wireinject
|
||||||
|
|
||||||
|
@ -14,6 +14,7 @@ import (
|
||||||
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||||
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/persistence"
|
"github.com/navidrome/navidrome/persistence"
|
||||||
|
@ -23,7 +24,6 @@ import (
|
||||||
"github.com/navidrome/navidrome/server/nativeapi"
|
"github.com/navidrome/navidrome/server/nativeapi"
|
||||||
"github.com/navidrome/navidrome/server/public"
|
"github.com/navidrome/navidrome/server/public"
|
||||||
"github.com/navidrome/navidrome/server/subsonic"
|
"github.com/navidrome/navidrome/server/subsonic"
|
||||||
"sync"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Injectors from wire_injectors.go:
|
// Injectors from wire_injectors.go:
|
||||||
|
@ -58,11 +58,13 @@ func CreateSubsonicAPIRouter() *subsonic.Router {
|
||||||
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)
|
||||||
scanner := GetScanner()
|
|
||||||
broker := events.GetBroker()
|
|
||||||
playlists := core.NewPlaylists(dataStore)
|
playlists := core.NewPlaylists(dataStore)
|
||||||
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
|
broker := events.GetBroker()
|
||||||
|
scannerScanner := scanner.GetInstance(dataStore, playlists, cacheWarmer, broker)
|
||||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker)
|
playTracker := scrobbler.GetPlayTracker(dataStore, broker)
|
||||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, externalMetadata, scanner, broker, playlists, playTracker, share)
|
playbackServer := playback.GetInstance(dataStore)
|
||||||
|
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, externalMetadata, scannerScanner, broker, playlists, playTracker, share, playbackServer)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -96,7 +98,7 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
func createScanner() scanner.Scanner {
|
func GetScanner() scanner.Scanner {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
playlists := core.NewPlaylists(dataStore)
|
playlists := core.NewPlaylists(dataStore)
|
||||||
|
@ -107,23 +109,17 @@ func createScanner() scanner.Scanner {
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
|
||||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
broker := events.GetBroker()
|
broker := events.GetBroker()
|
||||||
scannerScanner := scanner.New(dataStore, playlists, cacheWarmer, broker)
|
scannerScanner := scanner.GetInstance(dataStore, playlists, cacheWarmer, broker)
|
||||||
return scannerScanner
|
return scannerScanner
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetPlaybackServer() playback.PlaybackServer {
|
||||||
|
sqlDB := db.Db()
|
||||||
|
dataStore := persistence.New(sqlDB)
|
||||||
|
playbackServer := playback.GetInstance(dataStore)
|
||||||
|
return playbackServer
|
||||||
|
}
|
||||||
|
|
||||||
// wire_injectors.go:
|
// wire_injectors.go:
|
||||||
|
|
||||||
var allProviders = wire.NewSet(core.Set, artwork.Set, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, db.Db)
|
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.GetInstance, db.Db)
|
||||||
|
|
||||||
// Scanner must be a Singleton
|
|
||||||
var (
|
|
||||||
onceScanner sync.Once
|
|
||||||
scannerInstance scanner.Scanner
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetScanner() scanner.Scanner {
|
|
||||||
onceScanner.Do(func() {
|
|
||||||
scannerInstance = createScanner()
|
|
||||||
})
|
|
||||||
return scannerInstance
|
|
||||||
}
|
|
||||||
|
|
|
@ -3,13 +3,12 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/google/wire"
|
"github.com/google/wire"
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/agents/lastfm"
|
"github.com/navidrome/navidrome/core/agents/lastfm"
|
||||||
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/persistence"
|
"github.com/navidrome/navidrome/persistence"
|
||||||
"github.com/navidrome/navidrome/scanner"
|
"github.com/navidrome/navidrome/scanner"
|
||||||
|
@ -23,6 +22,7 @@ import (
|
||||||
var allProviders = wire.NewSet(
|
var allProviders = wire.NewSet(
|
||||||
core.Set,
|
core.Set,
|
||||||
artwork.Set,
|
artwork.Set,
|
||||||
|
server.New,
|
||||||
subsonic.New,
|
subsonic.New,
|
||||||
nativeapi.New,
|
nativeapi.New,
|
||||||
public.New,
|
public.New,
|
||||||
|
@ -30,12 +30,12 @@ var allProviders = wire.NewSet(
|
||||||
lastfm.NewRouter,
|
lastfm.NewRouter,
|
||||||
listenbrainz.NewRouter,
|
listenbrainz.NewRouter,
|
||||||
events.GetBroker,
|
events.GetBroker,
|
||||||
|
scanner.GetInstance,
|
||||||
db.Db,
|
db.Db,
|
||||||
)
|
)
|
||||||
|
|
||||||
func CreateServer(musicFolder string) *server.Server {
|
func CreateServer(musicFolder string) *server.Server {
|
||||||
panic(wire.Build(
|
panic(wire.Build(
|
||||||
server.New,
|
|
||||||
allProviders,
|
allProviders,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
@ -49,7 +49,6 @@ func CreateNativeAPIRouter() *nativeapi.Router {
|
||||||
func CreateSubsonicAPIRouter() *subsonic.Router {
|
func CreateSubsonicAPIRouter() *subsonic.Router {
|
||||||
panic(wire.Build(
|
panic(wire.Build(
|
||||||
allProviders,
|
allProviders,
|
||||||
GetScanner,
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -71,22 +70,14 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scanner must be a Singleton
|
|
||||||
var (
|
|
||||||
onceScanner sync.Once
|
|
||||||
scannerInstance scanner.Scanner
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetScanner() scanner.Scanner {
|
func GetScanner() scanner.Scanner {
|
||||||
onceScanner.Do(func() {
|
|
||||||
scannerInstance = createScanner()
|
|
||||||
})
|
|
||||||
return scannerInstance
|
|
||||||
}
|
|
||||||
|
|
||||||
func createScanner() scanner.Scanner {
|
|
||||||
panic(wire.Build(
|
panic(wire.Build(
|
||||||
allProviders,
|
allProviders,
|
||||||
scanner.New,
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPlaybackServer() playback.PlaybackServer {
|
||||||
|
panic(wire.Build(
|
||||||
|
allProviders,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,6 @@ import (
|
||||||
"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/utils/number"
|
|
||||||
"github.com/robfig/cron/v3"
|
"github.com/robfig/cron/v3"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
@ -45,6 +44,7 @@ type configOptions struct {
|
||||||
EnableMediaFileCoverArt bool
|
EnableMediaFileCoverArt bool
|
||||||
TranscodingCacheSize string
|
TranscodingCacheSize string
|
||||||
ImageCacheSize string
|
ImageCacheSize string
|
||||||
|
AlbumPlayCountMode string
|
||||||
EnableArtworkPrecache bool
|
EnableArtworkPrecache bool
|
||||||
AutoImportPlaylists bool
|
AutoImportPlaylists bool
|
||||||
PlaylistsPath string
|
PlaylistsPath string
|
||||||
|
@ -58,6 +58,7 @@ type configOptions struct {
|
||||||
SubsonicArtistParticipations bool
|
SubsonicArtistParticipations bool
|
||||||
FFmpegPath string
|
FFmpegPath string
|
||||||
MPVPath string
|
MPVPath string
|
||||||
|
MPVCmdTemplate string
|
||||||
CoverArtPriority string
|
CoverArtPriority string
|
||||||
CoverJpegQuality int
|
CoverJpegQuality int
|
||||||
ArtistArtPriority string
|
ArtistArtPriority string
|
||||||
|
@ -79,6 +80,7 @@ type configOptions struct {
|
||||||
PasswordEncryptionKey string
|
PasswordEncryptionKey string
|
||||||
ReverseProxyUserHeader string
|
ReverseProxyUserHeader string
|
||||||
ReverseProxyWhitelist string
|
ReverseProxyWhitelist string
|
||||||
|
HTTPSecurityHeaders secureOptions
|
||||||
Prometheus prometheusOptions
|
Prometheus prometheusOptions
|
||||||
Scanner scannerOptions
|
Scanner scannerOptions
|
||||||
Jukebox jukeboxOptions
|
Jukebox jukeboxOptions
|
||||||
|
@ -129,6 +131,10 @@ type listenBrainzOptions struct {
|
||||||
BaseURL string
|
BaseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type secureOptions struct {
|
||||||
|
CustomFrameOptionsValue string
|
||||||
|
}
|
||||||
|
|
||||||
type prometheusOptions struct {
|
type prometheusOptions struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
MetricsPath string
|
MetricsPath string
|
||||||
|
@ -137,9 +143,10 @@ type prometheusOptions struct {
|
||||||
type AudioDeviceDefinition []string
|
type AudioDeviceDefinition []string
|
||||||
|
|
||||||
type jukeboxOptions struct {
|
type jukeboxOptions struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
Devices []AudioDeviceDefinition
|
Devices []AudioDeviceDefinition
|
||||||
Default string
|
Default string
|
||||||
|
AdminOnly bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
@ -289,6 +296,7 @@ func init() {
|
||||||
viper.SetDefault("enabletranscodingconfig", false)
|
viper.SetDefault("enabletranscodingconfig", false)
|
||||||
viper.SetDefault("transcodingcachesize", "100MB")
|
viper.SetDefault("transcodingcachesize", "100MB")
|
||||||
viper.SetDefault("imagecachesize", "100MB")
|
viper.SetDefault("imagecachesize", "100MB")
|
||||||
|
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
|
||||||
viper.SetDefault("enableartworkprecache", true)
|
viper.SetDefault("enableartworkprecache", true)
|
||||||
viper.SetDefault("autoimportplaylists", true)
|
viper.SetDefault("autoimportplaylists", true)
|
||||||
viper.SetDefault("playlistspath", consts.DefaultPlaylistsPath)
|
viper.SetDefault("playlistspath", consts.DefaultPlaylistsPath)
|
||||||
|
@ -304,6 +312,8 @@ func init() {
|
||||||
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("subsonicartistparticipations", false)
|
viper.SetDefault("subsonicartistparticipations", false)
|
||||||
viper.SetDefault("ffmpegpath", "")
|
viper.SetDefault("ffmpegpath", "")
|
||||||
|
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display --pause %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("coverjpegquality", 75)
|
||||||
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
||||||
|
@ -331,6 +341,7 @@ func init() {
|
||||||
viper.SetDefault("jukebox.enabled", false)
|
viper.SetDefault("jukebox.enabled", false)
|
||||||
viper.SetDefault("jukebox.devices", []AudioDeviceDefinition{})
|
viper.SetDefault("jukebox.devices", []AudioDeviceDefinition{})
|
||||||
viper.SetDefault("jukebox.default", "")
|
viper.SetDefault("jukebox.default", "")
|
||||||
|
viper.SetDefault("jukebox.adminonly", true)
|
||||||
|
|
||||||
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
|
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
|
||||||
viper.SetDefault("scanner.genreseparators", ";/,")
|
viper.SetDefault("scanner.genreseparators", ";/,")
|
||||||
|
@ -346,6 +357,8 @@ func init() {
|
||||||
viper.SetDefault("listenbrainz.enabled", true)
|
viper.SetDefault("listenbrainz.enabled", true)
|
||||||
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
|
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
|
||||||
|
|
||||||
|
viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")
|
||||||
|
|
||||||
// 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)
|
||||||
viper.SetDefault("devenableprofiler", false)
|
viper.SetDefault("devenableprofiler", false)
|
||||||
|
@ -358,7 +371,7 @@ func init() {
|
||||||
viper.SetDefault("devsidebarplaylists", true)
|
viper.SetDefault("devsidebarplaylists", true)
|
||||||
viper.SetDefault("devshowartistpage", true)
|
viper.SetDefault("devshowartistpage", true)
|
||||||
viper.SetDefault("devoffsetoptimize", 50000)
|
viper.SetDefault("devoffsetoptimize", 50000)
|
||||||
viper.SetDefault("devartworkmaxrequests", number.Max(2, runtime.NumCPU()/3))
|
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/3))
|
||||||
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)
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
package mime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"mime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/resources"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mimeConf struct {
|
||||||
|
Types map[string]string `yaml:"types"`
|
||||||
|
Lossless []string `yaml:"lossless"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var LosslessFormats []string
|
||||||
|
|
||||||
|
func initMimeTypes() {
|
||||||
|
// In some circumstances, Windows sets JS mime-type to `text/plain`!
|
||||||
|
_ = mime.AddExtensionType(".js", "text/javascript")
|
||||||
|
_ = mime.AddExtensionType(".css", "text/css")
|
||||||
|
|
||||||
|
f, err := resources.FS().Open("mime_types.yaml")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Fatal error opening mime_types.yaml", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var mimeConf mimeConf
|
||||||
|
err = yaml.NewDecoder(f).Decode(&mimeConf)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Fatal error parsing mime_types.yaml", err)
|
||||||
|
}
|
||||||
|
for ext, typ := range mimeConf.Types {
|
||||||
|
_ = mime.AddExtensionType(ext, typ)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ext := range mimeConf.Lossless {
|
||||||
|
LosslessFormats = append(LosslessFormats, strings.TrimPrefix(ext, "."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
conf.AddHook(initMimeTypes)
|
||||||
|
}
|
|
@ -81,26 +81,36 @@ const (
|
||||||
DefaultCacheCleanUpInterval = 10 * time.Minute
|
DefaultCacheCleanUpInterval = 10 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
AlbumPlayCountModeAbsolute = "absolute"
|
||||||
|
AlbumPlayCountModeNormalized = "normalized"
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
DefaultDownsamplingFormat = "opus"
|
DefaultDownsamplingFormat = "opus"
|
||||||
DefaultTranscodings = []map[string]interface{}{
|
DefaultTranscodings = []struct {
|
||||||
|
Name string
|
||||||
|
TargetFormat string
|
||||||
|
DefaultBitRate int
|
||||||
|
Command string
|
||||||
|
}{
|
||||||
{
|
{
|
||||||
"name": "mp3 audio",
|
Name: "mp3 audio",
|
||||||
"targetFormat": "mp3",
|
TargetFormat: "mp3",
|
||||||
"defaultBitRate": 192,
|
DefaultBitRate: 192,
|
||||||
"command": "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
|
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "opus audio",
|
Name: "opus audio",
|
||||||
"targetFormat": "opus",
|
TargetFormat: "opus",
|
||||||
"defaultBitRate": 128,
|
DefaultBitRate: 128,
|
||||||
"command": "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
|
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "aac audio",
|
Name: "aac audio",
|
||||||
"targetFormat": "aac",
|
TargetFormat: "aac",
|
||||||
"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 -",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,65 +0,0 @@
|
||||||
package consts
|
|
||||||
|
|
||||||
import (
|
|
||||||
"mime"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type format struct {
|
|
||||||
typ string
|
|
||||||
lossless bool
|
|
||||||
}
|
|
||||||
|
|
||||||
var audioFormats = map[string]format{
|
|
||||||
".mp3": {typ: "audio/mpeg"},
|
|
||||||
".ogg": {typ: "audio/ogg"},
|
|
||||||
".oga": {typ: "audio/ogg"},
|
|
||||||
".opus": {typ: "audio/ogg"},
|
|
||||||
".aac": {typ: "audio/mp4"},
|
|
||||||
".alac": {typ: "audio/mp4", lossless: true},
|
|
||||||
".m4a": {typ: "audio/mp4"},
|
|
||||||
".m4b": {typ: "audio/mp4"},
|
|
||||||
".flac": {typ: "audio/flac", lossless: true},
|
|
||||||
".wav": {typ: "audio/x-wav", lossless: true},
|
|
||||||
".wma": {typ: "audio/x-ms-wma"},
|
|
||||||
".ape": {typ: "audio/x-monkeys-audio", lossless: true},
|
|
||||||
".mpc": {typ: "audio/x-musepack"},
|
|
||||||
".shn": {typ: "audio/x-shn", lossless: true},
|
|
||||||
".aif": {typ: "audio/x-aiff"},
|
|
||||||
".aiff": {typ: "audio/x-aiff"},
|
|
||||||
".m3u": {typ: "audio/x-mpegurl"},
|
|
||||||
".pls": {typ: "audio/x-scpls"},
|
|
||||||
".dsf": {typ: "audio/dsd", lossless: true},
|
|
||||||
".wv": {typ: "audio/x-wavpack", lossless: true},
|
|
||||||
".wvp": {typ: "audio/x-wavpack", lossless: true},
|
|
||||||
".tak": {typ: "audio/tak", lossless: true},
|
|
||||||
".mka": {typ: "audio/x-matroska"},
|
|
||||||
}
|
|
||||||
var imageFormats = map[string]string{
|
|
||||||
".gif": "image/gif",
|
|
||||||
".jpg": "image/jpeg",
|
|
||||||
".jpeg": "image/jpeg",
|
|
||||||
".webp": "image/webp",
|
|
||||||
".png": "image/png",
|
|
||||||
".bmp": "image/bmp",
|
|
||||||
}
|
|
||||||
|
|
||||||
var LosslessFormats []string
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
for ext, fmt := range audioFormats {
|
|
||||||
_ = mime.AddExtensionType(ext, fmt.typ)
|
|
||||||
if fmt.lossless {
|
|
||||||
LosslessFormats = append(LosslessFormats, strings.TrimPrefix(ext, "."))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(LosslessFormats)
|
|
||||||
for ext, typ := range imageFormats {
|
|
||||||
_ = mime.AddExtensionType(ext, typ)
|
|
||||||
}
|
|
||||||
|
|
||||||
// In some circumstances, Windows sets JS mime-type to `text/plain`!
|
|
||||||
_ = mime.AddExtensionType(".js", "text/javascript")
|
|
||||||
_ = mime.AddExtensionType(".css", "text/css")
|
|
||||||
}
|
|
|
@ -11,7 +11,7 @@
|
||||||
#
|
#
|
||||||
# navidrome_enable (bool): Set to YES to enable navidrome
|
# navidrome_enable (bool): Set to YES to enable navidrome
|
||||||
# Default: NO
|
# Default: NO
|
||||||
# navidrome_config (str): navidrome configration file
|
# navidrome_config (str): navidrome configuration file
|
||||||
# Default: /usr/local/etc/navidrome/config.toml
|
# Default: /usr/local/etc/navidrome/config.toml
|
||||||
# navidrome_datafolder (str): navidrome Folder to store application data
|
# navidrome_datafolder (str): navidrome Folder to store application data
|
||||||
# Default: www
|
# Default: www
|
||||||
|
|
|
@ -14,7 +14,7 @@ import (
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
"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"
|
"github.com/navidrome/navidrome/utils/cache"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -47,7 +47,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
|
||||||
hc := &http.Client{
|
hc := &http.Client{
|
||||||
Timeout: consts.DefaultHttpClientTimeOut,
|
Timeout: consts.DefaultHttpClientTimeOut,
|
||||||
}
|
}
|
||||||
chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||||
l.client = newClient(l.apiKey, l.secret, l.lang, chc)
|
l.client = newClient(l.apiKey, l.secret, l.lang, chc)
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,13 +8,13 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"golang.org/x/exp/slices"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
|
@ -11,7 +11,7 @@ import (
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
"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"
|
"github.com/navidrome/navidrome/utils/cache"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -35,7 +35,7 @@ func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent {
|
||||||
hc := &http.Client{
|
hc := &http.Client{
|
||||||
Timeout: consts.DefaultHttpClientTimeOut,
|
Timeout: consts.DefaultHttpClientTimeOut,
|
||||||
}
|
}
|
||||||
chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||||
l.client = newClient(l.baseURL, chc)
|
l.client = newClient(l.baseURL, chc)
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,7 @@ import (
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
"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"
|
"github.com/navidrome/navidrome/utils/cache"
|
||||||
"github.com/xrash/smetrics"
|
"github.com/xrash/smetrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -35,7 +35,7 @@ func spotifyConstructor(ds model.DataStore) agents.Interface {
|
||||||
hc := &http.Client{
|
hc := &http.Client{
|
||||||
Timeout: consts.DefaultHttpClientTimeOut,
|
Timeout: consts.DefaultHttpClientTimeOut,
|
||||||
}
|
}
|
||||||
chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||||
l.client = newClient(l.id, l.secret, chc)
|
l.client = newClient(l.id, l.secret, chc)
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
|
@ -216,12 +216,9 @@ var _ = Describe("Artwork", func() {
|
||||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15)
|
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
br, format, err := asImageReader(r)
|
img, format, err := image.Decode(r)
|
||||||
Expect(format).To(Equal("image/png"))
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
img, _, err := image.Decode(br)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(format).To(Equal("png"))
|
||||||
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))
|
||||||
})
|
})
|
||||||
|
@ -230,11 +227,8 @@ var _ = Describe("Artwork", func() {
|
||||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200)
|
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
br, format, err := asImageReader(r)
|
img, format, err := image.Decode(r)
|
||||||
Expect(format).To(Equal("image/jpeg"))
|
Expect(format).To(Equal("jpeg"))
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
img, _, err := image.Decode(br)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||||
|
|
|
@ -131,7 +131,7 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro
|
||||||
|
|
||||||
r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize)
|
r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error cacheing id='%s': %w", id, err)
|
return fmt.Errorf("error caching id='%s': %w", id, err)
|
||||||
}
|
}
|
||||||
defer r.Close()
|
defer r.Close()
|
||||||
_, err = io.Copy(io.Discard, r)
|
_, err = io.Copy(io.Discard, r)
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
package artwork
|
package artwork
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
@ -9,14 +8,12 @@ import (
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
"image/png"
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/disintegration/imaging"
|
"github.com/disintegration/imaging"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"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/number"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type resizedArtworkReader struct {
|
type resizedArtworkReader struct {
|
||||||
|
@ -84,54 +81,27 @@ func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, strin
|
||||||
return io.NopCloser(resized), fmt.Sprintf("%s@%d", a.artID, a.size), nil
|
return io.NopCloser(resized), fmt.Sprintf("%s@%d", a.artID, a.size), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func asImageReader(r io.Reader) (io.Reader, string, error) {
|
|
||||||
br := bufio.NewReader(r)
|
|
||||||
buf, err := br.Peek(512)
|
|
||||||
if err == io.EOF && len(buf) > 0 {
|
|
||||||
// Check if there are enough bytes to detect type
|
|
||||||
typ := http.DetectContentType(buf)
|
|
||||||
if typ != "" {
|
|
||||||
return br, typ, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
return br, http.DetectContentType(buf), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resizeImage(reader io.Reader, size int) (io.Reader, int, error) {
|
func resizeImage(reader io.Reader, size int) (io.Reader, int, error) {
|
||||||
r, format, err := asImageReader(reader)
|
original, format, err := image.Decode(reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
img, _, err := image.Decode(r)
|
bounds := original.Bounds()
|
||||||
if err != nil {
|
originalSize := max(bounds.Max.X, bounds.Max.Y)
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't upscale the image
|
// Don't upscale the image
|
||||||
bounds := img.Bounds()
|
|
||||||
originalSize := number.Max(bounds.Max.X, bounds.Max.Y)
|
|
||||||
if originalSize <= size {
|
if originalSize <= size {
|
||||||
return nil, originalSize, nil
|
return nil, originalSize, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var m *image.NRGBA
|
resized := imaging.Fit(original, size, size, imaging.Lanczos)
|
||||||
// Preserve the aspect ratio of the image.
|
|
||||||
if bounds.Max.X > bounds.Max.Y {
|
|
||||||
m = imaging.Resize(img, size, 0, imaging.Lanczos)
|
|
||||||
} else {
|
|
||||||
m = imaging.Resize(img, 0, size, imaging.Lanczos)
|
|
||||||
}
|
|
||||||
|
|
||||||
buf := new(bytes.Buffer)
|
buf := new(bytes.Buffer)
|
||||||
buf.Reset()
|
if format == "png" {
|
||||||
if format == "image/png" {
|
err = png.Encode(buf, resized)
|
||||||
err = png.Encode(buf, m)
|
|
||||||
} else {
|
} else {
|
||||||
err = jpeg.Encode(buf, m, &jpeg.Options{Quality: conf.Server.CoverJpegQuality})
|
err = jpeg.Encode(buf, resized, &jpeg.Options{Quality: conf.Server.CoverJpegQuality})
|
||||||
}
|
}
|
||||||
return buf, originalSize, err
|
return buf, originalSize, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,6 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
"github.com/deluan/sanitize"
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"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/lastfm"
|
||||||
|
@ -18,7 +17,8 @@ import (
|
||||||
"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"
|
"github.com/navidrome/navidrome/utils"
|
||||||
"github.com/navidrome/navidrome/utils/number"
|
. "github.com/navidrome/navidrome/utils/gg"
|
||||||
|
"github.com/navidrome/navidrome/utils/random"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -90,15 +90,16 @@ func (e *externalMetadata) UpdateAlbumInfo(ctx context.Context, id string) (*mod
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if album.ExternalInfoUpdatedAt.IsZero() {
|
updatedAt := V(album.ExternalInfoUpdatedAt)
|
||||||
log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", album.ExternalInfoUpdatedAt, "id", id, "name", album.Name)
|
if updatedAt.IsZero() {
|
||||||
|
log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", album.Name)
|
||||||
err = e.populateAlbumInfo(ctx, album)
|
err = e.populateAlbumInfo(ctx, album)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if time.Since(album.ExternalInfoUpdatedAt) > conf.Server.DevAlbumInfoTimeToLive {
|
if time.Since(updatedAt) > conf.Server.DevAlbumInfoTimeToLive {
|
||||||
log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", album.Name)
|
log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", album.Name)
|
||||||
enqueueRefresh(e.albumQueue, album)
|
enqueueRefresh(e.albumQueue, album)
|
||||||
}
|
}
|
||||||
|
@ -118,7 +119,7 @@ func (e *externalMetadata) populateAlbumInfo(ctx context.Context, album *auxAlbu
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
album.ExternalInfoUpdatedAt = time.Now()
|
album.ExternalInfoUpdatedAt = P(time.Now())
|
||||||
album.ExternalUrl = info.URL
|
album.ExternalUrl = info.URL
|
||||||
|
|
||||||
if info.Description != "" {
|
if info.Description != "" {
|
||||||
|
@ -202,8 +203,9 @@ func (e *externalMetadata) refreshArtistInfo(ctx context.Context, id string) (*a
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we don't have any info, retrieves it now
|
// If we don't have any info, retrieves it now
|
||||||
if artist.ExternalInfoUpdatedAt.IsZero() {
|
updatedAt := V(artist.ExternalInfoUpdatedAt)
|
||||||
log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", artist.ExternalInfoUpdatedAt, "id", id, "name", artist.Name)
|
if updatedAt.IsZero() {
|
||||||
|
log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", artist.Name)
|
||||||
err := e.populateArtistInfo(ctx, artist)
|
err := e.populateArtistInfo(ctx, artist)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -211,8 +213,8 @@ func (e *externalMetadata) refreshArtistInfo(ctx context.Context, id string) (*a
|
||||||
}
|
}
|
||||||
|
|
||||||
// If info is expired, trigger a populateArtistInfo in the background
|
// If info is expired, trigger a populateArtistInfo in the background
|
||||||
if time.Since(artist.ExternalInfoUpdatedAt) > conf.Server.DevArtistInfoTimeToLive {
|
if time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive {
|
||||||
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", artist.ExternalInfoUpdatedAt, "name", artist.Name)
|
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artist.Name)
|
||||||
enqueueRefresh(e.artistQueue, artist)
|
enqueueRefresh(e.artistQueue, artist)
|
||||||
}
|
}
|
||||||
return artist, nil
|
return artist, nil
|
||||||
|
@ -242,7 +244,7 @@ func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist *auxAr
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
artist.ExternalInfoUpdatedAt = time.Now()
|
artist.ExternalInfoUpdatedAt = P(time.Now())
|
||||||
err := e.ds.Artist(ctx).Put(&artist.Artist)
|
err := e.ds.Artist(ctx).Put(&artist.Artist)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artist.Name,
|
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artist.Name,
|
||||||
|
@ -265,14 +267,14 @@ func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count in
|
||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
weightedSongs := utils.NewWeightedRandomChooser()
|
weightedSongs := random.NewWeightedChooser[model.MediaFile]()
|
||||||
addArtist := func(a model.Artist, weightedSongs *utils.WeightedChooser, count, artistWeight int) error {
|
addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error {
|
||||||
if utils.IsCtxDone(ctx) {
|
if utils.IsCtxDone(ctx) {
|
||||||
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
|
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
topCount := number.Max(count, 20)
|
topCount := max(count, 20)
|
||||||
topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Name: a.Name, Artist: a}, topCount)
|
topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Name: a.Name, Artist: a}, topCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err)
|
log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err)
|
||||||
|
@ -300,12 +302,12 @@ func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count in
|
||||||
|
|
||||||
var similarSongs model.MediaFiles
|
var similarSongs model.MediaFiles
|
||||||
for len(similarSongs) < count && weightedSongs.Size() > 0 {
|
for len(similarSongs) < count && weightedSongs.Size() > 0 {
|
||||||
s, err := weightedSongs.GetAndRemove()
|
s, err := weightedSongs.Pick()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn(ctx, "Error getting weighted song", err)
|
log.Warn(ctx, "Error getting weighted song", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
similarSongs = append(similarSongs, s.(model.MediaFile))
|
similarSongs = append(similarSongs, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
return similarSongs, nil
|
return similarSongs, nil
|
||||||
|
@ -412,7 +414,7 @@ func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, a
|
||||||
squirrel.Eq{"artist_id": artistID},
|
squirrel.Eq{"artist_id": artistID},
|
||||||
squirrel.Eq{"album_artist_id": artistID},
|
squirrel.Eq{"album_artist_id": artistID},
|
||||||
},
|
},
|
||||||
squirrel.Like{"order_title": strings.TrimSpace(sanitize.Accents(title))},
|
squirrel.Like{"order_title": utils.SanitizeFieldForSorting(title)},
|
||||||
},
|
},
|
||||||
Sort: "starred desc, rating desc, year asc, compilation asc ",
|
Sort: "starred desc, rating desc, year asc, compilation asc ",
|
||||||
Max: 1,
|
Max: 1,
|
||||||
|
|
|
@ -23,6 +23,7 @@ type FFmpeg interface {
|
||||||
Probe(ctx context.Context, files []string) (string, error)
|
Probe(ctx context.Context, files []string) (string, error)
|
||||||
CmdPath() (string, error)
|
CmdPath() (string, error)
|
||||||
IsAvailable() bool
|
IsAvailable() bool
|
||||||
|
Version() string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() FFmpeg {
|
func New() FFmpeg {
|
||||||
|
@ -84,6 +85,24 @@ func (e *ffmpeg) IsAvailable() bool {
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Version executes ffmpeg -version and extracts the version from the output.
|
||||||
|
// Sample output: ffmpeg version 6.0 Copyright (c) 2000-2023 the FFmpeg developers
|
||||||
|
func (e *ffmpeg) Version() string {
|
||||||
|
cmd, err := ffmpegCmd()
|
||||||
|
if err != nil {
|
||||||
|
return "N/A"
|
||||||
|
}
|
||||||
|
out, err := exec.Command(cmd, "-version").CombinedOutput() // #nosec
|
||||||
|
if err != nil {
|
||||||
|
return "N/A"
|
||||||
|
}
|
||||||
|
parts := strings.Split(string(out), " ")
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return "N/A"
|
||||||
|
}
|
||||||
|
return parts[2]
|
||||||
|
}
|
||||||
|
|
||||||
func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error) {
|
func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error) {
|
||||||
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
|
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
|
||||||
j := &ffCmd{args: args}
|
j := &ffCmd{args: args}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/core/playback/mpv"
|
"github.com/navidrome/navidrome/core/playback/mpv"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
|
@ -22,6 +23,7 @@ type Track interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
type playbackDevice struct {
|
type playbackDevice struct {
|
||||||
|
serviceCtx context.Context
|
||||||
ParentPlaybackServer PlaybackServer
|
ParentPlaybackServer PlaybackServer
|
||||||
Default bool
|
Default bool
|
||||||
User string
|
User string
|
||||||
|
@ -31,7 +33,7 @@ type playbackDevice struct {
|
||||||
Gain float32
|
Gain float32
|
||||||
PlaybackDone chan bool
|
PlaybackDone chan bool
|
||||||
ActiveTrack Track
|
ActiveTrack Track
|
||||||
TrackSwitcherStarted bool
|
startTrackSwitcher sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeviceStatus struct {
|
type DeviceStatus struct {
|
||||||
|
@ -43,8 +45,6 @@ type DeviceStatus struct {
|
||||||
|
|
||||||
const DefaultGain float32 = 1.0
|
const DefaultGain float32 = 1.0
|
||||||
|
|
||||||
var EmptyStatus = DeviceStatus{CurrentIndex: -1, Playing: false, Gain: DefaultGain, Position: 0}
|
|
||||||
|
|
||||||
func (pd *playbackDevice) getStatus() DeviceStatus {
|
func (pd *playbackDevice) getStatus() DeviceStatus {
|
||||||
pos := 0
|
pos := 0
|
||||||
if pd.ActiveTrack != nil {
|
if pd.ActiveTrack != nil {
|
||||||
|
@ -61,8 +61,9 @@ func (pd *playbackDevice) getStatus() DeviceStatus {
|
||||||
// NewPlaybackDevice creates a new playback device which implements all the basic Jukebox mode commands defined here:
|
// NewPlaybackDevice creates a new playback device which implements all the basic Jukebox mode commands defined here:
|
||||||
// http://www.subsonic.org/pages/api.jsp#jukeboxControl
|
// http://www.subsonic.org/pages/api.jsp#jukeboxControl
|
||||||
// Starts the trackSwitcher goroutine for the device.
|
// Starts the trackSwitcher goroutine for the device.
|
||||||
func NewPlaybackDevice(playbackServer PlaybackServer, name string, deviceName string) *playbackDevice {
|
func NewPlaybackDevice(ctx context.Context, playbackServer PlaybackServer, name string, deviceName string) *playbackDevice {
|
||||||
return &playbackDevice{
|
return &playbackDevice{
|
||||||
|
serviceCtx: ctx,
|
||||||
ParentPlaybackServer: playbackServer,
|
ParentPlaybackServer: playbackServer,
|
||||||
User: "",
|
User: "",
|
||||||
Name: name,
|
Name: name,
|
||||||
|
@ -70,7 +71,6 @@ func NewPlaybackDevice(playbackServer PlaybackServer, name string, deviceName st
|
||||||
Gain: DefaultGain,
|
Gain: DefaultGain,
|
||||||
PlaybackQueue: NewQueue(),
|
PlaybackQueue: NewQueue(),
|
||||||
PlaybackDone: make(chan bool),
|
PlaybackDone: make(chan bool),
|
||||||
TrackSwitcherStarted: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,14 +103,13 @@ func (pd *playbackDevice) Set(ctx context.Context, ids []string) (DeviceStatus,
|
||||||
func (pd *playbackDevice) Start(ctx context.Context) (DeviceStatus, error) {
|
func (pd *playbackDevice) Start(ctx context.Context) (DeviceStatus, error) {
|
||||||
log.Debug(ctx, "Processing Start action", "device", pd)
|
log.Debug(ctx, "Processing Start action", "device", pd)
|
||||||
|
|
||||||
if !pd.TrackSwitcherStarted {
|
pd.startTrackSwitcher.Do(func() {
|
||||||
log.Info(ctx, "Starting trackSwitcher goroutine")
|
log.Info(ctx, "Starting trackSwitcher goroutine")
|
||||||
// Start one trackSwitcher goroutine with each device
|
// Start one trackSwitcher goroutine with each device
|
||||||
go func() {
|
go func() {
|
||||||
pd.trackSwitcherGoroutine()
|
pd.trackSwitcherGoroutine()
|
||||||
}()
|
}()
|
||||||
pd.TrackSwitcherStarted = true
|
})
|
||||||
}
|
|
||||||
|
|
||||||
if pd.ActiveTrack != nil {
|
if pd.ActiveTrack != nil {
|
||||||
if pd.isPlaying() {
|
if pd.isPlaying() {
|
||||||
|
@ -255,23 +254,30 @@ func (pd *playbackDevice) isPlaying() bool {
|
||||||
func (pd *playbackDevice) trackSwitcherGoroutine() {
|
func (pd *playbackDevice) trackSwitcherGoroutine() {
|
||||||
log.Debug("Started trackSwitcher goroutine", "device", pd)
|
log.Debug("Started trackSwitcher goroutine", "device", pd)
|
||||||
for {
|
for {
|
||||||
<-pd.PlaybackDone
|
select {
|
||||||
log.Debug("Track switching detected")
|
case <-pd.PlaybackDone:
|
||||||
if pd.ActiveTrack != nil {
|
log.Debug("Track switching detected")
|
||||||
pd.ActiveTrack.Close()
|
if pd.ActiveTrack != nil {
|
||||||
pd.ActiveTrack = nil
|
pd.ActiveTrack.Close()
|
||||||
}
|
pd.ActiveTrack = nil
|
||||||
|
|
||||||
if !pd.PlaybackQueue.IsAtLastElement() {
|
|
||||||
pd.PlaybackQueue.IncreaseIndex()
|
|
||||||
log.Debug("Switching to next song", "queue", pd.PlaybackQueue.String())
|
|
||||||
err := pd.switchActiveTrackByIndex(pd.PlaybackQueue.Index)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Error switching track", err)
|
|
||||||
}
|
}
|
||||||
pd.ActiveTrack.Unpause()
|
|
||||||
} else {
|
if !pd.PlaybackQueue.IsAtLastElement() {
|
||||||
log.Debug("There is no song left in the playlist. Finish.")
|
pd.PlaybackQueue.IncreaseIndex()
|
||||||
|
log.Debug("Switching to next song", "queue", pd.PlaybackQueue.String())
|
||||||
|
err := pd.switchActiveTrackByIndex(pd.PlaybackQueue.Index)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Error switching track", err)
|
||||||
|
}
|
||||||
|
if pd.ActiveTrack != nil {
|
||||||
|
pd.ActiveTrack.Unpause()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Debug("There is no song left in the playlist. Finish.")
|
||||||
|
}
|
||||||
|
case <-pd.serviceCtx.Done():
|
||||||
|
log.Debug("Stopping trackSwitcher goroutine", "device", pd.Name)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -283,10 +289,11 @@ func (pd *playbackDevice) switchActiveTrackByIndex(index int) error {
|
||||||
return errors.New("could not get current track")
|
return errors.New("could not get current track")
|
||||||
}
|
}
|
||||||
|
|
||||||
track, err := mpv.NewTrack(pd.PlaybackDone, pd.DeviceName, *currentTrack)
|
track, err := mpv.NewTrack(pd.serviceCtx, pd.PlaybackDone, pd.DeviceName, *currentTrack)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
pd.ActiveTrack = track
|
pd.ActiveTrack = track
|
||||||
|
pd.ActiveTrack.SetVolume(pd.Gain)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,14 +2,11 @@ package mpv
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
@ -17,16 +14,11 @@ import (
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mpv --no-audio-display --pause 'Jack Johnson/On And On/01 Times Like These.m4a' --input-ipc-server=/tmp/gonzo.socket
|
func start(ctx context.Context, args []string) (Executor, error) {
|
||||||
const (
|
|
||||||
mpvComdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s"
|
|
||||||
)
|
|
||||||
|
|
||||||
func start(args []string) (Executor, error) {
|
|
||||||
log.Debug("Executing mpv command", "cmd", args)
|
log.Debug("Executing mpv command", "cmd", args)
|
||||||
j := Executor{args: args}
|
j := Executor{args: args}
|
||||||
j.PipeReader, j.out = io.Pipe()
|
j.PipeReader, j.out = io.Pipe()
|
||||||
err := j.start()
|
err := j.start(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Executor{}, err
|
return Executor{}, err
|
||||||
}
|
}
|
||||||
|
@ -46,12 +38,9 @@ type Executor struct {
|
||||||
out *io.PipeWriter
|
out *io.PipeWriter
|
||||||
args []string
|
args []string
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
ctx context.Context
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *Executor) start() error {
|
func (j *Executor) start(ctx context.Context) error {
|
||||||
ctx := context.Background()
|
|
||||||
j.ctx = ctx
|
|
||||||
cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
|
cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
|
||||||
cmd.Stdout = j.out
|
cmd.Stdout = j.out
|
||||||
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
||||||
|
@ -81,15 +70,14 @@ func (j *Executor) wait() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path will always be an absolute path
|
// Path will always be an absolute path
|
||||||
func createMPVCommand(cmd, deviceName string, filename string, socketName string) []string {
|
func createMPVCommand(deviceName string, filename string, socketName string) []string {
|
||||||
split := strings.Split(fixCmd(cmd), " ")
|
split := strings.Split(fixCmd(conf.Server.MPVCmdTemplate), " ")
|
||||||
for i, s := range split {
|
for i, s := range split {
|
||||||
s = strings.ReplaceAll(s, "%d", deviceName)
|
s = strings.ReplaceAll(s, "%d", deviceName)
|
||||||
s = strings.ReplaceAll(s, "%f", filename)
|
s = strings.ReplaceAll(s, "%f", filename)
|
||||||
s = strings.ReplaceAll(s, "%s", socketName)
|
s = strings.ReplaceAll(s, "%s", socketName)
|
||||||
split[i] = s
|
split[i] = s
|
||||||
}
|
}
|
||||||
|
|
||||||
return split
|
return split
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -133,10 +121,3 @@ var (
|
||||||
mpvPath string
|
mpvPath string
|
||||||
mpvErr error
|
mpvErr error
|
||||||
)
|
)
|
||||||
|
|
||||||
func TempFileName(prefix, suffix string) string {
|
|
||||||
randBytes := make([]byte, 16)
|
|
||||||
// we can savely ignore the return value since we're loading into a precreated, fixedsized buffer
|
|
||||||
_, _ = rand.Read(randBytes)
|
|
||||||
return filepath.Join(os.TempDir(), prefix+hex.EncodeToString(randBytes)+suffix)
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package mpv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func socketName(prefix, suffix string) string {
|
||||||
|
return utils.TempFileName(prefix, suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeSocket(socketName string) {
|
||||||
|
log.Debug("Removing socketfile", "socketfile", socketName)
|
||||||
|
err := os.Remove(socketName)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Error cleaning up socketfile", "socketfile", socketName, err)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package mpv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func socketName(prefix, suffix string) string {
|
||||||
|
// Windows needs to use a named pipe for the socket
|
||||||
|
// see https://mpv.io/manual/master#using-mpv-from-other-programs-or-scripts
|
||||||
|
return filepath.Join(`\\.\pipe\mpvsocket`, prefix+uuid.NewString()+suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeSocket(string) {
|
||||||
|
// Windows automatically handles cleaning up named pipe
|
||||||
|
}
|
|
@ -6,6 +6,7 @@ package mpv
|
||||||
// https://mpv.io/manual/master/#properties
|
// https://mpv.io/manual/master/#properties
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
@ -24,17 +25,17 @@ type MpvTrack struct {
|
||||||
CloseCalled bool
|
CloseCalled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTrack(playbackDoneChannel chan bool, deviceName string, mf model.MediaFile) (*MpvTrack, error) {
|
func NewTrack(ctx context.Context, playbackDoneChannel chan bool, deviceName string, mf model.MediaFile) (*MpvTrack, error) {
|
||||||
log.Debug("Loading track", "trackPath", mf.Path, "mediaType", mf.ContentType())
|
log.Debug("Loading track", "trackPath", mf.Path, "mediaType", mf.ContentType())
|
||||||
|
|
||||||
if _, err := mpvCommand(); err != nil {
|
if _, err := mpvCommand(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpSocketName := TempFileName("mpv-ctrl-", ".socket")
|
tmpSocketName := socketName("mpv-ctrl-", ".socket")
|
||||||
|
|
||||||
args := createMPVCommand(mpvComdTemplate, deviceName, mf.Path, tmpSocketName)
|
args := createMPVCommand(deviceName, mf.Path, tmpSocketName)
|
||||||
exe, err := start(args)
|
exe, err := start(ctx, args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Error starting mpv process", err)
|
log.Error("Error starting mpv process", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -110,24 +111,20 @@ func (t *MpvTrack) Close() {
|
||||||
log.Debug("sending shutdown command")
|
log.Debug("sending shutdown command")
|
||||||
_, err := t.Conn.Call("quit")
|
_, err := t.Conn.Call("quit")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Error sending quit command to mpv-ipc socket", err)
|
log.Warn("Error sending quit command to mpv-ipc socket", err)
|
||||||
|
|
||||||
if t.Exe != nil {
|
if t.Exe != nil {
|
||||||
log.Debug("cancelling executor")
|
log.Debug("cancelling executor")
|
||||||
err = t.Exe.Cancel()
|
err = t.Exe.Cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Error canceling executor", err)
|
log.Warn("Error canceling executor", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.isSocketFilePresent() {
|
if t.isSocketFilePresent() {
|
||||||
log.Debug("Removing socketfile", "socketfile", t.IPCSocketName)
|
removeSocket(t.IPCSocketName)
|
||||||
err := os.Remove(t.IPCSocketName)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Error cleaning up socketfile", "socketfile", t.IPCSocketName, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -153,7 +150,8 @@ func (t *MpvTrack) Position() int {
|
||||||
if retryCount > 5 {
|
if retryCount > 5 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
break
|
time.Sleep(time.Duration(retryCount) * time.Millisecond)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -169,7 +167,6 @@ func (t *MpvTrack) Position() int {
|
||||||
return int(pos)
|
return int(pos)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *MpvTrack) SetPosition(offset int) error {
|
func (t *MpvTrack) SetPosition(offset int) error {
|
||||||
|
|
|
@ -10,10 +10,8 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"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"
|
||||||
"github.com/navidrome/navidrome/persistence"
|
|
||||||
"github.com/navidrome/navidrome/utils/singleton"
|
"github.com/navidrome/navidrome/utils/singleton"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -21,7 +19,6 @@ type PlaybackServer interface {
|
||||||
Run(ctx context.Context) error
|
Run(ctx context.Context) error
|
||||||
GetDeviceForUser(user string) (*playbackDevice, error)
|
GetDeviceForUser(user string) (*playbackDevice, error)
|
||||||
GetMediaFile(id string) (*model.MediaFile, error)
|
GetMediaFile(id string) (*model.MediaFile, error)
|
||||||
GetCtx() *context.Context
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type playbackServer struct {
|
type playbackServer struct {
|
||||||
|
@ -31,46 +28,41 @@ type playbackServer struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetInstance returns the playback-server singleton
|
// GetInstance returns the playback-server singleton
|
||||||
func GetInstance() PlaybackServer {
|
func GetInstance(ds model.DataStore) PlaybackServer {
|
||||||
return singleton.GetInstance(func() *playbackServer {
|
return singleton.GetInstance(func() *playbackServer {
|
||||||
return &playbackServer{}
|
return &playbackServer{datastore: ds}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the playback server which serves request until canceled using the given context
|
// Run starts the playback server which serves request until canceled using the given context
|
||||||
func (ps *playbackServer) Run(ctx context.Context) error {
|
func (ps *playbackServer) Run(ctx context.Context) error {
|
||||||
ps.datastore = persistence.New(db.Db())
|
ps.ctx = &ctx
|
||||||
devices, err := ps.initDeviceStatus(conf.Server.Jukebox.Devices, conf.Server.Jukebox.Default)
|
|
||||||
ps.playbackDevices = devices
|
|
||||||
|
|
||||||
|
devices, err := ps.initDeviceStatus(ctx, conf.Server.Jukebox.Devices, conf.Server.Jukebox.Default)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
ps.playbackDevices = devices
|
||||||
log.Info(ctx, fmt.Sprintf("%d audio devices found", len(devices)))
|
log.Info(ctx, fmt.Sprintf("%d audio devices found", len(devices)))
|
||||||
|
|
||||||
defaultDevice, _ := ps.getDefaultDevice()
|
defaultDevice, _ := ps.getDefaultDevice()
|
||||||
|
|
||||||
log.Info(ctx, "Using audio device: "+defaultDevice.DeviceName)
|
log.Info(ctx, "Using audio device: "+defaultDevice.DeviceName)
|
||||||
|
|
||||||
ps.ctx = &ctx
|
|
||||||
|
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
|
|
||||||
|
// Should confirm all subprocess are terminated before returning
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCtx produces the context this server was started with. Used for data-retrieval and cancellation
|
func (ps *playbackServer) initDeviceStatus(ctx context.Context, devices []conf.AudioDeviceDefinition, defaultDevice string) ([]playbackDevice, error) {
|
||||||
func (ps *playbackServer) GetCtx() *context.Context {
|
|
||||||
return ps.ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ps *playbackServer) initDeviceStatus(devices []conf.AudioDeviceDefinition, defaultDevice string) ([]playbackDevice, error) {
|
|
||||||
pbDevices := make([]playbackDevice, max(1, len(devices)))
|
pbDevices := make([]playbackDevice, max(1, len(devices)))
|
||||||
defaultDeviceFound := false
|
defaultDeviceFound := false
|
||||||
|
|
||||||
if defaultDevice == "" {
|
if defaultDevice == "" {
|
||||||
// if there are no devices given and no default device, we create a sythetic device named "auto"
|
// if there are no devices given and no default device, we create a synthetic device named "auto"
|
||||||
if len(devices) == 0 {
|
if len(devices) == 0 {
|
||||||
pbDevices[0] = *NewPlaybackDevice(ps, "auto", "auto")
|
pbDevices[0] = *NewPlaybackDevice(ctx, ps, "auto", "auto")
|
||||||
}
|
}
|
||||||
|
|
||||||
// if there is but only one entry and no default given, just use that.
|
// if there is but only one entry and no default given, just use that.
|
||||||
|
@ -78,7 +70,7 @@ func (ps *playbackServer) initDeviceStatus(devices []conf.AudioDeviceDefinition,
|
||||||
if len(devices[0]) != 2 {
|
if len(devices[0]) != 2 {
|
||||||
return []playbackDevice{}, fmt.Errorf("audio device definition ought to contain 2 fields, found: %d ", len(devices[0]))
|
return []playbackDevice{}, fmt.Errorf("audio device definition ought to contain 2 fields, found: %d ", len(devices[0]))
|
||||||
}
|
}
|
||||||
pbDevices[0] = *NewPlaybackDevice(ps, devices[0][0], devices[0][1])
|
pbDevices[0] = *NewPlaybackDevice(ctx, ps, devices[0][0], devices[0][1])
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(devices) > 1 {
|
if len(devices) > 1 {
|
||||||
|
@ -94,7 +86,7 @@ func (ps *playbackServer) initDeviceStatus(devices []conf.AudioDeviceDefinition,
|
||||||
return []playbackDevice{}, fmt.Errorf("audio device definition ought to contain 2 fields, found: %d ", len(audioDevice))
|
return []playbackDevice{}, fmt.Errorf("audio device definition ought to contain 2 fields, found: %d ", len(audioDevice))
|
||||||
}
|
}
|
||||||
|
|
||||||
pbDevices[idx] = *NewPlaybackDevice(ps, audioDevice[0], audioDevice[1])
|
pbDevices[idx] = *NewPlaybackDevice(ctx, ps, audioDevice[0], audioDevice[1])
|
||||||
|
|
||||||
if audioDevice[0] == defaultDevice {
|
if audioDevice[0] == defaultDevice {
|
||||||
pbDevices[idx].Default = true
|
pbDevices[idx].Default = true
|
||||||
|
@ -109,12 +101,12 @@ func (ps *playbackServer) initDeviceStatus(devices []conf.AudioDeviceDefinition,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ps *playbackServer) getDefaultDevice() (*playbackDevice, error) {
|
func (ps *playbackServer) getDefaultDevice() (*playbackDevice, error) {
|
||||||
for idx, audioDevice := range ps.playbackDevices {
|
for idx := range ps.playbackDevices {
|
||||||
if audioDevice.Default {
|
if ps.playbackDevices[idx].Default {
|
||||||
return &ps.playbackDevices[idx], nil
|
return &ps.playbackDevices[idx], nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &playbackDevice{}, fmt.Errorf("no default device found")
|
return nil, fmt.Errorf("no default device found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMediaFile retrieves the MediaFile given by the id parameter
|
// GetMediaFile retrieves the MediaFile given by the id parameter
|
||||||
|
@ -128,7 +120,7 @@ func (ps *playbackServer) GetDeviceForUser(user string) (*playbackDevice, error)
|
||||||
// README: here we might plug-in the user-device mapping one fine day
|
// README: here we might plug-in the user-device mapping one fine day
|
||||||
device, err := ps.getDefaultDevice()
|
device, err := ps.getDefaultDevice()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &playbackDevice{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
device.User = user
|
device.User = user
|
||||||
return device, nil
|
return device, nil
|
||||||
|
|
|
@ -134,17 +134,3 @@ func (pd *Queue) IncreaseIndex() {
|
||||||
pd.SetIndex(pd.Index + 1)
|
pd.SetIndex(pd.Index + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func max(x, y int) int {
|
|
||||||
if x < y {
|
|
||||||
return y
|
|
||||||
}
|
|
||||||
return x
|
|
||||||
}
|
|
||||||
|
|
||||||
func min(x, y int) int {
|
|
||||||
if x > y {
|
|
||||||
return y
|
|
||||||
}
|
|
||||||
return x
|
|
||||||
}
|
|
||||||
|
|
|
@ -14,6 +14,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/RaveNoX/go-jsoncommentstrip"
|
||||||
"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/criteria"
|
"github.com/navidrome/navidrome/model/criteria"
|
||||||
|
@ -112,7 +113,8 @@ func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*mod
|
||||||
|
|
||||||
func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.Reader) (*model.Playlist, error) {
|
func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.Reader) (*model.Playlist, error) {
|
||||||
nsp := &nspFile{}
|
nsp := &nspFile{}
|
||||||
dec := json.NewDecoder(file)
|
reader := jsoncommentstrip.NewReader(file)
|
||||||
|
dec := json.NewDecoder(reader)
|
||||||
err := dec.Decode(nsp)
|
err := dec.Decode(nsp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error parsing SmartPlaylist", "playlist", pls.Name, err)
|
log.Error(ctx, "Error parsing SmartPlaylist", "playlist", pls.Name, err)
|
||||||
|
@ -229,13 +231,17 @@ func (s *playlists) Update(ctx context.Context, playlistID string,
|
||||||
var pls *model.Playlist
|
var pls *model.Playlist
|
||||||
var err error
|
var err error
|
||||||
repo := tx.Playlist(ctx)
|
repo := tx.Playlist(ctx)
|
||||||
|
tracks := repo.Tracks(playlistID, true)
|
||||||
|
if tracks == nil {
|
||||||
|
return fmt.Errorf("%w: playlist '%s'", model.ErrNotFound, playlistID)
|
||||||
|
}
|
||||||
if needsTrackRefresh {
|
if needsTrackRefresh {
|
||||||
pls, err = repo.GetWithTracks(playlistID, true)
|
pls, err = repo.GetWithTracks(playlistID, true)
|
||||||
pls.RemoveTracks(idxToRemove)
|
pls.RemoveTracks(idxToRemove)
|
||||||
pls.AddTracks(idsToAdd)
|
pls.AddTracks(idsToAdd)
|
||||||
} else {
|
} else {
|
||||||
if len(idsToAdd) > 0 {
|
if len(idsToAdd) > 0 {
|
||||||
_, err = repo.Tracks(playlistID, true).Add(idsToAdd)
|
_, err = tracks.Add(idsToAdd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -262,7 +268,7 @@ func (s *playlists) Update(ctx context.Context, playlistID string,
|
||||||
}
|
}
|
||||||
// Special case: The playlist is now empty
|
// Special case: The playlist is now empty
|
||||||
if len(idxToRemove) > 0 && len(pls.Tracks) == 0 {
|
if len(idxToRemove) > 0 && len(pls.Tracks) == 0 {
|
||||||
if err = repo.Tracks(playlistID, true).DeleteAll(); err != nil {
|
if err = tracks.DeleteAll(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/model/criteria"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
"github.com/navidrome/navidrome/model/request"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
@ -33,29 +34,45 @@ var _ = Describe("Playlists", func() {
|
||||||
ps = NewPlaylists(ds)
|
ps = NewPlaylists(ds)
|
||||||
})
|
})
|
||||||
|
|
||||||
It("parses well-formed playlists", func() {
|
Describe("M3U", func() {
|
||||||
pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/pls1.m3u")
|
It("parses well-formed playlists", func() {
|
||||||
Expect(err).To(BeNil())
|
pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/pls1.m3u")
|
||||||
Expect(pls.OwnerID).To(Equal("123"))
|
Expect(err).To(BeNil())
|
||||||
Expect(pls.Tracks).To(HaveLen(3))
|
Expect(pls.OwnerID).To(Equal("123"))
|
||||||
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/test.mp3"))
|
Expect(pls.Tracks).To(HaveLen(3))
|
||||||
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/test.ogg"))
|
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/test.mp3"))
|
||||||
Expect(pls.Tracks[2].Path).To(Equal("/tests/fixtures/01 Invisible (RED) Edit Version.mp3"))
|
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/test.ogg"))
|
||||||
Expect(mp.last).To(Equal(pls))
|
Expect(pls.Tracks[2].Path).To(Equal("/tests/fixtures/01 Invisible (RED) Edit Version.mp3"))
|
||||||
|
Expect(mp.last).To(Equal(pls))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("parses playlists using LF ending", func() {
|
||||||
|
pls, err := ps.ImportFile(ctx, "tests/fixtures/playlists", "lf-ended.m3u")
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
Expect(pls.Tracks).To(HaveLen(2))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("parses playlists using CR ending (old Mac format)", func() {
|
||||||
|
pls, err := ps.ImportFile(ctx, "tests/fixtures/playlists", "cr-ended.m3u")
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
Expect(pls.Tracks).To(HaveLen(2))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
It("parses playlists using LF ending", func() {
|
Describe("NSP", func() {
|
||||||
pls, err := ps.ImportFile(ctx, "tests/fixtures/playlists", "lf-ended.m3u")
|
It("parses well-formed playlists", func() {
|
||||||
Expect(err).To(BeNil())
|
pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/recently_played.nsp")
|
||||||
Expect(pls.Tracks).To(HaveLen(2))
|
Expect(err).To(BeNil())
|
||||||
|
Expect(mp.last).To(Equal(pls))
|
||||||
|
Expect(pls.OwnerID).To(Equal("123"))
|
||||||
|
Expect(pls.Name).To(Equal("Recently Played"))
|
||||||
|
Expect(pls.Comment).To(Equal("Recently played tracks"))
|
||||||
|
Expect(pls.Rules.Sort).To(Equal("lastPlayed"))
|
||||||
|
Expect(pls.Rules.Order).To(Equal("desc"))
|
||||||
|
Expect(pls.Rules.Limit).To(Equal(100))
|
||||||
|
Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{}))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
It("parses playlists using CR ending (old Mac format)", func() {
|
|
||||||
pls, err := ps.ImportFile(ctx, "tests/fixtures/playlists", "cr-ended.m3u")
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
Expect(pls.Tracks).To(HaveLen(2))
|
|
||||||
})
|
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("ImportM3U", func() {
|
Describe("ImportM3U", func() {
|
||||||
|
|
|
@ -5,10 +5,9 @@ import (
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/jellydator/ttlcache/v2"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
|
|
||||||
"github.com/ReneKroon/ttlcache/v2"
|
|
||||||
"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"
|
||||||
|
|
|
@ -10,6 +10,7 @@ import (
|
||||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||||
"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/gg"
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
"github.com/navidrome/navidrome/utils/slice"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -34,10 +35,11 @@ func (s *shareService) Load(ctx context.Context, id string) (*model.Share, error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !share.ExpiresAt.IsZero() && share.ExpiresAt.Before(time.Now()) {
|
expiresAt := V(share.ExpiresAt)
|
||||||
|
if !expiresAt.IsZero() && expiresAt.Before(time.Now()) {
|
||||||
return nil, model.ErrExpired
|
return nil, model.ErrExpired
|
||||||
}
|
}
|
||||||
share.LastVisitedAt = time.Now()
|
share.LastVisitedAt = P(time.Now())
|
||||||
share.VisitCount++
|
share.VisitCount++
|
||||||
|
|
||||||
err = repo.(rest.Persistable).Update(id, share, "last_visited_at", "visit_count")
|
err = repo.(rest.Persistable).Update(id, share, "last_visited_at", "visit_count")
|
||||||
|
@ -90,8 +92,8 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
s.ID = id
|
s.ID = id
|
||||||
if s.ExpiresAt.IsZero() {
|
if V(s.ExpiresAt).IsZero() {
|
||||||
s.ExpiresAt = time.Now().Add(365 * 24 * time.Hour)
|
s.ExpiresAt = P(time.Now().Add(365 * 24 * time.Hour))
|
||||||
}
|
}
|
||||||
|
|
||||||
firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0]
|
firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0]
|
||||||
|
@ -128,7 +130,7 @@ func (r *shareRepositoryWrapper) Update(id string, entity interface{}, _ ...stri
|
||||||
cols := []string{"description", "downloadable"}
|
cols := []string{"description", "downloadable"}
|
||||||
|
|
||||||
// TODO Better handling of Share expiration
|
// TODO Better handling of Share expiration
|
||||||
if !entity.(*model.Share).ExpiresAt.IsZero() {
|
if !V(entity.(*model.Share).ExpiresAt).IsZero() {
|
||||||
cols = append(cols, "expires_at")
|
cols = append(cols, "expires_at")
|
||||||
}
|
}
|
||||||
return r.Persistable.Update(id, entity, cols...)
|
return r.Persistable.Update(id, entity, cols...)
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"github.com/google/wire"
|
"github.com/google/wire"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||||
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -18,4 +19,5 @@ var Set = wire.NewSet(
|
||||||
agents.New,
|
agents.New,
|
||||||
ffmpeg.New,
|
ffmpeg.New,
|
||||||
scrobbler.GetPlayTracker,
|
scrobbler.GetPlayTracker,
|
||||||
|
playback.GetInstance,
|
||||||
)
|
)
|
||||||
|
|
54
db/db.go
54
db/db.go
|
@ -5,10 +5,11 @@ import (
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
"github.com/mattn/go-sqlite3"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
_ "github.com/navidrome/navidrome/db/migration"
|
_ "github.com/navidrome/navidrome/db/migrations"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/utils/hasher"
|
||||||
"github.com/navidrome/navidrome/utils/singleton"
|
"github.com/navidrome/navidrome/utils/singleton"
|
||||||
"github.com/pressly/goose/v3"
|
"github.com/pressly/goose/v3"
|
||||||
)
|
)
|
||||||
|
@ -18,20 +19,26 @@ var (
|
||||||
Path string
|
Path string
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed migration/*.sql
|
//go:embed migrations/*.sql
|
||||||
var embedMigrations embed.FS
|
var embedMigrations embed.FS
|
||||||
|
|
||||||
const migrationsFolder = "migration"
|
const migrationsFolder = "migrations"
|
||||||
|
|
||||||
func Db() *sql.DB {
|
func Db() *sql.DB {
|
||||||
return singleton.GetInstance(func() *sql.DB {
|
return singleton.GetInstance(func() *sql.DB {
|
||||||
|
sql.Register(Driver+"_custom", &sqlite3.SQLiteDriver{
|
||||||
|
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
|
||||||
|
return conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
Path = conf.Server.DbPath
|
Path = conf.Server.DbPath
|
||||||
if Path == ":memory:" {
|
if Path == ":memory:" {
|
||||||
Path = "file::memory:?cache=shared&_foreign_keys=on"
|
Path = "file::memory:?cache=shared&_foreign_keys=on"
|
||||||
conf.Server.DbPath = Path
|
conf.Server.DbPath = Path
|
||||||
}
|
}
|
||||||
log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver)
|
log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver)
|
||||||
instance, err := sql.Open(Driver, Path)
|
instance, err := sql.Open(Driver+"_custom", Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
@ -44,7 +51,7 @@ func Close() error {
|
||||||
return Db().Close()
|
return Db().Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func Init() {
|
func Init() func() {
|
||||||
db := Db()
|
db := Db()
|
||||||
|
|
||||||
// Disable foreign_keys to allow re-creating tables in migrations
|
// Disable foreign_keys to allow re-creating tables in migrations
|
||||||
|
@ -60,17 +67,50 @@ func Init() {
|
||||||
}
|
}
|
||||||
|
|
||||||
gooseLogger := &logAdapter{silent: isSchemaEmpty(db)}
|
gooseLogger := &logAdapter{silent: isSchemaEmpty(db)}
|
||||||
goose.SetLogger(gooseLogger)
|
|
||||||
goose.SetBaseFS(embedMigrations)
|
goose.SetBaseFS(embedMigrations)
|
||||||
|
|
||||||
err = goose.SetDialect(Driver)
|
err = goose.SetDialect(Driver)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Invalid DB driver", "driver", Driver, err)
|
log.Fatal("Invalid DB driver", "driver", Driver, err)
|
||||||
}
|
}
|
||||||
|
if !isSchemaEmpty(db) && hasPendingMigrations(db, migrationsFolder) {
|
||||||
|
log.Info("Upgrading DB Schema to latest version")
|
||||||
|
}
|
||||||
|
goose.SetLogger(gooseLogger)
|
||||||
err = goose.Up(db, migrationsFolder)
|
err = goose.Up(db, migrationsFolder)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Failed to apply new migrations", err)
|
log.Fatal("Failed to apply new migrations", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
if err := Close(); err != nil {
|
||||||
|
log.Error("Error closing DB", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusLogger struct{ numPending int }
|
||||||
|
|
||||||
|
func (*statusLogger) Fatalf(format string, v ...interface{}) { log.Fatal(fmt.Sprintf(format, v...)) }
|
||||||
|
func (l *statusLogger) Printf(format string, v ...interface{}) {
|
||||||
|
if len(v) < 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v0, ok := v[0].(string); !ok {
|
||||||
|
return
|
||||||
|
} else if v0 == "Pending" {
|
||||||
|
l.numPending++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasPendingMigrations(db *sql.DB, folder string) bool {
|
||||||
|
l := &statusLogger{}
|
||||||
|
goose.SetLogger(l)
|
||||||
|
err := goose.Status(db, folder)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Failed to check for pending migrations", err)
|
||||||
|
}
|
||||||
|
return l.numPending > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func isSchemaEmpty(db *sql.DB) bool {
|
func isSchemaEmpty(db *sql.DB) bool {
|
||||||
|
|
|
@ -30,7 +30,7 @@ func upAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, t := range consts.DefaultTranscodings {
|
for _, t := range consts.DefaultTranscodings {
|
||||||
_, err := stmt.Exec(uuid.NewString(), t["name"], t["targetFormat"], t["defaultBitRate"], t["command"])
|
_, err := stmt.Exec(uuid.NewString(), t.Name, t.TargetFormat, t.DefaultBitRate, t.Command)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
|
@ -4,12 +4,12 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/pressly/goose/v3"
|
"github.com/pressly/goose/v3"
|
||||||
"golang.org/x/exp/slices"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue