Compare commits
281 Commits
Author | SHA1 | Date |
---|---|---|
dependabot[bot] | 6d8d519807 | |
dependabot[bot] | da9cf22b6b | |
Deluan | 8c3919d6a0 | |
dependabot[bot] | 4df69bd334 | |
Deluan | ee73a9d297 | |
Caio Cotts | 0488fb92cb | |
Deluan | 61903facdf | |
Drew Weymouth | b6fce0e686 | |
Deluan | f88d3f82da | |
Deluan | 55bff343cd | |
dependabot[bot] | 68f03d0167 | |
Deluan | 643c763cb4 | |
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 | |
Deluan | fd61b29a84 | |
Deluan | 2b33ef72e3 | |
Deluan | 2fb913f5c9 | |
Deluan | 6c05493cda | |
Deluan | 3ca4f44118 | |
Deluan | 34c29a156f | |
dependabot[bot] | b442736a0f | |
dependabot[bot] | 90fccf00d1 | |
dependabot[bot] | bcd4a52616 | |
dependabot[bot] | 84cffa6b94 | |
dependabot[bot] | a51b1b25d2 | |
dependabot[bot] | 9f317c054b | |
dependabot[bot] | 5f8d01a207 | |
dependabot[bot] | 8a648d717a | |
dependabot[bot] | a0dc2ee051 | |
dependabot[bot] | ffb4de1e27 | |
dependabot[bot] | e1fc7983a5 | |
dependabot[bot] | 2a43f54eb1 | |
dependabot[bot] | f654e92113 | |
flyingOwl | dfa453cc4a | |
Johannes Engl | 8f03454312 | |
dependabot[bot] | 8570773b90 | |
caiocotts | 6cff91e17d | |
dependabot[bot] | d0df81a8df | |
dependabot[bot] | 75f3ef64e2 | |
dependabot[bot] | 170ac93926 | |
Deluan | 6f7b48202e | |
Deluan | 6e2be7f95f | |
Deluan | 0d8f8e3afd | |
Deluan | e50382e3bf | |
Kendall Garner | 814161d78d | |
Deluan | 130ab76c79 | |
Deluan | a186a795f6 | |
Deluan | 798b03eabd | |
Deluan | ea7ba22699 | |
Andrew Katsikas | b4815ecee5 | |
Deluan | 51e07d4cb5 | |
Deluan | 03119e5ccf | |
Deluan Quintão | 15e1394fa3 | |
Deluan | 3f349b1b58 | |
Deluan | dfcc189cff | |
Deluan | 00597e01e9 | |
Dany Marcoux | 965fc9d9be | |
Deluan Quintão | 781ff40464 | |
Deluan | a6ed0442f2 | |
dependabot[bot] | 515efe37f0 | |
dependabot[bot] | 6c28c111bb | |
dependabot[bot] | 92a88ad4d9 | |
dependabot[bot] | 4ccc0a92bf | |
dependabot[bot] | df3de047ca | |
Caio Cotts | 86757663d6 | |
dependabot[bot] | 735d670a5b | |
dependabot[bot] | 30179146c3 | |
dependabot[bot] | 03a9f22ed9 | |
dependabot[bot] | 39e92a1918 | |
Deluan | 421ce91a9e | |
Deluan | 12aae5e951 | |
Deluan | 932152eb7e | |
Deluan | 0e3175ea17 | |
Deluan | d3f6b4692d | |
Deluan | 70effa09e8 | |
Deluan | 7ccf685973 | |
Deluan | 2aef227572 | |
Deluan | d80e1a260b | |
dependabot[bot] | fd4605d7dc | |
Deluan | a6493c4c36 | |
Kendall Garner | 54597bd575 | |
Deluan Quintão | ab53313273 | |
Deluan | d3669f46a9 | |
Deluan | d89de9060a | |
Deluan | ac3668a33e | |
dependabot[bot] | 6d924ad742 | |
Deluan | 78d557c185 | |
Deluan | 546aa26a0a | |
dependabot[bot] | fc677f7951 | |
Deluan | aed0309161 | |
Deluan | 465cc091b0 | |
Deluan | 2c9035fdd0 | |
Deluan | af7eead037 | |
Deluan Quintão | 0ca0d5da22 | |
dependabot[bot] | 7074455e0e | |
caiocotts | 2f2fbeb009 | |
Kendall Garner | 742fd16a01 | |
Deluan Quintão | 7766ee069c | |
Deluan | 4cd7c7f39f | |
Deluan | 81daee3b9b | |
Deluan | 9b434d743f | |
Deluan | 4641dc0b2b | |
Deluan | 812dc2090f | |
Deluan | a9cf54afef | |
Deluan | 595186b1b2 | |
Deluan | cdccdc56c9 | |
Deluan | f580c5b8bc | |
deluan | f0e25c251d | |
Deluan | abde399e7b | |
Deluan | 1b4483d32b | |
Deluan | f7fe8ba938 | |
Deluan | f543e7accc | |
Deluan Quintão | 60a5fbe1fe | |
Deluan | 28dc98dec4 | |
Deluan | 8c8e1ea701 | |
Deluan | b964018cd7 | |
Deluan | 9aa7b80d0d | |
Deluan | c3efc57259 | |
Deluan | 27a92b05e7 | |
Deluan | 21f1354cd1 | |
Deluan | 069da5d91c | |
Deluan | 69d2ced852 | |
Deluan | 17ac8d25cb | |
Deluan | 474f32f1b8 | |
Deluan | ecadcfb403 |
|
@ -4,10 +4,10 @@
|
|||
"dockerfile": "Dockerfile",
|
||||
"args": {
|
||||
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
|
||||
"VARIANT": "1.21",
|
||||
"VARIANT": "1.22",
|
||||
// Options
|
||||
"INSTALL_NODE": "true",
|
||||
"NODE_VERSION": "v18"
|
||||
"NODE_VERSION": "v20"
|
||||
}
|
||||
},
|
||||
"workspaceMount": "",
|
||||
|
|
|
@ -16,11 +16,13 @@ RUN chmod +x /navidrome
|
|||
|
||||
#####################################################
|
||||
### Build Final Image
|
||||
FROM alpine as release
|
||||
FROM alpine:3.18
|
||||
LABEL maintainer="deluan@navidrome.org"
|
||||
|
||||
# Install ffmpeg and output build config
|
||||
RUN apk add --no-cache ffmpeg
|
||||
# Install ffmpeg and mpv
|
||||
RUN apk add -U --no-cache ffmpeg mpv
|
||||
|
||||
# Show ffmpeg build info, for troubleshooting purposes
|
||||
RUN ffmpeg -buildconf
|
||||
|
||||
COPY --from=copy-binary /navidrome /app/
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
name: 'Pipeline: Test, Lint, Build'
|
||||
name: "Pipeline: Test, Lint, Build"
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
|
@ -8,30 +8,28 @@ on:
|
|||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
go-lint:
|
||||
name: Lint Go code
|
||||
runs-on: ubuntu-latest
|
||||
container: deluan/ci-goreleaser:1.22.3-1
|
||||
steps:
|
||||
- name: Install taglib
|
||||
run: sudo apt-get install libtag1-dev
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go 1.21
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.21.x
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
- name: Config workspace folder as trusted
|
||||
run: git config --global --add safe.directory $GITHUB_WORKSPACE; git describe --dirty --always --tags
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: latest
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
problem-matchers: true
|
||||
args: --timeout 2m
|
||||
|
||||
- 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: go mod tidy
|
||||
|
@ -44,23 +42,15 @@ jobs:
|
|||
fi
|
||||
|
||||
go:
|
||||
name: Test with Go ${{ matrix.go_version }}
|
||||
name: Test Go code
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
go_version: [1.21.x,1.20.x]
|
||||
container: deluan/ci-goreleaser:1.22.3-1
|
||||
steps:
|
||||
- name: Install taglib
|
||||
run: sudo apt-get install libtag1-dev
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go ${{ matrix.go_version }}
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ matrix.go_version }}
|
||||
cache: true
|
||||
- name: Config workspace folder as trusted
|
||||
run: git config --global --add safe.directory $GITHUB_WORKSPACE; git describe --dirty --always --tags
|
||||
|
||||
- name: Download dependencies
|
||||
if: steps.cache-go.outputs.cache-hit != 'true'
|
||||
|
@ -75,14 +65,14 @@ jobs:
|
|||
name: Build JS bundle
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
node-version: 20
|
||||
cache: "npm"
|
||||
cache-dependency-path: "**/package-lock.json"
|
||||
|
||||
- name: npm install dependencies
|
||||
run: |
|
||||
|
@ -104,7 +94,7 @@ jobs:
|
|||
cd ui
|
||||
npm run build
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: js-bundle
|
||||
path: ui/build
|
||||
|
@ -114,41 +104,34 @@ jobs:
|
|||
name: Build binaries
|
||||
needs: [js, go, go-lint]
|
||||
runs-on: ubuntu-latest
|
||||
container: deluan/ci-goreleaser:1.22.3-1
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
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:
|
||||
name: js-bundle
|
||||
path: ui/build
|
||||
|
||||
- name: Config /github/workspace folder as trusted
|
||||
uses: docker://deluan/ci-goreleaser:1.21.0-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
|
||||
if: startsWith(github.ref, 'refs/tags/') != true
|
||||
uses: docker://deluan/ci-goreleaser:1.21.0-1
|
||||
run: goreleaser release --clean --skip=publish --snapshot
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: goreleaser release --rm-dist --skip-publish --snapshot
|
||||
|
||||
- name: Run GoReleaser - RELEASE
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: docker://deluan/ci-goreleaser:1.21.0-1
|
||||
run: goreleaser release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: goreleaser release --rm-dist
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: binaries
|
||||
path: |
|
||||
|
@ -166,18 +149,18 @@ jobs:
|
|||
steps:
|
||||
- name: Set up QEMU
|
||||
id: qemu
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
|
||||
- uses: actions/download-artifact@v3
|
||||
- uses: actions/download-artifact@v4
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
with:
|
||||
name: binaries
|
||||
|
@ -185,14 +168,14 @@ jobs:
|
|||
|
||||
- name: Login to Docker Hub
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
@ -201,7 +184,7 @@ jobs:
|
|||
- name: Extract metadata for Docker
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
labels: |
|
||||
maintainer=deluan
|
||||
|
@ -215,7 +198,7 @@ jobs:
|
|||
|
||||
- name: Build and Push
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: .github/workflows/pipeline.dockerfile
|
||||
|
|
|
@ -12,8 +12,9 @@ jobs:
|
|||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v4.0.0
|
||||
- uses: dessant/lock-threads@v5
|
||||
with:
|
||||
process-only: 'issues, prs'
|
||||
issue-inactive-days: 120
|
||||
pr-inactive-days: 120
|
||||
log-output: true
|
||||
|
@ -27,7 +28,7 @@ jobs:
|
|||
This pull request has been automatically locked since there
|
||||
has not been any recent activity after it was closed.
|
||||
Please open a new issue for related bugs.
|
||||
- uses: actions/stale@v7
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
operations-per-run: 999
|
||||
days-before-issue-stale: 180
|
||||
|
|
|
@ -8,7 +8,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'navidrome' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Get updated translations
|
||||
env:
|
||||
POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
|
||||
|
@ -20,7 +20,7 @@ jobs:
|
|||
git status --porcelain
|
||||
git diff
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v4
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
token: ${{ secrets.PAT }}
|
||||
commit-message: Update translations
|
||||
|
|
|
@ -25,3 +25,4 @@ tags
|
|||
.gitinfo
|
||||
docker-compose.yml
|
||||
!contrib/docker-compose.yml
|
||||
test-123.db
|
||||
|
|
|
@ -1,6 +1,3 @@
|
|||
run:
|
||||
go: "1.20"
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- asasalint
|
||||
|
@ -28,8 +25,12 @@ linters:
|
|||
- unused
|
||||
- whitespace
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
- linters:
|
||||
- gosec
|
||||
text: "(G501|G401|G505):"
|
||||
linters-settings:
|
||||
govet:
|
||||
enable:
|
||||
- nilness
|
||||
gosec:
|
||||
excludes:
|
||||
- G501
|
||||
- G401
|
||||
- G505
|
||||
|
|
21
Makefile
21
Makefile
|
@ -9,7 +9,7 @@ GIT_SHA=source_archive
|
|||
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))
|
||||
endif
|
||||
|
||||
CI_RELEASER_VERSION=1.21.0-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
|
||||
@echo Downloading Node dependencies...
|
||||
|
@ -47,7 +47,7 @@ lintall: lint ##@Development Lint Go and JS code
|
|||
|
||||
format: ##@Development Format code
|
||||
@(cd ./ui && npm run prettier)
|
||||
@go run golang.org/x/tools/cmd/goimports -w `find . -name '*.go' | grep -v _gen.go$$`
|
||||
@go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v _gen.go$$`
|
||||
@go mod tidy
|
||||
.PHONY: format
|
||||
|
||||
|
@ -61,12 +61,12 @@ snapshots: ##@Development Update (GoLang) Snapshot tests
|
|||
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
setup-dev: setup
|
||||
|
@ -94,8 +94,9 @@ buildjs: check_node_env ##@Build Build only frontend
|
|||
.PHONY: buildjs
|
||||
|
||||
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) \
|
||||
goreleaser release --rm-dist --skip-publish --snapshot
|
||||
goreleaser release --clean --skip=publish --snapshot
|
||||
.PHONY: all
|
||||
|
||||
single: warning-noui-build ##@Cross_Compilation Build binaries for a single supported platforms. It does not build the frontend
|
||||
|
@ -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'; \
|
||||
exit 1; \
|
||||
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) \
|
||||
goreleaser build --rm-dist --snapshot --single-target --id navidrome_${GOOS}_${GOARCH}
|
||||
goreleaser build --clean --snapshot -p 2 --single-target --id navidrome_${GOOS}_${GOARCH}
|
||||
.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:
|
||||
@echo "WARNING: This command does not build the frontend, it uses the latest built with 'make buildjs'"
|
||||
.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)
|
||||
[![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)
|
||||
[![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)
|
||||
|
||||
Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your
|
||||
|
|
|
@ -0,0 +1,99 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/scanner/metadata"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"github.com/spf13/cobra"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
extractor string
|
||||
format string
|
||||
)
|
||||
|
||||
func init() {
|
||||
inspectCmd.Flags().StringVarP(&extractor, "extractor", "x", "", "extractor to use (ffmpeg or taglib, default: auto)")
|
||||
inspectCmd.Flags().StringVarP(&format, "format", "f", "pretty", "output format (pretty, toml, yaml, json, jsonindent)")
|
||||
rootCmd.AddCommand(inspectCmd)
|
||||
}
|
||||
|
||||
var inspectCmd = &cobra.Command{
|
||||
Use: "inspect [files to inspect]",
|
||||
Short: "Inspect tags",
|
||||
Long: "Show file tags as seen by Navidrome",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runInspector(args)
|
||||
},
|
||||
}
|
||||
|
||||
var marshalers = map[string]func(interface{}) ([]byte, error){
|
||||
"pretty": prettyMarshal,
|
||||
"toml": toml.Marshal,
|
||||
"yaml": yaml.Marshal,
|
||||
"json": json.Marshal,
|
||||
"jsonindent": func(v interface{}) ([]byte, error) {
|
||||
return json.MarshalIndent(v, "", " ")
|
||||
},
|
||||
}
|
||||
|
||||
func prettyMarshal(v interface{}) ([]byte, error) {
|
||||
out := v.([]inspectorOutput)
|
||||
var res strings.Builder
|
||||
for i := range out {
|
||||
res.WriteString(fmt.Sprintf("====================\nFile: %s\n\n", out[i].File))
|
||||
t, _ := toml.Marshal(out[i].RawTags)
|
||||
res.WriteString(fmt.Sprintf("Raw tags:\n%s\n\n", t))
|
||||
t, _ = toml.Marshal(out[i].MappedTags)
|
||||
res.WriteString(fmt.Sprintf("Mapped tags:\n%s\n\n", t))
|
||||
}
|
||||
return []byte(res.String()), nil
|
||||
}
|
||||
|
||||
type inspectorOutput struct {
|
||||
File string
|
||||
RawTags metadata.ParsedTags
|
||||
MappedTags model.MediaFile
|
||||
}
|
||||
|
||||
func runInspector(args []string) {
|
||||
if extractor != "" {
|
||||
conf.Server.Scanner.Extractor = extractor
|
||||
}
|
||||
log.Info("Using extractor", "extractor", conf.Server.Scanner.Extractor)
|
||||
md, err := metadata.Extract(args...)
|
||||
if err != nil {
|
||||
log.Fatal("Error extracting tags", err)
|
||||
}
|
||||
mapper := scanner.NewMediaFileMapper(conf.Server.MusicFolder, &tests.MockedGenreRepo{})
|
||||
marshal := marshalers[format]
|
||||
if marshal == nil {
|
||||
log.Fatal("Invalid format", "format", format)
|
||||
}
|
||||
var out []inspectorOutput
|
||||
for k, v := range md {
|
||||
if !model.IsAudioFile(k) {
|
||||
continue
|
||||
}
|
||||
if len(v.Tags) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, inspectorOutput{
|
||||
File: k,
|
||||
RawTags: v.Tags,
|
||||
MappedTags: mapper.ToMediaFile(v),
|
||||
})
|
||||
}
|
||||
data, _ := marshal(out)
|
||||
fmt.Println(string(data))
|
||||
}
|
77
cmd/root.go
77
cmd/root.go
|
@ -2,31 +2,28 @@ package cmd
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
"github.com/navidrome/navidrome/scheduler"
|
||||
"github.com/navidrome/navidrome/server/backgrounds"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
var interrupted = errors.New("service was interrupted")
|
||||
|
||||
var (
|
||||
cfgFile string
|
||||
noBanner bool
|
||||
|
@ -42,10 +39,14 @@ Complete documentation is available at https://www.navidrome.org/docs`,
|
|||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runNavidrome()
|
||||
},
|
||||
PostRun: func(cmd *cobra.Command, args []string) {
|
||||
postRun()
|
||||
},
|
||||
Version: consts.Version,
|
||||
}
|
||||
)
|
||||
|
||||
// Execute runs the root cobra command, which will start the Navidrome server by calling the runNavidrome function.
|
||||
func Execute() {
|
||||
rootCmd.SetVersionTemplate(`{{println .Version}}`)
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
|
@ -61,30 +62,42 @@ func preRun() {
|
|||
conf.Load()
|
||||
}
|
||||
|
||||
func runNavidrome() {
|
||||
db.Init()
|
||||
defer func() {
|
||||
if err := db.Close(); err != nil {
|
||||
log.Error("Error closing DB", err)
|
||||
}
|
||||
log.Info("Navidrome stopped, bye.")
|
||||
}()
|
||||
func postRun() {
|
||||
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(startSignaler(ctx))
|
||||
g.Go(startSignaller(ctx))
|
||||
g.Go(startScheduler(ctx))
|
||||
g.Go(startPlaybackServer(ctx))
|
||||
g.Go(schedulePeriodicScan(ctx))
|
||||
|
||||
if conf.Server.Jukebox.Enabled {
|
||||
g.Go(startPlaybackServer(ctx))
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil && !errors.Is(err, interrupted) {
|
||||
if err := g.Wait(); err != nil {
|
||||
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 {
|
||||
return func() error {
|
||||
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 {
|
||||
return func() error {
|
||||
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 {
|
||||
log.Info(ctx, "Starting scheduler")
|
||||
schedulerInstance := scheduler.GetInstance()
|
||||
|
||||
return func() error {
|
||||
log.Info(ctx, "Starting scheduler")
|
||||
schedulerInstance := scheduler.GetInstance()
|
||||
schedulerInstance.Run(ctx)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// startPlaybackServer starts the Navidrome playback server, if configured.
|
||||
// It is responsible for the Jukebox functionality
|
||||
func startPlaybackServer(ctx context.Context) func() error {
|
||||
log.Info(ctx, "Starting playback server")
|
||||
|
||||
playbackInstance := playback.GetInstance()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
@ -183,6 +201,7 @@ func init() {
|
|||
rootCmd.Flags().IntP("port", "p", viper.GetInt("port"), "HTTP port Navidrome will listen to")
|
||||
rootCmd.Flags().String("baseurl", viper.GetString("baseurl"), "base URL to configure Navidrome behind a proxy (ex: /music or http://my.server.com)")
|
||||
rootCmd.Flags().String("tlscert", viper.GetString("tlscert"), "optional path to a TLS cert file (enables HTTPS listening)")
|
||||
rootCmd.Flags().String("unixsocketperm", viper.GetString("unixsocketperm"), "optional file permission for the unix socket")
|
||||
rootCmd.Flags().String("tlskey", viper.GetString("tlskey"), "optional path to a TLS key file (enables HTTPS listening)")
|
||||
|
||||
rootCmd.Flags().Duration("sessiontimeout", viper.GetDuration("sessiontimeout"), "how long Navidrome will wait before closing web ui idle sessions")
|
||||
|
@ -191,6 +210,7 @@ func init() {
|
|||
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("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("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint`")
|
||||
|
@ -199,6 +219,7 @@ func init() {
|
|||
_ = viper.BindPFlag("address", rootCmd.Flags().Lookup("address"))
|
||||
_ = viper.BindPFlag("port", rootCmd.Flags().Lookup("port"))
|
||||
_ = viper.BindPFlag("tlscert", rootCmd.Flags().Lookup("tlscert"))
|
||||
_ = viper.BindPFlag("unixsocketperm", rootCmd.Flags().Lookup("unixsocketperm"))
|
||||
_ = viper.BindPFlag("tlskey", rootCmd.Flags().Lookup("tlskey"))
|
||||
_ = viper.BindPFlag("baseurl", rootCmd.Flags().Lookup("baseurl"))
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
func startSignaler(ctx context.Context) func() error {
|
||||
func startSignaller(ctx context.Context) func() error {
|
||||
log.Info(ctx, "Starting signaler")
|
||||
scanner := GetScanner()
|
||||
|
||||
return func() error {
|
||||
var sigChan = make(chan os.Signal, 1)
|
||||
signal.Notify(
|
||||
sigChan,
|
||||
os.Interrupt,
|
||||
triggerScanSignal,
|
||||
syscall.SIGHUP,
|
||||
syscall.SIGTERM,
|
||||
syscall.SIGABRT,
|
||||
)
|
||||
signal.Notify(sigChan, triggerScanSignal)
|
||||
|
||||
for {
|
||||
select {
|
||||
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)
|
||||
start := time.Now()
|
||||
err := scanner.RescanAll(ctx, false)
|
|
@ -1,6 +1,6 @@
|
|||
// 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
|
||||
// +build !wireinject
|
||||
|
||||
|
@ -14,6 +14,7 @@ import (
|
|||
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
|
@ -23,22 +24,21 @@ import (
|
|||
"github.com/navidrome/navidrome/server/nativeapi"
|
||||
"github.com/navidrome/navidrome/server/public"
|
||||
"github.com/navidrome/navidrome/server/subsonic"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Injectors from wire_injectors.go:
|
||||
|
||||
func CreateServer(musicFolder string) *server.Server {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
broker := events.GetBroker()
|
||||
serverServer := server.New(dataStore, broker)
|
||||
return serverServer
|
||||
}
|
||||
|
||||
func CreateNativeAPIRouter() *nativeapi.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
share := core.NewShare(dataStore)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
router := nativeapi.New(dataStore, share, playlists)
|
||||
|
@ -46,8 +46,8 @@ func CreateNativeAPIRouter() *nativeapi.Router {
|
|||
}
|
||||
|
||||
func CreateSubsonicAPIRouter() *subsonic.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
agentsAgents := agents.New(dataStore)
|
||||
|
@ -58,17 +58,19 @@ func CreateSubsonicAPIRouter() *subsonic.Router {
|
|||
share := core.NewShare(dataStore)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
players := core.NewPlayers(dataStore)
|
||||
scanner := GetScanner()
|
||||
broker := events.GetBroker()
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
broker := events.GetBroker()
|
||||
scannerScanner := scanner.GetInstance(dataStore, playlists, cacheWarmer, 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
|
||||
}
|
||||
|
||||
func CreatePublicRouter() *public.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
agentsAgents := agents.New(dataStore)
|
||||
|
@ -83,22 +85,22 @@ func CreatePublicRouter() *public.Router {
|
|||
}
|
||||
|
||||
func CreateLastFMRouter() *lastfm.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
router := lastfm.NewRouter(dataStore)
|
||||
return router
|
||||
}
|
||||
|
||||
func CreateListenBrainzRouter() *listenbrainz.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
router := listenbrainz.NewRouter(dataStore)
|
||||
return router
|
||||
}
|
||||
|
||||
func createScanner() scanner.Scanner {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
func GetScanner() scanner.Scanner {
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
|
@ -107,23 +109,17 @@ func createScanner() scanner.Scanner {
|
|||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
broker := events.GetBroker()
|
||||
scannerScanner := scanner.New(dataStore, playlists, cacheWarmer, broker)
|
||||
scannerScanner := scanner.GetInstance(dataStore, playlists, cacheWarmer, broker)
|
||||
return scannerScanner
|
||||
}
|
||||
|
||||
func GetPlaybackServer() playback.PlaybackServer {
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
return playbackServer
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// Scanner must be a Singleton
|
||||
var (
|
||||
onceScanner sync.Once
|
||||
scannerInstance scanner.Scanner
|
||||
)
|
||||
|
||||
func GetScanner() scanner.Scanner {
|
||||
onceScanner.Do(func() {
|
||||
scannerInstance = createScanner()
|
||||
})
|
||||
return scannerInstance
|
||||
}
|
||||
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)
|
||||
|
|
|
@ -3,13 +3,12 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/google/wire"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
|
@ -23,6 +22,7 @@ import (
|
|||
var allProviders = wire.NewSet(
|
||||
core.Set,
|
||||
artwork.Set,
|
||||
server.New,
|
||||
subsonic.New,
|
||||
nativeapi.New,
|
||||
public.New,
|
||||
|
@ -30,12 +30,12 @@ var allProviders = wire.NewSet(
|
|||
lastfm.NewRouter,
|
||||
listenbrainz.NewRouter,
|
||||
events.GetBroker,
|
||||
scanner.GetInstance,
|
||||
db.Db,
|
||||
)
|
||||
|
||||
func CreateServer(musicFolder string) *server.Server {
|
||||
panic(wire.Build(
|
||||
server.New,
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
@ -49,7 +49,6 @@ func CreateNativeAPIRouter() *nativeapi.Router {
|
|||
func CreateSubsonicAPIRouter() *subsonic.Router {
|
||||
panic(wire.Build(
|
||||
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 {
|
||||
onceScanner.Do(func() {
|
||||
scannerInstance = createScanner()
|
||||
})
|
||||
return scannerInstance
|
||||
}
|
||||
|
||||
func createScanner() scanner.Scanner {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
scanner.New,
|
||||
))
|
||||
}
|
||||
|
||||
func GetPlaybackServer() playback.PlaybackServer {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
|
|
@ -12,7 +12,6 @@ import (
|
|||
"github.com/kr/pretty"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils/number"
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
@ -21,6 +20,7 @@ type configOptions struct {
|
|||
ConfigFile string
|
||||
Address string
|
||||
Port int
|
||||
UnixSocketPerm string
|
||||
MusicFolder string
|
||||
DataFolder string
|
||||
CacheFolder string
|
||||
|
@ -44,6 +44,7 @@ type configOptions struct {
|
|||
EnableMediaFileCoverArt bool
|
||||
TranscodingCacheSize string
|
||||
ImageCacheSize string
|
||||
AlbumPlayCountMode string
|
||||
EnableArtworkPrecache bool
|
||||
AutoImportPlaylists bool
|
||||
PlaylistsPath string
|
||||
|
@ -51,11 +52,13 @@ type configOptions struct {
|
|||
DefaultDownsamplingFormat string
|
||||
SearchFullString bool
|
||||
RecentlyAddedByModTime bool
|
||||
PreferSortTags bool
|
||||
IgnoredArticles string
|
||||
IndexGroups string
|
||||
SubsonicArtistParticipations bool
|
||||
FFmpegPath string
|
||||
MPVPath string
|
||||
MPVCmdTemplate string
|
||||
CoverArtPriority string
|
||||
CoverJpegQuality int
|
||||
ArtistArtPriority string
|
||||
|
@ -77,6 +80,7 @@ type configOptions struct {
|
|||
PasswordEncryptionKey string
|
||||
ReverseProxyUserHeader string
|
||||
ReverseProxyWhitelist string
|
||||
HTTPSecurityHeaders secureOptions
|
||||
Prometheus prometheusOptions
|
||||
Scanner scannerOptions
|
||||
Jukebox jukeboxOptions
|
||||
|
@ -96,6 +100,7 @@ type configOptions struct {
|
|||
DevSidebarPlaylists bool
|
||||
DevEnableBufferedScrobble bool
|
||||
DevShowArtistPage bool
|
||||
DevOffsetOptimize int
|
||||
DevArtworkMaxRequests int
|
||||
DevArtworkThrottleBacklogLimit int
|
||||
DevArtworkThrottleBacklogTimeout time.Duration
|
||||
|
@ -126,6 +131,10 @@ type listenBrainzOptions struct {
|
|||
BaseURL string
|
||||
}
|
||||
|
||||
type secureOptions struct {
|
||||
CustomFrameOptionsValue string
|
||||
}
|
||||
|
||||
type prometheusOptions struct {
|
||||
Enabled bool
|
||||
MetricsPath string
|
||||
|
@ -134,9 +143,10 @@ type prometheusOptions struct {
|
|||
type AudioDeviceDefinition []string
|
||||
|
||||
type jukeboxOptions struct {
|
||||
Enabled bool
|
||||
Devices []AudioDeviceDefinition
|
||||
Default string
|
||||
Enabled bool
|
||||
Devices []AudioDeviceDefinition
|
||||
Default string
|
||||
AdminOnly bool
|
||||
}
|
||||
|
||||
var (
|
||||
|
@ -203,7 +213,7 @@ func Load() {
|
|||
}
|
||||
|
||||
// Print current configuration if log level is Debug
|
||||
if log.CurrentLevel() >= log.LevelDebug {
|
||||
if log.IsGreaterOrEqualTo(log.LevelDebug) {
|
||||
prettyConf := pretty.Sprintf("Loaded configuration from '%s': %# v", Server.ConfigFile, Server)
|
||||
if Server.EnableLogRedacting {
|
||||
prettyConf = log.Redact(prettyConf)
|
||||
|
@ -273,6 +283,7 @@ func init() {
|
|||
viper.SetDefault("loglevel", "info")
|
||||
viper.SetDefault("address", "0.0.0.0")
|
||||
viper.SetDefault("port", 4533)
|
||||
viper.SetDefault("unixsocketperm", "0660")
|
||||
viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout)
|
||||
viper.SetDefault("scaninterval", -1)
|
||||
viper.SetDefault("scanschedule", "@every 1m")
|
||||
|
@ -285,6 +296,7 @@ func init() {
|
|||
viper.SetDefault("enabletranscodingconfig", false)
|
||||
viper.SetDefault("transcodingcachesize", "100MB")
|
||||
viper.SetDefault("imagecachesize", "100MB")
|
||||
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
|
||||
viper.SetDefault("enableartworkprecache", true)
|
||||
viper.SetDefault("autoimportplaylists", true)
|
||||
viper.SetDefault("playlistspath", consts.DefaultPlaylistsPath)
|
||||
|
@ -295,10 +307,13 @@ func init() {
|
|||
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
|
||||
viper.SetDefault("searchfullstring", false)
|
||||
viper.SetDefault("recentlyaddedbymodtime", false)
|
||||
viper.SetDefault("prefersorttags", false)
|
||||
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
|
||||
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
|
||||
viper.SetDefault("subsonicartistparticipations", false)
|
||||
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("coverjpegquality", 75)
|
||||
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
||||
|
@ -326,6 +341,7 @@ func init() {
|
|||
viper.SetDefault("jukebox.enabled", false)
|
||||
viper.SetDefault("jukebox.devices", []AudioDeviceDefinition{})
|
||||
viper.SetDefault("jukebox.default", "")
|
||||
viper.SetDefault("jukebox.adminonly", true)
|
||||
|
||||
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
|
||||
viper.SetDefault("scanner.genreseparators", ";/,")
|
||||
|
@ -334,13 +350,15 @@ func init() {
|
|||
viper.SetDefault("agents", "lastfm,spotify")
|
||||
viper.SetDefault("lastfm.enabled", true)
|
||||
viper.SetDefault("lastfm.language", "en")
|
||||
viper.SetDefault("lastfm.apikey", consts.LastFMAPIKey)
|
||||
viper.SetDefault("lastfm.secret", consts.LastFMAPISecret)
|
||||
viper.SetDefault("lastfm.apikey", "")
|
||||
viper.SetDefault("lastfm.secret", "")
|
||||
viper.SetDefault("spotify.id", "")
|
||||
viper.SetDefault("spotify.secret", "")
|
||||
viper.SetDefault("listenbrainz.enabled", true)
|
||||
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
|
||||
viper.SetDefault("devlogsourceline", false)
|
||||
viper.SetDefault("devenableprofiler", false)
|
||||
|
@ -352,7 +370,8 @@ func init() {
|
|||
viper.SetDefault("devenablebufferedscrobble", true)
|
||||
viper.SetDefault("devsidebarplaylists", true)
|
||||
viper.SetDefault("devshowartistpage", true)
|
||||
viper.SetDefault("devartworkmaxrequests", number.Max(2, runtime.NumCPU()/3))
|
||||
viper.SetDefault("devoffsetoptimize", 50000)
|
||||
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/3))
|
||||
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
||||
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||
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)
|
||||
}
|
|
@ -11,7 +11,7 @@ import (
|
|||
const (
|
||||
AppName = "navidrome"
|
||||
|
||||
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on"
|
||||
DefaultDbPath = "navidrome.db?cache=shared&_cache_size=1000000000&_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL&_foreign_keys=on&_txlock=immediate"
|
||||
InitialSetupFlagKey = "InitialSetup"
|
||||
|
||||
UIAuthorizationHeader = "X-ND-Authorization"
|
||||
|
@ -81,32 +81,36 @@ const (
|
|||
DefaultCacheCleanUpInterval = 10 * time.Minute
|
||||
)
|
||||
|
||||
// Shared secrets (only add here "secrets" that can be public)
|
||||
const (
|
||||
LastFMAPIKey = "9b94a5515ea66b2da3ec03c12300327e" // nolint:gosec
|
||||
LastFMAPISecret = "74cb6557cec7171d921af5d7d887c587" // nolint:gosec
|
||||
AlbumPlayCountModeAbsolute = "absolute"
|
||||
AlbumPlayCountModeNormalized = "normalized"
|
||||
)
|
||||
|
||||
var (
|
||||
DefaultDownsamplingFormat = "opus"
|
||||
DefaultTranscodings = []map[string]interface{}{
|
||||
DefaultTranscodings = []struct {
|
||||
Name string
|
||||
TargetFormat string
|
||||
DefaultBitRate int
|
||||
Command string
|
||||
}{
|
||||
{
|
||||
"name": "mp3 audio",
|
||||
"targetFormat": "mp3",
|
||||
"defaultBitRate": 192,
|
||||
"command": "ffmpeg -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
|
||||
Name: "mp3 audio",
|
||||
TargetFormat: "mp3",
|
||||
DefaultBitRate: 192,
|
||||
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
|
||||
},
|
||||
{
|
||||
"name": "opus audio",
|
||||
"targetFormat": "opus",
|
||||
"defaultBitRate": 128,
|
||||
"command": "ffmpeg -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
|
||||
Name: "opus audio",
|
||||
TargetFormat: "opus",
|
||||
DefaultBitRate: 128,
|
||||
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
|
||||
},
|
||||
{
|
||||
"name": "aac audio",
|
||||
"targetFormat": "aac",
|
||||
"defaultBitRate": 256,
|
||||
"command": "ffmpeg -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
|
||||
Name: "aac audio",
|
||||
TargetFormat: "aac",
|
||||
DefaultBitRate: 256,
|
||||
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -1,64 +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},
|
||||
".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
|
||||
# Default: NO
|
||||
# navidrome_config (str): navidrome configration file
|
||||
# navidrome_config (str): navidrome configuration file
|
||||
# Default: /usr/local/etc/navidrome/config.toml
|
||||
# navidrome_datafolder (str): navidrome Folder to store application data
|
||||
# Default: www
|
||||
|
|
|
@ -134,7 +134,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
|
|||
}
|
||||
similar, err := agent.GetSimilarArtists(ctx, id, name, mbid, limit)
|
||||
if len(similar) > 0 && err == nil {
|
||||
if log.CurrentLevel() >= log.LevelTrace {
|
||||
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
||||
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start))
|
||||
} else {
|
||||
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similarReceived", len(similar), "elapsed", time.Since(start))
|
||||
|
|
|
@ -14,7 +14,7 @@ import (
|
|||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -47,7 +47,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
|
|||
hc := &http.Client{
|
||||
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)
|
||||
return l
|
||||
}
|
||||
|
@ -311,12 +311,14 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
|
|||
func init() {
|
||||
conf.AddHook(func() {
|
||||
if conf.Server.LastFM.Enabled {
|
||||
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
|
||||
return lastFMConstructor(ds)
|
||||
})
|
||||
scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
|
||||
return lastFMConstructor(ds)
|
||||
})
|
||||
if conf.Server.LastFM.ApiKey != "" && conf.Server.LastFM.Secret != "" {
|
||||
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
|
||||
return lastFMConstructor(ds)
|
||||
})
|
||||
scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
|
||||
return lastFMConstructor(ds)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ import (
|
|||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
//go:embed token_received.html
|
||||
|
@ -89,13 +89,14 @@ func (s *Router) unlink(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
|
||||
token := utils.ParamString(r, "token")
|
||||
if token == "" {
|
||||
p := req.Params(r)
|
||||
token, err := p.String("token")
|
||||
if err != nil {
|
||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "token not received")
|
||||
return
|
||||
}
|
||||
uid := utils.ParamString(r, "uid")
|
||||
if uid == "" {
|
||||
uid, err := p.String("uid")
|
||||
if err != nil {
|
||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "uid not received")
|
||||
return
|
||||
}
|
||||
|
@ -103,7 +104,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
|
|||
// Need to add user to context, as this is a non-authenticated endpoint, so it does not
|
||||
// automatically contain any user info
|
||||
ctx := request.WithUser(r.Context(), model.User{ID: uid})
|
||||
err := s.fetchSessionKey(ctx, uid, token)
|
||||
err = s.fetchSessionKey(ctx, uid, token)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
|
|
@ -8,13 +8,13 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
|
@ -11,7 +11,7 @@ import (
|
|||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -35,7 +35,7 @@ func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent {
|
|||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
l.client = newClient(l.baseURL, chc)
|
||||
return l
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ import (
|
|||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/xrash/smetrics"
|
||||
)
|
||||
|
||||
|
@ -35,7 +35,7 @@ func spotifyConstructor(ds model.DataStore) agents.Interface {
|
|||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
l.client = newClient(l.id, l.secret, chc)
|
||||
return l
|
||||
}
|
||||
|
|
|
@ -150,7 +150,7 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
|||
|
||||
var r io.ReadCloser
|
||||
if format != "raw" && format != "" {
|
||||
r, err = a.ms.DoStream(ctx, &mf, format, bitrate)
|
||||
r, err = a.ms.DoStream(ctx, &mf, format, bitrate, 0)
|
||||
} else {
|
||||
r, err = os.Open(mf.Path)
|
||||
}
|
||||
|
@ -160,7 +160,7 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
|||
}
|
||||
|
||||
defer func() {
|
||||
if err := r.Close(); err != nil && log.CurrentLevel() >= log.LevelDebug {
|
||||
if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
|
||||
log.Error(ctx, "Error closing stream", "id", mf.ID, "file", mf.Path, err)
|
||||
}
|
||||
}()
|
||||
|
|
|
@ -44,7 +44,7 @@ var _ = Describe("Archiver", func() {
|
|||
}}).Return(mfs, nil)
|
||||
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
|
||||
|
@ -73,7 +73,7 @@ var _ = Describe("Archiver", func() {
|
|||
}}).Return(mfs, nil)
|
||||
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out)
|
||||
|
@ -104,7 +104,7 @@ var _ = Describe("Archiver", func() {
|
|||
}
|
||||
|
||||
sh.On("Load", mock.Anything, "1").Return(share, nil)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipShare(context.Background(), "1", out)
|
||||
|
@ -136,7 +136,7 @@ var _ = Describe("Archiver", func() {
|
|||
plRepo := &mockPlaylistRepository{}
|
||||
plRepo.On("GetWithTracks", "1", true).Return(pls, nil)
|
||||
ds.On("Playlist", mock.Anything).Return(plRepo)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)
|
||||
|
@ -192,8 +192,8 @@ type mockMediaStreamer struct {
|
|||
core.MediaStreamer
|
||||
}
|
||||
|
||||
func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, format string, bitrate int) (*core.Stream, error) {
|
||||
args := m.Called(ctx, mf, format, bitrate)
|
||||
func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*core.Stream, error) {
|
||||
args := m.Called(ctx, mf, reqFormat, reqBitRate, reqOffset)
|
||||
if args.Error(1) != nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
|
|
@ -20,8 +20,8 @@ import (
|
|||
var ErrUnavailable = errors.New("artwork unavailable")
|
||||
|
||||
type Artwork interface {
|
||||
Get(ctx context.Context, artID model.ArtworkID, size int) (io.ReadCloser, time.Time, error)
|
||||
GetOrPlaceholder(ctx context.Context, id string, size int) (io.ReadCloser, time.Time, error)
|
||||
Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error)
|
||||
GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error)
|
||||
}
|
||||
|
||||
func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, em core.ExternalMetadata) Artwork {
|
||||
|
@ -41,10 +41,10 @@ type artworkReader interface {
|
|||
Reader(ctx context.Context) (io.ReadCloser, string, error)
|
||||
}
|
||||
|
||||
func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int) (reader io.ReadCloser, lastUpdate time.Time, err error) {
|
||||
func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) {
|
||||
artID, err := a.getArtworkId(ctx, id)
|
||||
if err == nil {
|
||||
reader, lastUpdate, err = a.Get(ctx, artID, size)
|
||||
reader, lastUpdate, err = a.Get(ctx, artID, size, square)
|
||||
}
|
||||
if errors.Is(err, ErrUnavailable) {
|
||||
if artID.Kind == model.KindArtistArtwork {
|
||||
|
@ -57,8 +57,8 @@ func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int) (re
|
|||
return reader, lastUpdate, err
|
||||
}
|
||||
|
||||
func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int) (reader io.ReadCloser, lastUpdate time.Time, err error) {
|
||||
artReader, err := a.getArtworkReader(ctx, artID, size)
|
||||
func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) {
|
||||
artReader, err := a.getArtworkReader(ctx, artID, size, square)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
|
@ -107,11 +107,11 @@ func (a *artwork) getArtworkId(ctx context.Context, id string) (model.ArtworkID,
|
|||
return artID, nil
|
||||
}
|
||||
|
||||
func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, size int) (artworkReader, error) {
|
||||
func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, size int, square bool) (artworkReader, error) {
|
||||
var artReader artworkReader
|
||||
var err error
|
||||
if size > 0 {
|
||||
artReader, err = resizedFromOriginal(ctx, a, artID, size)
|
||||
if size > 0 || square {
|
||||
artReader, err = resizedFromOriginal(ctx, a, artID, size, square)
|
||||
} else {
|
||||
switch artID.Kind {
|
||||
case model.KindArtistArtwork:
|
||||
|
|
|
@ -4,7 +4,11 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
|
@ -211,33 +215,83 @@ var _ = Describe("Artwork", func() {
|
|||
alMultipleCovers,
|
||||
})
|
||||
})
|
||||
It("returns a PNG if original image is a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
When("Square is false", func() {
|
||||
It("returns a PNG if original image is a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
br, format, err := asImageReader(r)
|
||||
Expect(format).To(Equal("image/png"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("png"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(15))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(15))
|
||||
})
|
||||
It("returns a JPEG if original image is not a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, _, err := image.Decode(br)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Bounds().Size().X).To(Equal(15))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(15))
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
})
|
||||
It("returns a JPEG if original image is not a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
When("When square is true", func() {
|
||||
var alCover model.Album
|
||||
|
||||
br, format, err := asImageReader(r)
|
||||
Expect(format).To(Equal("image/jpeg"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
DescribeTable("resize",
|
||||
func(format string, landscape bool, size int) {
|
||||
coverFileName := "cover." + format
|
||||
dirName := createImage(format, landscape, size)
|
||||
alCover = model.Album{
|
||||
ID: "444",
|
||||
Name: "Only external",
|
||||
ImageFiles: filepath.Join(dirName, coverFileName),
|
||||
}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alCover,
|
||||
})
|
||||
|
||||
img, _, err := image.Decode(br)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
conf.Server.CoverArtPriority = coverFileName
|
||||
r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), size, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("png"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(size))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(size))
|
||||
},
|
||||
Entry("portrait png image", "png", false, 200),
|
||||
Entry("landscape png image", "png", true, 200),
|
||||
Entry("portrait jpg image", "jpg", false, 200),
|
||||
Entry("landscape jpg image", "jpg", true, 200),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func createImage(format string, landscape bool, size int) string {
|
||||
var img image.Image
|
||||
|
||||
if landscape {
|
||||
img = image.NewRGBA(image.Rect(0, 0, size, size/2))
|
||||
} else {
|
||||
img = image.NewRGBA(image.Rect(0, 0, size/2, size))
|
||||
}
|
||||
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
f, _ := os.Create(filepath.Join(tmpDir, "cover."+format))
|
||||
defer f.Close()
|
||||
switch format {
|
||||
case "png":
|
||||
_ = png.Encode(f, img)
|
||||
case "jpg":
|
||||
_ = jpeg.Encode(f, img, &jpeg.Options{Quality: 75})
|
||||
}
|
||||
|
||||
return tmpDir
|
||||
}
|
||||
|
|
|
@ -31,7 +31,7 @@ var _ = Describe("Artwork", func() {
|
|||
Context("GetOrPlaceholder", func() {
|
||||
Context("Empty ID", func() {
|
||||
It("returns placeholder if album is not in the DB", func() {
|
||||
r, _, err := aw.GetOrPlaceholder(context.Background(), "", 0)
|
||||
r, _, err := aw.GetOrPlaceholder(context.Background(), "", 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
ph, err := resources.FS().Open(consts.PlaceholderAlbumArt)
|
||||
|
@ -49,7 +49,7 @@ var _ = Describe("Artwork", func() {
|
|||
Context("Get", func() {
|
||||
Context("Empty ID", func() {
|
||||
It("returns an ErrUnavailable error", func() {
|
||||
_, _, err := aw.Get(context.Background(), model.ArtworkID{}, 0)
|
||||
_, _, err := aw.Get(context.Background(), model.ArtworkID{}, 0, false)
|
||||
Expect(err).To(MatchError(artwork.ErrUnavailable))
|
||||
})
|
||||
})
|
||||
|
|
|
@ -129,9 +129,9 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro
|
|||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize)
|
||||
r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize, false)
|
||||
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()
|
||||
_, err = io.Copy(io.Discard, r)
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
|
@ -9,14 +8,12 @@ import (
|
|||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/number"
|
||||
)
|
||||
|
||||
type resizedArtworkReader struct {
|
||||
|
@ -24,16 +21,18 @@ type resizedArtworkReader struct {
|
|||
cacheKey string
|
||||
lastUpdate time.Time
|
||||
size int
|
||||
square bool
|
||||
a *artwork
|
||||
}
|
||||
|
||||
func resizedFromOriginal(ctx context.Context, a *artwork, artID model.ArtworkID, size int) (*resizedArtworkReader, error) {
|
||||
func resizedFromOriginal(ctx context.Context, a *artwork, artID model.ArtworkID, size int, square bool) (*resizedArtworkReader, error) {
|
||||
r := &resizedArtworkReader{a: a}
|
||||
r.artID = artID
|
||||
r.size = size
|
||||
r.square = square
|
||||
|
||||
// Get lastUpdated and cacheKey from original artwork
|
||||
original, err := a.getArtworkReader(ctx, artID, 0)
|
||||
original, err := a.getArtworkReader(ctx, artID, 0, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -44,9 +43,10 @@ func resizedFromOriginal(ctx context.Context, a *artwork, artID model.ArtworkID,
|
|||
|
||||
func (a *resizedArtworkReader) Key() string {
|
||||
return fmt.Sprintf(
|
||||
"%s.%d.%d",
|
||||
"%s.%d.%t.%d",
|
||||
a.cacheKey,
|
||||
a.size,
|
||||
a.square,
|
||||
conf.Server.CoverJpegQuality,
|
||||
)
|
||||
}
|
||||
|
@ -57,7 +57,7 @@ func (a *resizedArtworkReader) LastUpdated() time.Time {
|
|||
|
||||
func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
// Get artwork in original size, possibly from cache
|
||||
orig, _, err := a.a.Get(ctx, a.artID, 0)
|
||||
orig, _, err := a.a.Get(ctx, a.artID, 0, false)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
@ -67,7 +67,7 @@ func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, strin
|
|||
r := io.TeeReader(orig, buf)
|
||||
defer orig.Close()
|
||||
|
||||
resized, origSize, err := resizeImage(r, a.size)
|
||||
resized, origSize, err := resizeImage(r, a.size, a.square)
|
||||
if resized == nil {
|
||||
log.Trace(ctx, "Image smaller than requested size", "artID", a.artID, "original", origSize, "resized", a.size)
|
||||
} else {
|
||||
|
@ -84,54 +84,39 @@ func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, strin
|
|||
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) {
|
||||
r, format, err := asImageReader(reader)
|
||||
func resizeImage(reader io.Reader, size int, square bool) (io.Reader, int, error) {
|
||||
original, format, err := image.Decode(reader)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
img, _, err := image.Decode(r)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
bounds := original.Bounds()
|
||||
originalSize := max(bounds.Max.X, bounds.Max.Y)
|
||||
|
||||
// Don't upscale the image
|
||||
bounds := img.Bounds()
|
||||
originalSize := number.Max(bounds.Max.X, bounds.Max.Y)
|
||||
if originalSize <= size {
|
||||
if originalSize <= size && !square {
|
||||
return nil, originalSize, nil
|
||||
}
|
||||
|
||||
var m *image.NRGBA
|
||||
// Preserve the aspect ratio of the image.
|
||||
if bounds.Max.X > bounds.Max.Y {
|
||||
m = imaging.Resize(img, size, 0, imaging.Lanczos)
|
||||
var resized image.Image
|
||||
if originalSize >= size {
|
||||
resized = imaging.Fit(original, size, size, imaging.Lanczos)
|
||||
} else {
|
||||
m = imaging.Resize(img, 0, size, imaging.Lanczos)
|
||||
if bounds.Max.Y < bounds.Max.X {
|
||||
resized = imaging.Resize(original, size, 0, imaging.Lanczos)
|
||||
} else {
|
||||
resized = imaging.Resize(original, 0, size, imaging.Lanczos)
|
||||
}
|
||||
}
|
||||
if square {
|
||||
bg := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
resized = imaging.OverlayCenter(bg, resized, 1)
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
buf.Reset()
|
||||
if format == "image/png" {
|
||||
err = png.Encode(buf, m)
|
||||
if format == "png" || square {
|
||||
err = png.Encode(buf, resized)
|
||||
} 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
|
||||
}
|
||||
|
|
|
@ -124,7 +124,7 @@ func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourc
|
|||
|
||||
func fromAlbum(ctx context.Context, a *artwork, id model.ArtworkID) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
r, _, err := a.Get(ctx, id, 0)
|
||||
r, _, err := a.Get(ctx, id, 0, false)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
|
@ -23,9 +24,10 @@ var (
|
|||
func Init(ds model.DataStore) {
|
||||
once.Do(func() {
|
||||
log.Info("Setting Session Timeout", "value", conf.Server.SessionTimeout)
|
||||
secret, err := ds.Property(context.TODO()).DefaultGet(consts.JWTSecretKey, "not so secret")
|
||||
if err != nil {
|
||||
secret, err := ds.Property(context.TODO()).Get(consts.JWTSecretKey)
|
||||
if err != nil || secret == "" {
|
||||
log.Error("No JWT secret found in DB. Setting a temp one, but please report this error", err)
|
||||
secret = uuid.NewString()
|
||||
}
|
||||
Secret = []byte(secret)
|
||||
TokenAuth = jwtauth.New("HS256", Secret, nil)
|
||||
|
|
|
@ -9,7 +9,6 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/sanitize"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
_ "github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
|
@ -18,7 +17,8 @@ import (
|
|||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"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"
|
||||
)
|
||||
|
||||
|
@ -90,15 +90,16 @@ func (e *externalMetadata) UpdateAlbumInfo(ctx context.Context, id string) (*mod
|
|||
return nil, err
|
||||
}
|
||||
|
||||
if album.ExternalInfoUpdatedAt.IsZero() {
|
||||
log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", album.ExternalInfoUpdatedAt, "id", id, "name", album.Name)
|
||||
updatedAt := V(album.ExternalInfoUpdatedAt)
|
||||
if updatedAt.IsZero() {
|
||||
log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", album.Name)
|
||||
err = e.populateAlbumInfo(ctx, album)
|
||||
if err != nil {
|
||||
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)
|
||||
enqueueRefresh(e.albumQueue, album)
|
||||
}
|
||||
|
@ -118,7 +119,7 @@ func (e *externalMetadata) populateAlbumInfo(ctx context.Context, album *auxAlbu
|
|||
return err
|
||||
}
|
||||
|
||||
album.ExternalInfoUpdatedAt = time.Now()
|
||||
album.ExternalInfoUpdatedAt = P(time.Now())
|
||||
album.ExternalUrl = info.URL
|
||||
|
||||
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 artist.ExternalInfoUpdatedAt.IsZero() {
|
||||
log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", artist.ExternalInfoUpdatedAt, "id", id, "name", artist.Name)
|
||||
updatedAt := V(artist.ExternalInfoUpdatedAt)
|
||||
if updatedAt.IsZero() {
|
||||
log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", artist.Name)
|
||||
err := e.populateArtistInfo(ctx, artist)
|
||||
if err != nil {
|
||||
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 time.Since(artist.ExternalInfoUpdatedAt) > conf.Server.DevArtistInfoTimeToLive {
|
||||
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", artist.ExternalInfoUpdatedAt, "name", artist.Name)
|
||||
if time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive {
|
||||
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artist.Name)
|
||||
enqueueRefresh(e.artistQueue, artist)
|
||||
}
|
||||
return artist, nil
|
||||
|
@ -242,7 +244,7 @@ func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist *auxAr
|
|||
return ctx.Err()
|
||||
}
|
||||
|
||||
artist.ExternalInfoUpdatedAt = time.Now()
|
||||
artist.ExternalInfoUpdatedAt = P(time.Now())
|
||||
err := e.ds.Artist(ctx).Put(&artist.Artist)
|
||||
if err != nil {
|
||||
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()
|
||||
}
|
||||
|
||||
weightedSongs := utils.NewWeightedRandomChooser()
|
||||
addArtist := func(a model.Artist, weightedSongs *utils.WeightedChooser, count, artistWeight int) error {
|
||||
weightedSongs := random.NewWeightedChooser[model.MediaFile]()
|
||||
addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "SimilarSongs call canceled", 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)
|
||||
if err != nil {
|
||||
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
|
||||
for len(similarSongs) < count && weightedSongs.Size() > 0 {
|
||||
s, err := weightedSongs.GetAndRemove()
|
||||
s, err := weightedSongs.Pick()
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error getting weighted song", err)
|
||||
continue
|
||||
}
|
||||
similarSongs = append(similarSongs, s.(model.MediaFile))
|
||||
similarSongs = append(similarSongs, s)
|
||||
}
|
||||
|
||||
return similarSongs, nil
|
||||
|
@ -412,7 +414,7 @@ func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, a
|
|||
squirrel.Eq{"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 ",
|
||||
Max: 1,
|
||||
|
|
|
@ -16,12 +16,14 @@ import (
|
|||
)
|
||||
|
||||
type FFmpeg interface {
|
||||
Transcode(ctx context.Context, command, path string, maxBitRate int) (io.ReadCloser, error)
|
||||
Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error)
|
||||
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
|
||||
ConvertToWAV(ctx context.Context, path string) (io.ReadCloser, error)
|
||||
ConvertToFLAC(ctx context.Context, path string) (io.ReadCloser, error)
|
||||
Probe(ctx context.Context, files []string) (string, error)
|
||||
CmdPath() (string, error)
|
||||
IsAvailable() bool
|
||||
Version() string
|
||||
}
|
||||
|
||||
func New() FFmpeg {
|
||||
|
@ -37,11 +39,11 @@ const (
|
|||
|
||||
type ffmpeg struct{}
|
||||
|
||||
func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate int) (io.ReadCloser, error) {
|
||||
func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error) {
|
||||
if _, err := ffmpegCmd(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := createFFmpegCommand(command, path, maxBitRate)
|
||||
args := createFFmpegCommand(command, path, maxBitRate, offset)
|
||||
return e.start(ctx, args)
|
||||
}
|
||||
|
||||
|
@ -49,17 +51,17 @@ func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser,
|
|||
if _, err := ffmpegCmd(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := createFFmpegCommand(extractImageCmd, path, 0)
|
||||
args := createFFmpegCommand(extractImageCmd, path, 0, 0)
|
||||
return e.start(ctx, args)
|
||||
}
|
||||
|
||||
func (e *ffmpeg) ConvertToWAV(ctx context.Context, path string) (io.ReadCloser, error) {
|
||||
args := createFFmpegCommand(createWavCmd, path, 0)
|
||||
args := createFFmpegCommand(createWavCmd, path, 0, 0)
|
||||
return e.start(ctx, args)
|
||||
}
|
||||
|
||||
func (e *ffmpeg) ConvertToFLAC(ctx context.Context, path string) (io.ReadCloser, error) {
|
||||
args := createFFmpegCommand(createFLACCmd, path, 0)
|
||||
args := createFFmpegCommand(createFLACCmd, path, 0, 0)
|
||||
return e.start(ctx, args)
|
||||
}
|
||||
|
||||
|
@ -78,6 +80,29 @@ func (e *ffmpeg) CmdPath() (string, error) {
|
|||
return ffmpegCmd()
|
||||
}
|
||||
|
||||
func (e *ffmpeg) IsAvailable() bool {
|
||||
_, err := ffmpegCmd()
|
||||
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) {
|
||||
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
|
||||
j := &ffCmd{args: args}
|
||||
|
@ -100,7 +125,7 @@ type ffCmd struct {
|
|||
func (j *ffCmd) start() error {
|
||||
cmd := exec.Command(j.args[0], j.args[1:]...) // #nosec
|
||||
cmd.Stdout = j.out
|
||||
if log.CurrentLevel() >= log.LevelTrace {
|
||||
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
||||
cmd.Stderr = os.Stderr
|
||||
} else {
|
||||
cmd.Stderr = io.Discard
|
||||
|
@ -127,15 +152,25 @@ func (j *ffCmd) wait() {
|
|||
}
|
||||
|
||||
// Path will always be an absolute path
|
||||
func createFFmpegCommand(cmd, path string, maxBitRate int) []string {
|
||||
func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
|
||||
split := strings.Split(fixCmd(cmd), " ")
|
||||
for i, s := range split {
|
||||
s = strings.ReplaceAll(s, "%s", path)
|
||||
s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate))
|
||||
split[i] = s
|
||||
var parts []string
|
||||
|
||||
for _, s := range split {
|
||||
if strings.Contains(s, "%s") {
|
||||
s = strings.ReplaceAll(s, "%s", path)
|
||||
parts = append(parts, s)
|
||||
if offset > 0 && !strings.Contains(cmd, "%t") {
|
||||
parts = append(parts, "-ss", strconv.Itoa(offset))
|
||||
}
|
||||
} else {
|
||||
s = strings.ReplaceAll(s, "%t", strconv.Itoa(offset))
|
||||
s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate))
|
||||
parts = append(parts, s)
|
||||
}
|
||||
}
|
||||
|
||||
return split
|
||||
return parts
|
||||
}
|
||||
|
||||
func createProbeCommand(cmd string, inputs []string) []string {
|
||||
|
|
|
@ -24,9 +24,22 @@ var _ = Describe("ffmpeg", func() {
|
|||
})
|
||||
Describe("createFFmpegCommand", func() {
|
||||
It("creates a valid command line", func() {
|
||||
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123)
|
||||
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 0)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
||||
})
|
||||
Context("when command has time offset param", func() {
|
||||
It("creates a valid command line with offset", func() {
|
||||
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk -ss %t mp3 -", "/music library/file.mp3", 123, 456)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "-ss", "456", "mp3", "-"}))
|
||||
})
|
||||
|
||||
})
|
||||
Context("when command does not have time offset param", func() {
|
||||
It("adds time offset after the input file name", func() {
|
||||
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-ss", "456", "-b:a", "123k", "mp3", "-"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("createProbeCommand", func() {
|
||||
|
|
|
@ -19,8 +19,8 @@ import (
|
|||
)
|
||||
|
||||
type MediaStreamer interface {
|
||||
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error)
|
||||
DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int) (*Stream, error)
|
||||
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, offset int) (*Stream, error)
|
||||
DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error)
|
||||
}
|
||||
|
||||
type TranscodingCache cache.FileCache
|
||||
|
@ -40,22 +40,23 @@ type streamJob struct {
|
|||
mf *model.MediaFile
|
||||
format string
|
||||
bitRate int
|
||||
offset int
|
||||
}
|
||||
|
||||
func (j *streamJob) Key() string {
|
||||
return fmt.Sprintf("%s.%s.%d.%s", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format)
|
||||
return fmt.Sprintf("%s.%s.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format, j.offset)
|
||||
}
|
||||
|
||||
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error) {
|
||||
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
|
||||
mf, err := ms.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ms.DoStream(ctx, mf, reqFormat, reqBitRate)
|
||||
return ms.DoStream(ctx, mf, reqFormat, reqBitRate, reqOffset)
|
||||
}
|
||||
|
||||
func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int) (*Stream, error) {
|
||||
func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
|
||||
var format string
|
||||
var bitRate int
|
||||
var cached bool
|
||||
|
@ -70,7 +71,7 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF
|
|||
|
||||
if format == "raw" {
|
||||
log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", mf.Path,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
|
||||
"selectedBitrate", bitRate, "selectedFormat", format)
|
||||
f, err := os.Open(mf.Path)
|
||||
|
@ -88,6 +89,7 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF
|
|||
mf: mf,
|
||||
format: format,
|
||||
bitRate: bitRate,
|
||||
offset: reqOffset,
|
||||
}
|
||||
r, err := ms.cache.Get(ctx, job)
|
||||
if err != nil {
|
||||
|
@ -100,7 +102,7 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF
|
|||
s.Seeker = r.Seeker
|
||||
|
||||
log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", mf.Path,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
|
||||
"selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable())
|
||||
|
||||
|
@ -199,7 +201,7 @@ func NewTranscodingCache() TranscodingCache {
|
|||
log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
out, err := job.ms.transcoder.Transcode(ctx, t.Command, job.mf.Path, job.bitRate)
|
||||
out, err := job.ms.transcoder.Transcode(ctx, t.Command, job.mf.Path, job.bitRate, job.offset)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
|
||||
return nil, os.ErrInvalid
|
||||
|
|
|
@ -39,34 +39,34 @@ var _ = Describe("MediaStreamer", func() {
|
|||
|
||||
Context("NewStream", func() {
|
||||
It("returns a seekable stream if format is 'raw'", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "raw", 0)
|
||||
s, err := streamer.NewStream(ctx, "123", "raw", 0, 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
It("returns a seekable stream if maxBitRate is 0", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 0)
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 0, 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
It("returns a seekable stream if maxBitRate is higher than file bitRate", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 320)
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 320, 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
It("returns a NON seekable stream if transcode is required", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 64)
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 64, 0)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s.Seekable()).To(BeFalse())
|
||||
Expect(s.Duration()).To(Equal(float32(257.0)))
|
||||
})
|
||||
It("returns a seekable stream if the file is complete in the cache", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 32)
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 32, 0)
|
||||
Expect(err).To(BeNil())
|
||||
_, _ = io.ReadAll(s)
|
||||
_ = s.Close()
|
||||
Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue())
|
||||
|
||||
s, err = streamer.NewStream(ctx, "123", "mp3", 32)
|
||||
s, err = streamer.NewStream(ctx, "123", "mp3", 32, 0)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
|
|
|
@ -1,66 +0,0 @@
|
|||
//go:build beep
|
||||
|
||||
package beepaudio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/faiface/beep"
|
||||
"github.com/faiface/beep/flac"
|
||||
"github.com/faiface/beep/mp3"
|
||||
"github.com/faiface/beep/wav"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
func DecodeMp3(path string) (s beep.StreamSeekCloser, format beep.Format, err error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, beep.Format{}, err
|
||||
}
|
||||
return mp3.Decode(f)
|
||||
}
|
||||
|
||||
func DecodeWAV(path string) (s beep.StreamSeekCloser, format beep.Format, err error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, beep.Format{}, err
|
||||
}
|
||||
return wav.Decode(f)
|
||||
}
|
||||
|
||||
func DecodeFLAC(path string) (s beep.StreamSeekCloser, format beep.Format, fileToCleanup string, err error) {
|
||||
// TODO: Turn this into a semi-parallel operation: start playing while still transcoding/copying
|
||||
log.Debug("decode to FLAC", "filename", path)
|
||||
fFmpeg := ffmpeg.New()
|
||||
readCloser, err := fFmpeg.ConvertToFLAC(context.TODO(), path)
|
||||
if err != nil {
|
||||
log.Error("error converting file to FLAC", path, err)
|
||||
return nil, beep.Format{}, "", err
|
||||
}
|
||||
|
||||
tempFile, err := os.CreateTemp("", "*.flac")
|
||||
|
||||
if err != nil {
|
||||
log.Error("error creating temp file", err)
|
||||
return nil, beep.Format{}, "", err
|
||||
}
|
||||
log.Debug("created tempfile", "filename", tempFile.Name())
|
||||
|
||||
written, err := io.Copy(tempFile, readCloser)
|
||||
if err != nil {
|
||||
log.Error("error coping file", "dest", tempFile.Name())
|
||||
}
|
||||
log.Debug("copy pipe into tempfile", "bytes written", written, "filename", tempFile.Name())
|
||||
|
||||
f, err := os.Open(tempFile.Name())
|
||||
if err != nil {
|
||||
log.Error("could not re-open tempfile", "filename", tempFile.Name())
|
||||
return nil, beep.Format{}, "", err
|
||||
}
|
||||
|
||||
s, format, err = flac.Decode(f)
|
||||
return s, format, tempFile.Name(), err
|
||||
}
|
|
@ -1,162 +0,0 @@
|
|||
//go:build beep
|
||||
|
||||
package beepaudio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/faiface/beep"
|
||||
"github.com/faiface/beep/effects"
|
||||
"github.com/faiface/beep/speaker"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type BeepTrack struct {
|
||||
MediaFile model.MediaFile
|
||||
Ctrl *beep.Ctrl
|
||||
Volume *effects.Volume
|
||||
ActiveStream beep.StreamSeekCloser
|
||||
TempfileToCleanup string
|
||||
SampleRate beep.SampleRate
|
||||
PlaybackDone chan bool
|
||||
}
|
||||
|
||||
func NewTrack(playbackDoneChannel chan bool, mf model.MediaFile) (*BeepTrack, error) {
|
||||
t := BeepTrack{}
|
||||
|
||||
contentType := mf.ContentType()
|
||||
log.Debug("loading track", "trackname", mf.Path, "mediatype", contentType)
|
||||
|
||||
var streamer beep.StreamSeekCloser
|
||||
var format beep.Format
|
||||
var err error
|
||||
var tmpfileToCleanup = ""
|
||||
|
||||
switch contentType {
|
||||
case "audio/mpeg":
|
||||
streamer, format, err = DecodeMp3(mf.Path)
|
||||
case "audio/x-wav":
|
||||
streamer, format, err = DecodeWAV(mf.Path)
|
||||
case "audio/mp4":
|
||||
streamer, format, tmpfileToCleanup, err = DecodeFLAC(mf.Path)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported content type: %s", contentType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// save running stream for closing when switching tracks
|
||||
t.ActiveStream = streamer
|
||||
t.TempfileToCleanup = tmpfileToCleanup
|
||||
|
||||
log.Debug("Setting up audio device")
|
||||
t.Ctrl = &beep.Ctrl{Streamer: streamer, Paused: true}
|
||||
t.Volume = &effects.Volume{Streamer: t.Ctrl, Base: 2}
|
||||
t.SampleRate = format.SampleRate
|
||||
t.PlaybackDone = playbackDoneChannel
|
||||
t.MediaFile = mf
|
||||
|
||||
err = speaker.Init(format.SampleRate, format.SampleRate.N(time.Second/10))
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
log.Debug("speaker.Init() finished")
|
||||
|
||||
go func() {
|
||||
speaker.Play(beep.Seq(t.Volume, beep.Callback(func() {
|
||||
log.Info("Hitting end-of-stream, signalling on channel")
|
||||
t.PlaybackDone <- true
|
||||
log.Debug("Signalling finished")
|
||||
})))
|
||||
log.Debug("dropping out of speaker.Play()")
|
||||
}()
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (t *BeepTrack) String() string {
|
||||
return fmt.Sprintf("Name: %s", t.MediaFile.Path)
|
||||
}
|
||||
|
||||
func (t *BeepTrack) SetVolume(value float64) {
|
||||
speaker.Lock()
|
||||
t.Volume.Volume += value
|
||||
speaker.Unlock()
|
||||
}
|
||||
|
||||
func (t *BeepTrack) Unpause() {
|
||||
speaker.Lock()
|
||||
if t.Ctrl.Paused {
|
||||
t.Ctrl.Paused = false
|
||||
} else {
|
||||
log.Debug("tried to unpause while not paused")
|
||||
}
|
||||
speaker.Unlock()
|
||||
}
|
||||
|
||||
func (t *BeepTrack) Pause() {
|
||||
speaker.Lock()
|
||||
if t.Ctrl.Paused {
|
||||
log.Debug("tried to pause while already paused")
|
||||
} else {
|
||||
t.Ctrl.Paused = true
|
||||
}
|
||||
speaker.Unlock()
|
||||
}
|
||||
|
||||
func (t *BeepTrack) Close() {
|
||||
if t.ActiveStream != nil {
|
||||
log.Debug("closing activ stream")
|
||||
t.ActiveStream.Close()
|
||||
t.ActiveStream = nil
|
||||
}
|
||||
|
||||
speaker.Close()
|
||||
|
||||
if t.TempfileToCleanup != "" {
|
||||
log.Debug("Removing tempfile", "tmpfilename", t.TempfileToCleanup)
|
||||
err := os.Remove(t.TempfileToCleanup)
|
||||
if err != nil {
|
||||
log.Error("error cleaning up tempfile: ", t.TempfileToCleanup)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Position returns the playback position in seconds
|
||||
func (t *BeepTrack) Position() int {
|
||||
if t.Ctrl.Streamer == nil {
|
||||
log.Debug("streamer is not setup (nil), could not get position")
|
||||
return 0
|
||||
}
|
||||
|
||||
streamer, ok := t.Ctrl.Streamer.(beep.StreamSeeker)
|
||||
if ok {
|
||||
position := t.SampleRate.D(streamer.Position())
|
||||
posSecs := position.Round(time.Second).Seconds()
|
||||
return int(posSecs)
|
||||
} else {
|
||||
log.Debug("streamer is no beep.StreamSeeker, could not get position")
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// offset = pd.PlaybackQueue.Offset
|
||||
func (t *BeepTrack) SetPosition(offset int) error {
|
||||
streamer, ok := t.Ctrl.Streamer.(beep.StreamSeeker)
|
||||
if ok {
|
||||
sampleRatePerSecond := t.SampleRate.N(time.Second)
|
||||
nextPosition := sampleRatePerSecond * offset
|
||||
log.Debug("SetPosition", "samplerate", sampleRatePerSecond, "nextPosition", nextPosition)
|
||||
return streamer.Seek(nextPosition)
|
||||
}
|
||||
return fmt.Errorf("streamer is not seekable")
|
||||
}
|
||||
|
||||
func (t *BeepTrack) IsPlaying() bool {
|
||||
return t.Ctrl != nil && !t.Ctrl.Paused
|
||||
}
|
|
@ -2,7 +2,9 @@ package playback
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/navidrome/navidrome/core/playback/mpv"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
|
@ -17,9 +19,11 @@ type Track interface {
|
|||
Position() int
|
||||
SetPosition(offset int) error
|
||||
Close()
|
||||
String() string
|
||||
}
|
||||
|
||||
type PlaybackDevice struct {
|
||||
type playbackDevice struct {
|
||||
serviceCtx context.Context
|
||||
ParentPlaybackServer PlaybackServer
|
||||
Default bool
|
||||
User string
|
||||
|
@ -29,7 +33,7 @@ type PlaybackDevice struct {
|
|||
Gain float32
|
||||
PlaybackDone chan bool
|
||||
ActiveTrack Track
|
||||
TrackSwitcherStarted bool
|
||||
startTrackSwitcher sync.Once
|
||||
}
|
||||
|
||||
type DeviceStatus struct {
|
||||
|
@ -41,9 +45,7 @@ type DeviceStatus struct {
|
|||
|
||||
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
|
||||
if pd.ActiveTrack != nil {
|
||||
pos = pd.ActiveTrack.Position()
|
||||
|
@ -59,8 +61,9 @@ func (pd *PlaybackDevice) getStatus() DeviceStatus {
|
|||
// NewPlaybackDevice creates a new playback device which implements all the basic Jukebox mode commands defined here:
|
||||
// http://www.subsonic.org/pages/api.jsp#jukeboxControl
|
||||
// Starts the trackSwitcher goroutine for the device.
|
||||
func NewPlaybackDevice(playbackServer PlaybackServer, name string, deviceName string) *PlaybackDevice {
|
||||
return &PlaybackDevice{
|
||||
func NewPlaybackDevice(ctx context.Context, playbackServer PlaybackServer, name string, deviceName string) *playbackDevice {
|
||||
return &playbackDevice{
|
||||
serviceCtx: ctx,
|
||||
ParentPlaybackServer: playbackServer,
|
||||
User: "",
|
||||
Name: name,
|
||||
|
@ -68,26 +71,27 @@ func NewPlaybackDevice(playbackServer PlaybackServer, name string, deviceName st
|
|||
Gain: DefaultGain,
|
||||
PlaybackQueue: NewQueue(),
|
||||
PlaybackDone: make(chan bool),
|
||||
TrackSwitcherStarted: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (pd *PlaybackDevice) String() string {
|
||||
func (pd *playbackDevice) String() string {
|
||||
return fmt.Sprintf("Name: %s, Gain: %.4f, Loaded track: %s", pd.Name, pd.Gain, pd.ActiveTrack)
|
||||
}
|
||||
|
||||
func (pd *PlaybackDevice) Get(ctx context.Context) (model.MediaFiles, DeviceStatus, error) {
|
||||
log.Debug(ctx, "processing Get action")
|
||||
func (pd *playbackDevice) Get(ctx context.Context) (model.MediaFiles, DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Get action", "device", pd)
|
||||
return pd.PlaybackQueue.Get(), pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *PlaybackDevice) Status(ctx context.Context) (DeviceStatus, error) {
|
||||
func (pd *playbackDevice) Status(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, fmt.Sprintf("processing Status action on: %s, queue: %s", pd, pd.PlaybackQueue))
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
// set is similar to a clear followed by a add, but will not change the currently playing track.
|
||||
func (pd *PlaybackDevice) Set(ctx context.Context, ids []string) (DeviceStatus, error) {
|
||||
// Set is similar to a clear followed by a add, but will not change the currently playing track.
|
||||
func (pd *playbackDevice) Set(ctx context.Context, ids []string) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Set action", "ids", ids, "device", pd)
|
||||
|
||||
_, err := pd.Clear(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "error setting tracks", ids)
|
||||
|
@ -96,17 +100,16 @@ func (pd *PlaybackDevice) Set(ctx context.Context, ids []string) (DeviceStatus,
|
|||
return pd.Add(ctx, ids)
|
||||
}
|
||||
|
||||
func (pd *PlaybackDevice) Start(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "processing Start action")
|
||||
func (pd *playbackDevice) Start(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Start action", "device", pd)
|
||||
|
||||
if !pd.TrackSwitcherStarted {
|
||||
pd.startTrackSwitcher.Do(func() {
|
||||
log.Info(ctx, "Starting trackSwitcher goroutine")
|
||||
// Start one trackSwitcher goroutine with each device
|
||||
go func() {
|
||||
pd.trackSwitcherGoroutine()
|
||||
}()
|
||||
pd.TrackSwitcherStarted = true
|
||||
}
|
||||
})
|
||||
|
||||
if pd.ActiveTrack != nil {
|
||||
if pd.isPlaying() {
|
||||
|
@ -127,16 +130,16 @@ func (pd *PlaybackDevice) Start(ctx context.Context) (DeviceStatus, error) {
|
|||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *PlaybackDevice) Stop(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "processing Stop action")
|
||||
func (pd *playbackDevice) Stop(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Stop action", "device", pd)
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.Pause()
|
||||
}
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *PlaybackDevice) Skip(ctx context.Context, index int, offset int) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "processing Skip action", "index", index, "offset", offset)
|
||||
func (pd *playbackDevice) Skip(ctx context.Context, index int, offset int) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Skip action", "index", index, "offset", offset, "device", pd)
|
||||
|
||||
wasPlaying := pd.isPlaying()
|
||||
|
||||
|
@ -173,8 +176,11 @@ func (pd *PlaybackDevice) Skip(ctx context.Context, index int, offset int) (Devi
|
|||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *PlaybackDevice) Add(ctx context.Context, ids []string) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "processing Add action")
|
||||
func (pd *playbackDevice) Add(ctx context.Context, ids []string) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Add action", "ids", ids, "device", pd)
|
||||
if len(ids) < 1 {
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
items := model.MediaFiles{}
|
||||
|
||||
|
@ -191,8 +197,8 @@ func (pd *PlaybackDevice) Add(ctx context.Context, ids []string) (DeviceStatus,
|
|||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *PlaybackDevice) Clear(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, fmt.Sprintf("processing Clear action on: %s", pd))
|
||||
func (pd *playbackDevice) Clear(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Clear action", "device", pd)
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.Pause()
|
||||
pd.ActiveTrack.Close()
|
||||
|
@ -202,8 +208,8 @@ func (pd *PlaybackDevice) Clear(ctx context.Context) (DeviceStatus, error) {
|
|||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *PlaybackDevice) Remove(ctx context.Context, index int) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "processing Remove action")
|
||||
func (pd *playbackDevice) Remove(ctx context.Context, index int) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Remove action", "index", index, "device", pd)
|
||||
// pausing if attempting to remove running track
|
||||
if pd.isPlaying() && pd.PlaybackQueue.Index == index {
|
||||
_, err := pd.Stop(ctx)
|
||||
|
@ -221,17 +227,17 @@ func (pd *PlaybackDevice) Remove(ctx context.Context, index int) (DeviceStatus,
|
|||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *PlaybackDevice) Shuffle(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "processing Shuffle action")
|
||||
func (pd *playbackDevice) Shuffle(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Shuffle action", "device", pd)
|
||||
if pd.PlaybackQueue.Size() > 1 {
|
||||
pd.PlaybackQueue.Shuffle()
|
||||
}
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
// Used to control the playback volume. A float value between 0.0 and 1.0.
|
||||
func (pd *PlaybackDevice) SetGain(ctx context.Context, gain float32) (DeviceStatus, error) {
|
||||
log.Debug(ctx, fmt.Sprintf("processing SetGain action. Actual gain: %f, gain to set: %f", pd.Gain, gain))
|
||||
// SetGain is used to control the playback volume. A float value between 0.0 and 1.0.
|
||||
func (pd *playbackDevice) SetGain(ctx context.Context, gain float32) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing SetGain action", "newGain", gain, "device", pd)
|
||||
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.SetVolume(gain)
|
||||
|
@ -241,45 +247,53 @@ func (pd *PlaybackDevice) SetGain(ctx context.Context, gain float32) (DeviceStat
|
|||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *PlaybackDevice) isPlaying() bool {
|
||||
func (pd *playbackDevice) isPlaying() bool {
|
||||
return pd.ActiveTrack != nil && pd.ActiveTrack.IsPlaying()
|
||||
}
|
||||
|
||||
func (pd *PlaybackDevice) trackSwitcherGoroutine() {
|
||||
log.Info("Starting trackSwitcher goroutine")
|
||||
func (pd *playbackDevice) trackSwitcherGoroutine() {
|
||||
log.Debug("Started trackSwitcher goroutine", "device", pd)
|
||||
for {
|
||||
<-pd.PlaybackDone
|
||||
log.Info("track switching detected")
|
||||
if 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", "error", err)
|
||||
select {
|
||||
case <-pd.PlaybackDone:
|
||||
log.Debug("Track switching detected")
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.Close()
|
||||
pd.ActiveTrack = nil
|
||||
}
|
||||
pd.ActiveTrack.Unpause()
|
||||
} else {
|
||||
log.Debug("There is no song left in the playlist. Finish.")
|
||||
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (pd *PlaybackDevice) switchActiveTrackByIndex(index int) error {
|
||||
func (pd *playbackDevice) switchActiveTrackByIndex(index int) error {
|
||||
pd.PlaybackQueue.SetIndex(index)
|
||||
currentTrack := pd.PlaybackQueue.Current()
|
||||
if currentTrack == nil {
|
||||
return fmt.Errorf("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 {
|
||||
return err
|
||||
}
|
||||
pd.ActiveTrack = track
|
||||
pd.ActiveTrack.SetVolume(pd.Gain)
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -2,14 +2,11 @@ package mpv
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
|
@ -17,16 +14,11 @@ import (
|
|||
"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
|
||||
const (
|
||||
mpvComdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s"
|
||||
)
|
||||
|
||||
func start(args []string) (Executor, error) {
|
||||
func start(ctx context.Context, args []string) (Executor, error) {
|
||||
log.Debug("Executing mpv command", "cmd", args)
|
||||
j := Executor{args: args}
|
||||
j.PipeReader, j.out = io.Pipe()
|
||||
err := j.start()
|
||||
err := j.start(ctx)
|
||||
if err != nil {
|
||||
return Executor{}, err
|
||||
}
|
||||
|
@ -46,15 +38,12 @@ type Executor struct {
|
|||
out *io.PipeWriter
|
||||
args []string
|
||||
cmd *exec.Cmd
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (j *Executor) start() error {
|
||||
ctx := context.Background()
|
||||
j.ctx = ctx
|
||||
func (j *Executor) start(ctx context.Context) error {
|
||||
cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
|
||||
cmd.Stdout = j.out
|
||||
if log.CurrentLevel() >= log.LevelTrace {
|
||||
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
||||
cmd.Stderr = os.Stderr
|
||||
} else {
|
||||
cmd.Stderr = io.Discard
|
||||
|
@ -81,15 +70,14 @@ func (j *Executor) wait() {
|
|||
}
|
||||
|
||||
// Path will always be an absolute path
|
||||
func createMPVCommand(cmd, deviceName string, filename string, socketName string) []string {
|
||||
split := strings.Split(fixCmd(cmd), " ")
|
||||
func createMPVCommand(deviceName string, filename string, socketName string) []string {
|
||||
split := strings.Split(fixCmd(conf.Server.MPVCmdTemplate), " ")
|
||||
for i, s := range split {
|
||||
s = strings.ReplaceAll(s, "%d", deviceName)
|
||||
s = strings.ReplaceAll(s, "%f", filename)
|
||||
s = strings.ReplaceAll(s, "%s", socketName)
|
||||
split[i] = s
|
||||
}
|
||||
|
||||
return split
|
||||
}
|
||||
|
||||
|
@ -133,10 +121,3 @@ var (
|
|||
mpvPath string
|
||||
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,11 +6,12 @@ package mpv
|
|||
// https://mpv.io/manual/master/#properties
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/DexterLB/mpvipc"
|
||||
"github.com/dexterlb/mpvipc"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
@ -24,26 +25,26 @@ type MpvTrack struct {
|
|||
CloseCalled bool
|
||||
}
|
||||
|
||||
func NewTrack(playbackDoneChannel chan bool, deviceName string, mf model.MediaFile) (*MpvTrack, error) {
|
||||
log.Debug("loading track", "trackname", mf.Path, "mediatype", mf.ContentType())
|
||||
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())
|
||||
|
||||
if _, err := mpvCommand(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tmpSocketName := TempFileName("mpv-ctrl-", ".socket")
|
||||
tmpSocketName := socketName("mpv-ctrl-", ".socket")
|
||||
|
||||
args := createMPVCommand(mpvComdTemplate, deviceName, mf.Path, tmpSocketName)
|
||||
exe, err := start(args)
|
||||
args := createMPVCommand(deviceName, mf.Path, tmpSocketName)
|
||||
exe, err := start(ctx, args)
|
||||
if err != nil {
|
||||
log.Error("error starting mpv process", "error", err)
|
||||
log.Error("Error starting mpv process", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// wait for socket to show up
|
||||
err = waitForFile(tmpSocketName, 3*time.Second, 100*time.Millisecond)
|
||||
err = waitForSocket(tmpSocketName, 3*time.Second, 100*time.Millisecond)
|
||||
if err != nil {
|
||||
log.Error("error or timeout waiting for control socket", "socketname", tmpSocketName, "error", err)
|
||||
log.Error("Error or timeout waiting for control socket", "socketname", tmpSocketName, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
@ -51,7 +52,7 @@ func NewTrack(playbackDoneChannel chan bool, deviceName string, mf model.MediaFi
|
|||
err = conn.Open()
|
||||
|
||||
if err != nil {
|
||||
log.Error("error opening new connection", "error", err)
|
||||
log.Error("Error opening new connection", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
@ -77,62 +78,57 @@ func (t *MpvTrack) SetVolume(value float32) {
|
|||
// mpv's volume as described in the --volume parameter:
|
||||
// Set the startup volume. 0 means silence, 100 means no volume reduction or amplification.
|
||||
// Negative values can be passed for compatibility, but are treated as 0.
|
||||
log.Debug("request for gain", "gain", value)
|
||||
log.Debug("Setting volume", "volume", value, "track", t)
|
||||
vol := int(value * 100)
|
||||
|
||||
err := t.Conn.Set("volume", vol)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
log.Error("Error setting volume", "volume", value, "track", t, err)
|
||||
}
|
||||
log.Debug("set volume", "volume", vol)
|
||||
}
|
||||
|
||||
func (t *MpvTrack) Unpause() {
|
||||
log.Debug("Unpausing track", "track", t)
|
||||
err := t.Conn.Set("pause", false)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
log.Error("Error unpausing track", "track", t, err)
|
||||
}
|
||||
log.Info("unpaused track")
|
||||
}
|
||||
|
||||
func (t *MpvTrack) Pause() {
|
||||
log.Debug("Pausing track", "track", t)
|
||||
err := t.Conn.Set("pause", true)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
log.Error("Error pausing track", "track", t, err)
|
||||
}
|
||||
log.Info("paused track")
|
||||
}
|
||||
|
||||
func (t *MpvTrack) Close() {
|
||||
log.Debug("closing resources")
|
||||
log.Debug("Closing resources", "track", t)
|
||||
t.CloseCalled = true
|
||||
// trying to shutdown mpv process using socket
|
||||
if t.isSocketfilePresent() {
|
||||
if t.isSocketFilePresent() {
|
||||
log.Debug("sending shutdown command")
|
||||
_, err := t.Conn.Call("quit")
|
||||
if err != nil {
|
||||
log.Error("error sending quit command to mpv-ipc socket", "error", err)
|
||||
log.Warn("Error sending quit command to mpv-ipc socket", err)
|
||||
|
||||
if t.Exe != nil {
|
||||
log.Debug("cancelling executor")
|
||||
err = t.Exe.Cancel()
|
||||
if err != nil {
|
||||
log.Error("error canceling executor")
|
||||
log.Warn("Error canceling executor", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if t.isSocketfilePresent() {
|
||||
log.Debug("Removing socketfile", "socketfile", t.IPCSocketName)
|
||||
err := os.Remove(t.IPCSocketName)
|
||||
if err != nil {
|
||||
log.Error("error cleaning up socketfile: ", t.IPCSocketName)
|
||||
}
|
||||
if t.isSocketFilePresent() {
|
||||
removeSocket(t.IPCSocketName)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MpvTrack) isSocketfilePresent() bool {
|
||||
func (t *MpvTrack) isSocketFilePresent() bool {
|
||||
if len(t.IPCSocketName) < 1 {
|
||||
return false
|
||||
}
|
||||
|
@ -141,69 +137,70 @@ func (t *MpvTrack) isSocketfilePresent() bool {
|
|||
return err == nil && fileInfo != nil && !fileInfo.IsDir()
|
||||
}
|
||||
|
||||
// Position returns the playback position in seconds
|
||||
// every now and then the mpv IPC interface returns "mpv error: property unavailable"
|
||||
// Position returns the playback position in seconds.
|
||||
// Every now and then the mpv IPC interface returns "mpv error: property unavailable"
|
||||
// in this case we have to retry
|
||||
func (t *MpvTrack) Position() int {
|
||||
retryCount := 0
|
||||
for {
|
||||
position, err := t.Conn.Get("time-pos")
|
||||
if err != nil && err.Error() == "mpv error: property unavailable" {
|
||||
log.Debug("got the mpv error: property unavailable error, retry ...")
|
||||
retryCount += 1
|
||||
log.Debug("Got mpv error, retrying...", "retries", retryCount, err)
|
||||
if retryCount > 5 {
|
||||
return 0
|
||||
}
|
||||
break
|
||||
time.Sleep(time.Duration(retryCount) * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error("error getting position in track", "error", err)
|
||||
log.Error("Error getting position in track", "track", t, err)
|
||||
return 0
|
||||
}
|
||||
|
||||
pos, ok := position.(float64)
|
||||
if !ok {
|
||||
log.Error("could not cast position from mpv into float64")
|
||||
log.Error("Could not cast position from mpv into float64", "position", position, "track", t)
|
||||
return 0
|
||||
} else {
|
||||
return int(pos)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (t *MpvTrack) SetPosition(offset int) error {
|
||||
log.Debug("Setting position", "offset", offset, "track", t)
|
||||
pos := t.Position()
|
||||
if pos == offset {
|
||||
log.Debug("no position difference, skipping operation")
|
||||
log.Debug("No position difference, skipping operation", "track", t)
|
||||
return nil
|
||||
}
|
||||
err := t.Conn.Set("time-pos", float64(offset))
|
||||
if err != nil {
|
||||
log.Error("could not set the position in track", "offset", offset, "error", err)
|
||||
log.Error("Could not set the position in track", "track", t, "offset", offset, err)
|
||||
return err
|
||||
}
|
||||
log.Info("set position", "offset", offset)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *MpvTrack) IsPlaying() bool {
|
||||
log.Debug("Checking if track is playing", "track", t)
|
||||
pausing, err := t.Conn.Get("pause")
|
||||
if err != nil {
|
||||
log.Error("problem getting paused status", "error", err)
|
||||
log.Error("Problem getting paused status", "track", t, err)
|
||||
return false
|
||||
}
|
||||
|
||||
pause, ok := pausing.(bool)
|
||||
if !ok {
|
||||
log.Error("could not cast pausing to boolean")
|
||||
log.Error("Could not cast pausing to boolean", "track", t, "value", pausing)
|
||||
return false
|
||||
}
|
||||
return !pause
|
||||
}
|
||||
|
||||
func waitForFile(path string, timeout time.Duration, pause time.Duration) error {
|
||||
func waitForSocket(path string, timeout time.Duration, pause time.Duration) error {
|
||||
start := time.Now()
|
||||
end := start.Add(timeout)
|
||||
var retries int = 0
|
||||
|
@ -211,7 +208,7 @@ func waitForFile(path string, timeout time.Duration, pause time.Duration) error
|
|||
for {
|
||||
fileInfo, err := os.Stat(path)
|
||||
if err == nil && fileInfo != nil && !fileInfo.IsDir() {
|
||||
log.Debug("file found", "retries", retries, "waittime", time.Since(start).Microseconds())
|
||||
log.Debug("Socket found", "retries", retries, "waitTime", time.Since(start))
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(end) {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
// Package playback implements audio playback using PlaybackDevices. It is used to implement the Jukebox mode in turn.
|
||||
// It makes use of the BEEP library to do the playback. Major parts are:
|
||||
// It makes use of the MPV library to do the playback. Major parts are:
|
||||
// - decoder which includes decoding and transcoding of various audio file formats
|
||||
// - device implementing the basic functions to work with audio devices like set, play, stop, skip, ...
|
||||
// - queue a simple playlist
|
||||
|
@ -10,79 +10,71 @@ import (
|
|||
"fmt"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
)
|
||||
|
||||
type PlaybackServer interface {
|
||||
Run(ctx context.Context) error
|
||||
GetDeviceForUser(user string) (*PlaybackDevice, error)
|
||||
GetDeviceForUser(user string) (*playbackDevice, error)
|
||||
GetMediaFile(id string) (*model.MediaFile, error)
|
||||
GetCtx() *context.Context
|
||||
}
|
||||
|
||||
type playbackServer struct {
|
||||
ctx *context.Context
|
||||
datastore model.DataStore
|
||||
playbackDevices []PlaybackDevice
|
||||
playbackDevices []playbackDevice
|
||||
}
|
||||
|
||||
// GetInstance returns the playback-server singleton
|
||||
func GetInstance() PlaybackServer {
|
||||
func GetInstance(ds model.DataStore) 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
|
||||
func (ps *playbackServer) Run(ctx context.Context) error {
|
||||
ps.datastore = persistence.New(db.Db())
|
||||
devices, err := ps.initDeviceStatus(conf.Server.Jukebox.Devices, conf.Server.Jukebox.Default)
|
||||
ps.playbackDevices = devices
|
||||
ps.ctx = &ctx
|
||||
|
||||
devices, err := ps.initDeviceStatus(ctx, conf.Server.Jukebox.Devices, conf.Server.Jukebox.Default)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ps.playbackDevices = devices
|
||||
log.Info(ctx, fmt.Sprintf("%d audio devices found", len(devices)))
|
||||
|
||||
defaultDevice, _ := ps.getDefaultDevice()
|
||||
|
||||
log.Info(ctx, "Using audio device: "+defaultDevice.DeviceName)
|
||||
|
||||
ps.ctx = &ctx
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
// Should confirm all subprocess are terminated before returning
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCtx produces the context this server was started with. Used for data-retrieval and cancellation
|
||||
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)))
|
||||
func (ps *playbackServer) initDeviceStatus(ctx context.Context, devices []conf.AudioDeviceDefinition, defaultDevice string) ([]playbackDevice, error) {
|
||||
pbDevices := make([]playbackDevice, max(1, len(devices)))
|
||||
defaultDeviceFound := false
|
||||
|
||||
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 {
|
||||
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 len(devices) == 1 {
|
||||
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 {
|
||||
return []PlaybackDevice{}, fmt.Errorf("number of audio device found is %d, but no default device defined. Set Jukebox.Default", len(devices))
|
||||
return []playbackDevice{}, fmt.Errorf("number of audio device found is %d, but no default device defined. Set Jukebox.Default", len(devices))
|
||||
}
|
||||
|
||||
pbDevices[0].Default = true
|
||||
|
@ -91,10 +83,10 @@ func (ps *playbackServer) initDeviceStatus(devices []conf.AudioDeviceDefinition,
|
|||
|
||||
for idx, audioDevice := range devices {
|
||||
if len(audioDevice) != 2 {
|
||||
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 {
|
||||
pbDevices[idx].Default = true
|
||||
|
@ -103,18 +95,18 @@ func (ps *playbackServer) initDeviceStatus(devices []conf.AudioDeviceDefinition,
|
|||
}
|
||||
|
||||
if !defaultDeviceFound {
|
||||
return []PlaybackDevice{}, fmt.Errorf("default device name not found: %s ", defaultDevice)
|
||||
return []playbackDevice{}, fmt.Errorf("default device name not found: %s ", defaultDevice)
|
||||
}
|
||||
return pbDevices, nil
|
||||
}
|
||||
|
||||
func (ps *playbackServer) getDefaultDevice() (*PlaybackDevice, error) {
|
||||
for idx, audioDevice := range ps.playbackDevices {
|
||||
if audioDevice.Default {
|
||||
func (ps *playbackServer) getDefaultDevice() (*playbackDevice, error) {
|
||||
for idx := range ps.playbackDevices {
|
||||
if ps.playbackDevices[idx].Default {
|
||||
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
|
||||
|
@ -123,12 +115,12 @@ func (ps *playbackServer) GetMediaFile(id string) (*model.MediaFile, error) {
|
|||
}
|
||||
|
||||
// GetDeviceForUser returns the audio playback device for the given user. As of now this is but only the default device.
|
||||
func (ps *playbackServer) GetDeviceForUser(user string) (*PlaybackDevice, error) {
|
||||
log.Debug("processing GetDevice")
|
||||
func (ps *playbackServer) GetDeviceForUser(user string) (*playbackDevice, error) {
|
||||
log.Debug("Processing GetDevice", "user", user)
|
||||
// README: here we might plug-in the user-device mapping one fine day
|
||||
device, err := ps.getDefaultDevice()
|
||||
if err != nil {
|
||||
return &PlaybackDevice{}, err
|
||||
return nil, err
|
||||
}
|
||||
device.User = user
|
||||
return device, nil
|
||||
|
|
|
@ -134,17 +134,3 @@ func (pd *Queue) IncreaseIndex() {
|
|||
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"
|
||||
"time"
|
||||
|
||||
"github.com/RaveNoX/go-jsoncommentstrip"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"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) {
|
||||
nsp := &nspFile{}
|
||||
dec := json.NewDecoder(file)
|
||||
reader := jsoncommentstrip.NewReader(file)
|
||||
dec := json.NewDecoder(reader)
|
||||
err := dec.Decode(nsp)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error parsing SmartPlaylist", "playlist", pls.Name, err)
|
||||
|
@ -187,7 +189,7 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist)
|
|||
newPls.Comment = pls.Comment
|
||||
newPls.OwnerID = pls.OwnerID
|
||||
newPls.Public = pls.Public
|
||||
newPls.EvaluatedAt = time.Time{}
|
||||
newPls.EvaluatedAt = &time.Time{}
|
||||
} else {
|
||||
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
||||
newPls.OwnerID = owner.ID
|
||||
|
@ -229,13 +231,17 @@ func (s *playlists) Update(ctx context.Context, playlistID string,
|
|||
var pls *model.Playlist
|
||||
var err error
|
||||
repo := tx.Playlist(ctx)
|
||||
tracks := repo.Tracks(playlistID, true)
|
||||
if tracks == nil {
|
||||
return fmt.Errorf("%w: playlist '%s'", model.ErrNotFound, playlistID)
|
||||
}
|
||||
if needsTrackRefresh {
|
||||
pls, err = repo.GetWithTracks(playlistID, true)
|
||||
pls.RemoveTracks(idxToRemove)
|
||||
pls.AddTracks(idsToAdd)
|
||||
} else {
|
||||
if len(idsToAdd) > 0 {
|
||||
_, err = repo.Tracks(playlistID, true).Add(idsToAdd)
|
||||
_, err = tracks.Add(idsToAdd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -262,7 +268,7 @@ func (s *playlists) Update(ctx context.Context, playlistID string,
|
|||
}
|
||||
// Special case: The playlist is now empty
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
|
@ -33,29 +34,45 @@ var _ = Describe("Playlists", func() {
|
|||
ps = NewPlaylists(ds)
|
||||
})
|
||||
|
||||
It("parses well-formed playlists", func() {
|
||||
pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/pls1.m3u")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(pls.OwnerID).To(Equal("123"))
|
||||
Expect(pls.Tracks).To(HaveLen(3))
|
||||
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/test.mp3"))
|
||||
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/test.ogg"))
|
||||
Expect(pls.Tracks[2].Path).To(Equal("/tests/fixtures/01 Invisible (RED) Edit Version.mp3"))
|
||||
Expect(mp.last).To(Equal(pls))
|
||||
Describe("M3U", func() {
|
||||
It("parses well-formed playlists", func() {
|
||||
pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/pls1.m3u")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(pls.OwnerID).To(Equal("123"))
|
||||
Expect(pls.Tracks).To(HaveLen(3))
|
||||
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/test.mp3"))
|
||||
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/test.ogg"))
|
||||
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() {
|
||||
pls, err := ps.ImportFile(ctx, "tests/fixtures/playlists", "lf-ended.m3u")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(pls.Tracks).To(HaveLen(2))
|
||||
Describe("NSP", func() {
|
||||
It("parses well-formed playlists", func() {
|
||||
pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/recently_played.nsp")
|
||||
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() {
|
||||
|
|
|
@ -5,10 +5,9 @@ import (
|
|||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/jellydator/ttlcache/v2"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
|
||||
"github.com/ReneKroon/ttlcache/v2"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
|
@ -163,15 +162,15 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro
|
|||
|
||||
func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, timestamp time.Time) error {
|
||||
return p.ds.WithTx(func(tx model.DataStore) error {
|
||||
err := p.ds.MediaFile(ctx).IncPlayCount(track.ID, timestamp)
|
||||
err := tx.MediaFile(ctx).IncPlayCount(track.ID, timestamp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = p.ds.Album(ctx).IncPlayCount(track.AlbumID, timestamp)
|
||||
err = tx.Album(ctx).IncPlayCount(track.AlbumID, timestamp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = p.ds.Artist(ctx).IncPlayCount(track.ArtistID, timestamp)
|
||||
err = tx.Artist(ctx).IncPlayCount(track.ArtistID, timestamp)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import (
|
|||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
"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 {
|
||||
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
|
||||
}
|
||||
share.LastVisitedAt = time.Now()
|
||||
share.LastVisitedAt = P(time.Now())
|
||||
share.VisitCount++
|
||||
|
||||
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
|
||||
}
|
||||
s.ID = id
|
||||
if s.ExpiresAt.IsZero() {
|
||||
s.ExpiresAt = time.Now().Add(365 * 24 * time.Hour)
|
||||
if V(s.ExpiresAt).IsZero() {
|
||||
s.ExpiresAt = P(time.Now().Add(365 * 24 * time.Hour))
|
||||
}
|
||||
|
||||
firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0]
|
||||
|
@ -128,7 +130,7 @@ func (r *shareRepositoryWrapper) Update(id string, entity interface{}, _ ...stri
|
|||
cols := []string{"description", "downloadable"}
|
||||
|
||||
// TODO Better handling of Share expiration
|
||||
if !entity.(*model.Share).ExpiresAt.IsZero() {
|
||||
if !V(entity.(*model.Share).ExpiresAt).IsZero() {
|
||||
cols = append(cols, "expires_at")
|
||||
}
|
||||
return r.Persistable.Update(id, entity, cols...)
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"github.com/google/wire"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
)
|
||||
|
||||
|
@ -18,4 +19,5 @@ var Set = wire.NewSet(
|
|||
agents.New,
|
||||
ffmpeg.New,
|
||||
scrobbler.GetPlayTracker,
|
||||
playback.GetInstance,
|
||||
)
|
||||
|
|
107
db/db.go
107
db/db.go
|
@ -4,11 +4,13 @@ import (
|
|||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/mattn/go-sqlite3"
|
||||
"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/utils/hasher"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
@ -18,34 +20,82 @@ var (
|
|||
Path string
|
||||
)
|
||||
|
||||
//go:embed migration/*.sql
|
||||
//go:embed migrations/*.sql
|
||||
var embedMigrations embed.FS
|
||||
|
||||
const migrationsFolder = "migration"
|
||||
const migrationsFolder = "migrations"
|
||||
|
||||
type DB interface {
|
||||
ReadDB() *sql.DB
|
||||
WriteDB() *sql.DB
|
||||
Close()
|
||||
}
|
||||
|
||||
type db struct {
|
||||
readDB *sql.DB
|
||||
writeDB *sql.DB
|
||||
}
|
||||
|
||||
func (d *db) ReadDB() *sql.DB {
|
||||
return d.readDB
|
||||
}
|
||||
|
||||
func (d *db) WriteDB() *sql.DB {
|
||||
return d.writeDB
|
||||
}
|
||||
|
||||
func (d *db) Close() {
|
||||
if err := d.readDB.Close(); err != nil {
|
||||
log.Error("Error closing read DB", err)
|
||||
}
|
||||
if err := d.writeDB.Close(); err != nil {
|
||||
log.Error("Error closing write DB", err)
|
||||
}
|
||||
}
|
||||
|
||||
func Db() DB {
|
||||
return singleton.GetInstance(func() *db {
|
||||
sql.Register(Driver+"_custom", &sqlite3.SQLiteDriver{
|
||||
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
|
||||
return conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false)
|
||||
},
|
||||
})
|
||||
|
||||
func Db() *sql.DB {
|
||||
return singleton.GetInstance(func() *sql.DB {
|
||||
Path = conf.Server.DbPath
|
||||
if Path == ":memory:" {
|
||||
Path = "file::memory:?cache=shared&_foreign_keys=on"
|
||||
conf.Server.DbPath = Path
|
||||
}
|
||||
log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver)
|
||||
instance, err := sql.Open(Driver, Path)
|
||||
|
||||
// Create a read database connection
|
||||
rdb, err := sql.Open(Driver+"_custom", Path)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
log.Fatal("Error opening read database", err)
|
||||
}
|
||||
rdb.SetMaxOpenConns(max(4, runtime.NumCPU()))
|
||||
|
||||
// Create a write database connection
|
||||
wdb, err := sql.Open(Driver+"_custom", Path)
|
||||
if err != nil {
|
||||
log.Fatal("Error opening write database", err)
|
||||
}
|
||||
wdb.SetMaxOpenConns(1)
|
||||
|
||||
return &db{
|
||||
readDB: rdb,
|
||||
writeDB: wdb,
|
||||
}
|
||||
return instance
|
||||
})
|
||||
}
|
||||
|
||||
func Close() error {
|
||||
func Close() {
|
||||
log.Info("Closing Database")
|
||||
return Db().Close()
|
||||
Db().Close()
|
||||
}
|
||||
|
||||
func Init() {
|
||||
db := Db()
|
||||
func Init() func() {
|
||||
db := Db().WriteDB()
|
||||
|
||||
// Disable foreign_keys to allow re-creating tables in migrations
|
||||
_, err := db.Exec("PRAGMA foreign_keys=off")
|
||||
|
@ -60,17 +110,46 @@ func Init() {
|
|||
}
|
||||
|
||||
gooseLogger := &logAdapter{silent: isSchemaEmpty(db)}
|
||||
goose.SetLogger(gooseLogger)
|
||||
goose.SetBaseFS(embedMigrations)
|
||||
|
||||
err = goose.SetDialect(Driver)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to apply new migrations", err)
|
||||
}
|
||||
|
||||
return Close
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200326090707, Down20200326090707)
|
||||
}
|
||||
|
||||
func Up20200326090707(tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200326090707(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upTouchPlaylists, downTouchPlaylists)
|
||||
}
|
||||
|
||||
func upTouchPlaylists(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`update playlist set updated_at = datetime('now');`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downTouchPlaylists(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upAddDownloadToShare, downAddDownloadToShare)
|
||||
}
|
||||
|
||||
func upAddDownloadToShare(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table share
|
||||
add downloadable bool not null default false;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddDownloadToShare(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
|
@ -8,10 +9,10 @@ import (
|
|||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200130083147, Down20200130083147)
|
||||
goose.AddMigrationContext(Up20200130083147, Down20200130083147)
|
||||
}
|
||||
|
||||
func Up20200130083147(tx *sql.Tx) error {
|
||||
func Up20200130083147(_ context.Context, tx *sql.Tx) error {
|
||||
log.Info("Creating DB Schema")
|
||||
_, err := tx.Exec(`
|
||||
create table if not exists album
|
||||
|
@ -178,6 +179,6 @@ create table if not exists user
|
|||
return err
|
||||
}
|
||||
|
||||
func Down20200130083147(tx *sql.Tx) error {
|
||||
func Down20200130083147(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200131183653, Down20200131183653)
|
||||
goose.AddMigrationContext(Up20200131183653, Down20200131183653)
|
||||
}
|
||||
|
||||
func Up20200131183653(tx *sql.Tx) error {
|
||||
func Up20200131183653(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table search_dg_tmp
|
||||
(
|
||||
|
@ -36,7 +37,7 @@ update annotation set item_type = 'media_file' where item_type = 'mediaFile';
|
|||
return err
|
||||
}
|
||||
|
||||
func Down20200131183653(tx *sql.Tx) error {
|
||||
func Down20200131183653(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table search_dg_tmp
|
||||
(
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200208222418, Down20200208222418)
|
||||
goose.AddMigrationContext(Up20200208222418, Down20200208222418)
|
||||
}
|
||||
|
||||
func Up20200208222418(tx *sql.Tx) error {
|
||||
func Up20200208222418(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
update annotation set play_count = 0 where play_count is null;
|
||||
update annotation set rating = 0 where rating is null;
|
||||
|
@ -50,6 +51,6 @@ create index annotation_starred
|
|||
return err
|
||||
}
|
||||
|
||||
func Down20200208222418(tx *sql.Tx) error {
|
||||
func Down20200208222418(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200220143731, Down20200220143731)
|
||||
goose.AddMigrationContext(Up20200220143731, Down20200220143731)
|
||||
}
|
||||
|
||||
func Up20200220143731(tx *sql.Tx) error {
|
||||
func Up20200220143731(_ context.Context, tx *sql.Tx) error {
|
||||
notice(tx, "This migration will force the next scan to be a full rescan!")
|
||||
_, err := tx.Exec(`
|
||||
create table media_file_dg_tmp
|
||||
|
@ -124,6 +125,6 @@ update media_file set updated_at = '0001-01-01';
|
|||
return err
|
||||
}
|
||||
|
||||
func Down20200220143731(tx *sql.Tx) error {
|
||||
func Down20200220143731(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,20 +1,21 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200310171621, Down20200310171621)
|
||||
goose.AddMigrationContext(Up20200310171621, Down20200310171621)
|
||||
}
|
||||
|
||||
func Up20200310171621(tx *sql.Tx) error {
|
||||
func Up20200310171621(_ context.Context, tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to enable search by Album Artist!")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200310171621(tx *sql.Tx) error {
|
||||
func Down20200310171621(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200310181627, Down20200310181627)
|
||||
goose.AddMigrationContext(Up20200310181627, Down20200310181627)
|
||||
}
|
||||
|
||||
func Up20200310181627(tx *sql.Tx) error {
|
||||
func Up20200310181627(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table transcoding
|
||||
(
|
||||
|
@ -44,7 +45,7 @@ create table player
|
|||
return err
|
||||
}
|
||||
|
||||
func Down20200310181627(tx *sql.Tx) error {
|
||||
func Down20200310181627(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
drop table transcoding;
|
||||
drop table player;
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200319211049, Down20200319211049)
|
||||
goose.AddMigrationContext(Up20200319211049, Down20200319211049)
|
||||
}
|
||||
|
||||
func Up20200319211049(tx *sql.Tx) error {
|
||||
func Up20200319211049(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table media_file
|
||||
add full_text varchar(255) default '';
|
||||
|
@ -36,6 +37,6 @@ drop table if exists search;
|
|||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200319211049(tx *sql.Tx) error {
|
||||
func Down20200319211049(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200325185135, Down20200325185135)
|
||||
goose.AddMigrationContext(Up20200325185135, Down20200325185135)
|
||||
}
|
||||
|
||||
func Up20200325185135(tx *sql.Tx) error {
|
||||
func Up20200325185135(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table album
|
||||
add album_artist_id varchar(255) default '';
|
||||
|
@ -29,6 +30,6 @@ create index media_file_artist_album_id
|
|||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200325185135(tx *sql.Tx) error {
|
||||
func Down20200325185135(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigrationContext(Up20200326090707, Down20200326090707)
|
||||
}
|
||||
|
||||
func Up20200326090707(_ context.Context, tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200326090707(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200327193744, Down20200327193744)
|
||||
goose.AddMigrationContext(Up20200327193744, Down20200327193744)
|
||||
}
|
||||
|
||||
func Up20200327193744(tx *sql.Tx) error {
|
||||
func Up20200327193744(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table album_dg_tmp
|
||||
(
|
||||
|
@ -75,6 +76,6 @@ create index album_max_year
|
|||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200327193744(tx *sql.Tx) error {
|
||||
func Down20200327193744(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200404214704, Down20200404214704)
|
||||
goose.AddMigrationContext(Up20200404214704, Down20200404214704)
|
||||
}
|
||||
|
||||
func Up20200404214704(tx *sql.Tx) error {
|
||||
func Up20200404214704(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create index if not exists media_file_year
|
||||
on media_file (year);
|
||||
|
@ -24,6 +25,6 @@ create index if not exists media_file_track_number
|
|||
return err
|
||||
}
|
||||
|
||||
func Down20200404214704(tx *sql.Tx) error {
|
||||
func Down20200404214704(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,20 +1,21 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200409002249, Down20200409002249)
|
||||
goose.AddMigrationContext(Up20200409002249, Down20200409002249)
|
||||
}
|
||||
|
||||
func Up20200409002249(tx *sql.Tx) error {
|
||||
func Up20200409002249(_ context.Context, tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to enable search by individual Artist in an Album!")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200409002249(tx *sql.Tx) error {
|
||||
func Down20200409002249(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200411164603, Down20200411164603)
|
||||
goose.AddMigrationContext(Up20200411164603, Down20200411164603)
|
||||
}
|
||||
|
||||
func Up20200411164603(tx *sql.Tx) error {
|
||||
func Up20200411164603(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table playlist
|
||||
add created_at datetime;
|
||||
|
@ -22,6 +23,6 @@ update playlist
|
|||
return err
|
||||
}
|
||||
|
||||
func Down20200411164603(tx *sql.Tx) error {
|
||||
func Down20200411164603(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,20 +1,21 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200418110522, Down20200418110522)
|
||||
goose.AddMigrationContext(Up20200418110522, Down20200418110522)
|
||||
}
|
||||
|
||||
func Up20200418110522(tx *sql.Tx) error {
|
||||
func Up20200418110522(_ context.Context, tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to fix search Albums by year")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200418110522(tx *sql.Tx) error {
|
||||
func Down20200418110522(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,20 +1,21 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200419222708, Down20200419222708)
|
||||
goose.AddMigrationContext(Up20200419222708, Down20200419222708)
|
||||
}
|
||||
|
||||
func Up20200419222708(tx *sql.Tx) error {
|
||||
func Up20200419222708(_ context.Context, tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to change the search behaviour")
|
||||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200419222708(tx *sql.Tx) error {
|
||||
func Down20200419222708(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200423204116, Down20200423204116)
|
||||
goose.AddMigrationContext(Up20200423204116, Down20200423204116)
|
||||
}
|
||||
|
||||
func Up20200423204116(tx *sql.Tx) error {
|
||||
func Up20200423204116(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table artist
|
||||
add order_artist_name varchar(255) collate nocase;
|
||||
|
@ -60,6 +61,6 @@ create index if not exists media_file_order_artist_name
|
|||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200423204116(tx *sql.Tx) error {
|
||||
func Down20200423204116(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200508093059, Down20200508093059)
|
||||
goose.AddMigrationContext(Up20200508093059, Down20200508093059)
|
||||
}
|
||||
|
||||
func Up20200508093059(tx *sql.Tx) error {
|
||||
func Up20200508093059(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table artist
|
||||
add song_count integer default 0 not null;
|
||||
|
@ -22,6 +23,6 @@ alter table artist
|
|||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200508093059(tx *sql.Tx) error {
|
||||
func Down20200508093059(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200512104202, Down20200512104202)
|
||||
goose.AddMigrationContext(Up20200512104202, Down20200512104202)
|
||||
}
|
||||
|
||||
func Up20200512104202(tx *sql.Tx) error {
|
||||
func Up20200512104202(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table media_file
|
||||
add disc_subtitle varchar(255);
|
||||
|
@ -22,6 +23,6 @@ alter table media_file
|
|||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20200512104202(tx *sql.Tx) error {
|
||||
func Down20200512104202(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
|
||||
|
@ -9,10 +10,10 @@ import (
|
|||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200516140647, Down20200516140647)
|
||||
goose.AddMigrationContext(Up20200516140647, Down20200516140647)
|
||||
}
|
||||
|
||||
func Up20200516140647(tx *sql.Tx) error {
|
||||
func Up20200516140647(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table if not exists playlist_tracks
|
||||
(
|
||||
|
@ -95,6 +96,6 @@ func Up20200516140647UpdatePlaylistTracks(tx *sql.Tx, id string, tracks string)
|
|||
return nil
|
||||
}
|
||||
|
||||
func Down20200516140647(tx *sql.Tx) error {
|
||||
func Down20200516140647(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20200608153717, Down20200608153717)
|
||||
goose.AddMigrationContext(Up20200608153717, Down20200608153717)
|
||||
}
|
||||
|
||||
func Up20200608153717(tx *sql.Tx) error {
|
||||
func Up20200608153717(_ context.Context, tx *sql.Tx) error {
|
||||
// First delete dangling players
|
||||
_, err := tx.Exec(`
|
||||
delete from player where user_name not in (select user_name from user)`)
|
||||
|
@ -132,6 +133,6 @@ create unique index playlist_tracks_pos
|
|||
return err
|
||||
}
|
||||
|
||||
func Down20200608153717(tx *sql.Tx) error {
|
||||
func Down20200608153717(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
@ -9,10 +10,10 @@ import (
|
|||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upAddDefaultTranscodings, downAddDefaultTranscodings)
|
||||
goose.AddMigrationContext(upAddDefaultTranscodings, downAddDefaultTranscodings)
|
||||
}
|
||||
|
||||
func upAddDefaultTranscodings(tx *sql.Tx) error {
|
||||
func upAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error {
|
||||
row := tx.QueryRow("SELECT COUNT(*) FROM transcoding")
|
||||
var count int
|
||||
err := row.Scan(&count)
|
||||
|
@ -29,7 +30,7 @@ func upAddDefaultTranscodings(tx *sql.Tx) error {
|
|||
}
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
@ -37,6 +38,6 @@ func upAddDefaultTranscodings(tx *sql.Tx) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func downAddDefaultTranscodings(tx *sql.Tx) error {
|
||||
func downAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upAddPlaylistPath, downAddPlaylistPath)
|
||||
goose.AddMigrationContext(upAddPlaylistPath, downAddPlaylistPath)
|
||||
}
|
||||
|
||||
func upAddPlaylistPath(tx *sql.Tx) error {
|
||||
func upAddPlaylistPath(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table playlist
|
||||
add path string default '' not null;
|
||||
|
@ -22,6 +23,6 @@ alter table playlist
|
|||
return err
|
||||
}
|
||||
|
||||
func downAddPlaylistPath(tx *sql.Tx) error {
|
||||
func downAddPlaylistPath(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upCreatePlayQueuesTable, downCreatePlayQueuesTable)
|
||||
goose.AddMigrationContext(upCreatePlayQueuesTable, downCreatePlayQueuesTable)
|
||||
}
|
||||
|
||||
func upCreatePlayQueuesTable(tx *sql.Tx) error {
|
||||
func upCreatePlayQueuesTable(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table playqueue
|
||||
(
|
||||
|
@ -31,6 +32,6 @@ create table playqueue
|
|||
return err
|
||||
}
|
||||
|
||||
func downCreatePlayQueuesTable(tx *sql.Tx) error {
|
||||
func downCreatePlayQueuesTable(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upCreateBookmarkTable, downCreateBookmarkTable)
|
||||
goose.AddMigrationContext(upCreateBookmarkTable, downCreateBookmarkTable)
|
||||
}
|
||||
|
||||
func upCreateBookmarkTable(tx *sql.Tx) error {
|
||||
func upCreateBookmarkTable(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table bookmark
|
||||
(
|
||||
|
@ -48,6 +49,6 @@ alter table playqueue_dg_tmp rename to playqueue;
|
|||
return err
|
||||
}
|
||||
|
||||
func downCreateBookmarkTable(tx *sql.Tx) error {
|
||||
func downCreateBookmarkTable(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upDropEmailUniqueConstraint, downDropEmailUniqueConstraint)
|
||||
goose.AddMigrationContext(upDropEmailUniqueConstraint, downDropEmailUniqueConstraint)
|
||||
}
|
||||
|
||||
func upDropEmailUniqueConstraint(tx *sql.Tx) error {
|
||||
func upDropEmailUniqueConstraint(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table user_dg_tmp
|
||||
(
|
||||
|
@ -37,6 +38,6 @@ alter table user_dg_tmp rename to user;
|
|||
return err
|
||||
}
|
||||
|
||||
func downDropEmailUniqueConstraint(tx *sql.Tx) error {
|
||||
func downDropEmailUniqueConstraint(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20201003111749, Down20201003111749)
|
||||
goose.AddMigrationContext(Up20201003111749, Down20201003111749)
|
||||
}
|
||||
|
||||
func Up20201003111749(tx *sql.Tx) error {
|
||||
func Up20201003111749(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create index if not exists annotation_starred_at
|
||||
on annotation (starred_at);
|
||||
|
@ -18,6 +19,6 @@ create index if not exists annotation_starred_at
|
|||
return err
|
||||
}
|
||||
|
||||
func Down20201003111749(tx *sql.Tx) error {
|
||||
func Down20201003111749(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20201010162350, Down20201010162350)
|
||||
goose.AddMigrationContext(Up20201010162350, Down20201010162350)
|
||||
}
|
||||
|
||||
func Up20201010162350(tx *sql.Tx) error {
|
||||
func Up20201010162350(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table album
|
||||
add size integer default 0 not null;
|
||||
|
@ -27,7 +28,7 @@ where id not null;`)
|
|||
return err
|
||||
}
|
||||
|
||||
func Down20201010162350(tx *sql.Tx) error {
|
||||
func Down20201010162350(_ context.Context, tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20201012210022, Down20201012210022)
|
||||
goose.AddMigrationContext(Up20201012210022, Down20201012210022)
|
||||
}
|
||||
|
||||
func Up20201012210022(tx *sql.Tx) error {
|
||||
func Up20201012210022(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table artist
|
||||
add size integer default 0 not null;
|
||||
|
@ -39,6 +40,6 @@ update playlist set size = ifnull((
|
|||
return err
|
||||
}
|
||||
|
||||
func Down20201012210022(tx *sql.Tx) error {
|
||||
func Down20201012210022(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20201021085410, Down20201021085410)
|
||||
goose.AddMigrationContext(Up20201021085410, Down20201021085410)
|
||||
}
|
||||
|
||||
func Up20201021085410(tx *sql.Tx) error {
|
||||
func Up20201021085410(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table media_file
|
||||
add mbz_track_id varchar(255);
|
||||
|
@ -52,7 +53,7 @@ alter table artist
|
|||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20201021085410(tx *sql.Tx) error {
|
||||
func Down20201021085410(_ context.Context, tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20201021093209, Down20201021093209)
|
||||
goose.AddMigrationContext(Up20201021093209, Down20201021093209)
|
||||
}
|
||||
|
||||
func Up20201021093209(tx *sql.Tx) error {
|
||||
func Up20201021093209(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create index if not exists media_file_artist
|
||||
on media_file (artist);
|
||||
|
@ -22,6 +23,6 @@ create index if not exists media_file_mbz_track_id
|
|||
return err
|
||||
}
|
||||
|
||||
func Down20201021093209(tx *sql.Tx) error {
|
||||
func Down20201021093209(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20201021135455, Down20201021135455)
|
||||
goose.AddMigrationContext(Up20201021135455, Down20201021135455)
|
||||
}
|
||||
|
||||
func Up20201021135455(tx *sql.Tx) error {
|
||||
func Up20201021135455(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create index if not exists media_file_artist_id
|
||||
on media_file (artist_id);
|
||||
|
@ -18,6 +19,6 @@ create index if not exists media_file_artist_id
|
|||
return err
|
||||
}
|
||||
|
||||
func Down20201021135455(tx *sql.Tx) error {
|
||||
func Down20201021135455(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upAddArtistImageUrl, downAddArtistImageUrl)
|
||||
goose.AddMigrationContext(upAddArtistImageUrl, downAddArtistImageUrl)
|
||||
}
|
||||
|
||||
func upAddArtistImageUrl(tx *sql.Tx) error {
|
||||
func upAddArtistImageUrl(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table artist
|
||||
add biography varchar(255) default '' not null;
|
||||
|
@ -30,6 +31,6 @@ alter table artist
|
|||
return err
|
||||
}
|
||||
|
||||
func downAddArtistImageUrl(tx *sql.Tx) error {
|
||||
func downAddArtistImageUrl(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20201110205344, Down20201110205344)
|
||||
goose.AddMigrationContext(Up20201110205344, Down20201110205344)
|
||||
}
|
||||
|
||||
func Up20201110205344(tx *sql.Tx) error {
|
||||
func Up20201110205344(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table media_file
|
||||
add comment varchar;
|
||||
|
@ -27,6 +28,6 @@ alter table album
|
|||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func Down20201110205344(tx *sql.Tx) error {
|
||||
func Down20201110205344(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20201128100726, Down20201128100726)
|
||||
goose.AddMigrationContext(Up20201128100726, Down20201128100726)
|
||||
}
|
||||
|
||||
func Up20201128100726(tx *sql.Tx) error {
|
||||
func Up20201128100726(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table player
|
||||
add report_real_path bool default FALSE not null;
|
||||
|
@ -18,6 +19,6 @@ alter table player
|
|||
return err
|
||||
}
|
||||
|
||||
func Down20201128100726(tx *sql.Tx) error {
|
||||
func Down20201128100726(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
|
@ -9,10 +10,10 @@ import (
|
|||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(Up20201213124814, Down20201213124814)
|
||||
goose.AddMigrationContext(Up20201213124814, Down20201213124814)
|
||||
}
|
||||
|
||||
func Up20201213124814(tx *sql.Tx) error {
|
||||
func Up20201213124814(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table album
|
||||
add all_artist_ids varchar;
|
||||
|
@ -58,6 +59,6 @@ select a.id, a.name, a.artist_id, a.album_artist_id, group_concat(mf.artist_id,
|
|||
return rows.Err()
|
||||
}
|
||||
|
||||
func Down20201213124814(tx *sql.Tx) error {
|
||||
func Down20201213124814(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upAddTimestampIndexesGo, downAddTimestampIndexesGo)
|
||||
goose.AddMigrationContext(upAddTimestampIndexesGo, downAddTimestampIndexesGo)
|
||||
}
|
||||
|
||||
func upAddTimestampIndexesGo(tx *sql.Tx) error {
|
||||
func upAddTimestampIndexesGo(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create index if not exists album_updated_at
|
||||
on album (updated_at);
|
||||
|
@ -28,6 +29,6 @@ create index if not exists media_file_updated_at
|
|||
return err
|
||||
}
|
||||
|
||||
func downAddTimestampIndexesGo(tx *sql.Tx) error {
|
||||
func downAddTimestampIndexesGo(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
|
||||
|
@ -10,10 +11,10 @@ import (
|
|||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upFixAlbumComments, downFixAlbumComments)
|
||||
goose.AddMigrationContext(upFixAlbumComments, downFixAlbumComments)
|
||||
}
|
||||
|
||||
func upFixAlbumComments(tx *sql.Tx) error {
|
||||
func upFixAlbumComments(_ context.Context, tx *sql.Tx) error {
|
||||
//nolint:gosec
|
||||
rows, err := tx.Query(`
|
||||
SELECT album.id, group_concat(media_file.comment, '` + consts.Zwsp + `') FROM album, media_file WHERE media_file.album_id = album.id GROUP BY album.id;
|
||||
|
@ -48,7 +49,7 @@ func upFixAlbumComments(tx *sql.Tx) error {
|
|||
return rows.Err()
|
||||
}
|
||||
|
||||
func downFixAlbumComments(tx *sql.Tx) error {
|
||||
func downFixAlbumComments(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upAddBpmMetadata, downAddBpmMetadata)
|
||||
goose.AddMigrationContext(upAddBpmMetadata, downAddBpmMetadata)
|
||||
}
|
||||
|
||||
func upAddBpmMetadata(tx *sql.Tx) error {
|
||||
func upAddBpmMetadata(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table media_file
|
||||
add bpm integer;
|
||||
|
@ -25,6 +26,6 @@ create index if not exists media_file_bpm
|
|||
return forceFullRescan(tx)
|
||||
}
|
||||
|
||||
func downAddBpmMetadata(tx *sql.Tx) error {
|
||||
func downAddBpmMetadata(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upCreateSharesTable, downCreateSharesTable)
|
||||
goose.AddMigrationContext(upCreateSharesTable, downCreateSharesTable)
|
||||
}
|
||||
|
||||
func upCreateSharesTable(tx *sql.Tx) error {
|
||||
func upCreateSharesTable(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table share
|
||||
(
|
||||
|
@ -29,6 +30,6 @@ create table share
|
|||
return err
|
||||
}
|
||||
|
||||
func downCreateSharesTable(tx *sql.Tx) error {
|
||||
func downCreateSharesTable(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigration(upUpdateShareFieldNames, downUpdateShareFieldNames)
|
||||
goose.AddMigrationContext(upUpdateShareFieldNames, downUpdateShareFieldNames)
|
||||
}
|
||||
|
||||
func upUpdateShareFieldNames(tx *sql.Tx) error {
|
||||
func upUpdateShareFieldNames(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
alter table share rename column expires to expires_at;
|
||||
alter table share rename column created to created_at;
|
||||
|
@ -20,6 +21,6 @@ alter table share rename column last_visited to last_visited_at;
|
|||
return err
|
||||
}
|
||||
|
||||
func downUpdateShareFieldNames(tx *sql.Tx) error {
|
||||
func downUpdateShareFieldNames(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue