mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-01 11:28:04 -05:00
Compare commits
45 Commits
chore/devc
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
735c0d9103 | ||
|
|
fc9817552d | ||
|
|
0c1b65d3e6 | ||
|
|
47b448c64f | ||
|
|
834fa494e4 | ||
|
|
5d34640065 | ||
|
|
9ed309ac81 | ||
|
|
8c80be56da | ||
|
|
cde5992c46 | ||
|
|
017676c457 | ||
|
|
2d7b716834 | ||
|
|
c7ac0e4414 | ||
|
|
c9409d306a | ||
|
|
ebbe62bbbd | ||
|
|
42c85a18e2 | ||
|
|
7ccf44b8ed | ||
|
|
603cccde11 | ||
|
|
6ed6524752 | ||
|
|
a081569ed4 | ||
|
|
e923c02c6a | ||
|
|
51ca2dee65 | ||
|
|
6b961bd99d | ||
|
|
396eee48c6 | ||
|
|
cc3cca6077 | ||
|
|
f6ac99e081 | ||
|
|
a521c74a59 | ||
|
|
bfd219e708 | ||
|
|
eaf7795716 | ||
|
|
96392f3af0 | ||
|
|
b7c4128b1b | ||
|
|
86f929499e | ||
|
|
5bc26de0e7 | ||
|
|
1f1a174542 | ||
|
|
9f0d3f3cf4 | ||
|
|
142a3136d4 | ||
|
|
13f6eb9a11 | ||
|
|
917726c166 | ||
|
|
654607ea53 | ||
|
|
5c43025ce1 | ||
|
|
ff5ebe1829 | ||
|
|
3ac2c6b6ed | ||
|
|
0faf744e32 | ||
|
|
33d9ce6ecc | ||
|
|
f14692c1f0 | ||
|
|
75b253687a |
@@ -9,12 +9,19 @@ ARG INSTALL_NODE="true"
|
||||
ARG NODE_VERSION="lts/*"
|
||||
RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
|
||||
|
||||
# [Optional] Uncomment this section to install additional OS packages.
|
||||
# Install additional OS packages
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
&& apt-get -y install --no-install-recommends libtag1-dev ffmpeg
|
||||
&& apt-get -y install --no-install-recommends ffmpeg
|
||||
|
||||
# [Optional] Uncomment the next line to use go get to install anything else you need
|
||||
# RUN go get -x <your-dependency-or-tool>
|
||||
# Install TagLib from cross-taglib releases
|
||||
ARG CROSS_TAGLIB_VERSION="2.1.1-1"
|
||||
ARG TARGETARCH
|
||||
RUN DOWNLOAD_ARCH="linux-${TARGETARCH}" \
|
||||
&& wget -q "https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/taglib-${DOWNLOAD_ARCH}.tar.gz" -O /tmp/cross-taglib.tar.gz \
|
||||
&& tar -xzf /tmp/cross-taglib.tar.gz -C /usr --strip-components=1 \
|
||||
&& mv /usr/include/taglib/* /usr/include/ \
|
||||
&& rmdir /usr/include/taglib \
|
||||
&& rm /tmp/cross-taglib.tar.gz /usr/provenance.json
|
||||
|
||||
# [Optional] Uncomment this line to install global node packages.
|
||||
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"VARIANT": "1.25",
|
||||
// Options
|
||||
"INSTALL_NODE": "true",
|
||||
"NODE_VERSION": "v24"
|
||||
"NODE_VERSION": "v24",
|
||||
"CROSS_TAGLIB_VERSION": "2.1.1-1"
|
||||
}
|
||||
},
|
||||
"workspaceMount": "",
|
||||
@@ -54,12 +55,10 @@
|
||||
4533,
|
||||
4633
|
||||
],
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "make setup-dev",
|
||||
// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||
"remoteUser": "vscode",
|
||||
"remoteEnv": {
|
||||
"ND_MUSICFOLDER": "./music",
|
||||
"ND_DATAFOLDER": "./data"
|
||||
}
|
||||
}
|
||||
}
|
||||
90
.github/workflows/pipeline.yml
vendored
90
.github/workflows/pipeline.yml
vendored
@@ -217,7 +217,7 @@ jobs:
|
||||
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
|
||||
|
||||
- name: Upload Binaries
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: navidrome-${{ env.PLATFORM }}
|
||||
path: ./output
|
||||
@@ -248,7 +248,7 @@ jobs:
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM }}
|
||||
@@ -256,8 +256,11 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
push-manifest:
|
||||
name: Push Docker manifest
|
||||
push-manifest-ghcr:
|
||||
name: Push to GHCR
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build, check-push-enabled]
|
||||
if: needs.check-push-enabled.outputs.is_enabled == 'true'
|
||||
@@ -267,7 +270,41 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Prepare Docker Buildx
|
||||
uses: ./.github/actions/prepare-docker
|
||||
id: docker
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create manifest list and push to ghcr.io
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map(select(startswith("ghcr.io"))) | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image in ghcr.io
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
|
||||
|
||||
push-manifest-dockerhub:
|
||||
name: Push to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [build, check-push-enabled]
|
||||
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
@@ -282,28 +319,27 @@ jobs:
|
||||
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Create manifest list and push to ghcr.io
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
|
||||
|
||||
- name: Create manifest list and push to Docker Hub
|
||||
working-directory: /tmp/digests
|
||||
if: vars.DOCKER_HUB_REPO != ''
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ vars.DOCKER_HUB_REPO }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image in ghcr.io
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 5
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: |
|
||||
cd /tmp/digests
|
||||
docker buildx imagetools create $(jq -cr '.tags | map(select(startswith("ghcr.io") | not)) | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image in Docker Hub
|
||||
if: vars.DOCKER_HUB_REPO != ''
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ vars.DOCKER_HUB_REPO }}:${{ steps.docker.outputs.version }}
|
||||
|
||||
cleanup-digests:
|
||||
name: Cleanup digest artifacts
|
||||
runs-on: ubuntu-latest
|
||||
needs: [push-manifest-ghcr, push-manifest-dockerhub]
|
||||
if: always() && needs.push-manifest-ghcr.result == 'success'
|
||||
steps:
|
||||
- name: Delete unnecessary digest artifacts
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -320,7 +356,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/download-artifact@v6
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: ./binaries
|
||||
pattern: navidrome-windows*
|
||||
@@ -339,7 +375,7 @@ jobs:
|
||||
du -h binaries/msi/*.msi
|
||||
|
||||
- name: Upload MSI files
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: navidrome-windows-installers
|
||||
path: binaries/msi/*.msi
|
||||
@@ -357,7 +393,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- uses: actions/download-artifact@v6
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: ./binaries
|
||||
pattern: navidrome-*
|
||||
@@ -383,7 +419,7 @@ jobs:
|
||||
rm ./dist/*.tar.gz ./dist/*.zip
|
||||
|
||||
- name: Upload all-packages artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: packages
|
||||
path: dist/navidrome_0*
|
||||
@@ -406,13 +442,13 @@ jobs:
|
||||
item: ${{ fromJson(needs.release.outputs.package_list) }}
|
||||
steps:
|
||||
- name: Download all-packages artifact
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: packages
|
||||
path: ./dist
|
||||
|
||||
- name: Upload all-packages artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: navidrome_linux_${{ matrix.item }}
|
||||
path: dist/navidrome_0*_linux_${{ matrix.item }}
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v5
|
||||
- uses: dessant/lock-threads@v6
|
||||
with:
|
||||
process-only: 'issues, prs'
|
||||
issue-inactive-days: 120
|
||||
|
||||
2
.github/workflows/update-translations.yml
vendored
2
.github/workflows/update-translations.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
git status --porcelain
|
||||
git diff
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
token: ${{ secrets.PAT }}
|
||||
author: "navidrome-bot <navidrome-bot@navidrome.org>"
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -31,4 +31,5 @@ AGENTS.md
|
||||
.github/git-commit-instructions.md
|
||||
*.exe
|
||||
*.test
|
||||
*.wasm
|
||||
*.wasm
|
||||
openspec/
|
||||
10
Dockerfile
10
Dockerfile
@@ -2,10 +2,10 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcros
|
||||
|
||||
########################################################################################################################
|
||||
### Build xx (original image: tonistiigi/xx)
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.19 AS xx-build
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS xx-build
|
||||
|
||||
# v1.5.0
|
||||
ENV XX_VERSION=b4e4c451c778822e6742bfc9d9a91d7c7d885c8a
|
||||
# v1.9.0
|
||||
ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50
|
||||
|
||||
RUN apk add -U --no-cache git
|
||||
RUN git clone https://github.com/tonistiigi/xx && \
|
||||
@@ -26,7 +26,7 @@ COPY --from=xx-build /out/ /usr/bin/
|
||||
|
||||
########################################################################################################################
|
||||
### Get TagLib
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.19 AS taglib-build
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS taglib-build
|
||||
ARG TARGETPLATFORM
|
||||
ARG CROSS_TAGLIB_VERSION=2.1.1-1
|
||||
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
|
||||
@@ -122,7 +122,7 @@ COPY --from=build /out /
|
||||
|
||||
########################################################################################################################
|
||||
### Build Final Image
|
||||
FROM public.ecr.aws/docker/library/alpine:3.19 AS final
|
||||
FROM public.ecr.aws/docker/library/alpine:3.20 AS final
|
||||
LABEL maintainer="deluan@navidrome.org"
|
||||
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
|
||||
|
||||
|
||||
2
Makefile
2
Makefile
@@ -16,7 +16,7 @@ DOCKER_TAG ?= deluan/navidrome:develop
|
||||
|
||||
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
|
||||
CROSS_TAGLIB_VERSION ?= 2.1.1-1
|
||||
GOLANGCI_LINT_VERSION ?= v2.6.2
|
||||
GOLANGCI_LINT_VERSION ?= v2.7.2
|
||||
|
||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||
|
||||
|
||||
35
cmd/pls.go
35
cmd/pls.go
@@ -10,11 +10,8 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -52,7 +49,7 @@ var (
|
||||
Short: "Export playlists",
|
||||
Long: "Export Navidrome playlists to M3U files",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runExporter()
|
||||
runExporter(cmd.Context())
|
||||
},
|
||||
}
|
||||
|
||||
@@ -60,15 +57,13 @@ var (
|
||||
Use: "list",
|
||||
Short: "List playlists",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runList()
|
||||
runList(cmd.Context())
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func runExporter() {
|
||||
sqlDB := db.Db()
|
||||
ds := persistence.New(sqlDB)
|
||||
ctx := auth.WithAdminUser(context.Background(), ds)
|
||||
func runExporter(ctx context.Context) {
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true, false)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
log.Fatal("Error retrieving playlist", "name", playlistID, err)
|
||||
@@ -100,31 +95,19 @@ func runExporter() {
|
||||
}
|
||||
}
|
||||
|
||||
func runList() {
|
||||
func runList(ctx context.Context) {
|
||||
if outputFormat != "csv" && outputFormat != "json" {
|
||||
log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat)
|
||||
}
|
||||
|
||||
sqlDB := db.Db()
|
||||
ds := persistence.New(sqlDB)
|
||||
ctx := auth.WithAdminUser(context.Background(), ds)
|
||||
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
options := model.QueryOptions{Sort: "owner_name"}
|
||||
|
||||
if userID != "" {
|
||||
user, err := ds.User(ctx).FindByUsername(userID)
|
||||
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
log.Fatal("Error retrieving user by name", "name", userID, err)
|
||||
user, err := getUser(ctx, userID, ds)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Error retrieving user", "username or id", userID)
|
||||
}
|
||||
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
user, err = ds.User(ctx).Get(userID)
|
||||
if err != nil {
|
||||
log.Fatal("Error retrieving user by id", "id", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
options.Filters = squirrel.Eq{"owner_id": user.ID}
|
||||
}
|
||||
|
||||
|
||||
@@ -374,6 +374,7 @@ func init() {
|
||||
rootCmd.Flags().Duration("scaninterval", viper.GetDuration("scaninterval"), "how frequently to scan for changes in your music library")
|
||||
rootCmd.Flags().String("uiloginbackgroundurl", viper.GetString("uiloginbackgroundurl"), "URL to a backaground image used in the Login page")
|
||||
rootCmd.Flags().Bool("enabletranscodingconfig", viper.GetBool("enabletranscodingconfig"), "enables transcoding configuration in the UI")
|
||||
rootCmd.Flags().Bool("enabletranscodingcancellation", viper.GetBool("enabletranscodingcancellation"), "enables transcoding context cancellation")
|
||||
rootCmd.Flags().String("transcodingcachesize", viper.GetString("transcodingcachesize"), "size of transcoding cache")
|
||||
rootCmd.Flags().String("imagecachesize", viper.GetString("imagecachesize"), "size of image (art work) cache. set to 0 to disable cache")
|
||||
rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized")
|
||||
@@ -397,6 +398,7 @@ func init() {
|
||||
_ = viper.BindPFlag("prometheus.metricspath", rootCmd.Flags().Lookup("prometheus.metricspath"))
|
||||
|
||||
_ = viper.BindPFlag("enabletranscodingconfig", rootCmd.Flags().Lookup("enabletranscodingconfig"))
|
||||
_ = viper.BindPFlag("enabletranscodingcancellation", rootCmd.Flags().Lookup("enabletranscodingcancellation"))
|
||||
_ = viper.BindPFlag("transcodingcachesize", rootCmd.Flags().Lookup("transcodingcachesize"))
|
||||
_ = viper.BindPFlag("imagecachesize", rootCmd.Flags().Lookup("imagecachesize"))
|
||||
}
|
||||
|
||||
46
cmd/scan.go
46
cmd/scan.go
@@ -1,9 +1,12 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
@@ -19,12 +22,14 @@ var (
|
||||
fullScan bool
|
||||
subprocess bool
|
||||
targets []string
|
||||
targetFile string
|
||||
)
|
||||
|
||||
func init() {
|
||||
scanCmd.Flags().BoolVarP(&fullScan, "full", "f", false, "check all subfolders, ignoring timestamps")
|
||||
scanCmd.Flags().BoolVarP(&subprocess, "subprocess", "", false, "run as subprocess (internal use)")
|
||||
scanCmd.Flags().StringArrayVarP(&targets, "target", "t", []string{}, "list of libraryID:folderPath pairs, can be repeated (e.g., \"-t 1:Music/Rock -t 1:Music/Jazz -t 2:Classical\")")
|
||||
scanCmd.Flags().StringVar(&targetFile, "target-file", "", "path to file containing targets (one libraryID:folderPath per line)")
|
||||
rootCmd.AddCommand(scanCmd)
|
||||
}
|
||||
|
||||
@@ -71,10 +76,17 @@ func runScanner(ctx context.Context) {
|
||||
ds := persistence.New(sqlDB)
|
||||
pls := core.NewPlaylists(ds)
|
||||
|
||||
// Parse targets if provided
|
||||
// Parse targets from command line or file
|
||||
var scanTargets []model.ScanTarget
|
||||
if len(targets) > 0 {
|
||||
var err error
|
||||
var err error
|
||||
|
||||
if targetFile != "" {
|
||||
scanTargets, err = readTargetsFromFile(targetFile)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to read targets from file", err)
|
||||
}
|
||||
log.Info(ctx, "Scanning specific folders from file", "numTargets", len(scanTargets))
|
||||
} else if len(targets) > 0 {
|
||||
scanTargets, err = model.ParseTargets(targets)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to parse targets", err)
|
||||
@@ -94,3 +106,31 @@ func runScanner(ctx context.Context) {
|
||||
trackScanInteractively(ctx, progress)
|
||||
}
|
||||
}
|
||||
|
||||
// readTargetsFromFile reads scan targets from a file, one per line.
|
||||
// Each line should be in the format "libraryID:folderPath".
|
||||
// Empty lines and lines starting with # are ignored.
|
||||
func readTargetsFromFile(filePath string) ([]model.ScanTarget, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open target file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var targetStrings []string
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
// Skip empty lines and comments
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
targetStrings = append(targetStrings, line)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("failed to read target file: %w", err)
|
||||
}
|
||||
|
||||
return model.ParseTargets(targetStrings)
|
||||
}
|
||||
|
||||
89
cmd/scan_test.go
Normal file
89
cmd/scan_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("readTargetsFromFile", func() {
|
||||
var tempDir string
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
tempDir, err = os.MkdirTemp("", "navidrome-test-")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
os.RemoveAll(tempDir)
|
||||
})
|
||||
|
||||
It("reads valid targets from file", func() {
|
||||
filePath := filepath.Join(tempDir, "targets.txt")
|
||||
content := "1:Music/Rock\n2:Music/Jazz\n3:Classical\n"
|
||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
targets, err := readTargetsFromFile(filePath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(targets).To(HaveLen(3))
|
||||
Expect(targets[0]).To(Equal(model.ScanTarget{LibraryID: 1, FolderPath: "Music/Rock"}))
|
||||
Expect(targets[1]).To(Equal(model.ScanTarget{LibraryID: 2, FolderPath: "Music/Jazz"}))
|
||||
Expect(targets[2]).To(Equal(model.ScanTarget{LibraryID: 3, FolderPath: "Classical"}))
|
||||
})
|
||||
|
||||
It("skips empty lines", func() {
|
||||
filePath := filepath.Join(tempDir, "targets.txt")
|
||||
content := "1:Music/Rock\n\n2:Music/Jazz\n\n"
|
||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
targets, err := readTargetsFromFile(filePath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(targets).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("trims whitespace", func() {
|
||||
filePath := filepath.Join(tempDir, "targets.txt")
|
||||
content := " 1:Music/Rock \n\t2:Music/Jazz\t\n"
|
||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
targets, err := readTargetsFromFile(filePath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(targets).To(HaveLen(2))
|
||||
Expect(targets[0].FolderPath).To(Equal("Music/Rock"))
|
||||
Expect(targets[1].FolderPath).To(Equal("Music/Jazz"))
|
||||
})
|
||||
|
||||
It("returns error for non-existent file", func() {
|
||||
_, err := readTargetsFromFile("/nonexistent/file.txt")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("failed to open target file"))
|
||||
})
|
||||
|
||||
It("returns error for invalid target format", func() {
|
||||
filePath := filepath.Join(tempDir, "targets.txt")
|
||||
content := "invalid-format\n"
|
||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = readTargetsFromFile(filePath)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("handles mixed valid and empty lines", func() {
|
||||
filePath := filepath.Join(tempDir, "targets.txt")
|
||||
content := "\n1:Music/Rock\n\n\n2:Music/Jazz\n\n"
|
||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
targets, err := readTargetsFromFile(filePath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(targets).To(HaveLen(2))
|
||||
})
|
||||
})
|
||||
477
cmd/user.go
Normal file
477
cmd/user.go
Normal file
@@ -0,0 +1,477 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var (
|
||||
email string
|
||||
libraryIds []int
|
||||
name string
|
||||
|
||||
removeEmail bool
|
||||
removeName bool
|
||||
setAdmin bool
|
||||
setPassword bool
|
||||
setRegularUser bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(userRoot)
|
||||
|
||||
userCreateCommand.Flags().StringVarP(&userID, "username", "u", "", "username")
|
||||
|
||||
userCreateCommand.Flags().StringVarP(&email, "email", "e", "", "New user email")
|
||||
userCreateCommand.Flags().IntSliceVarP(&libraryIds, "library-ids", "i", []int{}, "Comma-separated list of library IDs. Set the user's accessible libraries. If empty, the user can access all libraries. This is incompatible with admin, as admin can always access all libraries")
|
||||
|
||||
userCreateCommand.Flags().BoolVarP(&setAdmin, "admin", "a", false, "If set, make the user an admin. This user will have access to every library")
|
||||
userCreateCommand.Flags().StringVar(&name, "name", "", "New user's name (this is separate from username used to log in)")
|
||||
|
||||
_ = userCreateCommand.MarkFlagRequired("username")
|
||||
|
||||
userRoot.AddCommand(userCreateCommand)
|
||||
|
||||
userDeleteCommand.Flags().StringVarP(&userID, "user", "u", "", "username or id")
|
||||
_ = userDeleteCommand.MarkFlagRequired("user")
|
||||
userRoot.AddCommand(userDeleteCommand)
|
||||
|
||||
userEditCommand.Flags().StringVarP(&userID, "user", "u", "", "username or id")
|
||||
|
||||
userEditCommand.Flags().BoolVar(&setAdmin, "set-admin", false, "If set, make the user an admin")
|
||||
userEditCommand.Flags().BoolVar(&setRegularUser, "set-regular", false, "If set, make the user a non-admin")
|
||||
userEditCommand.MarkFlagsMutuallyExclusive("set-admin", "set-regular")
|
||||
|
||||
userEditCommand.Flags().BoolVar(&removeEmail, "remove-email", false, "If set, clear the user's email")
|
||||
userEditCommand.Flags().StringVarP(&email, "email", "e", "", "New user email")
|
||||
userEditCommand.MarkFlagsMutuallyExclusive("email", "remove-email")
|
||||
|
||||
userEditCommand.Flags().BoolVar(&removeName, "remove-name", false, "If set, clear the user's name")
|
||||
userEditCommand.Flags().StringVar(&name, "name", "", "New user name (this is separate from username used to log in)")
|
||||
userEditCommand.MarkFlagsMutuallyExclusive("name", "remove-name")
|
||||
|
||||
userEditCommand.Flags().BoolVar(&setPassword, "set-password", false, "If set, the user's new password will be prompted on the CLI")
|
||||
|
||||
userEditCommand.Flags().IntSliceVarP(&libraryIds, "library-ids", "i", []int{}, "Comma-separated list of library IDs. Set the user's accessible libraries by id")
|
||||
|
||||
_ = userEditCommand.MarkFlagRequired("user")
|
||||
userRoot.AddCommand(userEditCommand)
|
||||
|
||||
userListCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]")
|
||||
userRoot.AddCommand(userListCommand)
|
||||
}
|
||||
|
||||
var (
|
||||
userRoot = &cobra.Command{
|
||||
Use: "user",
|
||||
Short: "Administer users",
|
||||
Long: "Create, delete, list, or update users",
|
||||
}
|
||||
|
||||
userCreateCommand = &cobra.Command{
|
||||
Use: "create",
|
||||
Aliases: []string{"c"},
|
||||
Short: "Create a new user",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runCreateUser(cmd.Context())
|
||||
},
|
||||
}
|
||||
|
||||
userDeleteCommand = &cobra.Command{
|
||||
Use: "delete",
|
||||
Aliases: []string{"d"},
|
||||
Short: "Deletes an existing user",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runDeleteUser(cmd.Context())
|
||||
},
|
||||
}
|
||||
|
||||
userEditCommand = &cobra.Command{
|
||||
Use: "edit",
|
||||
Aliases: []string{"e"},
|
||||
Short: "Edit a user",
|
||||
Long: "Edit the password, admin status, and/or library access",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runUserEdit(cmd.Context())
|
||||
},
|
||||
}
|
||||
|
||||
userListCommand = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List users",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runUserList(cmd.Context())
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func promptPassword() string {
|
||||
for {
|
||||
fmt.Print("Enter new password (press enter with no password to cancel): ")
|
||||
// This cast is necessary for some platforms
|
||||
password, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert
|
||||
|
||||
if err != nil {
|
||||
log.Fatal("Error getting password", err)
|
||||
}
|
||||
|
||||
fmt.Print("\nConfirm new password (press enter with no password to cancel): ")
|
||||
confirmation, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert
|
||||
|
||||
if err != nil {
|
||||
log.Fatal("Error getting password confirmation", err)
|
||||
}
|
||||
|
||||
// clear the line.
|
||||
fmt.Println()
|
||||
|
||||
pass := string(password)
|
||||
confirm := string(confirmation)
|
||||
|
||||
if pass == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if pass == confirm {
|
||||
return pass
|
||||
}
|
||||
|
||||
fmt.Println("Password and password confirmation do not match")
|
||||
}
|
||||
}
|
||||
|
||||
func libraryError(libraries model.Libraries) error {
|
||||
ids := make([]int, len(libraries))
|
||||
for idx, library := range libraries {
|
||||
ids[idx] = library.ID
|
||||
}
|
||||
return fmt.Errorf("not all available libraries found. Requested ids: %v, Found libraries: %v", libraryIds, ids)
|
||||
}
|
||||
|
||||
func runCreateUser(ctx context.Context) {
|
||||
password := promptPassword()
|
||||
if password == "" {
|
||||
log.Fatal("Empty password provided, user creation cancelled")
|
||||
}
|
||||
|
||||
user := model.User{
|
||||
UserName: userID,
|
||||
Email: email,
|
||||
Name: name,
|
||||
IsAdmin: setAdmin,
|
||||
NewPassword: password,
|
||||
}
|
||||
|
||||
if user.Name == "" {
|
||||
user.Name = userID
|
||||
}
|
||||
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
|
||||
err := ds.WithTx(func(tx model.DataStore) error {
|
||||
existingUser, err := tx.User(ctx).FindByUsername(userID)
|
||||
if existingUser != nil {
|
||||
return fmt.Errorf("existing user '%s'", userID)
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return fmt.Errorf("failed to check existing username: %w", err)
|
||||
}
|
||||
|
||||
if len(libraryIds) > 0 && !setAdmin {
|
||||
user.Libraries, err = tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(user.Libraries) != len(libraryIds) {
|
||||
return libraryError(user.Libraries)
|
||||
}
|
||||
} else {
|
||||
user.Libraries, err = tx.Library(ctx).GetAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.User(ctx).Put(&user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updatedIds := make([]int, len(user.Libraries))
|
||||
for idx, lib := range user.Libraries {
|
||||
updatedIds[idx] = lib.ID
|
||||
}
|
||||
|
||||
err = tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
|
||||
return err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Fatal(ctx, err)
|
||||
}
|
||||
|
||||
log.Info(ctx, "Successfully created user", "id", user.ID, "username", user.UserName)
|
||||
}
|
||||
|
||||
func runDeleteUser(ctx context.Context) {
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
|
||||
var err error
|
||||
var user *model.User
|
||||
|
||||
err = ds.WithTx(func(tx model.DataStore) error {
|
||||
count, err := tx.User(ctx).CountAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if count == 1 {
|
||||
return errors.New("refusing to delete the last user")
|
||||
}
|
||||
|
||||
user, err = getUser(ctx, userID, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.User(ctx).Delete(user.ID)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to delete user", err)
|
||||
}
|
||||
|
||||
log.Info(ctx, "Deleted user", "username", user.UserName)
|
||||
}
|
||||
|
||||
func runUserEdit(ctx context.Context) {
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
|
||||
var err error
|
||||
var user *model.User
|
||||
changes := []string{}
|
||||
|
||||
err = ds.WithTx(func(tx model.DataStore) error {
|
||||
var newLibraries model.Libraries
|
||||
|
||||
user, err = getUser(ctx, userID, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(libraryIds) > 0 && !setAdmin {
|
||||
libraries, err := tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(libraries) != len(libraryIds) {
|
||||
return libraryError(libraries)
|
||||
}
|
||||
|
||||
newLibraries = libraries
|
||||
changes = append(changes, "updated library ids")
|
||||
}
|
||||
|
||||
if setAdmin && !user.IsAdmin {
|
||||
libraries, err := tx.Library(ctx).GetAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user.IsAdmin = true
|
||||
user.Libraries = libraries
|
||||
changes = append(changes, "set admin")
|
||||
|
||||
newLibraries = libraries
|
||||
}
|
||||
|
||||
if setRegularUser && user.IsAdmin {
|
||||
user.IsAdmin = false
|
||||
changes = append(changes, "set regular user")
|
||||
}
|
||||
|
||||
if setPassword {
|
||||
password := promptPassword()
|
||||
|
||||
if password != "" {
|
||||
user.NewPassword = password
|
||||
changes = append(changes, "updated password")
|
||||
}
|
||||
}
|
||||
|
||||
if email != "" && email != user.Email {
|
||||
user.Email = email
|
||||
changes = append(changes, "updated email")
|
||||
} else if removeEmail && user.Email != "" {
|
||||
user.Email = ""
|
||||
changes = append(changes, "removed email")
|
||||
}
|
||||
|
||||
if name != "" && name != user.Name {
|
||||
user.Name = name
|
||||
changes = append(changes, "updated name")
|
||||
} else if removeName && user.Name != "" {
|
||||
user.Name = ""
|
||||
changes = append(changes, "removed name")
|
||||
}
|
||||
|
||||
if len(changes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := tx.User(ctx).Put(user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(newLibraries) > 0 {
|
||||
updatedIds := make([]int, len(newLibraries))
|
||||
for idx, lib := range newLibraries {
|
||||
updatedIds[idx] = lib.ID
|
||||
}
|
||||
|
||||
err := tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to update user", err)
|
||||
}
|
||||
|
||||
if len(changes) == 0 {
|
||||
log.Info(ctx, "No changes for user", "user", user.UserName)
|
||||
} else {
|
||||
log.Info(ctx, "Updated user", "user", user.UserName, "changes", strings.Join(changes, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
type displayLibrary struct {
|
||||
ID int `json:"id"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type displayUser struct {
|
||||
Id string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Admin bool `json:"admin"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
LastAccess *time.Time `json:"lastAccess"`
|
||||
LastLogin *time.Time `json:"lastLogin"`
|
||||
Libraries []displayLibrary `json:"libraries"`
|
||||
}
|
||||
|
||||
func runUserList(ctx context.Context) {
|
||||
if outputFormat != "csv" && outputFormat != "json" {
|
||||
log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat)
|
||||
}
|
||||
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
|
||||
users, err := ds.User(ctx).ReadAll()
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to retrieve users", err)
|
||||
}
|
||||
|
||||
userList := users.(model.Users)
|
||||
|
||||
if outputFormat == "csv" {
|
||||
w := csv.NewWriter(os.Stdout)
|
||||
_ = w.Write([]string{
|
||||
"user id",
|
||||
"username",
|
||||
"user's name",
|
||||
"user email",
|
||||
"admin",
|
||||
"created at",
|
||||
"updated at",
|
||||
"last access",
|
||||
"last login",
|
||||
"libraries",
|
||||
})
|
||||
for _, user := range userList {
|
||||
paths := make([]string, len(user.Libraries))
|
||||
|
||||
for idx, library := range user.Libraries {
|
||||
paths[idx] = fmt.Sprintf("%d:%s", library.ID, library.Path)
|
||||
}
|
||||
|
||||
var lastAccess, lastLogin string
|
||||
|
||||
if user.LastAccessAt != nil {
|
||||
lastAccess = user.LastAccessAt.Format(time.RFC3339Nano)
|
||||
} else {
|
||||
lastAccess = "never"
|
||||
}
|
||||
|
||||
if user.LastLoginAt != nil {
|
||||
lastLogin = user.LastLoginAt.Format(time.RFC3339Nano)
|
||||
} else {
|
||||
lastLogin = "never"
|
||||
}
|
||||
|
||||
_ = w.Write([]string{
|
||||
user.ID,
|
||||
user.UserName,
|
||||
user.Name,
|
||||
user.Email,
|
||||
strconv.FormatBool(user.IsAdmin),
|
||||
user.CreatedAt.Format(time.RFC3339Nano),
|
||||
user.UpdatedAt.Format(time.RFC3339Nano),
|
||||
lastAccess,
|
||||
lastLogin,
|
||||
fmt.Sprintf("'%s'", strings.Join(paths, "|")),
|
||||
})
|
||||
}
|
||||
w.Flush()
|
||||
} else {
|
||||
users := make([]displayUser, len(userList))
|
||||
for idx, user := range userList {
|
||||
paths := make([]displayLibrary, len(user.Libraries))
|
||||
|
||||
for idx, library := range user.Libraries {
|
||||
paths[idx].ID = library.ID
|
||||
paths[idx].Path = library.Path
|
||||
}
|
||||
|
||||
users[idx].Id = user.ID
|
||||
users[idx].Username = user.UserName
|
||||
users[idx].Name = user.Name
|
||||
users[idx].Email = user.Email
|
||||
users[idx].Admin = user.IsAdmin
|
||||
users[idx].CreatedAt = user.CreatedAt
|
||||
users[idx].UpdatedAt = user.UpdatedAt
|
||||
users[idx].LastAccess = user.LastAccessAt
|
||||
users[idx].LastLogin = user.LastLoginAt
|
||||
users[idx].Libraries = paths
|
||||
}
|
||||
|
||||
j, _ := json.Marshal(users)
|
||||
fmt.Printf("%s\n", j)
|
||||
}
|
||||
}
|
||||
42
cmd/utils.go
Normal file
42
cmd/utils.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
)
|
||||
|
||||
func getAdminContext(ctx context.Context) (model.DataStore, context.Context) {
|
||||
sqlDB := db.Db()
|
||||
ds := persistence.New(sqlDB)
|
||||
ctx = auth.WithAdminUser(ctx, ds)
|
||||
u, _ := request.UserFrom(ctx)
|
||||
if !u.IsAdmin {
|
||||
log.Fatal(ctx, "There must be at least one admin user to run this command.")
|
||||
}
|
||||
return ds, ctx
|
||||
}
|
||||
|
||||
func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) {
|
||||
user, err := ds.User(ctx).FindByUsername(id)
|
||||
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return nil, fmt.Errorf("finding user by name: %w", err)
|
||||
}
|
||||
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
user, err = ds.User(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("finding user by id: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
@@ -41,6 +41,7 @@ type configOptions struct {
|
||||
UIWelcomeMessage string
|
||||
MaxSidebarPlaylists int
|
||||
EnableTranscodingConfig bool
|
||||
EnableTranscodingCancellation bool
|
||||
EnableDownloads bool
|
||||
EnableExternalServices bool
|
||||
EnableInsightsCollector bool
|
||||
@@ -86,11 +87,10 @@ type configOptions struct {
|
||||
AuthRequestLimit int
|
||||
AuthWindowLength time.Duration
|
||||
PasswordEncryptionKey string
|
||||
ReverseProxyUserHeader string
|
||||
ReverseProxyWhitelist string
|
||||
ExtAuth extAuthOptions
|
||||
Plugins pluginsOptions
|
||||
PluginConfig map[string]map[string]string
|
||||
HTTPSecurityHeaders secureOptions `json:",omitzero"`
|
||||
HTTPHeaders httpHeaderOptions `json:",omitzero"`
|
||||
Prometheus prometheusOptions `json:",omitzero"`
|
||||
Scanner scannerOptions `json:",omitzero"`
|
||||
Jukebox jukeboxOptions `json:",omitzero"`
|
||||
@@ -102,36 +102,38 @@ type configOptions struct {
|
||||
Spotify spotifyOptions `json:",omitzero"`
|
||||
Deezer deezerOptions `json:",omitzero"`
|
||||
ListenBrainz listenBrainzOptions `json:",omitzero"`
|
||||
Tags map[string]TagConf `json:",omitempty"`
|
||||
EnableScrobbleHistory bool
|
||||
Tags map[string]TagConf `json:",omitempty"`
|
||||
Agents string
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
DevLogLevels map[string]string `json:",omitempty"`
|
||||
DevLogSourceLine bool
|
||||
DevEnableProfiler bool
|
||||
DevAutoCreateAdminPassword string
|
||||
DevAutoLoginUsername string
|
||||
DevActivityPanel bool
|
||||
DevActivityPanelUpdateRate time.Duration
|
||||
DevSidebarPlaylists bool
|
||||
DevShowArtistPage bool
|
||||
DevUIShowConfig bool
|
||||
DevNewEventStream bool
|
||||
DevOffsetOptimize int
|
||||
DevArtworkMaxRequests int
|
||||
DevArtworkThrottleBacklogLimit int
|
||||
DevArtworkThrottleBacklogTimeout time.Duration
|
||||
DevArtistInfoTimeToLive time.Duration
|
||||
DevAlbumInfoTimeToLive time.Duration
|
||||
DevExternalScanner bool
|
||||
DevScannerThreads uint
|
||||
DevSelectiveWatcher bool
|
||||
DevInsightsInitialDelay time.Duration
|
||||
DevEnablePlayerInsights bool
|
||||
DevEnablePluginsInsights bool
|
||||
DevPluginCompilationTimeout time.Duration
|
||||
DevExternalArtistFetchMultiplier float64
|
||||
DevOptimizeDB bool
|
||||
DevLogLevels map[string]string `json:",omitempty"`
|
||||
DevLogSourceLine bool
|
||||
DevEnableProfiler bool
|
||||
DevAutoCreateAdminPassword string
|
||||
DevAutoLoginUsername string
|
||||
DevActivityPanel bool
|
||||
DevActivityPanelUpdateRate time.Duration
|
||||
DevSidebarPlaylists bool
|
||||
DevShowArtistPage bool
|
||||
DevUIShowConfig bool
|
||||
DevNewEventStream bool
|
||||
DevOffsetOptimize int
|
||||
DevArtworkMaxRequests int
|
||||
DevArtworkThrottleBacklogLimit int
|
||||
DevArtworkThrottleBacklogTimeout time.Duration
|
||||
DevArtistInfoTimeToLive time.Duration
|
||||
DevAlbumInfoTimeToLive time.Duration
|
||||
DevExternalScanner bool
|
||||
DevScannerThreads uint
|
||||
DevSelectiveWatcher bool
|
||||
DevInsightsInitialDelay time.Duration
|
||||
DevEnablePlayerInsights bool
|
||||
DevEnablePluginsInsights bool
|
||||
DevPluginCompilationTimeout time.Duration
|
||||
DevExternalArtistFetchMultiplier float64
|
||||
DevOptimizeDB bool
|
||||
DevPreserveUnicodeInExternalCalls bool
|
||||
}
|
||||
|
||||
type scannerOptions struct {
|
||||
@@ -186,8 +188,8 @@ type listenBrainzOptions struct {
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
type secureOptions struct {
|
||||
CustomFrameOptionsValue string
|
||||
type httpHeaderOptions struct {
|
||||
FrameOptions string
|
||||
}
|
||||
|
||||
type prometheusOptions struct {
|
||||
@@ -229,6 +231,11 @@ type pluginsOptions struct {
|
||||
CacheSize string
|
||||
}
|
||||
|
||||
type extAuthOptions struct {
|
||||
TrustedSources string
|
||||
UserHeader string
|
||||
}
|
||||
|
||||
var (
|
||||
Server = &configOptions{}
|
||||
hooks []func()
|
||||
@@ -247,6 +254,11 @@ func LoadFromFile(confFile string) {
|
||||
func Load(noConfigDump bool) {
|
||||
parseIniFileConfiguration()
|
||||
|
||||
// Map deprecated options to their new names for backwards compatibility
|
||||
mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
||||
mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
||||
mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||
|
||||
err := viper.Unmarshal(&Server)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
|
||||
@@ -330,9 +342,18 @@ func Load(noConfigDump bool) {
|
||||
Server.BaseScheme = u.Scheme
|
||||
}
|
||||
|
||||
// Log configuration source
|
||||
if Server.ConfigFile != "" {
|
||||
log.Info("Loaded configuration", "file", Server.ConfigFile)
|
||||
} else if hasNDEnvVars() {
|
||||
log.Info("No configuration file found. Loaded configuration only from environment variables")
|
||||
} else {
|
||||
log.Warn("No configuration file found. Using default values. To specify a config file, use the --configfile flag or set the ND_CONFIGFILE environment variable.")
|
||||
}
|
||||
|
||||
// Print current configuration if log level is Debug
|
||||
if log.IsGreaterOrEqualTo(log.LevelDebug) && !noConfigDump {
|
||||
prettyConf := pretty.Sprintf("Loaded configuration from '%s': %# v", Server.ConfigFile, Server)
|
||||
prettyConf := pretty.Sprintf("Configuration: %# v", Server)
|
||||
if Server.EnableLogRedacting {
|
||||
prettyConf = log.Redact(prettyConf)
|
||||
}
|
||||
@@ -347,9 +368,12 @@ func Load(noConfigDump bool) {
|
||||
log.Warn(fmt.Sprintf("Extractor '%s' is not implemented, using 'taglib'", Server.Scanner.Extractor))
|
||||
Server.Scanner.Extractor = consts.DefaultScannerExtractor
|
||||
}
|
||||
logDeprecatedOptions("Scanner.GenreSeparators")
|
||||
logDeprecatedOptions("Scanner.GroupAlbumReleases")
|
||||
logDeprecatedOptions("DevEnableBufferedScrobble") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
|
||||
logDeprecatedOptions("Scanner.GenreSeparators", "")
|
||||
logDeprecatedOptions("Scanner.GroupAlbumReleases", "")
|
||||
logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
|
||||
logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
||||
logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
||||
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||
|
||||
// Call init hooks
|
||||
for _, hook := range hooks {
|
||||
@@ -357,16 +381,30 @@ func Load(noConfigDump bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func logDeprecatedOptions(options ...string) {
|
||||
for _, option := range options {
|
||||
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_"))
|
||||
if os.Getenv(envVar) != "" {
|
||||
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", envVar))
|
||||
}
|
||||
if viper.InConfig(option) {
|
||||
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", option))
|
||||
func logDeprecatedOptions(oldName, newName string) {
|
||||
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(oldName, ".", "_"))
|
||||
newEnvVar := "ND_" + strings.ToUpper(strings.ReplaceAll(newName, ".", "_"))
|
||||
logWarning := func(oldName, newName string) {
|
||||
if newName != "" {
|
||||
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release. Please use the new '%s'", oldName, newName))
|
||||
} else {
|
||||
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", oldName))
|
||||
}
|
||||
}
|
||||
if os.Getenv(envVar) != "" {
|
||||
logWarning(envVar, newEnvVar)
|
||||
}
|
||||
if viper.InConfig(oldName) {
|
||||
logWarning(oldName, newName)
|
||||
}
|
||||
}
|
||||
|
||||
// mapDeprecatedOption is used to provide backwards compatibility for deprecated options. It should be called after
|
||||
// the config has been read by viper, but before unmarshalling it into the Config struct.
|
||||
func mapDeprecatedOption(legacyName, newName string) {
|
||||
if viper.IsSet(legacyName) {
|
||||
viper.Set(newName, viper.Get(legacyName))
|
||||
}
|
||||
}
|
||||
|
||||
// parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it
|
||||
@@ -475,6 +513,16 @@ func AddHook(hook func()) {
|
||||
hooks = append(hooks, hook)
|
||||
}
|
||||
|
||||
// hasNDEnvVars checks if any ND_ prefixed environment variables are set (excluding ND_CONFIGFILE)
|
||||
func hasNDEnvVars() bool {
|
||||
for _, env := range os.Environ() {
|
||||
if strings.HasPrefix(env, "ND_") && !strings.HasPrefix(env, "ND_CONFIGFILE=") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func setViperDefaults() {
|
||||
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
|
||||
viper.SetDefault("cachefolder", "")
|
||||
@@ -492,6 +540,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("uiwelcomemessage", "")
|
||||
viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists)
|
||||
viper.SetDefault("enabletranscodingconfig", false)
|
||||
viper.SetDefault("enabletranscodingcancellation", false)
|
||||
viper.SetDefault("transcodingcachesize", "100MB")
|
||||
viper.SetDefault("imagecachesize", "100MB")
|
||||
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
|
||||
@@ -536,8 +585,8 @@ func setViperDefaults() {
|
||||
viper.SetDefault("authrequestlimit", 5)
|
||||
viper.SetDefault("authwindowlength", 20*time.Second)
|
||||
viper.SetDefault("passwordencryptionkey", "")
|
||||
viper.SetDefault("reverseproxyuserheader", "Remote-User")
|
||||
viper.SetDefault("reverseproxywhitelist", "")
|
||||
viper.SetDefault("extauth.userheader", "Remote-User")
|
||||
viper.SetDefault("extauth.trustedsources", "")
|
||||
viper.SetDefault("prometheus.enabled", false)
|
||||
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
|
||||
viper.SetDefault("prometheus.password", "")
|
||||
@@ -558,7 +607,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("subsonic.appendsubtitle", true)
|
||||
viper.SetDefault("subsonic.artistparticipations", false)
|
||||
viper.SetDefault("subsonic.defaultreportrealpath", false)
|
||||
viper.SetDefault("subsonic.legacyclients", "DSub")
|
||||
viper.SetDefault("subsonic.legacyclients", "DSub,SubMusic")
|
||||
viper.SetDefault("agents", "lastfm,spotify,deezer")
|
||||
viper.SetDefault("lastfm.enabled", true)
|
||||
viper.SetDefault("lastfm.language", "en")
|
||||
@@ -571,7 +620,8 @@ func setViperDefaults() {
|
||||
viper.SetDefault("deezer.language", "en")
|
||||
viper.SetDefault("listenbrainz.enabled", true)
|
||||
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
|
||||
viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")
|
||||
viper.SetDefault("enablescrobblehistory", true)
|
||||
viper.SetDefault("httpheaders.frameoptions", "DENY")
|
||||
viper.SetDefault("backup.path", "")
|
||||
viper.SetDefault("backup.schedule", "")
|
||||
viper.SetDefault("backup.count", 0)
|
||||
@@ -611,6 +661,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("devplugincompilationtimeout", time.Minute)
|
||||
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
|
||||
viper.SetDefault("devoptimizedb", true)
|
||||
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -41,6 +41,9 @@ var _ = Describe("Configuration", func() {
|
||||
Expect(conf.Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"}))
|
||||
Expect(conf.Server.Tags["artist"].Split).To(Equal([]string{";"}))
|
||||
|
||||
// Check deprecated option mapping
|
||||
Expect(conf.Server.ExtAuth.UserHeader).To(Equal("X-Auth-User"))
|
||||
|
||||
// The config file used should be the one we created
|
||||
Expect(conf.Server.ConfigFile).To(Equal(filename))
|
||||
},
|
||||
|
||||
1
conf/testdata/cfg.ini
vendored
1
conf/testdata/cfg.ini
vendored
@@ -1,6 +1,7 @@
|
||||
[default]
|
||||
MusicFolder = /ini/music
|
||||
UIWelcomeMessage = 'Welcome ini' ; Just a comment to test the LoadOptions
|
||||
ReverseProxyUserHeader = 'X-Auth-User'
|
||||
|
||||
[Tags]
|
||||
Custom.Aliases = ini,test
|
||||
|
||||
1
conf/testdata/cfg.json
vendored
1
conf/testdata/cfg.json
vendored
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"musicFolder": "/json/music",
|
||||
"uiWelcomeMessage": "Welcome json",
|
||||
"reverseProxyUserHeader": "X-Auth-User",
|
||||
"Tags": {
|
||||
"artist": {
|
||||
"split": ";"
|
||||
|
||||
1
conf/testdata/cfg.toml
vendored
1
conf/testdata/cfg.toml
vendored
@@ -1,5 +1,6 @@
|
||||
musicFolder = "/toml/music"
|
||||
uiWelcomeMessage = "Welcome toml"
|
||||
ReverseProxyUserHeader = "X-Auth-User"
|
||||
|
||||
Tags.artist.Split = ';'
|
||||
|
||||
|
||||
1
conf/testdata/cfg.yaml
vendored
1
conf/testdata/cfg.yaml
vendored
@@ -1,5 +1,6 @@
|
||||
musicFolder: "/yaml/music"
|
||||
uiWelcomeMessage: "Welcome yaml"
|
||||
reverseProxyUserHeader: "X-Auth-User"
|
||||
Tags:
|
||||
artist:
|
||||
split: [";"]
|
||||
|
||||
@@ -43,6 +43,7 @@ func newClient(hc httpDoer, language string) *client {
|
||||
func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
|
||||
params := url.Values{}
|
||||
params.Add("q", name)
|
||||
params.Add("order", "RANKING")
|
||||
params.Add("limit", strconv.Itoa(limit))
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiBaseURL+"/search/artist", nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package deezer
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -82,10 +83,20 @@ func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, e
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Trace(ctx, "Artists found", "count", len(artists), "searched_name", name)
|
||||
for i := range artists {
|
||||
log.Trace(ctx, fmt.Sprintf("Artists found #%d", i), "name", artists[i].Name, "id", artists[i].ID, "link", artists[i].Link)
|
||||
if i > 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If the first one has the same name, that's the one
|
||||
if !strings.EqualFold(artists[0].Name, name) {
|
||||
log.Trace(ctx, "Top artist do not match", "searched_name", name, "found_name", artists[0].Name)
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
log.Trace(ctx, "Found artist", "name", artists[0].Name, "id", artists[0].ID, "link", artists[0].Link)
|
||||
return &artists[0], err
|
||||
}
|
||||
|
||||
|
||||
@@ -290,11 +290,11 @@ func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName str
|
||||
return t.Track, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile) string {
|
||||
if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[model.RoleArtist]) > 0 {
|
||||
return track.Participants[model.RoleArtist][0].Name
|
||||
func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile, role model.Role, displayName string) string {
|
||||
if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[role]) > 0 {
|
||||
return track.Participants[role][0].Name
|
||||
}
|
||||
return track.Artist
|
||||
return displayName
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
|
||||
@@ -304,13 +304,13 @@ func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *mode
|
||||
}
|
||||
|
||||
err = l.client.updateNowPlaying(ctx, sk, ScrobbleInfo{
|
||||
artist: l.getArtistForScrobble(track),
|
||||
artist: l.getArtistForScrobble(track, model.RoleArtist, track.Artist),
|
||||
track: track.Title,
|
||||
album: track.Album,
|
||||
trackNumber: track.TrackNumber,
|
||||
mbid: track.MbzRecordingID,
|
||||
duration: int(track.Duration),
|
||||
albumArtist: track.AlbumArtist,
|
||||
albumArtist: l.getArtistForScrobble(track, model.RoleAlbumArtist, track.AlbumArtist),
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Last.fm client.updateNowPlaying returned error", "track", track.Title, err)
|
||||
@@ -330,13 +330,13 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
|
||||
return nil
|
||||
}
|
||||
err = l.client.scrobble(ctx, sk, ScrobbleInfo{
|
||||
artist: l.getArtistForScrobble(&s.MediaFile),
|
||||
artist: l.getArtistForScrobble(&s.MediaFile, model.RoleArtist, s.Artist),
|
||||
track: s.Title,
|
||||
album: s.Album,
|
||||
trackNumber: s.TrackNumber,
|
||||
mbid: s.MbzRecordingID,
|
||||
duration: int(s.Duration),
|
||||
albumArtist: s.AlbumArtist,
|
||||
albumArtist: l.getArtistForScrobble(&s.MediaFile, model.RoleAlbumArtist, s.AlbumArtist),
|
||||
timestamp: s.TimeStamp,
|
||||
})
|
||||
if err == nil {
|
||||
|
||||
@@ -201,6 +201,10 @@ var _ = Describe("lastfmAgent", func() {
|
||||
{Artist: model.Artist{ID: "ar-1", Name: "First Artist"}},
|
||||
{Artist: model.Artist{ID: "ar-2", Name: "Second Artist"}},
|
||||
},
|
||||
model.RoleAlbumArtist: []model.Participant{
|
||||
{Artist: model.Artist{ID: "ar-1", Name: "First Album Artist"}},
|
||||
{Artist: model.Artist{ID: "ar-2", Name: "Second Album Artist"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -229,6 +233,23 @@ var _ = Describe("lastfmAgent", func() {
|
||||
err := agent.NowPlaying(ctx, "user-2", track, 0)
|
||||
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
When("ScrobbleFirstArtistOnly is true", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.LastFM.ScrobbleFirstArtistOnly = true
|
||||
})
|
||||
|
||||
It("uses only the first artist", func() {
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
|
||||
|
||||
err := agent.NowPlaying(ctx, "user-1", track, 0)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
sentParams := httpClient.SavedRequest.URL.Query()
|
||||
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
|
||||
Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("scrobble", func() {
|
||||
@@ -267,6 +288,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
sentParams := httpClient.SavedRequest.URL.Query()
|
||||
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
|
||||
Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
89
core/external/provider.go
vendored
89
core/external/provider.go
vendored
@@ -51,12 +51,28 @@ type provider struct {
|
||||
|
||||
type auxAlbum struct {
|
||||
model.Album
|
||||
Name string
|
||||
}
|
||||
|
||||
// Name returns the appropriate album name for external API calls
|
||||
// based on the DevPreserveUnicodeInExternalCalls configuration option
|
||||
func (a *auxAlbum) Name() string {
|
||||
if conf.Server.DevPreserveUnicodeInExternalCalls {
|
||||
return a.Album.Name
|
||||
}
|
||||
return str.Clear(a.Album.Name)
|
||||
}
|
||||
|
||||
type auxArtist struct {
|
||||
model.Artist
|
||||
Name string
|
||||
}
|
||||
|
||||
// Name returns the appropriate artist name for external API calls
|
||||
// based on the DevPreserveUnicodeInExternalCalls configuration option
|
||||
func (a *auxArtist) Name() string {
|
||||
if conf.Server.DevPreserveUnicodeInExternalCalls {
|
||||
return a.Artist.Name
|
||||
}
|
||||
return str.Clear(a.Artist.Name)
|
||||
}
|
||||
|
||||
type Agents interface {
|
||||
@@ -88,7 +104,6 @@ func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) {
|
||||
switch v := entity.(type) {
|
||||
case *model.Album:
|
||||
album.Album = *v
|
||||
album.Name = str.Clear(v.Name)
|
||||
case *model.MediaFile:
|
||||
return e.getAlbum(ctx, v.AlbumID)
|
||||
default:
|
||||
@@ -106,8 +121,9 @@ func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album
|
||||
}
|
||||
|
||||
updatedAt := V(album.ExternalInfoUpdatedAt)
|
||||
albumName := album.Name()
|
||||
if updatedAt.IsZero() {
|
||||
log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", album.Name)
|
||||
log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", albumName)
|
||||
album, err = e.populateAlbumInfo(ctx, album)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -116,7 +132,7 @@ func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album
|
||||
|
||||
// If info is expired, trigger a populateAlbumInfo in the background
|
||||
if time.Since(updatedAt) > conf.Server.DevAlbumInfoTimeToLive {
|
||||
log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", album.Name)
|
||||
log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", albumName)
|
||||
e.albumQueue.enqueue(&album)
|
||||
}
|
||||
|
||||
@@ -125,12 +141,13 @@ func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album
|
||||
|
||||
func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAlbum, error) {
|
||||
start := time.Now()
|
||||
info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
|
||||
albumName := album.Name()
|
||||
info, err := e.ag.GetAlbumInfo(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
|
||||
if errors.Is(err, agents.ErrNotFound) {
|
||||
return album, nil
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("Error refreshing AlbumInfo", "id", album.ID, "name", album.Name, "artist", album.AlbumArtist,
|
||||
log.Error("Error refreshing AlbumInfo", "id", album.ID, "name", albumName, "artist", album.AlbumArtist,
|
||||
"elapsed", time.Since(start), err)
|
||||
return album, err
|
||||
}
|
||||
@@ -142,7 +159,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
|
||||
album.Description = info.Description
|
||||
}
|
||||
|
||||
images, err := e.ag.GetAlbumImages(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
|
||||
images, err := e.ag.GetAlbumImages(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
|
||||
if err == nil && len(images) > 0 {
|
||||
sort.Slice(images, func(i, j int) bool {
|
||||
return images[i].Size > images[j].Size
|
||||
@@ -161,7 +178,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
|
||||
|
||||
err = e.ds.Album(ctx).UpdateExternalInfo(&album.Album)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", album.Name,
|
||||
log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", albumName,
|
||||
"elapsed", time.Since(start), err)
|
||||
} else {
|
||||
log.Trace(ctx, "AlbumInfo collected", "album", album, "elapsed", time.Since(start))
|
||||
@@ -181,7 +198,6 @@ func (e *provider) getArtist(ctx context.Context, id string) (auxArtist, error)
|
||||
switch v := entity.(type) {
|
||||
case *model.Artist:
|
||||
artist.Artist = *v
|
||||
artist.Name = str.Clear(v.Name)
|
||||
case *model.MediaFile:
|
||||
return e.getArtist(ctx, v.ArtistID)
|
||||
case *model.Album:
|
||||
@@ -210,8 +226,9 @@ func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist,
|
||||
|
||||
// If we don't have any info, retrieves it now
|
||||
updatedAt := V(artist.ExternalInfoUpdatedAt)
|
||||
artistName := artist.Name()
|
||||
if updatedAt.IsZero() {
|
||||
log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", artist.Name)
|
||||
log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", artistName)
|
||||
artist, err = e.populateArtistInfo(ctx, artist)
|
||||
if err != nil {
|
||||
return auxArtist{}, err
|
||||
@@ -220,7 +237,7 @@ func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist,
|
||||
|
||||
// If info is expired, trigger a populateArtistInfo in the background
|
||||
if time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive {
|
||||
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artist.Name)
|
||||
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artistName)
|
||||
e.artistQueue.enqueue(&artist)
|
||||
}
|
||||
return artist, nil
|
||||
@@ -229,8 +246,9 @@ func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist,
|
||||
func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (auxArtist, error) {
|
||||
start := time.Now()
|
||||
// Get MBID first, if it is not yet available
|
||||
artistName := artist.Name()
|
||||
if artist.MbzArtistID == "" {
|
||||
mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artist.Name)
|
||||
mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artistName)
|
||||
if mbid != "" && err == nil {
|
||||
artist.MbzArtistID = mbid
|
||||
}
|
||||
@@ -246,14 +264,14 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
|
||||
_ = g.Wait()
|
||||
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "ArtistInfo update canceled", "elapsed", "id", artist.ID, "name", artist.Name, time.Since(start), ctx.Err())
|
||||
log.Warn(ctx, "ArtistInfo update canceled", "id", artist.ID, "name", artistName, "elapsed", time.Since(start), ctx.Err())
|
||||
return artist, ctx.Err()
|
||||
}
|
||||
|
||||
artist.ExternalInfoUpdatedAt = P(time.Now())
|
||||
err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artist.Name,
|
||||
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName,
|
||||
"elapsed", time.Since(start), err)
|
||||
} else {
|
||||
log.Trace(ctx, "ArtistInfo collected", "artist", artist, "elapsed", time.Since(start))
|
||||
@@ -281,7 +299,7 @@ func (e *provider) ArtistRadio(ctx context.Context, id string, count int) (model
|
||||
}
|
||||
|
||||
topCount := max(count, 20)
|
||||
topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Name: a.Name, Artist: a}, topCount)
|
||||
topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Artist: a}, topCount)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err)
|
||||
return nil
|
||||
@@ -344,22 +362,23 @@ func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
images, err := e.ag.GetAlbumImages(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
|
||||
albumName := album.Name()
|
||||
images, err := e.ag.GetAlbumImages(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, agents.ErrNotFound):
|
||||
log.Trace(ctx, "Album not found in agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist)
|
||||
log.Trace(ctx, "Album not found in agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist)
|
||||
return nil, model.ErrNotFound
|
||||
case errors.Is(err, context.Canceled):
|
||||
log.Debug(ctx, "GetAlbumImages call canceled", err)
|
||||
default:
|
||||
log.Warn(ctx, "Error getting album images from agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist, err)
|
||||
log.Warn(ctx, "Error getting album images from agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(images) == 0 {
|
||||
log.Warn(ctx, "Agent returned no images without error", "albumID", id, "name", album.Name, "artist", album.AlbumArtist)
|
||||
log.Warn(ctx, "Agent returned no images without error", "albumID", id, "name", albumName, "artist", album.AlbumArtist)
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
@@ -401,9 +420,10 @@ func (e *provider) TopSongs(ctx context.Context, artistName string, count int) (
|
||||
}
|
||||
|
||||
func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistTopSongsRetriever, artist *auxArtist, count int) (model.MediaFiles, error) {
|
||||
songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artist.Name, artist.MbzArtistID, count)
|
||||
artistName := artist.Name()
|
||||
songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artistName, artist.MbzArtistID, count)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artist.Name, err)
|
||||
return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, err)
|
||||
}
|
||||
|
||||
mbidMatches, err := e.loadTracksByMBID(ctx, songs)
|
||||
@@ -415,13 +435,13 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT
|
||||
return nil, fmt.Errorf("failed to load tracks by title: %w", err)
|
||||
}
|
||||
|
||||
log.Trace(ctx, "Top Songs loaded", "name", artist.Name, "numSongs", len(songs), "numMBIDMatches", len(mbidMatches), "numTitleMatches", len(titleMatches))
|
||||
log.Trace(ctx, "Top Songs loaded", "name", artistName, "numSongs", len(songs), "numMBIDMatches", len(mbidMatches), "numTitleMatches", len(titleMatches))
|
||||
mfs := e.selectTopSongs(songs, mbidMatches, titleMatches, count)
|
||||
|
||||
if len(mfs) == 0 {
|
||||
log.Debug(ctx, "No matching top songs found", "name", artist.Name)
|
||||
log.Debug(ctx, "No matching top songs found", "name", artistName)
|
||||
} else {
|
||||
log.Debug(ctx, "Found matching top songs", "name", artist.Name, "numSongs", len(mfs))
|
||||
log.Debug(ctx, "Found matching top songs", "name", artistName, "numSongs", len(mfs))
|
||||
}
|
||||
|
||||
return mfs, nil
|
||||
@@ -518,7 +538,7 @@ func (e *provider) selectTopSongs(songs []agents.Song, byMBID, byTitle map[strin
|
||||
}
|
||||
|
||||
func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) {
|
||||
artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name, artist.MbzArtistID)
|
||||
artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -526,7 +546,7 @@ func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriev
|
||||
}
|
||||
|
||||
func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) {
|
||||
bio, err := agent.GetArtistBiography(ctx, artist.ID, str.Clear(artist.Name), artist.MbzArtistID)
|
||||
bio, err := agent.GetArtistBiography(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -536,7 +556,7 @@ func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiog
|
||||
}
|
||||
|
||||
func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRetriever, artist *auxArtist) {
|
||||
images, err := agent.GetArtistImages(ctx, artist.ID, artist.Name, artist.MbzArtistID)
|
||||
images, err := agent.GetArtistImages(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -555,13 +575,14 @@ func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRet
|
||||
|
||||
func (e *provider) callGetSimilar(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
|
||||
limit int, includeNotPresent bool) {
|
||||
similar, err := agent.GetSimilarArtists(ctx, artist.ID, artist.Name, artist.MbzArtistID, limit)
|
||||
artistName := artist.Name()
|
||||
similar, err := agent.GetSimilarArtists(ctx, artist.ID, artistName, artist.MbzArtistID, limit)
|
||||
if len(similar) == 0 || err != nil {
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
sa, err := e.mapSimilarArtists(ctx, similar, limit, includeNotPresent)
|
||||
log.Debug(ctx, "Mapped Similar Artists", "artist", artist.Name, "numSimilar", len(sa), "elapsed", time.Since(start))
|
||||
log.Debug(ctx, "Mapped Similar Artists", "artist", artistName, "numSimilar", len(sa), "elapsed", time.Since(start))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -635,11 +656,7 @@ func (e *provider) findArtistByName(ctx context.Context, artistName string) (*au
|
||||
if len(artists) == 0 {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
artist := &auxArtist{
|
||||
Artist: artists[0],
|
||||
Name: str.Clear(artists[0].Name),
|
||||
}
|
||||
return artist, nil
|
||||
return &auxArtist{Artist: artists[0]}, nil
|
||||
}
|
||||
|
||||
func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int, includeNotPresent bool) error {
|
||||
@@ -655,7 +672,7 @@ func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int
|
||||
Filters: squirrel.Eq{"artist.id": ids},
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("Error loading similar artists", "id", artist.ID, "name", artist.Name, err)
|
||||
log.Error("Error loading similar artists", "id", artist.ID, "name", artist.Name(), err)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
63
core/external/provider_albumimage_test.go
vendored
63
core/external/provider_albumimage_test.go
vendored
@@ -260,6 +260,69 @@ var _ = Describe("Provider - AlbumImage", func() {
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
Context("Unicode handling in album names", func() {
|
||||
var albumWithEnDash *model.Album
|
||||
var expectedURL *url.URL
|
||||
|
||||
const (
|
||||
originalAlbumName = "Raising Hell–Deluxe" // Album name with en dash
|
||||
normalizedAlbumName = "Raising Hell-Deluxe" // Normalized version with hyphen
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
// Test with en dash (–) in album name
|
||||
albumWithEnDash = &model.Album{ID: "album-endash", Name: originalAlbumName, AlbumArtistID: "artist-1"}
|
||||
mockArtistRepo.Mock = mock.Mock{} // Reset default expectations
|
||||
mockAlbumRepo.Mock = mock.Mock{} // Reset default expectations
|
||||
mockArtistRepo.On("Get", "album-endash").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "album-endash").Return(albumWithEnDash, nil).Once()
|
||||
|
||||
expectedURL, _ = url.Parse("http://example.com/album.jpg")
|
||||
|
||||
// Mock the album agent to return an image for the album
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, mock.AnythingOfType("string"), "", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/album.jpg", Size: 1000},
|
||||
}, nil).Once()
|
||||
})
|
||||
|
||||
When("DevPreserveUnicodeInExternalCalls is true", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DevPreserveUnicodeInExternalCalls = true
|
||||
})
|
||||
|
||||
It("preserves Unicode characters in album names", func() {
|
||||
// Act
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-endash")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash")
|
||||
// This is the key assertion: ensure the original Unicode name is used
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, originalAlbumName, "", "")
|
||||
})
|
||||
})
|
||||
|
||||
When("DevPreserveUnicodeInExternalCalls is false", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DevPreserveUnicodeInExternalCalls = false
|
||||
})
|
||||
|
||||
It("normalizes Unicode characters", func() {
|
||||
// Act
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-endash")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash")
|
||||
// This assertion ensures the normalized name is used (en dash → hyphen)
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, normalizedAlbumName, "", "")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// mockAlbumInfoAgent implementation
|
||||
|
||||
61
core/external/provider_artistimage_test.go
vendored
61
core/external/provider_artistimage_test.go
vendored
@@ -265,6 +265,67 @@ var _ = Describe("Provider - ArtistImage", func() {
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
Context("Unicode handling in artist names", func() {
|
||||
var artistWithEnDash *model.Artist
|
||||
var expectedURL *url.URL
|
||||
|
||||
const (
|
||||
originalArtistName = "Run–D.M.C." // Artist name with en dash
|
||||
normalizedArtistName = "Run-D.M.C." // Normalized version with hyphen
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
// Test with en dash (–) in artist name like "Run–D.M.C."
|
||||
artistWithEnDash = &model.Artist{ID: "artist-endash", Name: originalArtistName}
|
||||
mockArtistRepo.Mock = mock.Mock{} // Reset default expectations
|
||||
mockArtistRepo.On("Get", "artist-endash").Return(artistWithEnDash, nil).Once()
|
||||
|
||||
expectedURL, _ = url.Parse("http://example.com/rundmc.jpg")
|
||||
|
||||
// Mock the image agent to return an image for the artist
|
||||
mockImageAgent.On("GetArtistImages", ctx, "artist-endash", mock.AnythingOfType("string"), "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/rundmc.jpg", Size: 1000},
|
||||
}, nil).Once()
|
||||
|
||||
})
|
||||
|
||||
When("DevPreserveUnicodeInExternalCalls is true", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DevPreserveUnicodeInExternalCalls = true
|
||||
})
|
||||
It("preserves Unicode characters in artist names", func() {
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-endash")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash")
|
||||
// This is the key assertion: ensure the original Unicode name is used
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", originalArtistName, "")
|
||||
})
|
||||
})
|
||||
|
||||
When("DevPreserveUnicodeInExternalCalls is false", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DevPreserveUnicodeInExternalCalls = false
|
||||
})
|
||||
|
||||
It("normalizes Unicode characters", func() {
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-endash")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash")
|
||||
// This assertion ensures the normalized name is used (en dash → hyphen)
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", normalizedArtistName, "")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// mockArtistImageAgent implementation using testify/mock
|
||||
|
||||
@@ -112,7 +112,7 @@ func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error
|
||||
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
|
||||
j := &ffCmd{args: args}
|
||||
j.PipeReader, j.out = io.Pipe()
|
||||
err := j.start()
|
||||
err := j.start(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -127,8 +127,8 @@ type ffCmd struct {
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
func (j *ffCmd) start() error {
|
||||
cmd := exec.Command(j.args[0], j.args[1:]...) // #nosec
|
||||
func (j *ffCmd) start(ctx context.Context) error {
|
||||
cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
|
||||
cmd.Stdout = j.out
|
||||
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
sync "sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
@@ -65,4 +69,98 @@ var _ = Describe("ffmpeg", func() {
|
||||
Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "ffmetadata"}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("FFmpeg", func() {
|
||||
Context("when FFmpeg is available", func() {
|
||||
var ff FFmpeg
|
||||
|
||||
BeforeEach(func() {
|
||||
ffOnce = sync.Once{}
|
||||
ff = New()
|
||||
// Skip if FFmpeg is not available
|
||||
if !ff.IsAvailable() {
|
||||
Skip("FFmpeg not available on this system")
|
||||
}
|
||||
})
|
||||
|
||||
It("should interrupt transcoding when context is cancelled", func() {
|
||||
ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Use a command that generates audio indefinitely
|
||||
// -f lavfi uses FFmpeg's built-in audio source
|
||||
// -t 0 means no time limit (runs forever)
|
||||
command := "ffmpeg -f lavfi -i sine=frequency=1000:duration=0 -f mp3 -"
|
||||
|
||||
// The input file is not used here, but we need to provide a valid path to the Transcode function
|
||||
stream, err := ff.Transcode(ctx, command, "tests/fixtures/test.mp3", 128, 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer stream.Close()
|
||||
|
||||
// Read some data first to ensure FFmpeg is running
|
||||
buf := make([]byte, 1024)
|
||||
_, err = stream.Read(buf)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Cancel the context
|
||||
cancel()
|
||||
|
||||
// Next read should fail due to cancelled context
|
||||
_, err = stream.Read(buf)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should handle immediate context cancellation", func() {
|
||||
ctx, cancel := context.WithCancel(GinkgoT().Context())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
// This should fail immediately
|
||||
_, err := ff.Transcode(ctx, "ffmpeg -i %s -f mp3 -", "tests/fixtures/test.mp3", 128, 0)
|
||||
Expect(err).To(MatchError(context.Canceled))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with mock process behavior", func() {
|
||||
var longRunningCmd string
|
||||
BeforeEach(func() {
|
||||
// Use a long-running command for testing cancellation
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
// Use PowerShell's Start-Sleep
|
||||
ffmpegPath = "powershell"
|
||||
longRunningCmd = "powershell -Command Start-Sleep -Seconds 10"
|
||||
default:
|
||||
// Use sleep on Unix-like systems
|
||||
ffmpegPath = "sleep"
|
||||
longRunningCmd = "sleep 10"
|
||||
}
|
||||
})
|
||||
|
||||
It("should terminate the underlying process when context is cancelled", func() {
|
||||
ff := New()
|
||||
ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Start a process that will run for a while
|
||||
stream, err := ff.Transcode(ctx, longRunningCmd, "tests/fixtures/test.mp3", 0, 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer stream.Close()
|
||||
|
||||
// Give the process time to start
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Cancel the context
|
||||
cancel()
|
||||
|
||||
// Try to read from the stream, which should fail
|
||||
buf := make([]byte, 100)
|
||||
_, err = stream.Read(buf)
|
||||
Expect(err).To(HaveOccurred(), "Expected stream to be closed due to process termination")
|
||||
|
||||
// Verify the stream is closed by attempting another read
|
||||
_, err = stream.Read(buf)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -204,7 +204,20 @@ 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.filePath, job.bitRate, job.offset)
|
||||
|
||||
// Choose the appropriate context based on EnableTranscodingCancellation configuration.
|
||||
// This is where we decide whether transcoding processes should be cancellable or not.
|
||||
var transcodingCtx context.Context
|
||||
if conf.Server.EnableTranscodingCancellation {
|
||||
// Use the request context directly, allowing cancellation when client disconnects
|
||||
transcodingCtx = ctx
|
||||
} else {
|
||||
// Use background context with request values preserved.
|
||||
// This prevents cancellation but maintains request metadata (user, client, etc.)
|
||||
transcodingCtx = request.AddValues(context.Background(), ctx)
|
||||
}
|
||||
|
||||
out, err := job.ms.transcoder.Transcode(transcodingCtx, t.Command, job.filePath, job.bitRate, job.offset)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
|
||||
return nil, os.ErrInvalid
|
||||
|
||||
@@ -223,7 +223,7 @@ var staticData = sync.OnceValue(func() insights.Data {
|
||||
data.Config.ScanSchedule = conf.Server.Scanner.Schedule
|
||||
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
|
||||
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
|
||||
data.Config.ReverseProxyConfigured = conf.Server.ReverseProxyWhitelist != ""
|
||||
data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != ""
|
||||
data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != ""
|
||||
data.Config.HasCustomTags = len(conf.Server.Tags) > 0
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ type MountInfo struct {
|
||||
|
||||
var fsTypeMap = map[int64]string{
|
||||
0x5346414f: "afs",
|
||||
0x187: "autofs",
|
||||
0x61756673: "aufs",
|
||||
0x9123683E: "btrfs",
|
||||
0xc36400: "ceph",
|
||||
@@ -55,9 +56,11 @@ var fsTypeMap = map[int64]string{
|
||||
0x6a656a63: "fakeowner", // FS inside a container
|
||||
0x65735546: "fuse",
|
||||
0x4244: "hfs",
|
||||
0x482b: "hfs+",
|
||||
0x9660: "iso9660",
|
||||
0x3153464a: "jfs",
|
||||
0x00006969: "nfs",
|
||||
0x5346544e: "ntfs", // NTFS_SB_MAGIC
|
||||
0x7366746e: "ntfs",
|
||||
0x794c7630: "overlayfs",
|
||||
0x9fa0: "proc",
|
||||
@@ -69,8 +72,16 @@ var fsTypeMap = map[int64]string{
|
||||
0x01021997: "v9fs",
|
||||
0x786f4256: "vboxsf",
|
||||
0x4d44: "vfat",
|
||||
0xca451a4e: "virtiofs",
|
||||
0x58465342: "xfs",
|
||||
0x2FC12FC1: "zfs",
|
||||
0x7c7c6673: "prlfs", // Parallels Shared Folders
|
||||
|
||||
// Signed/unsigned conversion issues (negative hex values converted to uint32)
|
||||
-0x6edc97c2: "btrfs", // 0x9123683e
|
||||
-0x1acb2be: "smb2", // 0xfe534d42
|
||||
-0xacb2be: "cifs", // 0xff534d42
|
||||
-0xd0adff0: "f2fs", // 0xf2f52010
|
||||
}
|
||||
|
||||
func getFilesystemType(path string) (string, error) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -9,7 +10,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -194,22 +195,35 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
||||
}
|
||||
filteredLines = append(filteredLines, line)
|
||||
}
|
||||
paths, err := s.normalizePaths(ctx, pls, folder, filteredLines)
|
||||
resolvedPaths, err := s.resolvePaths(ctx, folder, filteredLines)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error normalizing paths in playlist", "playlist", pls.Name, err)
|
||||
log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err)
|
||||
continue
|
||||
}
|
||||
found, err := mediaFileRepository.FindByPaths(paths)
|
||||
|
||||
// Normalize to NFD for filesystem compatibility (macOS). Database stores paths in NFD.
|
||||
// See https://github.com/navidrome/navidrome/issues/4663
|
||||
resolvedPaths = slice.Map(resolvedPaths, func(path string) string {
|
||||
return strings.ToLower(norm.NFD.String(path))
|
||||
})
|
||||
|
||||
found, err := mediaFileRepository.FindByPaths(resolvedPaths)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
|
||||
continue
|
||||
}
|
||||
// Build lookup map with library-qualified keys, normalized for comparison
|
||||
existing := make(map[string]int, len(found))
|
||||
for idx := range found {
|
||||
existing[normalizePathForComparison(found[idx].Path)] = idx
|
||||
// Normalize to lowercase for case-insensitive comparison
|
||||
// Key format: "libraryID:path"
|
||||
key := fmt.Sprintf("%d:%s", found[idx].LibraryID, strings.ToLower(found[idx].Path))
|
||||
existing[key] = idx
|
||||
}
|
||||
for _, path := range paths {
|
||||
idx, ok := existing[normalizePathForComparison(path)]
|
||||
|
||||
// Find media files in the order of the resolved paths, to keep playlist order
|
||||
for _, path := range resolvedPaths {
|
||||
idx, ok := existing[path]
|
||||
if ok {
|
||||
mfs = append(mfs, found[idx])
|
||||
} else {
|
||||
@@ -226,69 +240,150 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizePathForComparison normalizes a file path to NFC form and converts to lowercase
|
||||
// for consistent comparison. This fixes Unicode normalization issues on macOS where
|
||||
// Apple Music creates playlists with NFC-encoded paths but the filesystem uses NFD.
|
||||
func normalizePathForComparison(path string) string {
|
||||
return strings.ToLower(norm.NFC.String(path))
|
||||
// pathResolution holds the result of resolving a playlist path to a library-relative path.
|
||||
type pathResolution struct {
|
||||
absolutePath string
|
||||
libraryPath string
|
||||
libraryID int
|
||||
valid bool
|
||||
}
|
||||
|
||||
// TODO This won't work for multiple libraries
|
||||
func (s *playlists) normalizePaths(ctx context.Context, pls *model.Playlist, folder *model.Folder, lines []string) ([]string, error) {
|
||||
libRegex, err := s.compileLibraryPaths(ctx)
|
||||
// ToQualifiedString converts the path resolution to a library-qualified string with forward slashes.
|
||||
// Format: "libraryID:relativePath" with forward slashes for path separators.
|
||||
func (r pathResolution) ToQualifiedString() (string, error) {
|
||||
if !r.valid {
|
||||
return "", fmt.Errorf("invalid path resolution")
|
||||
}
|
||||
relativePath, err := filepath.Rel(r.libraryPath, r.absolutePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
|
||||
res := make([]string, 0, len(lines))
|
||||
for idx, line := range lines {
|
||||
var libPath string
|
||||
var filePath string
|
||||
|
||||
if folder != nil && !filepath.IsAbs(line) {
|
||||
libPath = folder.LibraryPath
|
||||
filePath = filepath.Join(folder.AbsolutePath(), line)
|
||||
} else {
|
||||
cleanLine := filepath.Clean(line)
|
||||
if libPath = libRegex.FindString(cleanLine); libPath != "" {
|
||||
filePath = cleanLine
|
||||
}
|
||||
}
|
||||
|
||||
if libPath != "" {
|
||||
if rel, err := filepath.Rel(libPath, filePath); err == nil {
|
||||
res = append(res, rel)
|
||||
} else {
|
||||
log.Debug(ctx, "Error getting relative path", "playlist", pls.Name, "path", line, "libPath", libPath,
|
||||
"filePath", filePath, err)
|
||||
}
|
||||
} else {
|
||||
log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
|
||||
}
|
||||
}
|
||||
return slice.Map(res, filepath.ToSlash), nil
|
||||
// Convert path separators to forward slashes
|
||||
return fmt.Sprintf("%d:%s", r.libraryID, filepath.ToSlash(relativePath)), nil
|
||||
}
|
||||
|
||||
func (s *playlists) compileLibraryPaths(ctx context.Context) (*regexp.Regexp, error) {
|
||||
libs, err := s.ds.Library(ctx).GetAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// libraryMatcher holds sorted libraries with cleaned paths for efficient path matching.
|
||||
type libraryMatcher struct {
|
||||
libraries model.Libraries
|
||||
cleanedPaths []string
|
||||
}
|
||||
|
||||
// Create regex patterns for each library path
|
||||
patterns := make([]string, len(libs))
|
||||
// findLibraryForPath finds which library contains the given absolute path.
|
||||
// Returns library ID and path, or 0 and empty string if not found.
|
||||
func (lm *libraryMatcher) findLibraryForPath(absolutePath string) (int, string) {
|
||||
// Check sorted libraries (longest path first) to find the best match
|
||||
for i, cleanLibPath := range lm.cleanedPaths {
|
||||
// Check if absolutePath is under this library path
|
||||
if strings.HasPrefix(absolutePath, cleanLibPath) {
|
||||
// Ensure it's a proper path boundary (not just a prefix)
|
||||
if len(absolutePath) == len(cleanLibPath) || absolutePath[len(cleanLibPath)] == filepath.Separator {
|
||||
return lm.libraries[i].ID, cleanLibPath
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
// newLibraryMatcher creates a libraryMatcher with libraries sorted by path length (longest first).
|
||||
// This ensures correct matching when library paths are prefixes of each other.
|
||||
// Example: /music-classical must be checked before /music
|
||||
// Otherwise, /music-classical/track.mp3 would match /music instead of /music-classical
|
||||
func newLibraryMatcher(libs model.Libraries) *libraryMatcher {
|
||||
// Sort libraries by path length (descending) to ensure longest paths match first.
|
||||
slices.SortFunc(libs, func(i, j model.Library) int {
|
||||
return cmp.Compare(len(j.Path), len(i.Path)) // Reverse order for descending
|
||||
})
|
||||
|
||||
// Pre-clean all library paths once for efficient matching
|
||||
cleanedPaths := make([]string, len(libs))
|
||||
for i, lib := range libs {
|
||||
cleanPath := filepath.Clean(lib.Path)
|
||||
escapedPath := regexp.QuoteMeta(cleanPath)
|
||||
patterns[i] = fmt.Sprintf("^%s(?:/|$)", escapedPath)
|
||||
cleanedPaths[i] = filepath.Clean(lib.Path)
|
||||
}
|
||||
// Combine all patterns into a single regex
|
||||
combinedPattern := strings.Join(patterns, "|")
|
||||
re, err := regexp.Compile(combinedPattern)
|
||||
return &libraryMatcher{
|
||||
libraries: libs,
|
||||
cleanedPaths: cleanedPaths,
|
||||
}
|
||||
}
|
||||
|
||||
// pathResolver handles path resolution logic for playlist imports.
|
||||
type pathResolver struct {
|
||||
matcher *libraryMatcher
|
||||
}
|
||||
|
||||
// newPathResolver creates a pathResolver with libraries loaded from the datastore.
|
||||
func newPathResolver(ctx context.Context, ds model.DataStore) (*pathResolver, error) {
|
||||
libs, err := ds.Library(ctx).GetAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compiling library paths `%s`: %w", combinedPattern, err)
|
||||
return nil, err
|
||||
}
|
||||
return re, nil
|
||||
matcher := newLibraryMatcher(libs)
|
||||
return &pathResolver{matcher: matcher}, nil
|
||||
}
|
||||
|
||||
// resolvePath determines the absolute path and library path for a playlist entry.
|
||||
// For absolute paths, it uses them directly.
|
||||
// For relative paths, it resolves them relative to the playlist's folder location.
|
||||
// Example: playlist at /music/playlists/test.m3u with line "../songs/abc.mp3"
|
||||
//
|
||||
// resolves to /music/songs/abc.mp3
|
||||
func (r *pathResolver) resolvePath(line string, folder *model.Folder) pathResolution {
|
||||
var absolutePath string
|
||||
if folder != nil && !filepath.IsAbs(line) {
|
||||
// Resolve relative path to absolute path based on playlist location
|
||||
absolutePath = filepath.Clean(filepath.Join(folder.AbsolutePath(), line))
|
||||
} else {
|
||||
// Use absolute path directly after cleaning
|
||||
absolutePath = filepath.Clean(line)
|
||||
}
|
||||
|
||||
return r.findInLibraries(absolutePath)
|
||||
}
|
||||
|
||||
// findInLibraries matches an absolute path against all known libraries and returns
|
||||
// a pathResolution with the library information. Returns an invalid resolution if
|
||||
// the path is not found in any library.
|
||||
func (r *pathResolver) findInLibraries(absolutePath string) pathResolution {
|
||||
libID, libPath := r.matcher.findLibraryForPath(absolutePath)
|
||||
if libID == 0 {
|
||||
return pathResolution{valid: false}
|
||||
}
|
||||
return pathResolution{
|
||||
absolutePath: absolutePath,
|
||||
libraryPath: libPath,
|
||||
libraryID: libID,
|
||||
valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePaths converts playlist file paths to library-qualified paths (format: "libraryID:relativePath").
|
||||
// For relative paths, it resolves them to absolute paths first, then determines which
|
||||
// library they belong to. This allows playlists to reference files across library boundaries.
|
||||
func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
|
||||
resolver, err := newPathResolver(ctx, s.ds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := make([]string, 0, len(lines))
|
||||
for idx, line := range lines {
|
||||
resolution := resolver.resolvePath(line, folder)
|
||||
|
||||
if !resolution.valid {
|
||||
log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
|
||||
continue
|
||||
}
|
||||
|
||||
qualifiedPath, err := resolution.ToQualifiedString()
|
||||
if err != nil {
|
||||
log.Debug(ctx, "Error getting library-qualified path", "path", line,
|
||||
"libPath", resolution.libraryPath, "filePath", resolution.absolutePath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
results = append(results, qualifiedPath)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
|
||||
|
||||
406
core/playlists_internal_test.go
Normal file
406
core/playlists_internal_test.go
Normal file
@@ -0,0 +1,406 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("libraryMatcher", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var mockLibRepo *tests.MockLibraryRepo
|
||||
ctx := context.Background()
|
||||
|
||||
BeforeEach(func() {
|
||||
mockLibRepo = &tests.MockLibraryRepo{}
|
||||
ds = &tests.MockDataStore{
|
||||
MockedLibrary: mockLibRepo,
|
||||
}
|
||||
})
|
||||
|
||||
// Helper function to create a libraryMatcher from the mock datastore
|
||||
createMatcher := func(ds model.DataStore) *libraryMatcher {
|
||||
libs, err := ds.Library(ctx).GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return newLibraryMatcher(libs)
|
||||
}
|
||||
|
||||
Describe("Longest library path matching", func() {
|
||||
It("matches the longest library path when multiple libraries share a prefix", func() {
|
||||
// Setup libraries with prefix conflicts
|
||||
mockLibRepo.SetData([]model.Library{
|
||||
{ID: 1, Path: "/music"},
|
||||
{ID: 2, Path: "/music-classical"},
|
||||
{ID: 3, Path: "/music-classical/opera"},
|
||||
})
|
||||
|
||||
matcher := createMatcher(ds)
|
||||
|
||||
// Test that longest path matches first and returns correct library ID
|
||||
testCases := []struct {
|
||||
path string
|
||||
expectedLibID int
|
||||
expectedLibPath string
|
||||
}{
|
||||
{"/music-classical/opera/track.mp3", 3, "/music-classical/opera"},
|
||||
{"/music-classical/track.mp3", 2, "/music-classical"},
|
||||
{"/music/track.mp3", 1, "/music"},
|
||||
{"/music-classical/opera/subdir/file.mp3", 3, "/music-classical/opera"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
libID, libPath := matcher.findLibraryForPath(tc.path)
|
||||
Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d, but got %d", tc.path, tc.expectedLibID, libID)
|
||||
Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s, but got %s", tc.path, tc.expectedLibPath, libPath)
|
||||
}
|
||||
})
|
||||
|
||||
It("handles libraries with similar prefixes but different structures", func() {
|
||||
mockLibRepo.SetData([]model.Library{
|
||||
{ID: 1, Path: "/home/user/music"},
|
||||
{ID: 2, Path: "/home/user/music-backup"},
|
||||
})
|
||||
|
||||
matcher := createMatcher(ds)
|
||||
|
||||
// Test that music-backup library is matched correctly
|
||||
libID, libPath := matcher.findLibraryForPath("/home/user/music-backup/track.mp3")
|
||||
Expect(libID).To(Equal(2))
|
||||
Expect(libPath).To(Equal("/home/user/music-backup"))
|
||||
|
||||
// Test that music library is still matched correctly
|
||||
libID, libPath = matcher.findLibraryForPath("/home/user/music/track.mp3")
|
||||
Expect(libID).To(Equal(1))
|
||||
Expect(libPath).To(Equal("/home/user/music"))
|
||||
})
|
||||
|
||||
It("matches path that is exactly the library root", func() {
|
||||
mockLibRepo.SetData([]model.Library{
|
||||
{ID: 1, Path: "/music"},
|
||||
{ID: 2, Path: "/music-classical"},
|
||||
})
|
||||
|
||||
matcher := createMatcher(ds)
|
||||
|
||||
// Exact library path should match
|
||||
libID, libPath := matcher.findLibraryForPath("/music-classical")
|
||||
Expect(libID).To(Equal(2))
|
||||
Expect(libPath).To(Equal("/music-classical"))
|
||||
})
|
||||
|
||||
It("handles complex nested library structures", func() {
|
||||
mockLibRepo.SetData([]model.Library{
|
||||
{ID: 1, Path: "/media"},
|
||||
{ID: 2, Path: "/media/audio"},
|
||||
{ID: 3, Path: "/media/audio/classical"},
|
||||
{ID: 4, Path: "/media/audio/classical/baroque"},
|
||||
})
|
||||
|
||||
matcher := createMatcher(ds)
|
||||
|
||||
testCases := []struct {
|
||||
path string
|
||||
expectedLibID int
|
||||
expectedLibPath string
|
||||
}{
|
||||
{"/media/audio/classical/baroque/bach/track.mp3", 4, "/media/audio/classical/baroque"},
|
||||
{"/media/audio/classical/mozart/track.mp3", 3, "/media/audio/classical"},
|
||||
{"/media/audio/rock/track.mp3", 2, "/media/audio"},
|
||||
{"/media/video/movie.mp4", 1, "/media"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
libID, libPath := matcher.findLibraryForPath(tc.path)
|
||||
Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID)
|
||||
Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s", tc.path, tc.expectedLibPath)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Edge cases", func() {
|
||||
It("handles empty library list", func() {
|
||||
mockLibRepo.SetData([]model.Library{})
|
||||
|
||||
matcher := createMatcher(ds)
|
||||
Expect(matcher).ToNot(BeNil())
|
||||
|
||||
// Should not match anything
|
||||
libID, libPath := matcher.findLibraryForPath("/music/track.mp3")
|
||||
Expect(libID).To(Equal(0))
|
||||
Expect(libPath).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("handles single library", func() {
|
||||
mockLibRepo.SetData([]model.Library{
|
||||
{ID: 1, Path: "/music"},
|
||||
})
|
||||
|
||||
matcher := createMatcher(ds)
|
||||
|
||||
libID, libPath := matcher.findLibraryForPath("/music/track.mp3")
|
||||
Expect(libID).To(Equal(1))
|
||||
Expect(libPath).To(Equal("/music"))
|
||||
})
|
||||
|
||||
It("handles libraries with special characters in paths", func() {
|
||||
mockLibRepo.SetData([]model.Library{
|
||||
{ID: 1, Path: "/music[test]"},
|
||||
{ID: 2, Path: "/music(backup)"},
|
||||
})
|
||||
|
||||
matcher := createMatcher(ds)
|
||||
Expect(matcher).ToNot(BeNil())
|
||||
|
||||
// Special characters should match literally
|
||||
libID, libPath := matcher.findLibraryForPath("/music[test]/track.mp3")
|
||||
Expect(libID).To(Equal(1))
|
||||
Expect(libPath).To(Equal("/music[test]"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Path matching order", func() {
|
||||
It("ensures longest paths match first", func() {
|
||||
mockLibRepo.SetData([]model.Library{
|
||||
{ID: 1, Path: "/a"},
|
||||
{ID: 2, Path: "/ab"},
|
||||
{ID: 3, Path: "/abc"},
|
||||
})
|
||||
|
||||
matcher := createMatcher(ds)
|
||||
|
||||
// Verify that longer paths match correctly (not cut off by shorter prefix)
|
||||
testCases := []struct {
|
||||
path string
|
||||
expectedLibID int
|
||||
}{
|
||||
{"/abc/file.mp3", 3},
|
||||
{"/ab/file.mp3", 2},
|
||||
{"/a/file.mp3", 1},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
libID, _ := matcher.findLibraryForPath(tc.path)
|
||||
Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("pathResolver", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var mockLibRepo *tests.MockLibraryRepo
|
||||
var resolver *pathResolver
|
||||
ctx := context.Background()
|
||||
|
||||
BeforeEach(func() {
|
||||
mockLibRepo = &tests.MockLibraryRepo{}
|
||||
ds = &tests.MockDataStore{
|
||||
MockedLibrary: mockLibRepo,
|
||||
}
|
||||
|
||||
// Setup test libraries
|
||||
mockLibRepo.SetData([]model.Library{
|
||||
{ID: 1, Path: "/music"},
|
||||
{ID: 2, Path: "/music-classical"},
|
||||
{ID: 3, Path: "/podcasts"},
|
||||
})
|
||||
|
||||
var err error
|
||||
resolver, err = newPathResolver(ctx, ds)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
Describe("resolvePath", func() {
|
||||
It("resolves absolute paths", func() {
|
||||
resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
|
||||
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(1))
|
||||
Expect(resolution.libraryPath).To(Equal("/music"))
|
||||
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
|
||||
})
|
||||
|
||||
It("resolves relative paths when folder is provided", func() {
|
||||
folder := &model.Folder{
|
||||
Path: "playlists",
|
||||
LibraryPath: "/music",
|
||||
LibraryID: 1,
|
||||
}
|
||||
|
||||
resolution := resolver.resolvePath("../artist/album/track.mp3", folder)
|
||||
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(1))
|
||||
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
|
||||
})
|
||||
|
||||
It("returns invalid resolution for paths outside any library", func() {
|
||||
resolution := resolver.resolvePath("/outside/library/track.mp3", nil)
|
||||
|
||||
Expect(resolution.valid).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("resolvePath", func() {
|
||||
Context("With absolute paths", func() {
|
||||
It("resolves path within a library", func() {
|
||||
resolution := resolver.resolvePath("/music/track.mp3", nil)
|
||||
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(1))
|
||||
Expect(resolution.libraryPath).To(Equal("/music"))
|
||||
Expect(resolution.absolutePath).To(Equal("/music/track.mp3"))
|
||||
})
|
||||
|
||||
It("resolves path to the longest matching library", func() {
|
||||
resolution := resolver.resolvePath("/music-classical/track.mp3", nil)
|
||||
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(2))
|
||||
Expect(resolution.libraryPath).To(Equal("/music-classical"))
|
||||
})
|
||||
|
||||
It("returns invalid resolution for path outside libraries", func() {
|
||||
resolution := resolver.resolvePath("/videos/movie.mp4", nil)
|
||||
|
||||
Expect(resolution.valid).To(BeFalse())
|
||||
})
|
||||
|
||||
It("cleans the path before matching", func() {
|
||||
resolution := resolver.resolvePath("/music//artist/../artist/track.mp3", nil)
|
||||
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.absolutePath).To(Equal("/music/artist/track.mp3"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("With relative paths", func() {
|
||||
It("resolves relative path within same library", func() {
|
||||
folder := &model.Folder{
|
||||
Path: "playlists",
|
||||
LibraryPath: "/music",
|
||||
LibraryID: 1,
|
||||
}
|
||||
|
||||
resolution := resolver.resolvePath("../songs/track.mp3", folder)
|
||||
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(1))
|
||||
Expect(resolution.absolutePath).To(Equal("/music/songs/track.mp3"))
|
||||
})
|
||||
|
||||
It("resolves relative path to different library", func() {
|
||||
folder := &model.Folder{
|
||||
Path: "playlists",
|
||||
LibraryPath: "/music",
|
||||
LibraryID: 1,
|
||||
}
|
||||
|
||||
// Path goes up and into a different library
|
||||
resolution := resolver.resolvePath("../../podcasts/episode.mp3", folder)
|
||||
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(3))
|
||||
Expect(resolution.libraryPath).To(Equal("/podcasts"))
|
||||
})
|
||||
|
||||
It("uses matcher to find correct library for resolved path", func() {
|
||||
folder := &model.Folder{
|
||||
Path: "playlists",
|
||||
LibraryPath: "/music",
|
||||
LibraryID: 1,
|
||||
}
|
||||
|
||||
// This relative path resolves to music-classical library
|
||||
resolution := resolver.resolvePath("../../music-classical/track.mp3", folder)
|
||||
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(2))
|
||||
Expect(resolution.libraryPath).To(Equal("/music-classical"))
|
||||
})
|
||||
|
||||
It("returns invalid for relative paths escaping all libraries", func() {
|
||||
folder := &model.Folder{
|
||||
Path: "playlists",
|
||||
LibraryPath: "/music",
|
||||
LibraryID: 1,
|
||||
}
|
||||
|
||||
resolution := resolver.resolvePath("../../../../etc/passwd", folder)
|
||||
|
||||
Expect(resolution.valid).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Cross-library resolution scenarios", func() {
|
||||
It("handles playlist in library A referencing file in library B", func() {
|
||||
// Playlist is in /music/playlists
|
||||
folder := &model.Folder{
|
||||
Path: "playlists",
|
||||
LibraryPath: "/music",
|
||||
LibraryID: 1,
|
||||
}
|
||||
|
||||
// Relative path that goes to /podcasts library
|
||||
resolution := resolver.resolvePath("../../podcasts/show/episode.mp3", folder)
|
||||
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(3), "Should resolve to podcasts library")
|
||||
Expect(resolution.libraryPath).To(Equal("/podcasts"))
|
||||
})
|
||||
|
||||
It("prefers longer library paths when resolving", func() {
|
||||
// Ensure /music-classical is matched instead of /music
|
||||
resolution := resolver.resolvePath("/music-classical/baroque/track.mp3", nil)
|
||||
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(2), "Should match /music-classical, not /music")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("pathResolution", func() {
|
||||
Describe("ToQualifiedString", func() {
|
||||
It("converts valid resolution to qualified string with forward slashes", func() {
|
||||
resolution := pathResolution{
|
||||
absolutePath: "/music/artist/album/track.mp3",
|
||||
libraryPath: "/music",
|
||||
libraryID: 1,
|
||||
valid: true,
|
||||
}
|
||||
|
||||
qualifiedStr, err := resolution.ToQualifiedString()
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(qualifiedStr).To(Equal("1:artist/album/track.mp3"))
|
||||
})
|
||||
|
||||
It("handles Windows-style paths by converting to forward slashes", func() {
|
||||
resolution := pathResolution{
|
||||
absolutePath: "/music/artist/album/track.mp3",
|
||||
libraryPath: "/music",
|
||||
libraryID: 2,
|
||||
valid: true,
|
||||
}
|
||||
|
||||
qualifiedStr, err := resolution.ToQualifiedString()
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should always use forward slashes regardless of OS
|
||||
Expect(qualifiedStr).To(ContainSubstring("2:"))
|
||||
Expect(qualifiedStr).ToNot(ContainSubstring("\\"))
|
||||
})
|
||||
|
||||
It("returns error for invalid resolution", func() {
|
||||
resolution := pathResolution{valid: false}
|
||||
|
||||
_, err := resolution.ToQualifiedString()
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
package core
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -20,7 +21,7 @@ import (
|
||||
|
||||
var _ = Describe("Playlists", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var ps Playlists
|
||||
var ps core.Playlists
|
||||
var mockPlsRepo mockedPlaylistRepo
|
||||
var mockLibRepo *tests.MockLibraryRepo
|
||||
ctx := context.Background()
|
||||
@@ -33,16 +34,16 @@ var _ = Describe("Playlists", func() {
|
||||
MockedLibrary: mockLibRepo,
|
||||
}
|
||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||
// Path should be libPath, but we want to match the root folder referenced in the m3u, which is `/`
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/"}})
|
||||
})
|
||||
|
||||
Describe("ImportFile", func() {
|
||||
var folder *model.Folder
|
||||
BeforeEach(func() {
|
||||
ps = NewPlaylists(ds)
|
||||
ps = core.NewPlaylists(ds)
|
||||
ds.MockedMediaFile = &mockedMediaFileRepo{}
|
||||
libPath, _ := os.Getwd()
|
||||
// Set up library with the actual library path that matches the folder
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: libPath}})
|
||||
folder = &model.Folder{
|
||||
ID: "1",
|
||||
LibraryID: 1,
|
||||
@@ -112,6 +113,224 @@ var _ = Describe("Playlists", func() {
|
||||
Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Cross-library relative paths", func() {
|
||||
var tmpDir, plsDir, songsDir string
|
||||
|
||||
BeforeEach(func() {
|
||||
// Create temp directory structure
|
||||
tmpDir = GinkgoT().TempDir()
|
||||
plsDir = tmpDir + "/playlists"
|
||||
songsDir = tmpDir + "/songs"
|
||||
Expect(os.Mkdir(plsDir, 0755)).To(Succeed())
|
||||
Expect(os.Mkdir(songsDir, 0755)).To(Succeed())
|
||||
|
||||
// Setup two different libraries with paths matching our temp structure
|
||||
mockLibRepo.SetData([]model.Library{
|
||||
{ID: 1, Path: songsDir},
|
||||
{ID: 2, Path: plsDir},
|
||||
})
|
||||
|
||||
// Create a mock media file repository that returns files for both libraries
|
||||
// Note: The paths are relative to their respective library roots
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{
|
||||
data: []string{
|
||||
"abc.mp3", // This is songs/abc.mp3 relative to songsDir
|
||||
"def.mp3", // This is playlists/def.mp3 relative to plsDir
|
||||
},
|
||||
}
|
||||
ps = core.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
It("handles relative paths that reference files in other libraries", func() {
|
||||
// Create a temporary playlist file with relative path
|
||||
plsContent := "#PLAYLIST:Cross Library Test\n../songs/abc.mp3\ndef.mp3"
|
||||
plsFile := plsDir + "/test.m3u"
|
||||
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
|
||||
|
||||
// Playlist is in the Playlists library folder
|
||||
// Important: Path should be relative to LibraryPath, and Name is the folder name
|
||||
plsFolder := &model.Folder{
|
||||
ID: "2",
|
||||
LibraryID: 2,
|
||||
LibraryPath: plsDir,
|
||||
Path: "",
|
||||
Name: "",
|
||||
}
|
||||
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Tracks).To(HaveLen(2))
|
||||
Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library
|
||||
Expect(pls.Tracks[1].Path).To(Equal("def.mp3")) // From plsDir library
|
||||
})
|
||||
|
||||
It("ignores paths that point outside all libraries", func() {
|
||||
// Create a temporary playlist file with path outside libraries
|
||||
plsContent := "#PLAYLIST:Outside Test\n../../outside.mp3\nabc.mp3"
|
||||
plsFile := plsDir + "/test.m3u"
|
||||
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
|
||||
|
||||
plsFolder := &model.Folder{
|
||||
ID: "2",
|
||||
LibraryID: 2,
|
||||
LibraryPath: plsDir,
|
||||
Path: "",
|
||||
Name: "",
|
||||
}
|
||||
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should only find abc.mp3, not outside.mp3
|
||||
Expect(pls.Tracks).To(HaveLen(1))
|
||||
Expect(pls.Tracks[0].Path).To(Equal("abc.mp3"))
|
||||
})
|
||||
|
||||
It("handles relative paths with multiple '../' components", func() {
|
||||
// Create a nested structure: tmpDir/playlists/subfolder/test.m3u
|
||||
subFolder := plsDir + "/subfolder"
|
||||
Expect(os.Mkdir(subFolder, 0755)).To(Succeed())
|
||||
|
||||
// Create the media file in the subfolder directory
|
||||
// The mock will return it as "def.mp3" relative to plsDir
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{
|
||||
data: []string{
|
||||
"abc.mp3", // From songsDir library
|
||||
"def.mp3", // From plsDir library root
|
||||
},
|
||||
}
|
||||
|
||||
// From subfolder, ../../songs/abc.mp3 should resolve to songs library
|
||||
// ../def.mp3 should resolve to plsDir/def.mp3
|
||||
plsContent := "#PLAYLIST:Nested Test\n../../songs/abc.mp3\n../def.mp3"
|
||||
plsFile := subFolder + "/test.m3u"
|
||||
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
|
||||
|
||||
// The folder: AbsolutePath = LibraryPath + Path + Name
|
||||
// So for /playlists/subfolder: LibraryPath=/playlists, Path="", Name="subfolder"
|
||||
plsFolder := &model.Folder{
|
||||
ID: "2",
|
||||
LibraryID: 2,
|
||||
LibraryPath: plsDir,
|
||||
Path: "", // Empty because subfolder is directly under library root
|
||||
Name: "subfolder", // The folder name
|
||||
}
|
||||
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Tracks).To(HaveLen(2))
|
||||
Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library
|
||||
Expect(pls.Tracks[1].Path).To(Equal("def.mp3")) // From plsDir library root
|
||||
})
|
||||
|
||||
It("correctly resolves libraries when one path is a prefix of another", func() {
|
||||
// This tests the bug where /music would match before /music-classical
|
||||
// Create temp directory structure with prefix conflict
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
musicDir := tmpDir + "/music"
|
||||
musicClassicalDir := tmpDir + "/music-classical"
|
||||
Expect(os.Mkdir(musicDir, 0755)).To(Succeed())
|
||||
Expect(os.Mkdir(musicClassicalDir, 0755)).To(Succeed())
|
||||
|
||||
// Setup two libraries where one is a prefix of the other
|
||||
mockLibRepo.SetData([]model.Library{
|
||||
{ID: 1, Path: musicDir}, // /tmp/xxx/music
|
||||
{ID: 2, Path: musicClassicalDir}, // /tmp/xxx/music-classical
|
||||
})
|
||||
|
||||
// Mock will return tracks from both libraries
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{
|
||||
data: []string{
|
||||
"rock.mp3", // From music library
|
||||
"bach.mp3", // From music-classical library
|
||||
},
|
||||
}
|
||||
|
||||
// Create playlist in music library that references music-classical
|
||||
plsContent := "#PLAYLIST:Cross Prefix Test\nrock.mp3\n../music-classical/bach.mp3"
|
||||
plsFile := musicDir + "/test.m3u"
|
||||
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
|
||||
|
||||
plsFolder := &model.Folder{
|
||||
ID: "1",
|
||||
LibraryID: 1,
|
||||
LibraryPath: musicDir,
|
||||
Path: "",
|
||||
Name: "",
|
||||
}
|
||||
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Tracks).To(HaveLen(2))
|
||||
Expect(pls.Tracks[0].Path).To(Equal("rock.mp3")) // From music library
|
||||
Expect(pls.Tracks[1].Path).To(Equal("bach.mp3")) // From music-classical library (not music!)
|
||||
})
|
||||
|
||||
It("correctly handles identical relative paths from different libraries", func() {
|
||||
// This tests the bug where two libraries have files at the same relative path
|
||||
// and only one appears in the playlist
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
musicDir := tmpDir + "/music"
|
||||
classicalDir := tmpDir + "/classical"
|
||||
Expect(os.Mkdir(musicDir, 0755)).To(Succeed())
|
||||
Expect(os.Mkdir(classicalDir, 0755)).To(Succeed())
|
||||
Expect(os.MkdirAll(musicDir+"/album", 0755)).To(Succeed())
|
||||
Expect(os.MkdirAll(classicalDir+"/album", 0755)).To(Succeed())
|
||||
// Create placeholder files so paths resolve correctly
|
||||
Expect(os.WriteFile(musicDir+"/album/track.mp3", []byte{}, 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(classicalDir+"/album/track.mp3", []byte{}, 0600)).To(Succeed())
|
||||
|
||||
// Both libraries have a file at "album/track.mp3"
|
||||
mockLibRepo.SetData([]model.Library{
|
||||
{ID: 1, Path: musicDir},
|
||||
{ID: 2, Path: classicalDir},
|
||||
})
|
||||
|
||||
// Mock returns files with same relative path but different IDs and library IDs
|
||||
// Keys use the library-qualified format: "libraryID:path"
|
||||
ds.MockedMediaFile = &mockedMediaFileRepo{
|
||||
data: map[string]model.MediaFile{
|
||||
"1:album/track.mp3": {ID: "music-track", Path: "album/track.mp3", LibraryID: 1, Title: "Rock Song"},
|
||||
"2:album/track.mp3": {ID: "classical-track", Path: "album/track.mp3", LibraryID: 2, Title: "Classical Piece"},
|
||||
},
|
||||
}
|
||||
// Recreate playlists service to pick up new mock
|
||||
ps = core.NewPlaylists(ds)
|
||||
|
||||
// Create playlist in music library that references both tracks
|
||||
plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3"
|
||||
plsFile := musicDir + "/test.m3u"
|
||||
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
|
||||
|
||||
plsFolder := &model.Folder{
|
||||
ID: "1",
|
||||
LibraryID: 1,
|
||||
LibraryPath: musicDir,
|
||||
Path: "",
|
||||
Name: "",
|
||||
}
|
||||
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Should have BOTH tracks, not just one
|
||||
Expect(pls.Tracks).To(HaveLen(2), "Playlist should contain both tracks with same relative path")
|
||||
|
||||
// Verify we got tracks from DIFFERENT libraries (the key fix!)
|
||||
// Collect the library IDs
|
||||
libIDs := make(map[int]bool)
|
||||
for _, track := range pls.Tracks {
|
||||
libIDs[track.LibraryID] = true
|
||||
}
|
||||
Expect(libIDs).To(HaveLen(2), "Tracks should come from two different libraries")
|
||||
Expect(libIDs[1]).To(BeTrue(), "Should have track from library 1")
|
||||
Expect(libIDs[2]).To(BeTrue(), "Should have track from library 2")
|
||||
|
||||
// Both tracks should have the same relative path
|
||||
Expect(pls.Tracks[0].Path).To(Equal("album/track.mp3"))
|
||||
Expect(pls.Tracks[1].Path).To(Equal("album/track.mp3"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ImportM3U", func() {
|
||||
@@ -119,7 +338,7 @@ var _ = Describe("Playlists", func() {
|
||||
BeforeEach(func() {
|
||||
repo = &mockedMediaFileFromListRepo{}
|
||||
ds.MockedMediaFile = repo
|
||||
ps = NewPlaylists(ds)
|
||||
ps = core.NewPlaylists(ds)
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
|
||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||
})
|
||||
@@ -206,53 +425,23 @@ var _ = Describe("Playlists", func() {
|
||||
Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3"))
|
||||
})
|
||||
|
||||
It("handles Unicode normalization when comparing paths", func() {
|
||||
// Test case for Apple Music playlists that use NFC encoding vs macOS filesystem NFD
|
||||
// The character "è" can be represented as NFC (single codepoint) or NFD (e + combining accent)
|
||||
|
||||
const pathWithAccents = "artist/Michèle Desrosiers/album/Noël.m4a"
|
||||
|
||||
// Simulate a database entry with NFD encoding (as stored by macOS filesystem)
|
||||
nfdPath := norm.NFD.String(pathWithAccents)
|
||||
It("handles Unicode normalization when comparing paths (NFD vs NFC)", func() {
|
||||
// Simulate macOS filesystem: stores paths in NFD (decomposed) form
|
||||
// "è" (U+00E8) in NFC becomes "e" + "◌̀" (U+0065 + U+0300) in NFD
|
||||
nfdPath := "artist/Mich" + string([]rune{'e', '\u0300'}) + "le/song.mp3" // NFD: e + combining grave
|
||||
repo.data = []string{nfdPath}
|
||||
|
||||
// Simulate an Apple Music M3U playlist entry with NFC encoding
|
||||
nfcPath := norm.NFC.String("/music/" + pathWithAccents)
|
||||
m3u := strings.Join([]string{
|
||||
nfcPath,
|
||||
}, "\n")
|
||||
// Simulate Apple Music M3U: uses NFC (composed) form
|
||||
nfcPath := "/music/artist/Mich\u00E8le/song.mp3" // NFC: single è character
|
||||
m3u := nfcPath + "\n"
|
||||
f := strings.NewReader(m3u)
|
||||
|
||||
pls, err := ps.ImportM3U(ctx, f)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Tracks).To(HaveLen(1), "Should find the track despite Unicode normalization differences")
|
||||
Expect(pls.Tracks).To(HaveLen(1))
|
||||
// Should match despite different Unicode normalization forms
|
||||
Expect(pls.Tracks[0].Path).To(Equal(nfdPath))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("normalizePathForComparison", func() {
|
||||
It("normalizes Unicode characters to NFC form and converts to lowercase", func() {
|
||||
// Test with NFD (decomposed) input - as would come from macOS filesystem
|
||||
nfdPath := norm.NFD.String("Michèle") // Explicitly convert to NFD form
|
||||
normalized := normalizePathForComparison(nfdPath)
|
||||
Expect(normalized).To(Equal("michèle"))
|
||||
|
||||
// Test with NFC (composed) input - as would come from Apple Music M3U
|
||||
nfcPath := "Michèle" // This might be in NFC form
|
||||
normalizedNfc := normalizePathForComparison(nfcPath)
|
||||
|
||||
// Ensure the two paths are not equal in their original forms
|
||||
Expect(nfdPath).ToNot(Equal(nfcPath))
|
||||
|
||||
// Both should normalize to the same result
|
||||
Expect(normalized).To(Equal(normalizedNfc))
|
||||
})
|
||||
|
||||
It("handles paths with mixed case and Unicode characters", func() {
|
||||
path := "Artist/Noël Coward/Album/Song.mp3"
|
||||
normalized := normalizePathForComparison(path)
|
||||
Expect(normalized).To(Equal("artist/noël coward/album/song.mp3"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("InPlaylistsPath", func() {
|
||||
@@ -269,27 +458,27 @@ var _ = Describe("Playlists", func() {
|
||||
|
||||
It("returns true if PlaylistsPath is empty", func() {
|
||||
conf.Server.PlaylistsPath = ""
|
||||
Expect(InPlaylistsPath(folder)).To(BeTrue())
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns true if PlaylistsPath is any (**/**)", func() {
|
||||
conf.Server.PlaylistsPath = "**/**"
|
||||
Expect(InPlaylistsPath(folder)).To(BeTrue())
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns true if folder is in PlaylistsPath", func() {
|
||||
conf.Server.PlaylistsPath = "other/**:playlists/**"
|
||||
Expect(InPlaylistsPath(folder)).To(BeTrue())
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false if folder is not in PlaylistsPath", func() {
|
||||
conf.Server.PlaylistsPath = "other"
|
||||
Expect(InPlaylistsPath(folder)).To(BeFalse())
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() {
|
||||
conf.Server.PlaylistsPath = "."
|
||||
Expect(InPlaylistsPath(folder)).To(BeFalse())
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
|
||||
|
||||
folder2 := model.Folder{
|
||||
LibraryPath: "/music",
|
||||
@@ -297,22 +486,47 @@ var _ = Describe("Playlists", func() {
|
||||
Name: ".",
|
||||
}
|
||||
|
||||
Expect(InPlaylistsPath(folder2)).To(BeTrue())
|
||||
Expect(core.InPlaylistsPath(folder2)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// mockedMediaFileRepo's FindByPaths method returns a list of MediaFiles with the same paths as the input
|
||||
// mockedMediaFileRepo's FindByPaths method returns MediaFiles for the given paths.
|
||||
// If data map is provided, looks up files by key; otherwise creates them from paths.
|
||||
type mockedMediaFileRepo struct {
|
||||
model.MediaFileRepository
|
||||
data map[string]model.MediaFile
|
||||
}
|
||||
|
||||
func (r *mockedMediaFileRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
|
||||
var mfs model.MediaFiles
|
||||
|
||||
// If data map provided, look up files
|
||||
if r.data != nil {
|
||||
for _, path := range paths {
|
||||
if mf, ok := r.data[path]; ok {
|
||||
mfs = append(mfs, mf)
|
||||
}
|
||||
}
|
||||
return mfs, nil
|
||||
}
|
||||
|
||||
// Otherwise, create MediaFiles from paths
|
||||
for idx, path := range paths {
|
||||
// Strip library qualifier if present (format: "libraryID:path")
|
||||
actualPath := path
|
||||
libraryID := 1
|
||||
if parts := strings.SplitN(path, ":", 2); len(parts) == 2 {
|
||||
if id, err := strconv.Atoi(parts[0]); err == nil {
|
||||
libraryID = id
|
||||
actualPath = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
mfs = append(mfs, model.MediaFile{
|
||||
ID: strconv.Itoa(idx),
|
||||
Path: path,
|
||||
ID: strconv.Itoa(idx),
|
||||
Path: actualPath,
|
||||
LibraryID: libraryID,
|
||||
})
|
||||
}
|
||||
return mfs, nil
|
||||
@@ -324,13 +538,38 @@ type mockedMediaFileFromListRepo struct {
|
||||
data []string
|
||||
}
|
||||
|
||||
func (r *mockedMediaFileFromListRepo) FindByPaths([]string) (model.MediaFiles, error) {
|
||||
func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
|
||||
var mfs model.MediaFiles
|
||||
for idx, path := range r.data {
|
||||
mfs = append(mfs, model.MediaFile{
|
||||
ID: strconv.Itoa(idx),
|
||||
Path: path,
|
||||
})
|
||||
|
||||
for idx, dataPath := range r.data {
|
||||
// Normalize the data path to NFD (simulates macOS filesystem storage)
|
||||
normalizedDataPath := norm.NFD.String(dataPath)
|
||||
|
||||
for _, requestPath := range paths {
|
||||
// Strip library qualifier if present (format: "libraryID:path")
|
||||
actualPath := requestPath
|
||||
libraryID := 1
|
||||
if parts := strings.SplitN(requestPath, ":", 2); len(parts) == 2 {
|
||||
if id, err := strconv.Atoi(parts[0]); err == nil {
|
||||
libraryID = id
|
||||
actualPath = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
// The request path should already be normalized to NFD by production code
|
||||
// before calling FindByPaths (to match DB storage)
|
||||
normalizedRequestPath := norm.NFD.String(actualPath)
|
||||
|
||||
// Case-insensitive comparison (like SQL's "collate nocase")
|
||||
if strings.EqualFold(normalizedRequestPath, normalizedDataPath) {
|
||||
mfs = append(mfs, model.MediaFile{
|
||||
ID: strconv.Itoa(idx),
|
||||
Path: dataPath, // Return original path from DB
|
||||
LibraryID: libraryID,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return mfs, nil
|
||||
}
|
||||
|
||||
@@ -38,9 +38,9 @@ var _ = Describe("BufferedScrobbler", func() {
|
||||
It("forwards NowPlaying calls", func() {
|
||||
track := &model.MediaFile{ID: "123", Title: "Test Track"}
|
||||
Expect(bs.NowPlaying(ctx, "user1", track, 0)).To(Succeed())
|
||||
Expect(scr.NowPlayingCalled).To(BeTrue())
|
||||
Expect(scr.UserID).To(Equal("user1"))
|
||||
Expect(scr.Track).To(Equal(track))
|
||||
Expect(scr.GetNowPlayingCalled()).To(BeTrue())
|
||||
Expect(scr.GetUserID()).To(Equal("user1"))
|
||||
Expect(scr.GetTrack()).To(Equal(track))
|
||||
})
|
||||
|
||||
It("enqueues scrobbles to buffer", func() {
|
||||
@@ -51,9 +51,10 @@ var _ = Describe("BufferedScrobbler", func() {
|
||||
Expect(scr.ScrobbleCalled.Load()).To(BeFalse())
|
||||
|
||||
Expect(bs.Scrobble(ctx, "user1", scrobble)).To(Succeed())
|
||||
Expect(buffer.Length()).To(Equal(int64(1)))
|
||||
|
||||
// Wait for the scrobble to be sent
|
||||
// Wait for the background goroutine to process the scrobble.
|
||||
// We don't check buffer.Length() here because the background goroutine
|
||||
// may dequeue the entry before we can observe it.
|
||||
Eventually(scr.ScrobbleCalled.Load).Should(BeTrue())
|
||||
|
||||
lastScrobble := scr.LastScrobble.Load()
|
||||
|
||||
@@ -31,6 +31,13 @@ type Submission struct {
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
type nowPlayingEntry struct {
|
||||
ctx context.Context
|
||||
userId string
|
||||
track *model.MediaFile
|
||||
position int
|
||||
}
|
||||
|
||||
type PlayTracker interface {
|
||||
NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error
|
||||
GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error)
|
||||
@@ -52,6 +59,11 @@ type playTracker struct {
|
||||
pluginScrobblers map[string]Scrobbler
|
||||
pluginLoader PluginLoader
|
||||
mu sync.RWMutex
|
||||
npQueue map[string]nowPlayingEntry
|
||||
npMu sync.Mutex
|
||||
npSignal chan struct{}
|
||||
shutdown chan struct{}
|
||||
workerDone chan struct{}
|
||||
}
|
||||
|
||||
func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker {
|
||||
@@ -71,6 +83,10 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
|
||||
builtinScrobblers: make(map[string]Scrobbler),
|
||||
pluginScrobblers: make(map[string]Scrobbler),
|
||||
pluginLoader: pluginManager,
|
||||
npQueue: make(map[string]nowPlayingEntry),
|
||||
npSignal: make(chan struct{}, 1),
|
||||
shutdown: make(chan struct{}),
|
||||
workerDone: make(chan struct{}),
|
||||
}
|
||||
if conf.Server.EnableNowPlaying {
|
||||
m.OnExpiration(func(_ string, _ NowPlayingInfo) {
|
||||
@@ -90,9 +106,16 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
|
||||
p.builtinScrobblers[name] = s
|
||||
}
|
||||
log.Debug("List of builtin scrobblers enabled", "names", enabled)
|
||||
go p.nowPlayingWorker()
|
||||
return p
|
||||
}
|
||||
|
||||
// stopNowPlayingWorker stops the background worker. This is primarily for testing.
|
||||
func (p *playTracker) stopNowPlayingWorker() {
|
||||
close(p.shutdown)
|
||||
<-p.workerDone // Wait for worker to finish
|
||||
}
|
||||
|
||||
// pluginNamesMatchScrobblers returns true if the set of pluginNames matches the keys in pluginScrobblers
|
||||
func pluginNamesMatchScrobblers(pluginNames []string, scrobblers map[string]Scrobbler) bool {
|
||||
if len(pluginNames) != len(scrobblers) {
|
||||
@@ -198,11 +221,60 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam
|
||||
}
|
||||
player, _ := request.PlayerFrom(ctx)
|
||||
if player.ScrobbleEnabled {
|
||||
p.dispatchNowPlaying(ctx, user.ID, mf, position)
|
||||
p.enqueueNowPlaying(ctx, playerId, user.ID, mf, position)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *playTracker) enqueueNowPlaying(ctx context.Context, playerId string, userId string, track *model.MediaFile, position int) {
|
||||
p.npMu.Lock()
|
||||
defer p.npMu.Unlock()
|
||||
ctx = context.WithoutCancel(ctx) // Prevent cancellation from affecting background processing
|
||||
p.npQueue[playerId] = nowPlayingEntry{
|
||||
ctx: ctx,
|
||||
userId: userId,
|
||||
track: track,
|
||||
position: position,
|
||||
}
|
||||
p.sendNowPlayingSignal()
|
||||
}
|
||||
|
||||
func (p *playTracker) sendNowPlayingSignal() {
|
||||
// Don't block if the previous signal was not read yet
|
||||
select {
|
||||
case p.npSignal <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (p *playTracker) nowPlayingWorker() {
|
||||
defer close(p.workerDone)
|
||||
for {
|
||||
select {
|
||||
case <-p.shutdown:
|
||||
return
|
||||
case <-time.After(time.Second):
|
||||
case <-p.npSignal:
|
||||
}
|
||||
|
||||
p.npMu.Lock()
|
||||
if len(p.npQueue) == 0 {
|
||||
p.npMu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
// Keep a copy of the entries to process and clear the queue
|
||||
entries := p.npQueue
|
||||
p.npQueue = make(map[string]nowPlayingEntry)
|
||||
p.npMu.Unlock()
|
||||
|
||||
// Process entries without holding lock
|
||||
for _, entry := range entries {
|
||||
p.dispatchNowPlaying(entry.ctx, entry.userId, entry.track, entry.position)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) {
|
||||
if t.Artist == consts.UnknownArtist {
|
||||
log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist)
|
||||
@@ -276,8 +348,14 @@ func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, times
|
||||
}
|
||||
for _, artist := range track.Participants[model.RoleArtist] {
|
||||
err = tx.Artist(ctx).IncPlayCount(artist.ID, timestamp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return err
|
||||
if conf.Server.EnableScrobbleHistory {
|
||||
return tx.Scrobble(ctx).RecordScrobble(track.ID, timestamp)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -24,15 +24,26 @@ import (
|
||||
// Moved to top-level scope to avoid linter issues
|
||||
|
||||
type mockPluginLoader struct {
|
||||
mu sync.RWMutex
|
||||
names []string
|
||||
scrobblers map[string]Scrobbler
|
||||
}
|
||||
|
||||
func (m *mockPluginLoader) PluginNames(service string) []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.names
|
||||
}
|
||||
|
||||
func (m *mockPluginLoader) SetNames(names []string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.names = names
|
||||
}
|
||||
|
||||
func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
s, ok := m.scrobblers[name]
|
||||
return s, ok
|
||||
}
|
||||
@@ -46,24 +57,24 @@ var _ = Describe("PlayTracker", func() {
|
||||
var album model.Album
|
||||
var artist1 model.Artist
|
||||
var artist2 model.Artist
|
||||
var fake fakeScrobbler
|
||||
var fake *fakeScrobbler
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = context.Background()
|
||||
ctx = GinkgoT().Context()
|
||||
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
|
||||
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
|
||||
ds = &tests.MockDataStore{}
|
||||
fake = fakeScrobbler{Authorized: true}
|
||||
fake = &fakeScrobbler{Authorized: true}
|
||||
Register("fake", func(model.DataStore) Scrobbler {
|
||||
return &fake
|
||||
return fake
|
||||
})
|
||||
Register("disabled", func(model.DataStore) Scrobbler {
|
||||
return nil
|
||||
})
|
||||
eventBroker = &fakeEventBroker{}
|
||||
tracker = newPlayTracker(ds, eventBroker, nil)
|
||||
tracker.(*playTracker).builtinScrobblers["fake"] = &fake // Bypass buffering for tests
|
||||
tracker.(*playTracker).builtinScrobblers["fake"] = fake // Bypass buffering for tests
|
||||
|
||||
track = model.MediaFile{
|
||||
ID: "123",
|
||||
@@ -86,6 +97,11 @@ var _ = Describe("PlayTracker", func() {
|
||||
_ = ds.Album(ctx).(*tests.MockAlbumRepo).Put(&album)
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
// Stop the worker goroutine to prevent data races between tests
|
||||
tracker.(*playTracker).stopNowPlayingWorker()
|
||||
})
|
||||
|
||||
It("does not register disabled scrobblers", func() {
|
||||
Expect(tracker.(*playTracker).builtinScrobblers).To(HaveKey("fake"))
|
||||
Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled"))
|
||||
@@ -95,10 +111,10 @@ var _ = Describe("PlayTracker", func() {
|
||||
It("sends track to agent", func() {
|
||||
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.NowPlayingCalled).To(BeTrue())
|
||||
Expect(fake.UserID).To(Equal("u-1"))
|
||||
Expect(fake.Track.ID).To(Equal("123"))
|
||||
Expect(fake.Track.Participants).To(Equal(track.Participants))
|
||||
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
|
||||
Expect(fake.GetUserID()).To(Equal("u-1"))
|
||||
Expect(fake.GetTrack().ID).To(Equal("123"))
|
||||
Expect(fake.GetTrack().Participants).To(Equal(track.Participants))
|
||||
})
|
||||
It("does not send track to agent if user has not authorized", func() {
|
||||
fake.Authorized = false
|
||||
@@ -106,7 +122,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.NowPlayingCalled).To(BeFalse())
|
||||
Expect(fake.GetNowPlayingCalled()).To(BeFalse())
|
||||
})
|
||||
It("does not send track to agent if player is not enabled to send scrobbles", func() {
|
||||
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false})
|
||||
@@ -114,7 +130,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.NowPlayingCalled).To(BeFalse())
|
||||
Expect(fake.GetNowPlayingCalled()).To(BeFalse())
|
||||
})
|
||||
It("does not send track to agent if artist is unknown", func() {
|
||||
track.Artist = consts.UnknownArtist
|
||||
@@ -122,7 +138,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.NowPlayingCalled).To(BeFalse())
|
||||
Expect(fake.GetNowPlayingCalled()).To(BeFalse())
|
||||
})
|
||||
|
||||
It("stores position when greater than zero", func() {
|
||||
@@ -130,11 +146,12 @@ var _ = Describe("PlayTracker", func() {
|
||||
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", pos)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Eventually(func() int { return fake.GetPosition() }).Should(Equal(pos))
|
||||
|
||||
playing, err := tracker.GetNowPlaying(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(playing).To(HaveLen(1))
|
||||
Expect(playing[0].Position).To(Equal(pos))
|
||||
Expect(fake.Position).To(Equal(pos))
|
||||
})
|
||||
|
||||
It("sends event with count", func() {
|
||||
@@ -153,6 +170,17 @@ var _ = Describe("PlayTracker", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(eventBroker.getEvents()).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("passes user to scrobbler via context (fix for issue #4787)", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "testuser"})
|
||||
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
|
||||
|
||||
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
|
||||
// Verify the username was passed through async dispatch via context
|
||||
Eventually(func() string { return fake.GetUsername() }).Should(Equal("testuser"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetNowPlaying", func() {
|
||||
@@ -160,9 +188,9 @@ var _ = Describe("PlayTracker", func() {
|
||||
track2 := track
|
||||
track2.ID = "456"
|
||||
_ = ds.MediaFile(ctx).Put(&track2)
|
||||
ctx = request.WithUser(context.Background(), model.User{UserName: "user-1"})
|
||||
ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"})
|
||||
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
|
||||
ctx = request.WithUser(context.Background(), model.User{UserName: "user-2"})
|
||||
ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"})
|
||||
_ = tracker.NowPlaying(ctx, "player-2", "player-two", "456", 0)
|
||||
|
||||
playing, err := tracker.GetNowPlaying(ctx)
|
||||
@@ -210,7 +238,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
|
||||
Expect(fake.UserID).To(Equal("u-1"))
|
||||
Expect(fake.GetUserID()).To(Equal("u-1"))
|
||||
lastScrobble := fake.LastScrobble.Load()
|
||||
Expect(lastScrobble.TimeStamp).To(BeTemporally("~", ts, 1*time.Second))
|
||||
Expect(lastScrobble.ID).To(Equal("123"))
|
||||
@@ -274,49 +302,82 @@ var _ = Describe("PlayTracker", func() {
|
||||
Expect(artist1.PlayCount).To(Equal(int64(1)))
|
||||
Expect(artist2.PlayCount).To(Equal(int64(1)))
|
||||
})
|
||||
|
||||
Context("Scrobble History", func() {
|
||||
It("records scrobble in repository", func() {
|
||||
conf.Server.EnableScrobbleHistory = true
|
||||
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"})
|
||||
ts := time.Now()
|
||||
|
||||
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
mockDS := ds.(*tests.MockDataStore)
|
||||
mockScrobble := mockDS.Scrobble(ctx).(*tests.MockScrobbleRepo)
|
||||
Expect(mockScrobble.RecordedScrobbles).To(HaveLen(1))
|
||||
Expect(mockScrobble.RecordedScrobbles[0].MediaFileID).To(Equal("123"))
|
||||
Expect(mockScrobble.RecordedScrobbles[0].UserID).To(Equal("u-1"))
|
||||
Expect(mockScrobble.RecordedScrobbles[0].SubmissionTime).To(Equal(ts))
|
||||
})
|
||||
|
||||
It("does not record scrobble when history is disabled", func() {
|
||||
conf.Server.EnableScrobbleHistory = false
|
||||
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"})
|
||||
ts := time.Now()
|
||||
|
||||
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
mockDS := ds.(*tests.MockDataStore)
|
||||
mockScrobble := mockDS.Scrobble(ctx).(*tests.MockScrobbleRepo)
|
||||
Expect(mockScrobble.RecordedScrobbles).To(HaveLen(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Plugin scrobbler logic", func() {
|
||||
var pluginLoader *mockPluginLoader
|
||||
var pluginFake fakeScrobbler
|
||||
var pluginFake *fakeScrobbler
|
||||
|
||||
BeforeEach(func() {
|
||||
pluginFake = fakeScrobbler{Authorized: true}
|
||||
pluginFake = &fakeScrobbler{Authorized: true}
|
||||
pluginLoader = &mockPluginLoader{
|
||||
names: []string{"plugin1"},
|
||||
scrobblers: map[string]Scrobbler{"plugin1": &pluginFake},
|
||||
scrobblers: map[string]Scrobbler{"plugin1": pluginFake},
|
||||
}
|
||||
tracker = newPlayTracker(ds, events.GetBroker(), pluginLoader)
|
||||
|
||||
// Bypass buffering for both built-in and plugin scrobblers
|
||||
tracker.(*playTracker).builtinScrobblers["fake"] = &fake
|
||||
tracker.(*playTracker).pluginScrobblers["plugin1"] = &pluginFake
|
||||
tracker.(*playTracker).builtinScrobblers["fake"] = fake
|
||||
tracker.(*playTracker).pluginScrobblers["plugin1"] = pluginFake
|
||||
})
|
||||
|
||||
It("registers and uses plugin scrobbler for NowPlaying", func() {
|
||||
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pluginFake.NowPlayingCalled).To(BeTrue())
|
||||
Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
|
||||
})
|
||||
|
||||
It("removes plugin scrobbler if not present anymore", func() {
|
||||
// First call: plugin present
|
||||
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
|
||||
Expect(pluginFake.NowPlayingCalled).To(BeTrue())
|
||||
pluginFake.NowPlayingCalled = false
|
||||
Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
|
||||
pluginFake.nowPlayingCalled.Store(false)
|
||||
// Remove plugin
|
||||
pluginLoader.names = []string{}
|
||||
pluginLoader.SetNames([]string{})
|
||||
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
|
||||
Expect(pluginFake.NowPlayingCalled).To(BeFalse())
|
||||
// Should not be called since plugin was removed
|
||||
Consistently(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeFalse())
|
||||
})
|
||||
|
||||
It("calls both builtin and plugin scrobblers for NowPlaying", func() {
|
||||
fake.NowPlayingCalled = false
|
||||
pluginFake.NowPlayingCalled = false
|
||||
fake.nowPlayingCalled.Store(false)
|
||||
pluginFake.nowPlayingCalled.Store(false)
|
||||
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.NowPlayingCalled).To(BeTrue())
|
||||
Expect(pluginFake.NowPlayingCalled).To(BeTrue())
|
||||
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
|
||||
Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
|
||||
})
|
||||
|
||||
It("calls plugin scrobbler for Submit", func() {
|
||||
@@ -334,7 +395,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
var mockedBS *mockBufferedScrobbler
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
ctx = GinkgoT().Context()
|
||||
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
|
||||
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
|
||||
ds = &tests.MockDataStore{}
|
||||
@@ -359,7 +420,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
|
||||
It("calls Stop on scrobblers when removing them", func() {
|
||||
// Change the plugin names to simulate a plugin being removed
|
||||
mockPlugin.names = []string{}
|
||||
mockPlugin.SetNames([]string{})
|
||||
|
||||
// Call refreshPluginScrobblers which should detect the removed plugin
|
||||
pTracker.refreshPluginScrobblers()
|
||||
@@ -375,32 +436,69 @@ var _ = Describe("PlayTracker", func() {
|
||||
|
||||
type fakeScrobbler struct {
|
||||
Authorized bool
|
||||
NowPlayingCalled bool
|
||||
nowPlayingCalled atomic.Bool
|
||||
ScrobbleCalled atomic.Bool
|
||||
UserID string
|
||||
Track *model.MediaFile
|
||||
Position int
|
||||
userID atomic.Pointer[string]
|
||||
username atomic.Pointer[string]
|
||||
track atomic.Pointer[model.MediaFile]
|
||||
position atomic.Int32
|
||||
LastScrobble atomic.Pointer[Scrobble]
|
||||
Error error
|
||||
}
|
||||
|
||||
func (f *fakeScrobbler) GetNowPlayingCalled() bool {
|
||||
return f.nowPlayingCalled.Load()
|
||||
}
|
||||
|
||||
func (f *fakeScrobbler) GetUserID() string {
|
||||
if p := f.userID.Load(); p != nil {
|
||||
return *p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f *fakeScrobbler) GetTrack() *model.MediaFile {
|
||||
return f.track.Load()
|
||||
}
|
||||
|
||||
func (f *fakeScrobbler) GetPosition() int {
|
||||
return int(f.position.Load())
|
||||
}
|
||||
|
||||
func (f *fakeScrobbler) GetUsername() string {
|
||||
if p := f.username.Load(); p != nil {
|
||||
return *p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
return f.Error == nil && f.Authorized
|
||||
}
|
||||
|
||||
func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
|
||||
f.NowPlayingCalled = true
|
||||
f.nowPlayingCalled.Store(true)
|
||||
if f.Error != nil {
|
||||
return f.Error
|
||||
}
|
||||
f.UserID = userId
|
||||
f.Track = track
|
||||
f.Position = position
|
||||
f.userID.Store(&userId)
|
||||
// Capture username from context (this is what plugin scrobblers do)
|
||||
username, _ := request.UsernameFrom(ctx)
|
||||
if username == "" {
|
||||
if u, ok := request.UserFrom(ctx); ok {
|
||||
username = u.UserName
|
||||
}
|
||||
}
|
||||
if username != "" {
|
||||
f.username.Store(&username)
|
||||
}
|
||||
f.track.Store(track)
|
||||
f.position.Store(int32(position))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
|
||||
f.UserID = userId
|
||||
f.userID.Store(&userId)
|
||||
f.LastScrobble.Store(&s)
|
||||
f.ScrobbleCalled.Store(true)
|
||||
if f.Error != nil {
|
||||
|
||||
20
db/migrations/20251206013022_create_scrobbles_table.sql
Normal file
20
db/migrations/20251206013022_create_scrobbles_table.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE scrobbles(
|
||||
media_file_id VARCHAR(255) NOT NULL
|
||||
REFERENCES media_file(id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
user_id VARCHAR(255) NOT NULL
|
||||
REFERENCES user(id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
submission_time INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX scrobbles_date ON scrobbles (submission_time);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE scrobbles;
|
||||
-- +goose StatementEnd
|
||||
35
go.mod
35
go.mod
@@ -39,13 +39,13 @@ require (
|
||||
github.com/knqyf263/go-plugin v0.9.0
|
||||
github.com/kr/pretty v0.3.1
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6
|
||||
github.com/maruel/natural v1.2.1
|
||||
github.com/maruel/natural v1.3.0
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/onsi/ginkgo/v2 v2.27.2
|
||||
github.com/onsi/gomega v1.38.2
|
||||
github.com/onsi/ginkgo/v2 v2.27.3
|
||||
github.com/onsi/gomega v1.38.3
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
github.com/pocketbase/dbx v1.11.0
|
||||
github.com/pressly/goose/v3 v3.26.0
|
||||
@@ -54,21 +54,21 @@ require (
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tetratelabs/wazero v1.10.1
|
||||
github.com/tetratelabs/wazero v1.11.0
|
||||
github.com/unrolled/secure v1.17.0
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
|
||||
go.uber.org/goleak v1.3.0
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
|
||||
golang.org/x/image v0.33.0
|
||||
golang.org/x/net v0.47.0
|
||||
golang.org/x/sync v0.18.0
|
||||
golang.org/x/sys v0.38.0
|
||||
golang.org/x/text v0.31.0
|
||||
golang.org/x/image v0.34.0
|
||||
golang.org/x/net v0.48.0
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/sys v0.39.0
|
||||
golang.org/x/term v0.38.0
|
||||
golang.org/x/text v0.32.0
|
||||
golang.org/x/time v0.14.0
|
||||
google.golang.org/protobuf v1.36.10
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -90,7 +90,7 @@ require (
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 // indirect
|
||||
github.com/google/pprof v0.0.0-20251213031049-b05bdaca462f // indirect
|
||||
github.com/google/subcommands v1.2.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
@@ -128,10 +128,11 @@ require (
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/mod v0.30.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
||||
)
|
||||
|
||||
70
go.sum
70
go.sum
@@ -99,8 +99,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
|
||||
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 h1:3DsUAV+VNEQa2CUVLxCY3f87278uWfIDhJnbdvDjvmE=
|
||||
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
|
||||
github.com/google/pprof v0.0.0-20251213031049-b05bdaca462f h1:HU1RgM6NALf/KW9HEY6zry3ADbDKcmpQ+hJedoNGQYQ=
|
||||
github.com/google/pprof v0.0.0-20251213031049-b05bdaca462f/go.mod h1:67FPmZWbr+KDT/VlpWtw6sO9XSjpJmLuHpoLmWiTGgY=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -162,8 +162,8 @@ github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVf
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
|
||||
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||
github.com/maruel/natural v1.2.1 h1:G/y4pwtTA07lbQsMefvsmEO0VN0NfqpxprxXDM4R/4o=
|
||||
github.com/maruel/natural v1.2.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
|
||||
github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
|
||||
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -186,10 +186,10 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
|
||||
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
|
||||
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
|
||||
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||
github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8=
|
||||
github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
|
||||
github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
@@ -244,8 +244,8 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
@@ -265,8 +265,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tetratelabs/wazero v1.10.1 h1:2DugeJf6VVk58KTPszlNfeeN8AhhpwcZqkJj2wwFuH8=
|
||||
github.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
|
||||
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
|
||||
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
@@ -298,20 +298,20 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
|
||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
|
||||
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
|
||||
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
|
||||
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -323,8 +323,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -332,8 +332,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -350,11 +350,11 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 h1:E2/AqCUMZGgd73TQkxUMcMla25GB9i/5HOdLr+uH7Vo=
|
||||
golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ=
|
||||
golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc h1:bH6xUXay0AIFMElXG2rQ4uiE+7ncwtiOdPfYK1NK2XA=
|
||||
golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -363,6 +363,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -373,8 +375,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -384,12 +386,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -29,8 +29,8 @@ var redacted = &Hook{
|
||||
"(Secret:\")[\\w]*",
|
||||
"(Spotify.*ID:\")[\\w]*",
|
||||
"(PasswordEncryptionKey:[\\s]*\")[^\"]*",
|
||||
"(ReverseProxyUserHeader:[\\s]*\")[^\"]*",
|
||||
"(ReverseProxyWhitelist:[\\s]*\")[^\"]*",
|
||||
"(UserHeader:[\\s]*\")[^\"]*",
|
||||
"(TrustedSources:[\\s]*\")[^\"]*",
|
||||
"(MetricsPath:[\\s]*\")[^\"]*",
|
||||
"(DevAutoCreateAdminPassword:[\\s]*\")[^\"]*",
|
||||
"(DevAutoLoginUsername:[\\s]*\")[^\"]*",
|
||||
|
||||
@@ -38,6 +38,7 @@ type DataStore interface {
|
||||
User(ctx context.Context) UserRepository
|
||||
UserProps(ctx context.Context) UserPropsRepository
|
||||
ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository
|
||||
Scrobble(ctx context.Context) ScrobbleRepository
|
||||
|
||||
Resource(ctx context.Context, model interface{}) ResourceRepository
|
||||
|
||||
|
||||
13
model/scrobble.go
Normal file
13
model/scrobble.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Scrobble struct {
|
||||
MediaFileID string
|
||||
UserID string
|
||||
SubmissionTime time.Time
|
||||
}
|
||||
|
||||
type ScrobbleRepository interface {
|
||||
RecordScrobble(mediaFileID string, submissionTime time.Time) error
|
||||
}
|
||||
@@ -42,7 +42,9 @@ func (u User) HasLibraryAccess(libraryID int) bool {
|
||||
type Users []User
|
||||
|
||||
type UserRepository interface {
|
||||
ResourceRepository
|
||||
CountAll(...QueryOptions) (int64, error)
|
||||
Delete(id string) error
|
||||
Get(id string) (*User, error)
|
||||
Put(*User) error
|
||||
UpdateLastLoginAt(id string) error
|
||||
|
||||
@@ -512,6 +512,70 @@ var _ = Describe("AlbumRepository", func() {
|
||||
// Clean up the test album created for this test
|
||||
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID}))
|
||||
})
|
||||
|
||||
It("removes stale role associations when artist role changes", func() {
|
||||
// Regression test for issue #4242: Composers displayed in albumartist list
|
||||
// This happens when an artist's role changes (e.g., was both albumartist and composer,
|
||||
// now only composer) and the old role association isn't properly removed.
|
||||
|
||||
// Create an artist that will have changing roles
|
||||
artist := &model.Artist{
|
||||
ID: "role-change-artist-1",
|
||||
Name: "Role Change Artist",
|
||||
OrderArtistName: "role change artist",
|
||||
}
|
||||
err := createArtistWithLibrary(artistRepo, artist, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Create album with artist as both albumartist and composer
|
||||
album := &model.Album{
|
||||
LibraryID: 1,
|
||||
ID: "test-album-role-change",
|
||||
Name: "Test Album Role Change",
|
||||
AlbumArtistID: "role-change-artist-1",
|
||||
AlbumArtist: "Role Change Artist",
|
||||
Participants: model.Participants{
|
||||
model.RoleAlbumArtist: {
|
||||
{Artist: model.Artist{ID: "role-change-artist-1", Name: "Role Change Artist"}},
|
||||
},
|
||||
model.RoleComposer: {
|
||||
{Artist: model.Artist{ID: "role-change-artist-1", Name: "Role Change Artist"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err = albumRepo.Put(album)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Verify initial state: artist has both albumartist and composer roles
|
||||
expected := []albumArtistRecord{
|
||||
{ArtistID: "role-change-artist-1", Role: "albumartist", SubRole: ""},
|
||||
{ArtistID: "role-change-artist-1", Role: "composer", SubRole: ""},
|
||||
}
|
||||
verifyAlbumArtists(album.ID, expected)
|
||||
|
||||
// Now update album so artist is ONLY a composer (remove albumartist role)
|
||||
album.Participants = model.Participants{
|
||||
model.RoleComposer: {
|
||||
{Artist: model.Artist{ID: "role-change-artist-1", Name: "Role Change Artist"}},
|
||||
},
|
||||
}
|
||||
|
||||
err = albumRepo.Put(album)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Verify that the albumartist role was removed - only composer should remain
|
||||
// This is the key test: before the fix, the albumartist role would remain
|
||||
// causing composers to appear in the albumartist filter
|
||||
expectedAfter := []albumArtistRecord{
|
||||
{ArtistID: "role-change-artist-1", Role: "composer", SubRole: ""},
|
||||
}
|
||||
verifyAlbumArtists(album.ID, expectedAfter)
|
||||
|
||||
// Clean up
|
||||
_, _ = artistRepo.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID}))
|
||||
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -95,45 +95,82 @@ func (r folderRepository) CountAll(opt ...model.QueryOptions) (int64, error) {
|
||||
}
|
||||
|
||||
func (r folderRepository) GetFolderUpdateInfo(lib model.Library, targetPaths ...string) (map[string]model.FolderUpdateInfo, error) {
|
||||
// If no specific paths, return all folders in the library
|
||||
if len(targetPaths) == 0 {
|
||||
return r.getFolderUpdateInfoAll(lib)
|
||||
}
|
||||
|
||||
// Check if any path is root (return all folders)
|
||||
for _, targetPath := range targetPaths {
|
||||
if targetPath == "" || targetPath == "." {
|
||||
return r.getFolderUpdateInfoAll(lib)
|
||||
}
|
||||
}
|
||||
|
||||
// Process paths in batches to avoid SQLite's expression tree depth limit (max 1000).
|
||||
// Each path generates ~3 conditions, so batch size of 100 keeps us well under the limit.
|
||||
const batchSize = 100
|
||||
result := make(map[string]model.FolderUpdateInfo)
|
||||
|
||||
for batch := range slices.Chunk(targetPaths, batchSize) {
|
||||
batchResult, err := r.getFolderUpdateInfoBatch(lib, batch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for id, info := range batchResult {
|
||||
result[id] = info
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// getFolderUpdateInfoAll returns update info for all non-missing folders in the library
|
||||
func (r folderRepository) getFolderUpdateInfoAll(lib model.Library) (map[string]model.FolderUpdateInfo, error) {
|
||||
where := And{
|
||||
Eq{"library_id": lib.ID},
|
||||
Eq{"missing": false},
|
||||
}
|
||||
return r.queryFolderUpdateInfo(where)
|
||||
}
|
||||
|
||||
// getFolderUpdateInfoBatch returns update info for a batch of target paths and their descendants
|
||||
func (r folderRepository) getFolderUpdateInfoBatch(lib model.Library, targetPaths []string) (map[string]model.FolderUpdateInfo, error) {
|
||||
where := And{
|
||||
Eq{"library_id": lib.ID},
|
||||
Eq{"missing": false},
|
||||
}
|
||||
|
||||
// If specific paths are requested, include those folders and all their descendants
|
||||
if len(targetPaths) > 0 {
|
||||
// Collect folder IDs for exact target folders and path conditions for descendants
|
||||
folderIDs := make([]string, 0, len(targetPaths))
|
||||
pathConditions := make(Or, 0, len(targetPaths)*2)
|
||||
// Collect folder IDs for exact target folders and path conditions for descendants
|
||||
folderIDs := make([]string, 0, len(targetPaths))
|
||||
pathConditions := make(Or, 0, len(targetPaths)*2)
|
||||
|
||||
for _, targetPath := range targetPaths {
|
||||
if targetPath == "" || targetPath == "." {
|
||||
// Root path - include everything in this library
|
||||
pathConditions = Or{}
|
||||
folderIDs = nil
|
||||
break
|
||||
}
|
||||
// Clean the path to normalize it. Paths stored in the folder table do not have leading/trailing slashes.
|
||||
cleanPath := strings.TrimPrefix(targetPath, string(os.PathSeparator))
|
||||
cleanPath = filepath.Clean(cleanPath)
|
||||
for _, targetPath := range targetPaths {
|
||||
// Clean the path to normalize it. Paths stored in the folder table do not have leading/trailing slashes.
|
||||
cleanPath := strings.TrimPrefix(targetPath, string(os.PathSeparator))
|
||||
cleanPath = filepath.Clean(cleanPath)
|
||||
|
||||
// Include the target folder itself by ID
|
||||
folderIDs = append(folderIDs, model.FolderID(lib, cleanPath))
|
||||
// Include the target folder itself by ID
|
||||
folderIDs = append(folderIDs, model.FolderID(lib, cleanPath))
|
||||
|
||||
// Include all descendants: folders whose path field equals or starts with the target path
|
||||
// Note: Folder.Path is the directory path, so children have path = targetPath
|
||||
pathConditions = append(pathConditions, Eq{"path": cleanPath})
|
||||
pathConditions = append(pathConditions, Like{"path": cleanPath + "/%"})
|
||||
}
|
||||
|
||||
// Combine conditions: exact folder IDs OR descendant path patterns
|
||||
if len(folderIDs) > 0 {
|
||||
where = append(where, Or{Eq{"id": folderIDs}, pathConditions})
|
||||
} else if len(pathConditions) > 0 {
|
||||
where = append(where, pathConditions)
|
||||
}
|
||||
// Include all descendants: folders whose path field equals or starts with the target path
|
||||
// Note: Folder.Path is the directory path, so children have path = targetPath
|
||||
pathConditions = append(pathConditions, Eq{"path": cleanPath})
|
||||
pathConditions = append(pathConditions, Like{"path": cleanPath + "/%"})
|
||||
}
|
||||
|
||||
// Combine conditions: exact folder IDs OR descendant path patterns
|
||||
if len(folderIDs) > 0 {
|
||||
where = append(where, Or{Eq{"id": folderIDs}, pathConditions})
|
||||
} else if len(pathConditions) > 0 {
|
||||
where = append(where, pathConditions)
|
||||
}
|
||||
|
||||
return r.queryFolderUpdateInfo(where)
|
||||
}
|
||||
|
||||
// queryFolderUpdateInfo executes the query and returns the result map
|
||||
func (r folderRepository) queryFolderUpdateInfo(where And) (map[string]model.FolderUpdateInfo, error) {
|
||||
sq := r.newSelect().Columns("id", "updated_at", "hash").Where(where)
|
||||
var res []struct {
|
||||
ID string
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -193,12 +195,43 @@ func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.Me
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FindByPaths finds media files by their paths.
|
||||
// The paths can be library-qualified (format: "libraryID:path") or unqualified ("path").
|
||||
// Library-qualified paths search within the specified library, while unqualified paths
|
||||
// search across all libraries for backward compatibility.
|
||||
func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, error) {
|
||||
sel := r.newSelect().Columns("*").Where(Eq{"path collate nocase": paths})
|
||||
query := Or{}
|
||||
|
||||
for _, path := range paths {
|
||||
parts := strings.SplitN(path, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
// Library-qualified path: "libraryID:path"
|
||||
libraryID, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
// Invalid format, skip
|
||||
continue
|
||||
}
|
||||
relativePath := parts[1]
|
||||
query = append(query, And{
|
||||
Eq{"path collate nocase": relativePath},
|
||||
Eq{"library_id": libraryID},
|
||||
})
|
||||
} else {
|
||||
// Unqualified path: search across all libraries
|
||||
query = append(query, Eq{"path collate nocase": path})
|
||||
}
|
||||
}
|
||||
|
||||
if len(query) == 0 {
|
||||
return model.MediaFiles{}, nil
|
||||
}
|
||||
|
||||
sel := r.newSelect().Columns("*").Where(query)
|
||||
var res dbMediaFiles
|
||||
if err := r.queryAll(sel, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res.toModels(), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,10 @@ func (s *SQLStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepos
|
||||
return NewScrobbleBufferRepository(ctx, s.getDBXBuilder())
|
||||
}
|
||||
|
||||
func (s *SQLStore) Scrobble(ctx context.Context) model.ScrobbleRepository {
|
||||
return NewScrobbleRepository(ctx, s.getDBXBuilder())
|
||||
}
|
||||
|
||||
func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
|
||||
switch m.(type) {
|
||||
case model.User:
|
||||
|
||||
34
persistence/scrobble_repository.go
Normal file
34
persistence/scrobble_repository.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
type scrobbleRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewScrobbleRepository(ctx context.Context, db dbx.Builder) model.ScrobbleRepository {
|
||||
r := &scrobbleRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "scrobbles"
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *scrobbleRepository) RecordScrobble(mediaFileID string, submissionTime time.Time) error {
|
||||
userID := loggedUser(r.ctx).ID
|
||||
values := map[string]interface{}{
|
||||
"media_file_id": mediaFileID,
|
||||
"user_id": userID,
|
||||
"submission_time": submissionTime.Unix(),
|
||||
}
|
||||
insert := Insert(r.tableName).SetMap(values)
|
||||
_, err := r.executeSQL(insert)
|
||||
return err
|
||||
}
|
||||
84
persistence/scrobble_repository_test.go
Normal file
84
persistence/scrobble_repository_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
var _ = Describe("ScrobbleRepository", func() {
|
||||
var repo model.ScrobbleRepository
|
||||
var rawRepo sqlRepository
|
||||
var ctx context.Context
|
||||
var fileID string
|
||||
var userID string
|
||||
|
||||
BeforeEach(func() {
|
||||
fileID = id.NewRandom()
|
||||
userID = id.NewRandom()
|
||||
ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: userID, UserName: "johndoe", IsAdmin: true})
|
||||
db := GetDBXBuilder()
|
||||
repo = NewScrobbleRepository(ctx, db)
|
||||
|
||||
rawRepo = sqlRepository{
|
||||
ctx: ctx,
|
||||
tableName: "scrobbles",
|
||||
db: db,
|
||||
}
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
_, _ = rawRepo.db.Delete("scrobbles", dbx.HashExp{"media_file_id": fileID}).Execute()
|
||||
_, _ = rawRepo.db.Delete("media_file", dbx.HashExp{"id": fileID}).Execute()
|
||||
_, _ = rawRepo.db.Delete("user", dbx.HashExp{"id": userID}).Execute()
|
||||
})
|
||||
|
||||
Describe("RecordScrobble", func() {
|
||||
It("records a scrobble event", func() {
|
||||
submissionTime := time.Now().UTC()
|
||||
|
||||
// Insert User
|
||||
_, err := rawRepo.db.Insert("user", dbx.Params{
|
||||
"id": userID,
|
||||
"user_name": "user",
|
||||
"password": "pw",
|
||||
"created_at": time.Now(),
|
||||
"updated_at": time.Now(),
|
||||
}).Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Insert MediaFile
|
||||
_, err = rawRepo.db.Insert("media_file", dbx.Params{
|
||||
"id": fileID,
|
||||
"path": "path",
|
||||
"created_at": time.Now(),
|
||||
"updated_at": time.Now(),
|
||||
}).Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = repo.RecordScrobble(fileID, submissionTime)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Verify insertion
|
||||
var scrobble struct {
|
||||
MediaFileID string `db:"media_file_id"`
|
||||
UserID string `db:"user_id"`
|
||||
SubmissionTime int64 `db:"submission_time"`
|
||||
}
|
||||
err = rawRepo.db.Select("*").From("scrobbles").
|
||||
Where(dbx.HashExp{"media_file_id": fileID, "user_id": userID}).
|
||||
One(&scrobble)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(scrobble.MediaFileID).To(Equal(fileID))
|
||||
Expect(scrobble.UserID).To(Equal(userID))
|
||||
Expect(scrobble.SubmissionTime).To(Equal(submissionTime.Unix()))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -51,8 +51,10 @@ func unmarshalParticipants(data string) (model.Participants, error) {
|
||||
}
|
||||
|
||||
func (r sqlRepository) updateParticipants(itemID string, participants model.Participants) error {
|
||||
ids := participants.AllIDs()
|
||||
sqd := Delete(r.tableName + "_artists").Where(And{Eq{r.tableName + "_id": itemID}, NotEq{"artist_id": ids}})
|
||||
// Delete all existing participant entries for this item.
|
||||
// This ensures stale role associations are removed when an artist's role changes
|
||||
// (e.g., an artist was both albumartist and composer, but is now only composer).
|
||||
sqd := Delete(r.tableName + "_artists").Where(Eq{r.tableName + "_id": itemID})
|
||||
_, err := r.executeSQL(sqd)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -3,6 +3,7 @@ package plugins
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
@@ -23,6 +24,7 @@ var _ = Describe("Adapter Media Agent", func() {
|
||||
// Ensure plugins folder is set to testdata
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Plugins.Folder = testDataDir
|
||||
conf.Server.DevPluginCompilationTimeout = 2 * time.Minute
|
||||
|
||||
mgr = createManager(nil, metrics.NewNoopInstance())
|
||||
mgr.ScanPlugins()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -83,7 +83,7 @@
|
||||
"actions": {
|
||||
"playAll": "Afspil",
|
||||
"playNext": "Afspil næste",
|
||||
"addToQueue": "Afspil senere",
|
||||
"addToQueue": "Føj til kø",
|
||||
"shuffle": "Bland",
|
||||
"addToPlaylist": "Føj til afspilningsliste",
|
||||
"download": "Download",
|
||||
@@ -301,14 +301,19 @@
|
||||
"actions": {
|
||||
"scan": "Scanningsbibliotek",
|
||||
"manageUsers": "Administrer brugeradgang",
|
||||
"viewDetails": "Se detaljer"
|
||||
"viewDetails": "Se detaljer",
|
||||
"quickScan": "hurtig skanning",
|
||||
"fullScan": "Fuld skanning"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Bibliotek oprettet",
|
||||
"updated": "Biblioteket er blevet opdateret",
|
||||
"deleted": "Biblioteket er blevet slettet",
|
||||
"scanStarted": "Biblioteksscanning startet",
|
||||
"scanCompleted": "Biblioteksscanning fuldført"
|
||||
"scanCompleted": "Biblioteksscanning fuldført",
|
||||
"quickScanStarted": "hurtig skanning startet",
|
||||
"fullScanStarted": "Fuld skanning startet",
|
||||
"scanError": "Kan ikke starte skanning. Tjek loggen"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Biblioteksnavn er påkrævet",
|
||||
@@ -549,7 +554,7 @@
|
||||
"closeText": "Luk",
|
||||
"notContentText": "Ingen musik",
|
||||
"clickToPlayText": "Tryk for at afspille",
|
||||
"clickToPauseText": "Tryk for at pause",
|
||||
"clickToPauseText": "Tryk for at sætte på pause",
|
||||
"nextTrackText": "Næste nummer",
|
||||
"previousTrackText": "Forrige nummer",
|
||||
"reloadText": "Genindlæs",
|
||||
@@ -604,7 +609,8 @@
|
||||
"serverDown": "OFFLINE",
|
||||
"scanType": "Type",
|
||||
"status": "Scanningsfejl",
|
||||
"elapsedTime": "Medgået tid"
|
||||
"elapsedTime": "Medgået tid",
|
||||
"selectiveScan": "Selektiv"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome genvejstaster",
|
||||
|
||||
@@ -301,14 +301,19 @@
|
||||
"actions": {
|
||||
"scan": "Bibliothek scannen",
|
||||
"manageUsers": "Zugriff verwalten",
|
||||
"viewDetails": "Details ansehen"
|
||||
"viewDetails": "Details ansehen",
|
||||
"quickScan": "Schneller Scan",
|
||||
"fullScan": "Kompletter Scan"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Bibliothek erfolgreich erstellt",
|
||||
"updated": "Bibliothek erfolgreich geändert",
|
||||
"deleted": "Bibliothek erfolgreich gelöscht",
|
||||
"scanStarted": "Bibliothek Scan gestartet",
|
||||
"scanCompleted": "Bibliothek Scan vollständig"
|
||||
"scanCompleted": "Bibliothek Scan vollständig",
|
||||
"quickScanStarted": "Schneller Scan gestartet",
|
||||
"fullScanStarted": "Kompletter Scan gestartet",
|
||||
"scanError": "Fehler beim Starten des Scans. Logs prüfen"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Bibliotheksname ist Pflichtfeld",
|
||||
@@ -604,7 +609,8 @@
|
||||
"serverDown": "OFFLINE",
|
||||
"scanType": "Typ",
|
||||
"status": "Scan Fehler",
|
||||
"elapsedTime": "Laufzeit"
|
||||
"elapsedTime": "Laufzeit",
|
||||
"selectiveScan": "Selektiver Scan"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome Hotkeys",
|
||||
|
||||
@@ -301,14 +301,19 @@
|
||||
"actions": {
|
||||
"scan": "Σάρωση βιβλιοθήκης",
|
||||
"manageUsers": "Διαχείριση πρόσβασης χρήστη",
|
||||
"viewDetails": "Προβολή λεπτομερειών"
|
||||
"viewDetails": "Προβολή λεπτομερειών",
|
||||
"quickScan": "Γρήγορη σάρωση",
|
||||
"fullScan": "Πλήρης σάρωση"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Η βιβλιοθήκη δημιουργήθηκε με επιτυχία",
|
||||
"updated": "Η βιβλιοθήκη ενημερώθηκε με επιτυχία",
|
||||
"deleted": "Η βιβλιοθήκη διαγράφηκε με επιτυχία",
|
||||
"scanStarted": "Ξεκίνησε η σάρωση της βιβλιοθήκης",
|
||||
"scanCompleted": "Η σάρωση της βιβλιοθήκης ολοκληρώθηκε"
|
||||
"scanCompleted": "Η σάρωση της βιβλιοθήκης ολοκληρώθηκε",
|
||||
"quickScanStarted": "Η Γρήγορη Σάρωση ξεκίνησε",
|
||||
"fullScanStarted": "Η πλήρης σάρωση ξεκίνησε",
|
||||
"scanError": "Σφάλμα κατά την έναρξη της σάρωσης. Ελέγξτε τα αρχεία καταγραφής."
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Απαιτείται όνομα βιβλιοθήκης",
|
||||
@@ -604,7 +609,8 @@
|
||||
"serverDown": "ΕΚΤΟΣ ΣΥΝΔΕΣΗΣ",
|
||||
"scanType": "Τύπος",
|
||||
"status": "Σφάλμα σάρωσης",
|
||||
"elapsedTime": "Χρόνος που πέρασε"
|
||||
"elapsedTime": "Χρόνος που πέρασε",
|
||||
"selectiveScan": "Εκλεκτικός"
|
||||
},
|
||||
"help": {
|
||||
"title": "Συντομεύσεις του Navidrome",
|
||||
|
||||
@@ -27,15 +27,16 @@
|
||||
"playDate": "Laste Ludita",
|
||||
"channels": "Kanaloj",
|
||||
"createdAt": "Dato de aligo",
|
||||
"grouping": "",
|
||||
"grouping": "Grupo",
|
||||
"mood": "Humoro",
|
||||
"participants": "",
|
||||
"participants": "Aldonaj partoprenantoj",
|
||||
"tags": "Aldonaj Etikedoj",
|
||||
"mappedTags": "Mapigitaj etikedoj",
|
||||
"rawTags": "Krudaj etikedoj",
|
||||
"bitDepth": "",
|
||||
"sampleRate": "",
|
||||
"missing": ""
|
||||
"bitDepth": "Bitprofundo",
|
||||
"sampleRate": "Elprena rapido",
|
||||
"missing": "Mankaj",
|
||||
"libraryName": "Biblioteko"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Ludi Poste",
|
||||
@@ -44,7 +45,8 @@
|
||||
"shuffleAll": "Miksu Ĉiujn",
|
||||
"download": "Elŝuti",
|
||||
"playNext": "Ludu Poste",
|
||||
"info": "Akiri Informon"
|
||||
"info": "Akiri Informon",
|
||||
"showInPlaylist": "Montri en Ludlisto"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -68,14 +70,15 @@
|
||||
"releaseDate": "Publikiĝis",
|
||||
"releases": "Publikiĝo |||| Publikiĝoj",
|
||||
"released": "Publikiĝis",
|
||||
"recordLabel": "",
|
||||
"catalogNum": "",
|
||||
"recordLabel": "Eldonejo",
|
||||
"catalogNum": "Kataloga Numero",
|
||||
"releaseType": "Tipo",
|
||||
"grouping": "",
|
||||
"media": "",
|
||||
"grouping": "Grupo",
|
||||
"media": "Aŭdvidaĵo",
|
||||
"mood": "Humoro",
|
||||
"date": "",
|
||||
"missing": ""
|
||||
"date": "Registraĵa Dato",
|
||||
"missing": "Mankaj",
|
||||
"libraryName": "Biblioteko"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Ludi",
|
||||
@@ -107,8 +110,8 @@
|
||||
"rating": "Takso",
|
||||
"genre": "Ĝenro",
|
||||
"size": "Grando",
|
||||
"role": "",
|
||||
"missing": ""
|
||||
"role": "Rolo",
|
||||
"missing": "Mankaj"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Albuma Artisto |||| Albumaj Artistoj",
|
||||
@@ -117,13 +120,19 @@
|
||||
"conductor": "Dirigento |||| Dirigentoj",
|
||||
"lyricist": "Kantoteksisto |||| Kantotekstistoj",
|
||||
"arranger": "Aranĝisto |||| Aranĝistoj",
|
||||
"producer": "",
|
||||
"director": "",
|
||||
"engineer": "",
|
||||
"producer": "Produktisto |||| Produktistoj",
|
||||
"director": "Direktoro |||| Direktoroj",
|
||||
"engineer": "Inĝeniero |||| Inĝenieroj",
|
||||
"mixer": "Miksisto |||| Miksistoj",
|
||||
"remixer": "Remiksisto |||| Remiksistoj",
|
||||
"djmixer": "",
|
||||
"performer": ""
|
||||
"djmixer": "Dĵ-a Miksisto |||| Dĵ-a Miksistoj",
|
||||
"performer": "Plenumisto |||| Plenumistoj",
|
||||
"maincredit": "Albuma Artisto aŭ Artisto |||| Albumaj Artistoj aŭ Artistoj"
|
||||
},
|
||||
"actions": {
|
||||
"shuffle": "Miksi",
|
||||
"radio": "Radio",
|
||||
"topSongs": "Plej Luditaj Kantoj"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -140,10 +149,12 @@
|
||||
"currentPassword": "Nuna Pasvorto",
|
||||
"newPassword": "Nova Pasvorto",
|
||||
"token": "Ĵetono",
|
||||
"lastAccessAt": "Lasta Atingo"
|
||||
"lastAccessAt": "Lasta Atingo",
|
||||
"libraries": "Bibliotekoj"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Ŝanĝoj de via nomo nur ĝisdatiĝs je via sekvanta ensaluto"
|
||||
"name": "Ŝanĝoj de via nomo nur ĝisdatiĝs je via sekvanta ensaluto",
|
||||
"libraries": "Elekti specifajn bibliotekojn por ĉi tiu uzanto, aŭ lasi malplena por uzi defaŭltajn bibliotekojn"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Uzanto farita",
|
||||
@@ -152,7 +163,12 @@
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "Enigi vian uzantan ĵetonon de ListenBrainz.",
|
||||
"clickHereForToken": "Alkakli ĉi tie por akiri vian ĵetonon"
|
||||
"clickHereForToken": "Alkakli ĉi tie por akiri vian ĵetonon",
|
||||
"selectAllLibraries": "Elekti ĉiujn bibliotekojn",
|
||||
"adminAutoLibraries": "Administrantoj aŭtomate havas aliron al ĉiuj bibliotekoj"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "Almenaŭ unu biblioteko devas esti elektita por neadministrantoj"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -197,11 +213,16 @@
|
||||
"export": "Eksporti",
|
||||
"makePublic": "Publikigi",
|
||||
"makePrivate": "Malpublikigi",
|
||||
"saveQueue": ""
|
||||
"saveQueue": "Konservi Ludvicon al Ludlisto",
|
||||
"searchOrCreate": "Serĉi ludlistojn aŭ tajpi por krei novan...",
|
||||
"pressEnterToCreate": "Premu je Enter por krei novan ludliston",
|
||||
"removeFromSelection": "Forigi de elekto"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Aldoni duobligitajn kantojn",
|
||||
"song_exist": "Estas duoblaĵoj kiuj aldoniĝas al la kantolisto. Ĉu vi ŝatus aldoni la duoblaĵojn aŭ pasigi ilin?"
|
||||
"song_exist": "Estas duoblaĵoj kiuj aldoniĝas al la kantolisto. Ĉu vi ŝatus aldoni la duoblaĵojn aŭ pasigi ilin?",
|
||||
"noPlaylistsFound": "Neniuj ludlistoj trovitaj",
|
||||
"noPlaylists": "Neniuj ludlistoj haveblaj"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
@@ -235,20 +256,78 @@
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "",
|
||||
"name": "Manka Dosiero |||| Mankaj Dosieroj",
|
||||
"fields": {
|
||||
"path": "",
|
||||
"size": "",
|
||||
"updatedAt": ""
|
||||
"path": "Vojo",
|
||||
"size": "Grando",
|
||||
"updatedAt": "Malaperis je",
|
||||
"libraryName": "Biblioteko"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "",
|
||||
"remove_all": ""
|
||||
"remove": "Forigi",
|
||||
"remove_all": "Forigi Ĉiujn"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": ""
|
||||
"removed": "Manka(j) dosiero(j) forigite"
|
||||
},
|
||||
"empty": ""
|
||||
"empty": "Neniuj Mankaj Dosieroj"
|
||||
},
|
||||
"library": {
|
||||
"name": "Biblioteko |||| Bibliotekoj",
|
||||
"fields": {
|
||||
"name": "Nomo",
|
||||
"path": "Vojo",
|
||||
"remotePath": "Fora Vojo",
|
||||
"lastScanAt": "Plej Lasta Skano",
|
||||
"songCount": "Kantoj",
|
||||
"albumCount": "Albumoj",
|
||||
"artistCount": "Artistoj",
|
||||
"totalSongs": "Kantoj",
|
||||
"totalAlbums": "Albumoj",
|
||||
"totalArtists": "Artistoj",
|
||||
"totalFolders": "Dosierujoj",
|
||||
"totalFiles": "Dosieroj",
|
||||
"totalMissingFiles": "Mankaj Dosieroj",
|
||||
"totalSize": "Totala Grando",
|
||||
"totalDuration": "Daŭro",
|
||||
"defaultNewUsers": "Defaŭlto por Novaj Uzantoj",
|
||||
"createdAt": "Farite je",
|
||||
"updatedAt": "Ĝisdatiĝis je"
|
||||
},
|
||||
"sections": {
|
||||
"basic": "Bazaj Informoj",
|
||||
"statistics": "Statistikaĵoj"
|
||||
},
|
||||
"actions": {
|
||||
"scan": "Skani Bibliotekon",
|
||||
"manageUsers": "Agordi Uzantan Aliron",
|
||||
"viewDetails": "Montri Informojn",
|
||||
"quickScan": "Rapida Skano",
|
||||
"fullScan": "Plena Skano"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Biblioteko kreiĝis sukcese",
|
||||
"updated": "Biblioteko ĝisdatiĝis sukcese",
|
||||
"deleted": "Biblioteko foriĝis sukcese",
|
||||
"scanStarted": "Biblioteka skano komenciĝis",
|
||||
"scanCompleted": "Biblioteka skano finiĝis",
|
||||
"quickScanStarted": "Rapida skano komenciĝis",
|
||||
"fullScanStarted": "Plena skano komenciĝis",
|
||||
"scanError": "Eraro de skana komenco. Kontrolu la protokolojn"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Biblioteka nomo estas necesa",
|
||||
"pathRequired": "Biblioteka vojo estas necesa",
|
||||
"pathNotDirectory": "Biblioteka vojo devas esti dosierujo",
|
||||
"pathNotFound": "Biblioteka vojo ne trovite",
|
||||
"pathNotAccessible": "Biblioteka vojo ne estas alirebla",
|
||||
"pathInvalid": "Nevalida biblioteka vojo"
|
||||
},
|
||||
"messages": {
|
||||
"deleteConfirm": "Ĉu vi certas, ke vi volas forigi ĉi tiun bibliotekon? Ĉi tio forigos ĉiujn rilatajn datumojn kaj uzantan aliron.",
|
||||
"scanInProgress": "Skano progresas...",
|
||||
"noLibrariesAssigned": "Neniuj bibliotekoj asignitaj por ĉi tiu uzanto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -427,10 +506,12 @@
|
||||
"shareFailure": "Eraro de kopio de ligilo %{url} al la tondujo",
|
||||
"downloadDialogTitle": "Elŝuti %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Kopii al la tondujo: Ctrl+C, Enter",
|
||||
"remove_missing_title": "",
|
||||
"remove_missing_title": "Forigi mankajn dosierojn",
|
||||
"remove_missing_content": "Ĉu vi certas, ke vi volas forigi la elektitajn mankajn dosierojn de la datumbazo? Ĉi tio forigos eterne ĉiujn referencojn de ili, inkluzive iliajn ludkvantojn kaj taksojn.",
|
||||
"remove_all_missing_title": "",
|
||||
"remove_all_missing_content": ""
|
||||
"remove_all_missing_title": "Forigi ĉiujn mankajn dosierojn",
|
||||
"remove_all_missing_content": "Ĉu vi certas, ke vi volas forigi ĉiujn mankajn dosierojn de la datumbazo? Ĉi tio permanante forigos ĉiujn referencojn al ili, inkluzive iliajn ludnombrojn kaj taksojn.",
|
||||
"noSimilarSongsFound": "Neniuj similaj kantoj trovitaj",
|
||||
"noTopSongsFound": "Neniuj plej luditaj kantoj trovitaj"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteko",
|
||||
@@ -453,13 +534,19 @@
|
||||
"album": "Uzi Albuman Songajnon",
|
||||
"track": "Uzi Kantan Songajnon"
|
||||
},
|
||||
"lastfmNotConfigured": ""
|
||||
"lastfmNotConfigured": "API-ŝlosilo de Last.fm ne agordita"
|
||||
}
|
||||
},
|
||||
"albumList": "Albumoj",
|
||||
"about": "Pri",
|
||||
"playlists": "Ludlistoj",
|
||||
"sharedPlaylists": "Diskonigitaj Ludistoj"
|
||||
"sharedPlaylists": "Diskonigitaj Ludistoj",
|
||||
"librarySelector": {
|
||||
"allLibraries": "Ĉiuj Bibliotekoj (%{count})",
|
||||
"multipleLibraries": "%{selected} el %{total} Bibliotekoj",
|
||||
"selectLibraries": "Elekti Bibliotekojn",
|
||||
"none": "Neniu"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Atendovico",
|
||||
@@ -491,11 +578,26 @@
|
||||
"homepage": "Hejmpaĝo",
|
||||
"source": "Fontkodo",
|
||||
"featureRequests": "Trajta peto",
|
||||
"lastInsightsCollection": "",
|
||||
"lastInsightsCollection": "Plej lasta kolekto de datumoj",
|
||||
"insights": {
|
||||
"disabled": "Malebligita",
|
||||
"waiting": ""
|
||||
"waiting": "Atendante"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"about": "Pri",
|
||||
"config": "Agordo"
|
||||
},
|
||||
"config": {
|
||||
"configName": "Agorda Nomo",
|
||||
"environmentVariable": "Medivariablo",
|
||||
"currentValue": "Nuna Valoro",
|
||||
"configurationFile": "Agorda Dosiero",
|
||||
"exportToml": "Eksporti Agordojn (TOML)",
|
||||
"exportSuccess": "Agordoj eksportiĝis al la tondujo en TOML-a formato",
|
||||
"exportFailed": "Malsukcesis kopii agordojn",
|
||||
"devFlagsHeader": "Programadaj Flagoj (povas ŝanĝiĝi/foriĝi)",
|
||||
"devFlagsComment": "Ĉi tiuj estas eksperimentaj agordoj kaj eble foriĝos en estontaj versioj"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
@@ -505,9 +607,10 @@
|
||||
"fullScan": "Plena Skanado",
|
||||
"serverUptime": "Servila daŭro de funkciado",
|
||||
"serverDown": "SENKONEKTA",
|
||||
"scanType": "",
|
||||
"status": "",
|
||||
"elapsedTime": ""
|
||||
"scanType": "Plej Lasta Skano",
|
||||
"status": "Skana Eraro",
|
||||
"elapsedTime": "Pasinta Tempo",
|
||||
"selectiveScan": "Selektema"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome klavkomando",
|
||||
@@ -519,8 +622,13 @@
|
||||
"next_song": "Sekva kanto",
|
||||
"vol_up": "Pli volumo",
|
||||
"vol_down": "Malpli volumo",
|
||||
"toggle_love": "Baskuli la stelon de nuna kanto",
|
||||
"toggle_love": "Aldoni ĉi tiun kanton al plej ŝatataj",
|
||||
"current_song": "Iri al Nuna Kanto"
|
||||
}
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "Nun Ludanta",
|
||||
"empty": "Nenio ludas",
|
||||
"minutesAgo": "Antaŭ %{smart_count} minuto |||| Antaŭ %{smart_count} minutoj"
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@
|
||||
"bitDepth": "Profundidad de bits",
|
||||
"sampleRate": "Frecuencia de muestreo",
|
||||
"missing": "Faltante",
|
||||
"libraryName": ""
|
||||
"libraryName": "Biblioteca"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Reproducir después",
|
||||
@@ -78,7 +78,7 @@
|
||||
"mood": "Estado de ánimo",
|
||||
"date": "Fecha de grabación",
|
||||
"missing": "Faltante",
|
||||
"libraryName": ""
|
||||
"libraryName": "Biblioteca"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Reproducir",
|
||||
@@ -127,12 +127,12 @@
|
||||
"remixer": "Remixer",
|
||||
"djmixer": "DJ Mixer",
|
||||
"performer": "Intérprete",
|
||||
"maincredit": ""
|
||||
"maincredit": "Artista del álbum o Artista |||| Artistas del álbum o Artistas"
|
||||
},
|
||||
"actions": {
|
||||
"shuffle": "Aleatorio",
|
||||
"radio": "Radio",
|
||||
"topSongs": ""
|
||||
"topSongs": "Más destacadas"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -150,11 +150,11 @@
|
||||
"newPassword": "Nueva contraseña",
|
||||
"token": "Token",
|
||||
"lastAccessAt": "Último acceso",
|
||||
"libraries": ""
|
||||
"libraries": "Bibliotecas"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Los cambios a tu nombre se verán en el próximo inicio de sesión",
|
||||
"libraries": ""
|
||||
"libraries": "Selecciona bibliotecas específicas para este usuario o déjalo vacío para usar las bibliotecas por defecto"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Usuario creado",
|
||||
@@ -164,11 +164,11 @@
|
||||
"message": {
|
||||
"listenBrainzToken": "Escribe tu token de usuario de ListenBrainz",
|
||||
"clickHereForToken": "Click aquí para obtener tu token",
|
||||
"selectAllLibraries": "",
|
||||
"adminAutoLibraries": ""
|
||||
"selectAllLibraries": "Seleccionar todas las bibliotecas",
|
||||
"adminAutoLibraries": "Los usuarios administradores tienen acceso a todas las bibliotecas automáticamente"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": ""
|
||||
"librariesRequired": "Se debe seleccionar al menos una biblioteca para los usuarios que no sean administradores"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -261,7 +261,7 @@
|
||||
"path": "Ruta",
|
||||
"size": "Tamaño",
|
||||
"updatedAt": "Actualizado el",
|
||||
"libraryName": ""
|
||||
"libraryName": "Biblioteca"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Eliminar",
|
||||
@@ -273,55 +273,60 @@
|
||||
"empty": "No hay archivos perdidos"
|
||||
},
|
||||
"library": {
|
||||
"name": "",
|
||||
"name": "Biblioteca |||| Bibliotecas",
|
||||
"fields": {
|
||||
"name": "",
|
||||
"path": "",
|
||||
"remotePath": "",
|
||||
"lastScanAt": "",
|
||||
"songCount": "",
|
||||
"albumCount": "",
|
||||
"artistCount": "",
|
||||
"totalSongs": "",
|
||||
"totalAlbums": "",
|
||||
"totalArtists": "",
|
||||
"totalFolders": "",
|
||||
"totalFiles": "",
|
||||
"totalMissingFiles": "",
|
||||
"totalSize": "",
|
||||
"totalDuration": "",
|
||||
"defaultNewUsers": "",
|
||||
"createdAt": "",
|
||||
"updatedAt": ""
|
||||
"name": "Nombre",
|
||||
"path": "Ruta",
|
||||
"remotePath": "Ruta remota",
|
||||
"lastScanAt": "Último escaneo",
|
||||
"songCount": "Canciones",
|
||||
"albumCount": "Álbumes",
|
||||
"artistCount": "Artistas",
|
||||
"totalSongs": "Canciones",
|
||||
"totalAlbums": "Álbumes",
|
||||
"totalArtists": "Artistas",
|
||||
"totalFolders": "Carpetas",
|
||||
"totalFiles": "Archivos",
|
||||
"totalMissingFiles": "Archivos faltantes",
|
||||
"totalSize": "Tamaño total",
|
||||
"totalDuration": "Duración",
|
||||
"defaultNewUsers": "Valor por defecto para los nuevos usuarios",
|
||||
"createdAt": "Creado",
|
||||
"updatedAt": "Actualizado"
|
||||
},
|
||||
"sections": {
|
||||
"basic": "",
|
||||
"statistics": ""
|
||||
"basic": "Información básica",
|
||||
"statistics": "Estadísticas"
|
||||
},
|
||||
"actions": {
|
||||
"scan": "",
|
||||
"manageUsers": "",
|
||||
"viewDetails": ""
|
||||
"scan": "Escanear biblioteca",
|
||||
"manageUsers": "Gestionar el acceso de usarios",
|
||||
"viewDetails": "Ver detalles",
|
||||
"quickScan": "Escaneo rápido",
|
||||
"fullScan": "Escaneo completo"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "",
|
||||
"updated": "",
|
||||
"deleted": "",
|
||||
"scanStarted": "",
|
||||
"scanCompleted": ""
|
||||
"created": "La biblioteca se creó correctamente",
|
||||
"updated": "La biblioteca se actualizó correctamente",
|
||||
"deleted": "La biblioteca se eliminó correctamente",
|
||||
"scanStarted": "El escaneo de la biblioteca ha comenzado",
|
||||
"scanCompleted": "El escaneo de la biblioteca se completó",
|
||||
"quickScanStarted": "Escaneo rápido ha comenzado",
|
||||
"fullScanStarted": "Escaneo completo ha comenzado",
|
||||
"scanError": "Error al iniciar el escaneo. Revisa los registros"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "",
|
||||
"pathRequired": "",
|
||||
"pathNotDirectory": "",
|
||||
"pathNotFound": "",
|
||||
"pathNotAccessible": "",
|
||||
"pathInvalid": ""
|
||||
"nameRequired": "El nombre de la biblioteca es obligatorio",
|
||||
"pathRequired": "La ruta de la biblioteca es obligatoria",
|
||||
"pathNotDirectory": "La ruta de la biblioteca debe ser un directorio",
|
||||
"pathNotFound": "Ruta de la biblioteca no encontrada",
|
||||
"pathNotAccessible": "La ruta de la biblioteca no es accesible",
|
||||
"pathInvalid": "Ruta de la biblioteca no válida"
|
||||
},
|
||||
"messages": {
|
||||
"deleteConfirm": "",
|
||||
"scanInProgress": "",
|
||||
"noLibrariesAssigned": ""
|
||||
"deleteConfirm": "¿Estás seguro/a de que quieres eliminar esta biblioteca? Esto eliminará todos los datos asociados y el acceso de les usuaries.",
|
||||
"scanInProgress": "Escaneo en curso...",
|
||||
"noLibrariesAssigned": "No hay bibliotecas asignadas a este usuario"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -506,7 +511,7 @@
|
||||
"remove_all_missing_title": "Eliminar todos los archivos perdidos",
|
||||
"remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.",
|
||||
"noSimilarSongsFound": "No se encontraron canciones similares",
|
||||
"noTopSongsFound": ""
|
||||
"noTopSongsFound": "No se encontraron canciones destacadas"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteca",
|
||||
@@ -537,10 +542,10 @@
|
||||
"playlists": "Playlists",
|
||||
"sharedPlaylists": "Playlists Compartidas",
|
||||
"librarySelector": {
|
||||
"allLibraries": "",
|
||||
"multipleLibraries": "",
|
||||
"selectLibraries": "",
|
||||
"none": ""
|
||||
"allLibraries": "Todas las bibliotecas (%{count})",
|
||||
"multipleLibraries": "%{selected} de %{total} bibliotecas",
|
||||
"selectLibraries": "Seleccionar bibliotecas",
|
||||
"none": "Ninguno"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -604,7 +609,8 @@
|
||||
"serverDown": "OFFLINE",
|
||||
"scanType": "Tipo",
|
||||
"status": "Error de escaneo",
|
||||
"elapsedTime": "Tiempo transcurrido"
|
||||
"elapsedTime": "Tiempo transcurrido",
|
||||
"selectiveScan": "Selectivo"
|
||||
},
|
||||
"help": {
|
||||
"title": "Atajos de teclado de Navidrome",
|
||||
@@ -621,8 +627,8 @@
|
||||
}
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "",
|
||||
"empty": "",
|
||||
"minutesAgo": ""
|
||||
"title": "En reproducción",
|
||||
"empty": "Nada en reproducción",
|
||||
"minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos"
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
"artist": "Artista",
|
||||
"album": "Albuma",
|
||||
"path": "Fitxategiaren bidea",
|
||||
"libraryName": "Liburutegia",
|
||||
"genre": "Generoa",
|
||||
"compilation": "Konpilazioa",
|
||||
"year": "Urtea",
|
||||
@@ -58,6 +59,7 @@
|
||||
"playCount": "Erreprodukzioak",
|
||||
"size": "Fitxategiaren tamaina",
|
||||
"name": "Izena",
|
||||
"libraryName": "Liburutegia",
|
||||
"genre": "Generoa",
|
||||
"compilation": "Konpilazioa",
|
||||
"year": "Urtea",
|
||||
@@ -147,19 +149,26 @@
|
||||
"currentPassword": "Uneko pasahitza",
|
||||
"newPassword": "Pasahitz berria",
|
||||
"token": "Tokena",
|
||||
"lastAccessAt": "Azken sarbidea"
|
||||
"lastAccessAt": "Azken sarbidea",
|
||||
"libraries": "Liburutegiak"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Aldaketak saioa hasten duzun hurrengoan islatuko dira"
|
||||
"name": "Aldaketak saioa hasten duzun hurrengoan islatuko dira",
|
||||
"libraries": "Hautatu erabiltzaile honentzat liburutegi jakinak, edo utzi hutsik defektuzko liburutegiak erabiltzeko"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Erabiltzailea sortu da",
|
||||
"updated": "Erabiltzailea eguneratu da",
|
||||
"deleted": "Erabiltzailea ezabatu da"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "Gutxienez liburutegi bat hautatu behar da administratzaile ez diren erabiltzaileentzat"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "Idatzi zure ListenBrainz erabiltzailearen tokena",
|
||||
"clickHereForToken": "Egin klik hemen tokena lortzeko"
|
||||
"clickHereForToken": "Egin klik hemen tokena lortzeko",
|
||||
"selectAllLibraries": "Hautatu liburutegi guztiak",
|
||||
"adminAutoLibraries": "Administratzaileek automatikoki dute liburutegi guztietara sarbidea"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -254,6 +263,7 @@
|
||||
"fields": {
|
||||
"path": "Bidea",
|
||||
"size": "Tamaina",
|
||||
"libraryName": "Liburutegia",
|
||||
"updatedAt": "Desagertze-data:"
|
||||
},
|
||||
"actions": {
|
||||
@@ -263,6 +273,63 @@
|
||||
"notifications": {
|
||||
"removed": "Aurkitzen ez ziren fitxategiak kendu dira"
|
||||
}
|
||||
},
|
||||
"library": {
|
||||
"name": "Liburutegia |||| Liburutegiak",
|
||||
"fields": {
|
||||
"name": "Izena",
|
||||
"path": "Fitxategiaren bidea",
|
||||
"remotePath": "Urruneko bidea",
|
||||
"lastScanAt": "Azken araketa",
|
||||
"songCount": "Abestiak",
|
||||
"albumCount": "Albumak",
|
||||
"artistCount": "Artistak",
|
||||
"totalSongs": "Abestiak",
|
||||
"totalAlbums": "Albumak",
|
||||
"totalArtists": "Artistak",
|
||||
"totalFolders": "Karpetak",
|
||||
"totalFiles": "Fitxategiak",
|
||||
"totalMissingFiles": "Fitxategiak faltan",
|
||||
"totalSize": "Tamaina guztira",
|
||||
"totalDuration": "Iraupena",
|
||||
"defaultNewUsers": "Defektuz erabiltzaile berrientzat",
|
||||
"createdAt": "Sortze-data",
|
||||
"updatedAt": "Eguneratze-data"
|
||||
},
|
||||
"sections": {
|
||||
"basic": "Oinarrizko informazioa",
|
||||
"statistics": "Estatistikak"
|
||||
},
|
||||
"actions": {
|
||||
"scan": "Arakatu liburutegia",
|
||||
"quickScan": "Araketa bizkorra",
|
||||
"fullScan": "Araketa sakona",
|
||||
"manageUsers": "Kudeatu erabiltzaileen sarbidea",
|
||||
"viewDetails": "Ikusi xehetasunak"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Liburutegia ondo sortu da",
|
||||
"updated": "Liburutegia ondo eguneratu da",
|
||||
"deleted": "Liburutegia ondo ezabatu da",
|
||||
"scanStarted": "Liburutegiaren araketa hasi da",
|
||||
"quickScanStarted": "Araketa bizkorra hasi da",
|
||||
"fullScanStarted": "Araketa sakona hasi da",
|
||||
"scanError": "Errorea araketa abiaraztean. Aztertu erregistroak",
|
||||
"scanCompleted": "Liburutegiaren araketa amaitu da"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Liburutegiaren izena beharrezkoa da",
|
||||
"pathRequired": "Liburutegiaren bidea beharrezkoa da",
|
||||
"pathNotDirectory": "Liburutegiaren bidea direktorio bat izan behar da",
|
||||
"pathNotFound": "Ez da liburutegiaren bidea aurkitu",
|
||||
"pathNotAccessible": "Liburutegiaren bidea ez dago eskuragai",
|
||||
"pathInvalid": "Liburutegiaren bidea ez da baliozkoa"
|
||||
},
|
||||
"messages": {
|
||||
"deleteConfirm": "Ziur liburutegia ezabatu nahi duzula? Erlazionatutako datu guztiak eta erabiltzaileen sarbidea kenduko ditu.",
|
||||
"scanInProgress": "Araketa abian da…",
|
||||
"noLibrariesAssigned": "Ez da liburutegirik egokitu erabiltzaile honentzat"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -397,7 +464,7 @@
|
||||
"bad_item": "Elementu okerra",
|
||||
"item_doesnt_exist": "Elementua ez dago",
|
||||
"http_error": "Errorea zerbitzariarekin komunikatzerakoan",
|
||||
"data_provider_error": "Errorea datuen hornitzailean. Berrikusi kontsola xehetasun gehiagorako.",
|
||||
"data_provider_error": "Errorea datuen hornitzailean. Aztertu kontsola xehetasun gehiagorako.",
|
||||
"i18n_error": "Ezin izan dira zehaztutako hizkuntzaren itzulpenak kargatu",
|
||||
"canceled": "Ekintza bertan behera utzi da",
|
||||
"logged_out": "Saioa amaitu da, konektatu berriro.",
|
||||
@@ -450,6 +517,12 @@
|
||||
},
|
||||
"menu": {
|
||||
"library": "Liburutegia",
|
||||
"librarySelector": {
|
||||
"allLibraries": "Liburutegi guztiak (%{count})",
|
||||
"multipleLibraries": "%{total} liburutegitik %{selected} hautatuta",
|
||||
"selectLibraries": "Hautatu liburutegiak",
|
||||
"none": "Bat ere ez"
|
||||
},
|
||||
"settings": "Ezarpenak",
|
||||
"version": "Bertsioa",
|
||||
"theme": "Itxura",
|
||||
@@ -532,8 +605,9 @@
|
||||
"activity": {
|
||||
"title": "Ekintzak",
|
||||
"totalScanned": "Arakatutako karpeta guztiak",
|
||||
"quickScan": "Arakatze azkarra",
|
||||
"quickScan": "Arakatze bizkorra",
|
||||
"fullScan": "Arakatze sakona",
|
||||
"selectiveScan": "Arakatze selektiboa",
|
||||
"serverUptime": "Zerbitzariak piztuta daraman denbora",
|
||||
"serverDown": "LINEAZ KANPO",
|
||||
"scanType": "Mota",
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"mood": "Tunnelma",
|
||||
"participants": "Lisäosallistujat",
|
||||
"tags": "Lisätunnisteet",
|
||||
"mappedTags": "Mäpättyt tunnisteet",
|
||||
"mappedTags": "Mäpätyt tunnisteet",
|
||||
"rawTags": "Raakatunnisteet",
|
||||
"bitDepth": "Bittisyvyys",
|
||||
"sampleRate": "Näytteenottotaajuus",
|
||||
@@ -301,14 +301,19 @@
|
||||
"actions": {
|
||||
"scan": "Skannaa kirjasto",
|
||||
"manageUsers": "Hallitse käyttäjien pääsyä",
|
||||
"viewDetails": "Näytä tiedot"
|
||||
"viewDetails": "Näytä tiedot",
|
||||
"quickScan": "Nopea skannaus",
|
||||
"fullScan": "Täysi skannaus"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Kirjasto luotu onnistuneesti",
|
||||
"updated": "Kirjasto päivitetty onnistuneesti",
|
||||
"deleted": "Kirjasto poistettu onnistuneesti",
|
||||
"scanStarted": "Kirjaston skannaus aloitettu",
|
||||
"scanCompleted": "Kirjaston skannaus valmistunut"
|
||||
"scanCompleted": "Kirjaston skannaus valmistunut",
|
||||
"quickScanStarted": "Nopea skannaus aloitettu",
|
||||
"fullScanStarted": "Täysi skannaus aloitettu",
|
||||
"scanError": "Virhe skannauksen käynnistyksessä. Tarkista lokit"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Kirjaston nimi vaaditaan",
|
||||
@@ -319,7 +324,7 @@
|
||||
"pathInvalid": "Virheellinen kirjaston polku"
|
||||
},
|
||||
"messages": {
|
||||
"deleteConfirm": "Oletko varma, että haluat poistaa tämän kirjaston? Tämä poistaa kaikki liittyvät tiedot ja käyttäjien pääsyn.",
|
||||
"deleteConfirm": "Haluatko varmasti poistaa tämän kirjaston? Kaikki siihen liittyvät tiedot ja käyttäjien pääsy poistetaan.",
|
||||
"scanInProgress": "Skannaus käynnissä...",
|
||||
"noLibrariesAssigned": "Tälle käyttäjälle ei ole määritetty kirjastoja"
|
||||
}
|
||||
@@ -336,7 +341,7 @@
|
||||
"username": "Käyttäjänimi",
|
||||
"password": "Salasana",
|
||||
"sign_in": "Kirjaudu",
|
||||
"sign_in_error": "Autentikointi epäonnistui. Yritä uudelleen",
|
||||
"sign_in_error": "Kirjautuminen epäonnistui. Yritä uudelleen",
|
||||
"logout": "Kirjaudu ulos",
|
||||
"insightsCollectionNote": "Navidrome kerää anonyymejä käyttötietoja auttaakseen parantamaan\nprojektia. Paina [tästä] saadaksesi lisätietoa\nja halutessasi kieltäytyä"
|
||||
},
|
||||
@@ -346,7 +351,7 @@
|
||||
"required": "Pakollinen",
|
||||
"minLength": "Pitää vähintään olla %{min} merkkiä",
|
||||
"maxLength": "Saa olla enintään %{max} merkkiä",
|
||||
"minValue": "pitää olla vähintään %{min}",
|
||||
"minValue": "Pitää olla vähintään %{min}",
|
||||
"maxValue": "Saa olla enentään %{max}",
|
||||
"number": "Pitää olla numero",
|
||||
"email": "Pitää olla oikea sähköpostiosoite",
|
||||
@@ -440,7 +445,7 @@
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Ei tuloksia",
|
||||
"no_more_results": "Sivunumero %{page} on rajojen ulkopuolella. Kokeile edellinen sivu.",
|
||||
"no_more_results": "Sivunumeroa %{page} ei löydy. Yritä edellistä sivua.",
|
||||
"page_out_of_boundaries": "Sivunumero %{page} on rajojen ulkopuolella",
|
||||
"page_out_from_end": "Viimeinen sivu, ei voi edetä",
|
||||
"page_out_from_begin": "Ensimmäinen sivu, ei voi palata",
|
||||
@@ -522,7 +527,7 @@
|
||||
"desktop_notifications": "Työpöytäilmoitukset",
|
||||
"lastfmScrobbling": "Kuuntelutottumuksen lähetys Last.fm-palveluun",
|
||||
"listenBrainzScrobbling": "Kuuntelutottumuksen lähetys ListenBrainz-palveluun",
|
||||
"replaygain": "RepleyGain -tila",
|
||||
"replaygain": "ReplayGain -tila",
|
||||
"preAmp": "ReplayGain esivahvistus (dB)",
|
||||
"gain": {
|
||||
"none": "Pois käytöstä",
|
||||
@@ -554,7 +559,7 @@
|
||||
"previousTrackText": "Edellinen kappale",
|
||||
"reloadText": "Päivitä",
|
||||
"volumeText": "Äänenvoimakkuus",
|
||||
"toggleLyricText": "Toggle lyric",
|
||||
"toggleLyricText": "Näytä/piilota sanat",
|
||||
"toggleMiniModeText": "Minimoi",
|
||||
"destroyText": "Poista",
|
||||
"downloadText": "Lataa",
|
||||
@@ -604,7 +609,8 @@
|
||||
"serverDown": "SAMMUTETTU",
|
||||
"scanType": "Tyyppi",
|
||||
"status": "Skannausvirhe",
|
||||
"elapsedTime": "Kulunut aika"
|
||||
"elapsedTime": "Kulunut aika",
|
||||
"selectiveScan": "Valikoiva"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome pikapainikkeet",
|
||||
@@ -612,7 +618,7 @@
|
||||
"show_help": "Näytä tämä apuvalikko",
|
||||
"toggle_menu": "Menuvalikko päälle ja pois",
|
||||
"toggle_play": "Toista / Tauko",
|
||||
"prev_song": "Esellinen kappale",
|
||||
"prev_song": "Edellinen kappale",
|
||||
"next_song": "Seuraava kappale",
|
||||
"vol_up": "Kovemmalle",
|
||||
"vol_down": "Hiljemmalle",
|
||||
|
||||
@@ -301,14 +301,19 @@
|
||||
"actions": {
|
||||
"scan": "Scanner la bibliothèque",
|
||||
"manageUsers": "Gérer les accès utilisateurs",
|
||||
"viewDetails": "Voir les détails"
|
||||
"viewDetails": "Voir les détails",
|
||||
"quickScan": "Scan Rapide",
|
||||
"fullScan": "Scan Complet"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Bibliothèque créée avec succès",
|
||||
"updated": "Bibliothèque mise à jour avec succès",
|
||||
"deleted": "Bibliothèque supprimée avec succès",
|
||||
"scanStarted": "Le scan de la bibliothèque a commencé",
|
||||
"scanCompleted": "Le scan de la bibliothèque est terminé"
|
||||
"scanCompleted": "Le scan de la bibliothèque est terminé",
|
||||
"quickScanStarted": "Scan rapide démarré",
|
||||
"fullScanStarted": "Scan complet démarré",
|
||||
"scanError": "Une erreur est survenue en démarrant le scan. Veuillez regarder les logs"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "La bibliothèque doit obligatoirement avoir un nom",
|
||||
@@ -604,7 +609,8 @@
|
||||
"serverDown": "HORS LIGNE",
|
||||
"scanType": "Type",
|
||||
"status": "Erreur de scan",
|
||||
"elapsedTime": "Temps écoulé"
|
||||
"elapsedTime": "Temps écoulé",
|
||||
"selectiveScan": "Sélectif"
|
||||
},
|
||||
"help": {
|
||||
"title": "Raccourcis Navidrome",
|
||||
|
||||
@@ -301,14 +301,19 @@
|
||||
"actions": {
|
||||
"scan": "Escanear Biblioteca",
|
||||
"manageUsers": "Xestionar acceso das usuarias",
|
||||
"viewDetails": "Ver detalles"
|
||||
"viewDetails": "Ver detalles",
|
||||
"quickScan": "Escaneado rápido",
|
||||
"fullScan": "Escaneado completo"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Biblioteca creada correctamente",
|
||||
"updated": "Biblioteca actualizada correctamente",
|
||||
"deleted": "Biblioteca eliminada correctamente",
|
||||
"scanStarted": "Comezou o escaneo da biblioteca",
|
||||
"scanCompleted": "Completouse o escaneado da biblioteca"
|
||||
"scanCompleted": "Completouse o escaneado da biblioteca",
|
||||
"quickScanStarted": "Iniciado o escaneado rápido",
|
||||
"fullScanStarted": "Iniciado o escaneado completo",
|
||||
"scanError": "Erro ao escanear. Comproba o rexistro"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Requírese un nome para a biblioteca",
|
||||
@@ -604,7 +609,8 @@
|
||||
"serverDown": "SEN CONEXIÓN",
|
||||
"scanType": "Tipo",
|
||||
"status": "Erro de escaneado",
|
||||
"elapsedTime": "Tempo transcurrido"
|
||||
"elapsedTime": "Tempo transcurrido",
|
||||
"selectiveScan": "Selectivo"
|
||||
},
|
||||
"help": {
|
||||
"title": "Atallos de Navidrome",
|
||||
|
||||
@@ -300,7 +300,9 @@
|
||||
},
|
||||
"actions": {
|
||||
"scan": "Könyvtár szkennelése",
|
||||
"manageUsers": "Elérés kezelése",
|
||||
"quickScan": "Gyors szkennelés",
|
||||
"fullScan": "Teljes szkennelés",
|
||||
"manageUsers": "Hozzáférés kezelése",
|
||||
"viewDetails": "Részletek"
|
||||
},
|
||||
"notifications": {
|
||||
@@ -598,11 +600,12 @@
|
||||
"activity": {
|
||||
"title": "Aktivitás",
|
||||
"totalScanned": "Összes beolvasott mappa:",
|
||||
"quickScan": "Gyors szkennelés",
|
||||
"fullScan": "Teljes szkennelés",
|
||||
"quickScan": "Gyors",
|
||||
"fullScan": "Teljes",
|
||||
"selectiveScan": "Szelektív",
|
||||
"serverUptime": "Szerver üzemidő",
|
||||
"serverDown": "OFFLINE",
|
||||
"scanType": "Típus",
|
||||
"scanType": "Legutóbbi szkennelés",
|
||||
"status": "Szkennelési hiba",
|
||||
"elapsedTime": "Eltelt idő"
|
||||
},
|
||||
|
||||
@@ -27,12 +27,16 @@
|
||||
"playDate": "最後の再生",
|
||||
"channels": "チャンネル",
|
||||
"createdAt": "追加日",
|
||||
"grouping": "",
|
||||
"mood": "",
|
||||
"participants": "",
|
||||
"tags": "",
|
||||
"mappedTags": "",
|
||||
"rawTags": ""
|
||||
"grouping": "グループ分け",
|
||||
"mood": "ムード",
|
||||
"participants": "追加参加者",
|
||||
"tags": "追加タグ",
|
||||
"mappedTags": "マッピング済みタグ",
|
||||
"rawTags": "未処理タグ",
|
||||
"bitDepth": "ビット深度",
|
||||
"sampleRate": "サンプリングレート",
|
||||
"missing": "不明",
|
||||
"libraryName": "ライブラリ"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "最後に再生",
|
||||
@@ -41,7 +45,8 @@
|
||||
"shuffleAll": "全曲シャッフル",
|
||||
"download": "ダウンロード",
|
||||
"playNext": "次に再生",
|
||||
"info": "詳細"
|
||||
"info": "詳細",
|
||||
"showInPlaylist": "含まれるプレイリスト"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -65,12 +70,15 @@
|
||||
"releaseDate": "リリース日",
|
||||
"releases": "リリース",
|
||||
"released": "リリース",
|
||||
"recordLabel": "",
|
||||
"catalogNum": "",
|
||||
"releaseType": "",
|
||||
"grouping": "",
|
||||
"media": "",
|
||||
"mood": ""
|
||||
"recordLabel": "ラベル",
|
||||
"catalogNum": "カタログ番号",
|
||||
"releaseType": "タイプ",
|
||||
"grouping": "グループ分け",
|
||||
"media": "メディア",
|
||||
"mood": "ムード",
|
||||
"date": "録音日",
|
||||
"missing": "不明",
|
||||
"libraryName": "ライブラリ"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "再生",
|
||||
@@ -102,22 +110,29 @@
|
||||
"rating": "レート",
|
||||
"genre": "ジャンル",
|
||||
"size": "サイズ",
|
||||
"role": ""
|
||||
"role": "役割",
|
||||
"missing": "不明"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "",
|
||||
"artist": "",
|
||||
"composer": "",
|
||||
"conductor": "",
|
||||
"lyricist": "",
|
||||
"arranger": "",
|
||||
"producer": "",
|
||||
"director": "",
|
||||
"engineer": "",
|
||||
"mixer": "",
|
||||
"remixer": "",
|
||||
"djmixer": "",
|
||||
"performer": ""
|
||||
"albumartist": "アルバムアーティスト",
|
||||
"artist": "アーティスト",
|
||||
"composer": "作曲家",
|
||||
"conductor": "指揮者",
|
||||
"lyricist": "作詞家",
|
||||
"arranger": "編曲者",
|
||||
"producer": "プロデューサー",
|
||||
"director": "ディレクター",
|
||||
"engineer": "エンジニア",
|
||||
"mixer": "ミキサー",
|
||||
"remixer": "リミキサー",
|
||||
"djmixer": "DJ ミキサー",
|
||||
"performer": "演奏者",
|
||||
"maincredit": "アルバムアーティストもしくはアーティスト"
|
||||
},
|
||||
"actions": {
|
||||
"shuffle": "シャッフル",
|
||||
"radio": "ラジオ",
|
||||
"topSongs": "トップソング"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -134,10 +149,12 @@
|
||||
"currentPassword": "現在のパスワード",
|
||||
"newPassword": "新しいパスワード",
|
||||
"token": "トークン",
|
||||
"lastAccessAt": "最終アクセス"
|
||||
"lastAccessAt": "最終アクセス",
|
||||
"libraries": "ライブラリ"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "名前の変更は次回ログイン以降反映されます"
|
||||
"name": "名前の変更は次回ログイン以降反映されます",
|
||||
"libraries": "このユーザーに対して特定ライブラリを選択するか、デフォルトのライブラリを使用する場合は空欄のままにします"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "ユーザーが作成されました",
|
||||
@@ -146,7 +163,12 @@
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "ListenBrainzユーザートークンを入力",
|
||||
"clickHereForToken": "ここをクリックしトークンを入手"
|
||||
"clickHereForToken": "ここをクリックしトークンを入手",
|
||||
"selectAllLibraries": "全てのライブラリを選択",
|
||||
"adminAutoLibraries": "管理者ユーザーは自動的にすべてのライブラリにアクセスできます"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "管理者以外のユーザーには少なくとも1つのライブラリを選択する必要があります"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -190,11 +212,17 @@
|
||||
"addNewPlaylist": "'%{name}' を作成",
|
||||
"export": "エクスポート",
|
||||
"makePublic": "公開する",
|
||||
"makePrivate": "非公開にする"
|
||||
"makePrivate": "非公開にする",
|
||||
"saveQueue": "キューをプレイリストに保存",
|
||||
"searchOrCreate": "プレイリストを検索または入力して新規作成...",
|
||||
"pressEnterToCreate": "Enterキーを押して新しいプレイリストを作成",
|
||||
"removeFromSelection": "選択から削除"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "重複する曲を追加",
|
||||
"song_exist": "既にプレイリストに存在する曲です。追加しますか?"
|
||||
"song_exist": "既にプレイリストに存在する曲です。追加しますか?",
|
||||
"noPlaylistsFound": "プレイリストが見つかりません",
|
||||
"noPlaylists": "利用可能なプレイリストはありません"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
@@ -228,17 +256,77 @@
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "",
|
||||
"name": "欠落したファイル",
|
||||
"fields": {
|
||||
"path": "",
|
||||
"size": "",
|
||||
"updatedAt": ""
|
||||
"path": "パス",
|
||||
"size": "サイズ",
|
||||
"updatedAt": "欠落日",
|
||||
"libraryName": "ライブラリ"
|
||||
},
|
||||
"actions": {
|
||||
"remove": ""
|
||||
"remove": "削除",
|
||||
"remove_all": "全て削除"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": ""
|
||||
"removed": "欠落ファイルが削除されました"
|
||||
},
|
||||
"empty": "ファイルの欠落はありません"
|
||||
},
|
||||
"library": {
|
||||
"name": "ライブラリ",
|
||||
"fields": {
|
||||
"name": "名前",
|
||||
"path": "パス",
|
||||
"remotePath": "リモートパス",
|
||||
"lastScanAt": "最終スキャン",
|
||||
"songCount": "曲数",
|
||||
"albumCount": "アルバム数",
|
||||
"artistCount": "アーティスト数",
|
||||
"totalSongs": "曲数",
|
||||
"totalAlbums": "アルバム数",
|
||||
"totalArtists": "アーティスト数",
|
||||
"totalFolders": "フォルダー数",
|
||||
"totalFiles": "ファイル数",
|
||||
"totalMissingFiles": "欠落したファイル",
|
||||
"totalSize": "合計サイズ",
|
||||
"totalDuration": "合計時間",
|
||||
"defaultNewUsers": "新規ユーザーに対するデフォルト",
|
||||
"createdAt": "作成日",
|
||||
"updatedAt": "更新日"
|
||||
},
|
||||
"sections": {
|
||||
"basic": "基本情報",
|
||||
"statistics": "統計"
|
||||
},
|
||||
"actions": {
|
||||
"scan": "ライブラリをスキャン",
|
||||
"manageUsers": "ユーザーアクセス管理",
|
||||
"viewDetails": "詳細を表示",
|
||||
"quickScan": "クイックスキャン",
|
||||
"fullScan": "フルスキャン"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "ライブラリが正常に作成されました",
|
||||
"updated": "ライブラリが正常に更新されました",
|
||||
"deleted": "ライブラリが正常に削除されました",
|
||||
"scanStarted": "スキャンを開始しました",
|
||||
"scanCompleted": "スキャンが完了しました",
|
||||
"quickScanStarted": "クイックスキャンを開始しました",
|
||||
"fullScanStarted": "フルスキャンを開始しました",
|
||||
"scanError": "スキャン開始中にエラーが発生。ログを確認してください"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "ライブラリの名前が必要です",
|
||||
"pathRequired": "ライブラリのパスが必要です",
|
||||
"pathNotDirectory": "ライブラリパスはディレクトリである必要があります",
|
||||
"pathNotFound": "ライブラリのパスが見つかりません",
|
||||
"pathNotAccessible": "ライブラリパスへアクセスできません",
|
||||
"pathInvalid": "無効なライブラリパス"
|
||||
},
|
||||
"messages": {
|
||||
"deleteConfirm": "このライブラリを削除しますか?関連する全てのデータとユーザーアクセスが削除されます。",
|
||||
"scanInProgress": "スキャン中...",
|
||||
"noLibrariesAssigned": "このユーザーに割り当てられているライブラリはありません"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -418,8 +506,12 @@
|
||||
"shareFailure": "コピーに失敗しました %{url}",
|
||||
"downloadDialogTitle": "ダウンロード %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "クリップボードへコピー: Ctrl+C, Enter",
|
||||
"remove_missing_title": "",
|
||||
"remove_missing_content": ""
|
||||
"remove_missing_title": "欠落ファイルを削除",
|
||||
"remove_missing_content": "選択した欠落ファイルをデータベースから削除してもよろしいですか?これにより、再生数や評価を含むそれらのファイルへの参照が完全に削除されます。",
|
||||
"remove_all_missing_title": "全ての欠落ファイルを削除",
|
||||
"remove_all_missing_content": "データベースから欠落ファイルをすべて削除してもよろしいですか?これにより、再生数や評価を含むそれらのファイルへの参照が永久に削除されます。",
|
||||
"noSimilarSongsFound": "類似の曲が見つかりませんでした",
|
||||
"noTopSongsFound": "トップソングが見つかりません"
|
||||
},
|
||||
"menu": {
|
||||
"library": "ライブラリ",
|
||||
@@ -448,7 +540,13 @@
|
||||
"albumList": "アルバム",
|
||||
"about": "詳細",
|
||||
"playlists": "プレイリスト",
|
||||
"sharedPlaylists": "共有プレイリスト"
|
||||
"sharedPlaylists": "共有プレイリスト",
|
||||
"librarySelector": {
|
||||
"allLibraries": "全てのライブラリ( %{count} )",
|
||||
"multipleLibraries": "%{selected} 個 / %{total} 個のライブラリ",
|
||||
"selectLibraries": "ライブラリを選択",
|
||||
"none": "無し"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "再生リスト",
|
||||
@@ -485,15 +583,34 @@
|
||||
"disabled": "無効",
|
||||
"waiting": "待機中"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"about": "詳細",
|
||||
"config": "設定"
|
||||
},
|
||||
"config": {
|
||||
"configName": "設定名",
|
||||
"environmentVariable": "環境変数",
|
||||
"currentValue": "現在値",
|
||||
"configurationFile": "設定ファイル",
|
||||
"exportToml": "設定をエクスポート(TOML)",
|
||||
"exportSuccess": "設定をTOML形式でクリップボードへエクスポートしました",
|
||||
"exportFailed": "設定のコピーに失敗しました",
|
||||
"devFlagsHeader": "開発フラグ(変更・削除の可能性あり)",
|
||||
"devFlagsComment": "これらは実験的な設定であり、将来のバージョンで削除される可能性があります"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "活動",
|
||||
"totalScanned": "スキャン済みフォルダー",
|
||||
"quickScan": "クイックスキャン",
|
||||
"fullScan": "フルスキャン",
|
||||
"quickScan": "クイック",
|
||||
"fullScan": "フル",
|
||||
"serverUptime": "サーバー稼働時間",
|
||||
"serverDown": "サーバーオフライン"
|
||||
"serverDown": "サーバーオフライン",
|
||||
"scanType": "最終スキャン",
|
||||
"status": "スキャンエラー",
|
||||
"elapsedTime": "経過時間",
|
||||
"selectiveScan": "選択的スキャン"
|
||||
},
|
||||
"help": {
|
||||
"title": "ホットキー",
|
||||
@@ -508,5 +625,10 @@
|
||||
"toggle_love": "星の付け外し",
|
||||
"current_song": "現在の曲へ移動"
|
||||
}
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "再生中",
|
||||
"empty": "何も再生されていません",
|
||||
"minutesAgo": "%{smart_count} 分前 |||| %{smart_count} 分前"
|
||||
}
|
||||
}
|
||||
@@ -301,14 +301,19 @@
|
||||
"actions": {
|
||||
"scan": "Scan bibliotheek",
|
||||
"manageUsers": "Beheer gebruikerstoegang",
|
||||
"viewDetails": "Bekijk details"
|
||||
"viewDetails": "Bekijk details",
|
||||
"quickScan": "Snelle scan",
|
||||
"fullScan": "Volledige scan"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Bibliotheek succesvol aangemaakt",
|
||||
"updated": "Bibliotheek succesvol bijgewerkt",
|
||||
"deleted": "Bibliotheek succesvol verwijderd",
|
||||
"scanStarted": "Bibliotheekscan is gestart",
|
||||
"scanCompleted": "Bibliotheekscan is voltooid"
|
||||
"scanCompleted": "Bibliotheekscan is voltooid",
|
||||
"quickScanStarted": "Snelle scan gestart",
|
||||
"fullScanStarted": "Volledige scan gestart",
|
||||
"scanError": "Fout bij start van scan. Check de logs"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Bibliotheek naam is vereist",
|
||||
@@ -604,7 +609,8 @@
|
||||
"serverDown": "Offline",
|
||||
"scanType": "Type",
|
||||
"status": "Scan fout",
|
||||
"elapsedTime": "Verlopen tijd"
|
||||
"elapsedTime": "Verlopen tijd",
|
||||
"selectiveScan": "Selectief"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome sneltoetsen",
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
"size": "Filstørrelse",
|
||||
"updatedAt": "Oppdatert",
|
||||
"bitRate": "Bit rate",
|
||||
"bitDepth": "Bit depth",
|
||||
"channels": "Kanaler",
|
||||
"discSubtitle": "Disk Undertittel",
|
||||
"starred": "Favoritt",
|
||||
"comment": "Kommentar",
|
||||
@@ -27,13 +25,18 @@
|
||||
"quality": "Kvalitet",
|
||||
"bpm": "BPM",
|
||||
"playDate": "Sist Avspilt",
|
||||
"channels": "Kanaler",
|
||||
"createdAt": "Lagt til",
|
||||
"grouping": "Gruppering",
|
||||
"mood": "Stemning",
|
||||
"participants": "Ytterlige deltakere",
|
||||
"tags": "Ytterlige Tags",
|
||||
"mappedTags": "Kartlagte tags",
|
||||
"rawTags": "Rå tags"
|
||||
"rawTags": "Rå tags",
|
||||
"bitDepth": "Bit depth",
|
||||
"sampleRate": "",
|
||||
"missing": "",
|
||||
"libraryName": ""
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Avspill senere",
|
||||
@@ -42,7 +45,8 @@
|
||||
"shuffleAll": "Shuffle Alle",
|
||||
"download": "Last ned",
|
||||
"playNext": "Avspill neste",
|
||||
"info": "Få Info"
|
||||
"info": "Få Info",
|
||||
"showInPlaylist": ""
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -53,36 +57,38 @@
|
||||
"duration": "Tid",
|
||||
"songCount": "Sanger",
|
||||
"playCount": "Avspillinger",
|
||||
"size": "Størrelse",
|
||||
"name": "Navn",
|
||||
"genre": "Sjanger",
|
||||
"compilation": "Samling",
|
||||
"year": "År",
|
||||
"date": "Inspillingsdato",
|
||||
"originalDate": "Original",
|
||||
"releaseDate": "Utgitt",
|
||||
"releases": "Utgivelse |||| Utgivelser",
|
||||
"released": "Utgitt",
|
||||
"updatedAt": "Oppdatert",
|
||||
"comment": "Kommentar",
|
||||
"rating": "Rangering",
|
||||
"createdAt": "Lagt Til",
|
||||
"size": "Størrelse",
|
||||
"originalDate": "Original",
|
||||
"releaseDate": "Utgitt",
|
||||
"releases": "Utgivelse |||| Utgivelser",
|
||||
"released": "Utgitt",
|
||||
"recordLabel": "Plateselskap",
|
||||
"catalogNum": "Katalognummer",
|
||||
"releaseType": "Type",
|
||||
"grouping": "Gruppering",
|
||||
"media": "Media",
|
||||
"mood": "Stemning"
|
||||
"mood": "Stemning",
|
||||
"date": "Inspillingsdato",
|
||||
"missing": "",
|
||||
"libraryName": ""
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Avspill",
|
||||
"playNext": "Avspill Neste",
|
||||
"addToQueue": "Avspill Senere",
|
||||
"share": "Del",
|
||||
"shuffle": "Shuffle",
|
||||
"addToPlaylist": "Legg til i spilleliste",
|
||||
"download": "Last ned",
|
||||
"info": "Få Info"
|
||||
"info": "Få Info",
|
||||
"share": "Del"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Alle",
|
||||
@@ -100,11 +106,12 @@
|
||||
"name": "Navn",
|
||||
"albumCount": "Album Antall",
|
||||
"songCount": "Song Antall",
|
||||
"size": "Størrelse",
|
||||
"playCount": "Avspillinger",
|
||||
"rating": "Rangering",
|
||||
"genre": "Sjanger",
|
||||
"role": "Rolle"
|
||||
"size": "Størrelse",
|
||||
"role": "Rolle",
|
||||
"missing": ""
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Album Artist |||| Album Artister",
|
||||
@@ -119,7 +126,13 @@
|
||||
"mixer": "Mixer |||| Mixers",
|
||||
"remixer": "Remixer |||| Remixers",
|
||||
"djmixer": "DJ Mixer |||| DJ Mixers",
|
||||
"performer": "Performer |||| Performers"
|
||||
"performer": "Performer |||| Performers",
|
||||
"maincredit": ""
|
||||
},
|
||||
"actions": {
|
||||
"shuffle": "",
|
||||
"radio": "",
|
||||
"topSongs": ""
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -128,7 +141,6 @@
|
||||
"userName": "Brukernavn",
|
||||
"isAdmin": "Admin",
|
||||
"lastLoginAt": "Sist Pålogging",
|
||||
"lastAccessAt": "Sist Tilgang",
|
||||
"updatedAt": "Oppdatert",
|
||||
"name": "Navn",
|
||||
"password": "Passord",
|
||||
@@ -136,10 +148,13 @@
|
||||
"changePassword": "Bytt Passord?",
|
||||
"currentPassword": "Nåværende Passord",
|
||||
"newPassword": "Nytt Passord",
|
||||
"token": "Token"
|
||||
"token": "Token",
|
||||
"lastAccessAt": "Sist Tilgang",
|
||||
"libraries": ""
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Navnendringer vil ikke være synlig før neste pålogging"
|
||||
"name": "Navnendringer vil ikke være synlig før neste pålogging",
|
||||
"libraries": ""
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Bruker opprettet",
|
||||
@@ -148,7 +163,12 @@
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "Fyll inn din ListenBrainz bruker token.",
|
||||
"clickHereForToken": "Klikk her for å hente din token"
|
||||
"clickHereForToken": "Klikk her for å hente din token",
|
||||
"selectAllLibraries": "",
|
||||
"adminAutoLibraries": ""
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": ""
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -192,11 +212,17 @@
|
||||
"addNewPlaylist": "Opprett \"%{name}\"",
|
||||
"export": "Eksporter",
|
||||
"makePublic": "Gjør Offentlig",
|
||||
"makePrivate": "Gjør Privat"
|
||||
"makePrivate": "Gjør Privat",
|
||||
"saveQueue": "",
|
||||
"searchOrCreate": "",
|
||||
"pressEnterToCreate": "",
|
||||
"removeFromSelection": ""
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Legg til Duplikater",
|
||||
"song_exist": "Duplikater har blitt lagt til i spillelisten. Ønsker du å legge til duplikater eller hoppe over de?"
|
||||
"song_exist": "Duplikater har blitt lagt til i spillelisten. Ønsker du å legge til duplikater eller hoppe over de?",
|
||||
"noPlaylistsFound": "",
|
||||
"noPlaylists": ""
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
@@ -218,7 +244,6 @@
|
||||
"username": "Delt Av",
|
||||
"url": "URL",
|
||||
"description": "Beskrivelse",
|
||||
"downloadable": "Tillat Nedlastinger?",
|
||||
"contents": "Innhold",
|
||||
"expiresAt": "Utløper",
|
||||
"lastVisitedAt": "Sist Besøkt",
|
||||
@@ -226,24 +251,82 @@
|
||||
"format": "Format",
|
||||
"maxBitRate": "Maks. Bit Rate",
|
||||
"updatedAt": "Oppdatert",
|
||||
"createdAt": "Opprettet"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
"createdAt": "Opprettet",
|
||||
"downloadable": "Tillat Nedlastinger?"
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "Manglende Fil|||| Manglende Filer",
|
||||
"empty": "Ingen Manglende Filer",
|
||||
"fields": {
|
||||
"path": "Filsti",
|
||||
"size": "Størrelse",
|
||||
"updatedAt": "Ble borte"
|
||||
"updatedAt": "Ble borte",
|
||||
"libraryName": ""
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Fjern"
|
||||
"remove": "Fjern",
|
||||
"remove_all": ""
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Manglende fil(er) fjernet"
|
||||
},
|
||||
"empty": "Ingen Manglende Filer"
|
||||
},
|
||||
"library": {
|
||||
"name": "",
|
||||
"fields": {
|
||||
"name": "",
|
||||
"path": "",
|
||||
"remotePath": "",
|
||||
"lastScanAt": "",
|
||||
"songCount": "",
|
||||
"albumCount": "",
|
||||
"artistCount": "",
|
||||
"totalSongs": "",
|
||||
"totalAlbums": "",
|
||||
"totalArtists": "",
|
||||
"totalFolders": "",
|
||||
"totalFiles": "",
|
||||
"totalMissingFiles": "",
|
||||
"totalSize": "",
|
||||
"totalDuration": "",
|
||||
"defaultNewUsers": "",
|
||||
"createdAt": "",
|
||||
"updatedAt": ""
|
||||
},
|
||||
"sections": {
|
||||
"basic": "",
|
||||
"statistics": ""
|
||||
},
|
||||
"actions": {
|
||||
"scan": "",
|
||||
"manageUsers": "",
|
||||
"viewDetails": "",
|
||||
"quickScan": "",
|
||||
"fullScan": ""
|
||||
},
|
||||
"notifications": {
|
||||
"created": "",
|
||||
"updated": "",
|
||||
"deleted": "Biblioteket slettet",
|
||||
"scanStarted": "Skanning startet",
|
||||
"scanCompleted": "",
|
||||
"quickScanStarted": "",
|
||||
"fullScanStarted": "",
|
||||
"scanError": "Error starte skanning. Sjekk loggene"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "",
|
||||
"pathRequired": "",
|
||||
"pathNotDirectory": "",
|
||||
"pathNotFound": "",
|
||||
"pathNotAccessible": "",
|
||||
"pathInvalid": ""
|
||||
},
|
||||
"messages": {
|
||||
"deleteConfirm": "",
|
||||
"scanInProgress": "",
|
||||
"noLibrariesAssigned": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -282,7 +365,6 @@
|
||||
"add": "Legg Til",
|
||||
"back": "Tilbake",
|
||||
"bulk_actions": "1 element valgt |||| %{smart_count} elementer valgt",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"cancel": "Avbryt",
|
||||
"clear_input_value": "Nullstill verdi",
|
||||
"clone": "Klone",
|
||||
@@ -306,6 +388,7 @@
|
||||
"close_menu": "Lukk meny",
|
||||
"unselect": "Avvelg",
|
||||
"skip": "Hopp over",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"share": "Del",
|
||||
"download": "Last Ned"
|
||||
},
|
||||
@@ -400,31 +483,35 @@
|
||||
"noPlaylistsAvailable": "Ingen tilgjengelig",
|
||||
"delete_user_title": "Slett bruker '%{name}'",
|
||||
"delete_user_content": "Er du sikker på at du vil slette denne brukeren og all tilhørlig data (inkludert spillelister og preferanser)?",
|
||||
"remove_missing_title": "Fjern manglende filer",
|
||||
"remove_missing_content": "Er du sikker på at du ønsker å fjerne de valgte manglende filene fra databasen? Dette vil permanent fjerne alle referanser til de, inkludert antall avspillinger og rangeringer.",
|
||||
"notifications_blocked": "Du har blokkert notifikasjoner for denne nettsiden i din nettleser.",
|
||||
"notifications_not_available": "Denne nettleseren støtter ikke skrivebordsnotifikasjoner, eller så er du ikke tilkoblet Navidrome via https.",
|
||||
"lastfmLinkSuccess": "Last.fm er tilkoblet og scrobbling er aktivert",
|
||||
"lastfmLinkFailure": "Last.fm kunne ikke koble til",
|
||||
"lastfmUnlinkSuccess": "Last.fm er avkoblet og scrobbling er deaktivert",
|
||||
"lastfmUnlinkFailure": "Last.fm kunne ikke avkobles",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz er koblet til og scrobbling er aktivert som bruker: %{user}",
|
||||
"listenBrainzLinkFailure": "ListenBrainz kunne ikke koble til: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz er avkoblet og scrobbling er deaktivert",
|
||||
"listenBrainzUnlinkFailure": "ListenBrainz kunne ikke avkobles",
|
||||
"openIn": {
|
||||
"lastfm": "Åpne i Last.fm",
|
||||
"musicbrainz": "Åpne i MusicBrainz"
|
||||
},
|
||||
"lastfmLink": "Les Mer...",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz er koblet til og scrobbling er aktivert som bruker: %{user}",
|
||||
"listenBrainzLinkFailure": "ListenBrainz kunne ikke koble til: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz er avkoblet og scrobbling er deaktivert",
|
||||
"listenBrainzUnlinkFailure": "ListenBrainz kunne ikke avkobles",
|
||||
"downloadOriginalFormat": "Last ned i originalformat",
|
||||
"shareOriginalFormat": "Del i originalformat",
|
||||
"shareDialogTitle": "Del %{resource} '%{name}'",
|
||||
"shareBatchDialogTitle": "Del 1 %{resource} |||| Del %{smart_count} %{resource}",
|
||||
"shareCopyToClipboard": "Kopier til utklippstavle: Ctrl+C, Enter",
|
||||
"shareSuccess": "URL kopiert til utklippstavle: %{url}",
|
||||
"shareFailure": "Error ved kopiering av URL %{url} til utklippstavle",
|
||||
"downloadDialogTitle": "Last ned %{resource} '%{name}' (%{size})",
|
||||
"downloadOriginalFormat": "Last ned i originalformat"
|
||||
"shareCopyToClipboard": "Kopier til utklippstavle: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Fjern manglende filer",
|
||||
"remove_missing_content": "Er du sikker på at du ønsker å fjerne de valgte manglende filene fra databasen? Dette vil permanent fjerne alle referanser til de, inkludert antall avspillinger og rangeringer.",
|
||||
"remove_all_missing_title": "",
|
||||
"remove_all_missing_content": "",
|
||||
"noSimilarSongsFound": "",
|
||||
"noTopSongsFound": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliotek",
|
||||
@@ -438,7 +525,6 @@
|
||||
"language": "Språk",
|
||||
"defaultView": "Standardvisning",
|
||||
"desktop_notifications": "Skrivebordsnotifikasjoner",
|
||||
"lastfmNotConfigured": "Last.fm API-Key er ikke konfigurert",
|
||||
"lastfmScrobbling": "Scrobble til Last.fm",
|
||||
"listenBrainzScrobbling": "Scrobble til ListenBrainz",
|
||||
"replaygain": "ReplayGain Mode",
|
||||
@@ -447,13 +533,20 @@
|
||||
"none": "Deaktivert",
|
||||
"album": "Bruk Album Gain",
|
||||
"track": "Bruk Track Gain"
|
||||
}
|
||||
},
|
||||
"lastfmNotConfigured": "Last.fm API-Key er ikke konfigurert"
|
||||
}
|
||||
},
|
||||
"albumList": "Album",
|
||||
"about": "Om",
|
||||
"playlists": "Spillelister",
|
||||
"sharedPlaylists": "Delte Spillelister",
|
||||
"about": "Om"
|
||||
"librarySelector": {
|
||||
"allLibraries": "",
|
||||
"multipleLibraries": "",
|
||||
"selectLibraries": "",
|
||||
"none": ""
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Spill Av Kø",
|
||||
@@ -490,6 +583,21 @@
|
||||
"disabled": "Deaktivert",
|
||||
"waiting": "Venter"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"about": "",
|
||||
"config": ""
|
||||
},
|
||||
"config": {
|
||||
"configName": "",
|
||||
"environmentVariable": "",
|
||||
"currentValue": "",
|
||||
"configurationFile": "",
|
||||
"exportToml": "",
|
||||
"exportSuccess": "",
|
||||
"exportFailed": "",
|
||||
"devFlagsHeader": "",
|
||||
"devFlagsComment": ""
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
@@ -498,7 +606,11 @@
|
||||
"quickScan": "Hurtigskann",
|
||||
"fullScan": "Full Skann",
|
||||
"serverUptime": "Server Oppetid",
|
||||
"serverDown": "OFFLINE"
|
||||
"serverDown": "OFFLINE",
|
||||
"scanType": "",
|
||||
"status": "",
|
||||
"elapsedTime": "",
|
||||
"selectiveScan": "Utvalgt"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome Hurtigtaster",
|
||||
@@ -508,10 +620,15 @@
|
||||
"toggle_play": "Avspill / Pause",
|
||||
"prev_song": "Forrige Sang",
|
||||
"next_song": "Neste Sang",
|
||||
"current_song": "Gå til Nåværende Sang",
|
||||
"vol_up": "Volum Opp",
|
||||
"vol_down": "Volum Ned",
|
||||
"toggle_love": "Legg til spor i favoritter"
|
||||
"toggle_love": "Legg til spor i favoritter",
|
||||
"current_song": "Gå til Nåværende Sang"
|
||||
}
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "",
|
||||
"empty": "",
|
||||
"minutesAgo": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,14 +301,19 @@
|
||||
"actions": {
|
||||
"scan": "Skanuj Bibliotekę",
|
||||
"manageUsers": "Zarządzaj Dostępami Użytkownika",
|
||||
"viewDetails": "Zobacz Szczegóły"
|
||||
"viewDetails": "Zobacz Szczegóły",
|
||||
"quickScan": "Szybkie Skanowanie",
|
||||
"fullScan": "Pełne Skanowanie"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Biblioteka utworzona prawidłowo",
|
||||
"updated": "Biblioteka zaktualizowana prawidłowo",
|
||||
"deleted": "Biblioteka usunięta prawidłowo",
|
||||
"scanStarted": "Rozpoczęto skan biblioteki",
|
||||
"scanCompleted": "Zakończono skan biblioteki"
|
||||
"scanCompleted": "Zakończono skan biblioteki",
|
||||
"quickScanStarted": "Szybkie skanowanie rozpoczęte",
|
||||
"fullScanStarted": "Pełne skanowanie rozpoczęte",
|
||||
"scanError": "Błąd podczas startu skanowania. Sprawdź logi"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Nazwa biblioteki jest wymagana",
|
||||
@@ -604,7 +609,8 @@
|
||||
"serverDown": "NIEDOSTĘPNY",
|
||||
"scanType": "Typ",
|
||||
"status": "Błąd Skanowania",
|
||||
"elapsedTime": "Upłynięty Czas"
|
||||
"elapsedTime": "Upłynięty Czas",
|
||||
"selectiveScan": "Selektywne"
|
||||
},
|
||||
"help": {
|
||||
"title": "Skróty Klawiszowe Navidrome",
|
||||
|
||||
@@ -301,20 +301,25 @@
|
||||
"actions": {
|
||||
"scan": "Сканировать библиотеку",
|
||||
"manageUsers": "Управление доступом пользователей",
|
||||
"viewDetails": "Просмотреть подробности"
|
||||
"viewDetails": "Просмотреть подробности",
|
||||
"quickScan": "Быстрое сканирование",
|
||||
"fullScan": "Полное сканирование"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Библиотека успешно создана",
|
||||
"updated": "Библиотека успешно обновлена",
|
||||
"deleted": "Библиотека успешно удалена",
|
||||
"scanStarted": "Сканирование библиотеки начато",
|
||||
"scanCompleted": "Сканирование библиотеки закончено"
|
||||
"scanCompleted": "Сканирование библиотеки закончено",
|
||||
"quickScanStarted": "Быстрое сканирование началось",
|
||||
"fullScanStarted": "Началось полное сканирование",
|
||||
"scanError": "Ошибка при запуске сканирования. Проверьте логи"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Имя библиотеки обязательно",
|
||||
"pathRequired": "Путь к библиотеке обязателен",
|
||||
"pathNotDirectory": "Путь к библиотеке должен быть директорией",
|
||||
"pathNotFound": "Путь к библиотеке не найдено",
|
||||
"pathNotFound": "Путь к библиотеке не найден",
|
||||
"pathNotAccessible": "Путь к библиотеке недоступен",
|
||||
"pathInvalid": "Неверный путь к библиотеке"
|
||||
},
|
||||
@@ -604,7 +609,8 @@
|
||||
"serverDown": "Оффлайн",
|
||||
"scanType": "Тип",
|
||||
"status": "Ошибка сканирования",
|
||||
"elapsedTime": "Прошедшее время"
|
||||
"elapsedTime": "Прошедшее время",
|
||||
"selectiveScan": "Избирательный"
|
||||
},
|
||||
"help": {
|
||||
"title": "Горячие клавиши Navidrome",
|
||||
|
||||
@@ -301,14 +301,19 @@
|
||||
"actions": {
|
||||
"scan": "Scanna bibliotek",
|
||||
"manageUsers": "Hantera användaråtkomst",
|
||||
"viewDetails": "Se detaljer"
|
||||
"viewDetails": "Se detaljer",
|
||||
"quickScan": "Snabbscan",
|
||||
"fullScan": "Komplett scan"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Biblioteket har skapats",
|
||||
"updated": "Biblioteket har uppdaterats",
|
||||
"deleted": "Biblioteket har raderats",
|
||||
"scanStarted": "Biblioteksscan startad",
|
||||
"scanCompleted": "Biblioteksscan avslutad"
|
||||
"scanCompleted": "Biblioteksscan avslutad",
|
||||
"quickScanStarted": "Snabbscan startad",
|
||||
"fullScanStarted": "Komplett scan startad",
|
||||
"scanError": "Fel vid start av scan. Se loggarna"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Biblioteksnamn krävs",
|
||||
@@ -604,7 +609,8 @@
|
||||
"serverDown": "OFFLINE",
|
||||
"scanType": "Typ",
|
||||
"status": "Fel vid scanning",
|
||||
"elapsedTime": "Spelad tid"
|
||||
"elapsedTime": "Spelad tid",
|
||||
"selectiveScan": "Urval"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome kortkommandon",
|
||||
|
||||
@@ -301,14 +301,19 @@
|
||||
"actions": {
|
||||
"scan": "สแกนห้องสมุด",
|
||||
"manageUsers": "ตั้งค่าการเข้าถึง",
|
||||
"viewDetails": "ดูรายละเอียด"
|
||||
"viewDetails": "ดูรายละเอียด",
|
||||
"quickScan": "สแกนแบบเร็ว",
|
||||
"fullScan": "สแกนแบบเต็ม"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "สร้างห้องสมุดเรียบร้อย",
|
||||
"updated": "อัพเดทห้องสมุดเรียบร้อย",
|
||||
"deleted": "ลบห้องสมุดเพลงเรียบร้อยแล้ว",
|
||||
"scanStarted": "เริ่มสแกนห้องสมุด",
|
||||
"scanCompleted": "สแกนห้องสมุดเสร็จแล้ว"
|
||||
"scanCompleted": "สแกนห้องสมุดเสร็จแล้ว",
|
||||
"quickScanStarted": "เริ่มสแกนแบบเร็ว",
|
||||
"fullScanStarted": "เริ่มสแกนแบบเต็ม",
|
||||
"scanError": "การเริ่มสแกนผิดพลาด ดูในบันทึก"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "ต้องใส่ชื่อห้องสมุดเพลง",
|
||||
@@ -604,7 +609,8 @@
|
||||
"serverDown": "ออฟไลน์",
|
||||
"scanType": "ประเภท",
|
||||
"status": "สแกนผิดพลาด",
|
||||
"elapsedTime": "เวลาที่ใช้"
|
||||
"elapsedTime": "เวลาที่ใช้",
|
||||
"selectiveScan": "เลือก"
|
||||
},
|
||||
"help": {
|
||||
"title": "คีย์ลัด Navidrome",
|
||||
|
||||
@@ -301,14 +301,19 @@
|
||||
"actions": {
|
||||
"scan": "Kütüphaneyi Tara",
|
||||
"manageUsers": "Kullanıcı Erişimini Yönet",
|
||||
"viewDetails": "Ayrıntıları Görüntüle"
|
||||
"viewDetails": "Ayrıntıları Görüntüle",
|
||||
"quickScan": "Hızlı Tarama",
|
||||
"fullScan": "Tam Tarama"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Kütüphane başarıyla oluşturuldu",
|
||||
"updated": "Kütüphane başarıyla güncellendi",
|
||||
"deleted": "Kütüphane başarıyla silindi",
|
||||
"scanStarted": "Kütüphane taraması başladı",
|
||||
"scanCompleted": "Kütüphane taraması tamamlandı"
|
||||
"scanCompleted": "Kütüphane taraması tamamlandı",
|
||||
"quickScanStarted": "Hızlı tarama başlatıldı",
|
||||
"fullScanStarted": "Tam tarama başlatıldı",
|
||||
"scanError": "Tarama başlatılırken hata oluştu. Günlükleri kontrol edin."
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Kütüphane adı gereklidir",
|
||||
@@ -604,7 +609,8 @@
|
||||
"serverDown": "ÇEVRİMDIŞI",
|
||||
"scanType": "Tür",
|
||||
"status": "Tarama Hatası",
|
||||
"elapsedTime": "Geçen Süre"
|
||||
"elapsedTime": "Geçen Süre",
|
||||
"selectiveScan": "Seçmeli"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome Kısayolları",
|
||||
|
||||
@@ -301,14 +301,19 @@
|
||||
"actions": {
|
||||
"scan": "Сканувати бібліотеку",
|
||||
"manageUsers": "Керування доступом користувачів",
|
||||
"viewDetails": "Переглянути подробиці"
|
||||
"viewDetails": "Переглянути подробиці",
|
||||
"quickScan": "Швидке сканування",
|
||||
"fullScan": "Повне сканування"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Бібліотеку успішно створено",
|
||||
"updated": "Бібліотеку успішно оновлено",
|
||||
"deleted": "Бібліотеку успішно видалено",
|
||||
"scanStarted": "Сканування бібліотеки розпочато",
|
||||
"scanCompleted": "Сканування бібліотеки закінчено"
|
||||
"scanCompleted": "Сканування бібліотеки закінчено",
|
||||
"quickScanStarted": "Швидке сканування виконується",
|
||||
"fullScanStarted": "Повне сканування виконується",
|
||||
"scanError": "Помилка при виконанні сканування. Перевірте лоґи"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Ім'я бібліотеки обов'язкове",
|
||||
@@ -604,7 +609,8 @@
|
||||
"serverDown": "Оффлайн",
|
||||
"scanType": "Тип",
|
||||
"status": "Помилка сканування",
|
||||
"elapsedTime": "Пройдений час"
|
||||
"elapsedTime": "Пройдений час",
|
||||
"selectiveScan": "Вибірковий"
|
||||
},
|
||||
"help": {
|
||||
"title": "Гарячі клавіші Navidrome",
|
||||
|
||||
@@ -14,6 +14,12 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
const (
|
||||
// argLengthThreshold is the threshold for switching from command-line args to file-based target passing.
|
||||
// Set conservatively at 24KB to support Windows (~32KB limit) with margin for env vars.
|
||||
argLengthThreshold = 24 * 1024
|
||||
)
|
||||
|
||||
// scannerExternal is a scanner that runs an external process to do the scanning. It is used to avoid
|
||||
// memory leaks or retention in the main process, as the scanner can consume a lot of memory. The
|
||||
// external process will be spawned with the same executable as the current process, and will run
|
||||
@@ -45,10 +51,14 @@ func (s *scannerExternal) scan(ctx context.Context, fullScan bool, targets []mod
|
||||
|
||||
// Add targets if provided
|
||||
if len(targets) > 0 {
|
||||
for _, target := range targets {
|
||||
args = append(args, "-t", target.String())
|
||||
targetArgs, cleanup, err := targetArguments(ctx, targets, argLengthThreshold)
|
||||
if err != nil {
|
||||
progress <- &ProgressInfo{Error: err.Error()}
|
||||
return
|
||||
}
|
||||
log.Debug(ctx, "Spawning external scanner process with targets", "fullScan", fullScan, "path", exe, "targets", targets)
|
||||
defer cleanup()
|
||||
log.Debug(ctx, "Spawning external scanner process with target file", "fullScan", fullScan, "path", exe, "numTargets", len(targets))
|
||||
args = append(args, targetArgs...)
|
||||
} else {
|
||||
log.Debug(ctx, "Spawning external scanner process", "fullScan", fullScan, "path", exe)
|
||||
}
|
||||
@@ -98,4 +108,62 @@ func (s *scannerExternal) wait(cmd *exec.Cmd, out *io.PipeWriter) {
|
||||
_ = out.Close()
|
||||
}
|
||||
|
||||
// targetArguments builds command-line arguments for the given scan targets.
|
||||
// If the estimated argument length exceeds a threshold, it writes the targets to a temp file
|
||||
// and returns the --target-file argument instead.
|
||||
// Returns the arguments, a cleanup function to remove any temp file created, and an error if any.
|
||||
func targetArguments(ctx context.Context, targets []model.ScanTarget, lengthThreshold int) ([]string, func(), error) {
|
||||
var args []string
|
||||
|
||||
// Estimate argument length to decide whether to use file-based approach
|
||||
argLength := estimateArgLength(targets)
|
||||
|
||||
if argLength > lengthThreshold {
|
||||
// Write targets to temp file and pass via --target-file
|
||||
targetFile, err := writeTargetsToFile(targets)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to write targets to file: %w", err)
|
||||
}
|
||||
args = append(args, "--target-file", targetFile)
|
||||
return args, func() {
|
||||
os.Remove(targetFile) // Clean up temp file
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Use command-line arguments for small target lists
|
||||
for _, target := range targets {
|
||||
args = append(args, "-t", target.String())
|
||||
}
|
||||
return args, func() {}, nil
|
||||
}
|
||||
|
||||
// estimateArgLength estimates the total length of command-line arguments for the given targets.
|
||||
func estimateArgLength(targets []model.ScanTarget) int {
|
||||
length := 0
|
||||
for _, target := range targets {
|
||||
// Each target adds: "-t " + target string + space
|
||||
length += 3 + len(target.String()) + 1
|
||||
}
|
||||
return length
|
||||
}
|
||||
|
||||
// writeTargetsToFile writes the targets to a temporary file, one per line.
|
||||
// Returns the path to the temp file, which the caller should clean up.
|
||||
func writeTargetsToFile(targets []model.ScanTarget) (string, error) {
|
||||
tmpFile, err := os.CreateTemp("", "navidrome-scan-targets-*.txt")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
defer tmpFile.Close()
|
||||
|
||||
for _, target := range targets {
|
||||
if _, err := fmt.Fprintln(tmpFile, target.String()); err != nil {
|
||||
os.Remove(tmpFile.Name())
|
||||
return "", fmt.Errorf("failed to write to temp file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return tmpFile.Name(), nil
|
||||
}
|
||||
|
||||
var _ scanner = (*scannerExternal)(nil)
|
||||
|
||||
160
scanner/external_test.go
Normal file
160
scanner/external_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("targetArguments", func() {
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
})
|
||||
|
||||
Context("with small target list", func() {
|
||||
It("returns command-line arguments for single target", func() {
|
||||
targets := []model.ScanTarget{
|
||||
{LibraryID: 1, FolderPath: "Music/Rock"},
|
||||
}
|
||||
|
||||
args, cleanup, err := targetArguments(ctx, targets, argLengthThreshold)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer cleanup()
|
||||
Expect(args).To(Equal([]string{"-t", "1:Music/Rock"}))
|
||||
})
|
||||
|
||||
It("returns command-line arguments for multiple targets", func() {
|
||||
targets := []model.ScanTarget{
|
||||
{LibraryID: 1, FolderPath: "Music/Rock"},
|
||||
{LibraryID: 2, FolderPath: "Music/Jazz"},
|
||||
{LibraryID: 3, FolderPath: "Classical"},
|
||||
}
|
||||
|
||||
args, cleanup, err := targetArguments(ctx, targets, argLengthThreshold)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer cleanup()
|
||||
Expect(args).To(Equal([]string{
|
||||
"-t", "1:Music/Rock",
|
||||
"-t", "2:Music/Jazz",
|
||||
"-t", "3:Classical",
|
||||
}))
|
||||
})
|
||||
|
||||
It("handles targets with special characters", func() {
|
||||
targets := []model.ScanTarget{
|
||||
{LibraryID: 1, FolderPath: "Music/Rock & Roll"},
|
||||
{LibraryID: 2, FolderPath: "Music/Jazz (Modern)"},
|
||||
}
|
||||
|
||||
args, cleanup, err := targetArguments(ctx, targets, argLengthThreshold)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer cleanup()
|
||||
Expect(args).To(Equal([]string{
|
||||
"-t", "1:Music/Rock & Roll",
|
||||
"-t", "2:Music/Jazz (Modern)",
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with large target list exceeding threshold", func() {
|
||||
It("returns --target-file argument when exceeding threshold", func() {
|
||||
// Create enough targets to exceed the threshold
|
||||
var targets []model.ScanTarget
|
||||
for i := 1; i <= 600; i++ {
|
||||
targets = append(targets, model.ScanTarget{
|
||||
LibraryID: 1,
|
||||
FolderPath: "Music/VeryLongFolderPathToSimulateRealScenario/SubFolder",
|
||||
})
|
||||
}
|
||||
|
||||
args, cleanup, err := targetArguments(ctx, targets, argLengthThreshold)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer cleanup()
|
||||
Expect(args).To(HaveLen(2))
|
||||
Expect(args[0]).To(Equal("--target-file"))
|
||||
|
||||
// Verify the file exists and has correct format
|
||||
filePath := args[1]
|
||||
Expect(filePath).To(ContainSubstring("navidrome-scan-targets-"))
|
||||
Expect(filePath).To(HaveSuffix(".txt"))
|
||||
|
||||
// Verify file actually exists
|
||||
_, err = os.Stat(filePath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("creates temp file with correct format", func() {
|
||||
// Use custom threshold to easily exceed it
|
||||
targets := []model.ScanTarget{
|
||||
{LibraryID: 1, FolderPath: "Music/Rock"},
|
||||
{LibraryID: 2, FolderPath: "Music/Jazz"},
|
||||
{LibraryID: 3, FolderPath: "Classical"},
|
||||
}
|
||||
|
||||
// Set threshold very low to force file usage
|
||||
args, cleanup, err := targetArguments(ctx, targets, 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer cleanup()
|
||||
Expect(args[0]).To(Equal("--target-file"))
|
||||
|
||||
// Verify file exists with correct format
|
||||
filePath := args[1]
|
||||
Expect(filePath).To(ContainSubstring("navidrome-scan-targets-"))
|
||||
Expect(filePath).To(HaveSuffix(".txt"))
|
||||
|
||||
// Verify file content
|
||||
content, err := os.ReadFile(filePath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
lines := strings.Split(strings.TrimSpace(string(content)), "\n")
|
||||
Expect(lines).To(HaveLen(3))
|
||||
Expect(lines[0]).To(Equal("1:Music/Rock"))
|
||||
Expect(lines[1]).To(Equal("2:Music/Jazz"))
|
||||
Expect(lines[2]).To(Equal("3:Classical"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("edge cases", func() {
|
||||
It("handles empty target list", func() {
|
||||
var targets []model.ScanTarget
|
||||
|
||||
args, cleanup, err := targetArguments(ctx, targets, argLengthThreshold)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer cleanup()
|
||||
Expect(args).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("uses command-line args when exactly at threshold", func() {
|
||||
// Create targets that are exactly at threshold
|
||||
targets := []model.ScanTarget{
|
||||
{LibraryID: 1, FolderPath: "Music"},
|
||||
}
|
||||
|
||||
// Estimate length should be 11 bytes
|
||||
estimatedLength := estimateArgLength(targets)
|
||||
|
||||
args, cleanup, err := targetArguments(ctx, targets, estimatedLength)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer cleanup()
|
||||
Expect(args).To(Equal([]string{"-t", "1:Music"}))
|
||||
})
|
||||
|
||||
It("uses file when one byte over threshold", func() {
|
||||
targets := []model.ScanTarget{
|
||||
{LibraryID: 1, FolderPath: "Music"},
|
||||
}
|
||||
|
||||
// Set threshold just below the estimated length
|
||||
estimatedLength := estimateArgLength(targets)
|
||||
args, cleanup, err := targetArguments(ctx, targets, estimatedLength-1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer cleanup()
|
||||
Expect(args[0]).To(Equal("--target-file"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -76,6 +76,12 @@ func newScanJob(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer,
|
||||
log.Error(ctx, "Error getting fs for library", "library", lib.Name, "path", lib.Path, err)
|
||||
return nil, fmt.Errorf("getting fs for library: %w", err)
|
||||
}
|
||||
|
||||
// Ensure FullScanInProgress reflects the current scan request.
|
||||
// This is important when resuming an interrupted quick scan as a full scan:
|
||||
// the DB may have FullScanInProgress=false, but we need it true for isOutdated() to work correctly.
|
||||
lib.FullScanInProgress = lib.FullScanInProgress || fullScan
|
||||
|
||||
return &scanJob{
|
||||
lib: lib,
|
||||
fs: fsys,
|
||||
|
||||
@@ -675,6 +675,155 @@ var _ = Describe("Scanner", Ordered, func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Interrupted scan resumption", func() {
|
||||
var fsys storagetest.FakeFS
|
||||
var help func(...map[string]any) *fstest.MapFile
|
||||
|
||||
BeforeEach(func() {
|
||||
help = template(_t{"albumartist": "The Beatles", "album": "Help!", "year": 1965})
|
||||
fsys = createFS(fstest.MapFS{
|
||||
"The Beatles/Help!/01 - Help!.mp3": help(track(1, "Help!")),
|
||||
"The Beatles/Help!/02 - The Night Before.mp3": help(track(2, "The Night Before")),
|
||||
})
|
||||
})
|
||||
|
||||
simulateInterruptedScan := func(fullScan bool) {
|
||||
// Call ScanBegin to properly set LastScanStartedAt and FullScanInProgress
|
||||
// This simulates what would happen if a scan was interrupted (ScanBegin called but ScanEnd not)
|
||||
Expect(ds.Library(ctx).ScanBegin(lib.ID, fullScan)).To(Succeed())
|
||||
|
||||
// Verify the update was persisted
|
||||
reloaded, err := ds.Library(ctx).Get(lib.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reloaded.LastScanStartedAt).ToNot(BeZero())
|
||||
Expect(reloaded.FullScanInProgress).To(Equal(fullScan))
|
||||
}
|
||||
|
||||
Context("when a quick scan is interrupted and resumed with a full scan request", func() {
|
||||
BeforeEach(func() {
|
||||
// First, complete a full scan to populate the database
|
||||
Expect(runScanner(ctx, true)).To(Succeed())
|
||||
|
||||
// Verify files were imported
|
||||
mfs, err := ds.MediaFile(ctx).GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfs).To(HaveLen(2))
|
||||
|
||||
// Now simulate an interrupted quick scan
|
||||
// (LastScanStartedAt is set, FullScanInProgress is false)
|
||||
simulateInterruptedScan(false)
|
||||
})
|
||||
|
||||
It("should rescan all folders when resumed as full scan", func() {
|
||||
// Update a tag without changing the folder hash by preserving the original modtime.
|
||||
// In a quick scan, this wouldn't be detected because the folder hash hasn't changed.
|
||||
// But in a full scan, all files should be re-read regardless of hash.
|
||||
origModTime := fsys.MapFS["The Beatles/Help!/01 - Help!.mp3"].ModTime
|
||||
fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"comment": "updated comment"}, origModTime)
|
||||
|
||||
// Resume with a full scan - this should process all folders
|
||||
// even though folder hashes haven't changed
|
||||
Expect(runScanner(ctx, true)).To(Succeed())
|
||||
|
||||
// Verify the comment was updated (which means the folder was processed and file re-imported)
|
||||
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"title": "Help!"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfs).To(HaveLen(1))
|
||||
Expect(mfs[0].Comment).To(Equal("updated comment"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when a full scan is interrupted and resumed with a quick scan request", func() {
|
||||
BeforeEach(func() {
|
||||
// First, complete a full scan to populate the database
|
||||
Expect(runScanner(ctx, true)).To(Succeed())
|
||||
|
||||
// Verify files were imported
|
||||
mfs, err := ds.MediaFile(ctx).GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfs).To(HaveLen(2))
|
||||
|
||||
// Now simulate an interrupted full scan
|
||||
// (LastScanStartedAt is set, FullScanInProgress is true)
|
||||
simulateInterruptedScan(true)
|
||||
})
|
||||
|
||||
It("should continue as full scan even when quick scan is requested", func() {
|
||||
// Update a tag without changing the folder hash by preserving the original modtime.
|
||||
origModTime := fsys.MapFS["The Beatles/Help!/01 - Help!.mp3"].ModTime
|
||||
fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"comment": "full scan comment"}, origModTime)
|
||||
|
||||
// Request a quick scan - but because a full scan was in progress,
|
||||
// it should continue as a full scan
|
||||
Expect(runScanner(ctx, false)).To(Succeed())
|
||||
|
||||
// Verify the comment was updated (folder was processed despite unchanged hash)
|
||||
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"title": "Help!"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfs).To(HaveLen(1))
|
||||
Expect(mfs[0].Comment).To(Equal("full scan comment"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when no scan was in progress", func() {
|
||||
BeforeEach(func() {
|
||||
// First, complete a full scan to populate the database
|
||||
Expect(runScanner(ctx, true)).To(Succeed())
|
||||
|
||||
// Verify files were imported
|
||||
mfs, err := ds.MediaFile(ctx).GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfs).To(HaveLen(2))
|
||||
|
||||
// Library should have LastScanStartedAt cleared after successful scan
|
||||
updatedLib, err := ds.Library(ctx).Get(lib.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(updatedLib.LastScanStartedAt).To(BeZero())
|
||||
Expect(updatedLib.FullScanInProgress).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should respect the full scan flag for new scans", func() {
|
||||
// Update a tag without changing the folder hash by preserving the original modtime.
|
||||
origModTime := fsys.MapFS["The Beatles/Help!/01 - Help!.mp3"].ModTime
|
||||
fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"comment": "new full scan"}, origModTime)
|
||||
|
||||
// Start a new full scan
|
||||
Expect(runScanner(ctx, true)).To(Succeed())
|
||||
|
||||
// Verify the comment was updated
|
||||
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"title": "Help!"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfs).To(HaveLen(1))
|
||||
Expect(mfs[0].Comment).To(Equal("new full scan"))
|
||||
})
|
||||
|
||||
It("should not rescan unchanged folders during quick scan", func() {
|
||||
// Update a tag without changing the folder hash by preserving the original modtime.
|
||||
// This simulates editing tags in a file (e.g., with a tag editor) without modifying its timestamp.
|
||||
// In a quick scan, this should NOT be detected because the folder hash remains unchanged.
|
||||
origModTime := fsys.MapFS["The Beatles/Help!/01 - Help!.mp3"].ModTime
|
||||
fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"comment": "should not appear"}, origModTime)
|
||||
|
||||
// Do a quick scan - unchanged folders should be skipped
|
||||
Expect(runScanner(ctx, false)).To(Succeed())
|
||||
|
||||
// Verify the comment was NOT updated (folder was skipped)
|
||||
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"title": "Help!"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfs).To(HaveLen(1))
|
||||
Expect(mfs[0].Comment).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("RefreshStats", func() {
|
||||
var refreshStatsCalls []bool
|
||||
var fsys storagetest.FakeFS
|
||||
|
||||
@@ -193,24 +193,24 @@ func UsernameFromToken(r *http.Request) string {
|
||||
return token.Subject()
|
||||
}
|
||||
|
||||
func UsernameFromReverseProxyHeader(r *http.Request) string {
|
||||
if conf.Server.ReverseProxyWhitelist == "" {
|
||||
func UsernameFromExtAuthHeader(r *http.Request) string {
|
||||
if conf.Server.ExtAuth.TrustedSources == "" {
|
||||
return ""
|
||||
}
|
||||
reverseProxyIp, ok := request.ReverseProxyIpFrom(r.Context())
|
||||
if !ok {
|
||||
log.Error("ReverseProxyWhitelist enabled but no proxy IP found in request context. Please report this error.")
|
||||
log.Error("ExtAuth enabled but no proxy IP found in request context. Please report this error.")
|
||||
return ""
|
||||
}
|
||||
if !validateIPAgainstList(reverseProxyIp, conf.Server.ReverseProxyWhitelist) {
|
||||
log.Warn(r.Context(), "IP is not whitelisted for reverse proxy login", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr)
|
||||
if !validateIPAgainstList(reverseProxyIp, conf.Server.ExtAuth.TrustedSources) {
|
||||
log.Warn(r.Context(), "IP is not whitelisted for external authentication", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr)
|
||||
return ""
|
||||
}
|
||||
username := r.Header.Get(conf.Server.ReverseProxyUserHeader)
|
||||
username := r.Header.Get(conf.Server.ExtAuth.UserHeader)
|
||||
if username == "" {
|
||||
return ""
|
||||
}
|
||||
log.Trace(r, "Found username in ReverseProxyUserHeader", "username", username)
|
||||
log.Trace(r, "Found username in ExtAuth.UserHeader", "username", username)
|
||||
return username
|
||||
}
|
||||
|
||||
@@ -256,7 +256,7 @@ func authenticateRequest(ds model.DataStore, r *http.Request, findUsernameFns ..
|
||||
func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromReverseProxyHeader)
|
||||
ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromExtAuthHeader)
|
||||
if err != nil {
|
||||
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
|
||||
return
|
||||
@@ -291,7 +291,7 @@ func JWTRefresher(next http.Handler) http.Handler {
|
||||
func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]interface{} {
|
||||
username := UsernameFromConfig(r)
|
||||
if username == "" {
|
||||
username = UsernameFromReverseProxyHeader(r)
|
||||
username = UsernameFromExtAuthHeader(r)
|
||||
if username == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ var _ = Describe("Auth", func() {
|
||||
req.Header.Add("Remote-User", "janedoe")
|
||||
resp = httptest.NewRecorder()
|
||||
conf.Server.UILoginBackgroundURL = ""
|
||||
conf.Server.ReverseProxyWhitelist = "192.168.0.0/16,2001:4860:4860::/48"
|
||||
conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16,2001:4860:4860::/48"
|
||||
})
|
||||
|
||||
It("sets auth data if IPv4 matches whitelist", func() {
|
||||
@@ -155,7 +155,7 @@ var _ = Describe("Auth", func() {
|
||||
|
||||
It("does not set auth data when listening on unix socket without whitelist", func() {
|
||||
conf.Server.Address = "unix:/tmp/navidrome-test"
|
||||
conf.Server.ReverseProxyWhitelist = ""
|
||||
conf.Server.ExtAuth.TrustedSources = ""
|
||||
|
||||
// No ReverseProxyIp in request context
|
||||
serveIndex(ds, fs, nil)(resp, req)
|
||||
@@ -176,7 +176,7 @@ var _ = Describe("Auth", func() {
|
||||
|
||||
It("sets auth data when listening on unix socket with correct whitelist", func() {
|
||||
conf.Server.Address = "unix:/tmp/navidrome-test"
|
||||
conf.Server.ReverseProxyWhitelist = conf.Server.ReverseProxyWhitelist + ",@"
|
||||
conf.Server.ExtAuth.TrustedSources = conf.Server.ExtAuth.TrustedSources + ",@"
|
||||
|
||||
req = req.WithContext(request.WithReverseProxyIp(req.Context(), "@"))
|
||||
serveIndex(ds, fs, nil)(resp, req)
|
||||
@@ -302,8 +302,8 @@ var _ = Describe("Auth", func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
req = httptest.NewRequest("GET", "/", nil)
|
||||
req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIP))
|
||||
conf.Server.ReverseProxyWhitelist = "192.168.0.0/16"
|
||||
conf.Server.ReverseProxyUserHeader = "Remote-User"
|
||||
conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16"
|
||||
conf.Server.ExtAuth.UserHeader = "Remote-User"
|
||||
})
|
||||
|
||||
It("makes the first user an admin", func() {
|
||||
|
||||
@@ -107,7 +107,7 @@ func secureMiddleware() func(http.Handler) http.Handler {
|
||||
FrameDeny: true,
|
||||
ReferrerPolicy: "same-origin",
|
||||
PermissionsPolicy: "autoplay=(), camera=(), microphone=(), usb=()",
|
||||
CustomFrameOptionsValue: conf.Server.HTTPSecurityHeaders.CustomFrameOptionsValue,
|
||||
CustomFrameOptionsValue: conf.Server.HTTPHeaders.FrameOptions,
|
||||
//ContentSecurityPolicy: "script-src 'self' 'unsafe-inline'",
|
||||
})
|
||||
return sec.Handler
|
||||
@@ -168,7 +168,7 @@ func clientUniqueIDMiddleware(next http.Handler) http.Handler {
|
||||
// realIPMiddleware applies middleware.RealIP, and additionally saves the request's original RemoteAddr to the request's
|
||||
// context if navidrome is behind a trusted reverse proxy.
|
||||
func realIPMiddleware(next http.Handler) http.Handler {
|
||||
if conf.Server.ReverseProxyWhitelist != "" {
|
||||
if conf.Server.ExtAuth.TrustedSources != "" {
|
||||
return chi.Chain(
|
||||
reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr }),
|
||||
middleware.RealIP,
|
||||
|
||||
@@ -118,11 +118,7 @@ func (api *Router) routes() http.Handler {
|
||||
hr(r, "getAlbumList2", api.GetAlbumList2)
|
||||
h(r, "getStarred", api.GetStarred)
|
||||
h(r, "getStarred2", api.GetStarred2)
|
||||
if conf.Server.EnableNowPlaying {
|
||||
h(r, "getNowPlaying", api.GetNowPlaying)
|
||||
} else {
|
||||
h501(r, "getNowPlaying")
|
||||
}
|
||||
h(r, "getNowPlaying", api.GetNowPlaying)
|
||||
h(r, "getRandomSongs", api.GetRandomSongs)
|
||||
h(r, "getSongsByGenre", api.GetSongsByGenre)
|
||||
})
|
||||
|
||||
@@ -56,7 +56,7 @@ func fromInternalOrProxyAuth(r *http.Request) (string, bool) {
|
||||
return username, true
|
||||
}
|
||||
|
||||
return server.UsernameFromReverseProxyHeader(r), false
|
||||
return server.UsernameFromExtAuthHeader(r), false
|
||||
}
|
||||
|
||||
func checkRequiredParameters(next http.Handler) http.Handler {
|
||||
|
||||
@@ -95,8 +95,8 @@ var _ = Describe("Middlewares", func() {
|
||||
})
|
||||
|
||||
It("passes when all required params are available (reverse-proxy case)", func() {
|
||||
conf.Server.ReverseProxyWhitelist = "127.0.0.234/32"
|
||||
conf.Server.ReverseProxyUserHeader = "Remote-User"
|
||||
conf.Server.ExtAuth.TrustedSources = "127.0.0.234/32"
|
||||
conf.Server.ExtAuth.UserHeader = "Remote-User"
|
||||
|
||||
r := newGetRequest("v=1.15", "c=test")
|
||||
r.Header.Add("Remote-User", "user")
|
||||
@@ -254,8 +254,8 @@ var _ = Describe("Middlewares", func() {
|
||||
When("using reverse proxy authentication", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.ReverseProxyWhitelist = "192.168.1.1/24"
|
||||
conf.Server.ReverseProxyUserHeader = "Remote-User"
|
||||
conf.Server.ExtAuth.TrustedSources = "192.168.1.1/24"
|
||||
conf.Server.ExtAuth.UserHeader = "Remote-User"
|
||||
})
|
||||
|
||||
It("passes authentication with correct IP and header", func() {
|
||||
|
||||
@@ -2,11 +2,13 @@ package subsonic
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
@@ -35,7 +37,13 @@ func (api *Router) GetUser(r *http.Request) (*responses.Subsonic, error) {
|
||||
if !ok {
|
||||
return nil, newError(responses.ErrorGeneric, "Internal error")
|
||||
}
|
||||
|
||||
username, err := req.Params(r).String("username")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !strings.EqualFold(username, loggedUser.UserName) {
|
||||
return nil, newError(responses.ErrorAuthorizationFail)
|
||||
}
|
||||
response := newResponse()
|
||||
user := buildUserResponse(loggedUser)
|
||||
response.User = &user
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@@ -43,8 +43,8 @@ var _ = Describe("Users", func() {
|
||||
}
|
||||
|
||||
// Create request with user in context
|
||||
req := httptest.NewRequest("GET", "/rest/getUser", nil)
|
||||
ctx := request.WithUser(context.Background(), testUser)
|
||||
req := httptest.NewRequest("GET", "/rest/getUser?username=testuser", nil)
|
||||
ctx := request.WithUser(GinkgoT().Context(), testUser)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
userResponse, err1 := router.GetUser(req)
|
||||
@@ -116,4 +116,60 @@ var _ = Describe("Users", func() {
|
||||
Expect(response.Folder).To(ContainElements(int32(1), int32(2), int32(5)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetUser authorization", func() {
|
||||
It("should allow user to request their own information", func() {
|
||||
req := httptest.NewRequest("GET", "/rest/getUser?username=testuser", nil)
|
||||
ctx := request.WithUser(GinkgoT().Context(), testUser)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
response, err := router.GetUser(req)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response).ToNot(BeNil())
|
||||
Expect(response.User).ToNot(BeNil())
|
||||
Expect(response.User.Username).To(Equal("testuser"))
|
||||
})
|
||||
|
||||
It("should deny user from requesting another user's information", func() {
|
||||
req := httptest.NewRequest("GET", "/rest/getUser?username=anotheruser", nil)
|
||||
ctx := request.WithUser(GinkgoT().Context(), testUser)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
response, err := router.GetUser(req)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(response).To(BeNil())
|
||||
|
||||
var subErr subError
|
||||
ok := errors.As(err, &subErr)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(subErr.code).To(Equal(responses.ErrorAuthorizationFail))
|
||||
})
|
||||
|
||||
It("should return error when username parameter is missing", func() {
|
||||
req := httptest.NewRequest("GET", "/rest/getUser", nil)
|
||||
ctx := request.WithUser(GinkgoT().Context(), testUser)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
response, err := router.GetUser(req)
|
||||
|
||||
Expect(err).To(MatchError("missing parameter: 'username'"))
|
||||
Expect(response).To(BeNil())
|
||||
})
|
||||
|
||||
It("should return error when user context is missing", func() {
|
||||
req := httptest.NewRequest("GET", "/rest/getUser?username=testuser", nil)
|
||||
|
||||
response, err := router.GetUser(req)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(response).To(BeNil())
|
||||
|
||||
var subErr subError
|
||||
ok := errors.As(err, &subErr)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(subErr.code).To(Equal(responses.ErrorGeneric))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@ type MockDataStore struct {
|
||||
MockedTranscoding model.TranscodingRepository
|
||||
MockedUserProps model.UserPropsRepository
|
||||
MockedScrobbleBuffer model.ScrobbleBufferRepository
|
||||
MockedScrobble model.ScrobbleRepository
|
||||
MockedRadio model.RadioRepository
|
||||
scrobbleBufferMu sync.Mutex
|
||||
repoMu sync.Mutex
|
||||
@@ -208,12 +209,23 @@ func (db *MockDataStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBuffe
|
||||
if db.RealDS != nil {
|
||||
db.MockedScrobbleBuffer = db.RealDS.ScrobbleBuffer(ctx)
|
||||
} else {
|
||||
db.MockedScrobbleBuffer = CreateMockedScrobbleBufferRepo()
|
||||
db.MockedScrobbleBuffer = &MockedScrobbleBufferRepo{}
|
||||
}
|
||||
}
|
||||
return db.MockedScrobbleBuffer
|
||||
}
|
||||
|
||||
func (db *MockDataStore) Scrobble(ctx context.Context) model.ScrobbleRepository {
|
||||
if db.MockedScrobble == nil {
|
||||
if db.RealDS != nil {
|
||||
db.MockedScrobble = db.RealDS.Scrobble(ctx)
|
||||
} else {
|
||||
db.MockedScrobble = &MockScrobbleRepo{ctx: ctx}
|
||||
}
|
||||
}
|
||||
return db.MockedScrobble
|
||||
}
|
||||
|
||||
func (db *MockDataStore) Radio(ctx context.Context) model.RadioRepository {
|
||||
if db.MockedRadio == nil {
|
||||
if db.RealDS != nil {
|
||||
|
||||
24
tests/mock_scrobble_repo.go
Normal file
24
tests/mock_scrobble_repo.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
)
|
||||
|
||||
type MockScrobbleRepo struct {
|
||||
RecordedScrobbles []model.Scrobble
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (m *MockScrobbleRepo) RecordScrobble(fileID string, submissionTime time.Time) error {
|
||||
user, _ := request.UserFrom(m.ctx)
|
||||
m.RecordedScrobbles = append(m.RecordedScrobbles, model.Scrobble{
|
||||
MediaFileID: fileID,
|
||||
UserID: user.ID,
|
||||
SubmissionTime: submissionTime,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
7
ui/package-lock.json
generated
7
ui/package-lock.json
generated
@@ -7173,10 +7173,11 @@
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import React from 'react'
|
||||
import { TopToolbar, ExportButton } from 'react-admin'
|
||||
import { TopToolbar, ExportButton, useListContext } from 'react-admin'
|
||||
import DeleteMissingFilesButton from './DeleteMissingFilesButton.jsx'
|
||||
|
||||
const MissingListActions = (props) => (
|
||||
<TopToolbar {...props}>
|
||||
<ExportButton />
|
||||
<DeleteMissingFilesButton deleteAll />
|
||||
</TopToolbar>
|
||||
)
|
||||
const MissingListActions = (props) => {
|
||||
const { total } = useListContext()
|
||||
return (
|
||||
<TopToolbar {...props}>
|
||||
<ExportButton maxResults={total} />
|
||||
<DeleteMissingFilesButton deleteAll />
|
||||
</TopToolbar>
|
||||
)
|
||||
}
|
||||
|
||||
export default MissingListActions
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
usePermissions,
|
||||
} from 'react-admin'
|
||||
import Switch from '@material-ui/core/Switch'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import {
|
||||
DurationField,
|
||||
@@ -28,6 +29,12 @@ import {
|
||||
import PlaylistListActions from './PlaylistListActions'
|
||||
import ChangePublicStatusButton from './ChangePublicStatusButton'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
button: {
|
||||
color: theme.palette.type === 'dark' ? 'white' : undefined,
|
||||
},
|
||||
}))
|
||||
|
||||
const PlaylistFilter = (props) => {
|
||||
const { permissions } = usePermissions()
|
||||
return (
|
||||
@@ -112,13 +119,24 @@ const ToggleAutoImport = ({ resource, source }) => {
|
||||
) : null
|
||||
}
|
||||
|
||||
const PlaylistListBulkActions = (props) => (
|
||||
<>
|
||||
<ChangePublicStatusButton public={true} {...props} />
|
||||
<ChangePublicStatusButton public={false} {...props} />
|
||||
<BulkDeleteButton {...props} />
|
||||
</>
|
||||
)
|
||||
const PlaylistListBulkActions = (props) => {
|
||||
const classes = useStyles()
|
||||
return (
|
||||
<>
|
||||
<ChangePublicStatusButton
|
||||
public={true}
|
||||
{...props}
|
||||
className={classes.button}
|
||||
/>
|
||||
<ChangePublicStatusButton
|
||||
public={false}
|
||||
{...props}
|
||||
className={classes.button}
|
||||
/>
|
||||
<BulkDeleteButton {...props} className={classes.button} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const PlaylistList = (props) => {
|
||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
const stylesheet = `
|
||||
.react-jinke-music-player-main .music-player-panel svg {
|
||||
color: #eee
|
||||
}
|
||||
.react-jinke-music-player-main .music-player-panel button:disabled svg {
|
||||
opacity: 0.3
|
||||
}
|
||||
.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover {
|
||||
color: #D60017
|
||||
}
|
||||
@@ -27,7 +33,6 @@ const stylesheet = `
|
||||
.react-jinke-music-player-main .audio-item.playing .player-singer {
|
||||
color: #ff4e6b !important
|
||||
}
|
||||
.react-jinke-music-player-main .lyric-btn,
|
||||
.react-jinke-music-player-main .lyric-btn-active svg{
|
||||
color: #ff4e6b !important
|
||||
}
|
||||
|
||||
@@ -194,7 +194,12 @@ export default {
|
||||
},
|
||||
RaDeleteWithConfirmButton: {
|
||||
deleteButton: {
|
||||
color: 'unset',
|
||||
color: '#fff',
|
||||
},
|
||||
},
|
||||
RaBulkDeleteWithUndoButton: {
|
||||
deleteButton: {
|
||||
color: '#fff',
|
||||
},
|
||||
},
|
||||
RaPaginationActions: {
|
||||
|
||||
@@ -42,6 +42,12 @@ const useCurrentTheme = () => {
|
||||
document.head.removeChild(style)
|
||||
}
|
||||
}
|
||||
|
||||
// Set body background color to match theme (fixes white background on pull-to-refresh)
|
||||
const isDark = theme.palette?.type === 'dark'
|
||||
const bgColor =
|
||||
theme.palette?.background?.default || (isDark ? '#303030' : '#fafafa')
|
||||
document.body.style.backgroundColor = bgColor
|
||||
}, [theme])
|
||||
|
||||
return theme
|
||||
|
||||
@@ -15,6 +15,10 @@ function createMatchMedia(theme) {
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.style.backgroundColor = ''
|
||||
})
|
||||
|
||||
describe('useCurrentTheme', () => {
|
||||
describe('with user preference theme as light', () => {
|
||||
beforeAll(() => {
|
||||
@@ -117,4 +121,44 @@ describe('useCurrentTheme', () => {
|
||||
expect(result.current.themeName).toMatch('Spotify-ish')
|
||||
})
|
||||
})
|
||||
describe('body background color', () => {
|
||||
beforeAll(() => {
|
||||
window.matchMedia = createMatchMedia('dark')
|
||||
})
|
||||
it('sets body background for dark theme', () => {
|
||||
renderHook(() => useCurrentTheme(), {
|
||||
wrapper: ({ children }) => (
|
||||
<Provider store={createStore(themeReducer, { theme: 'DarkTheme' })}>
|
||||
{children}
|
||||
</Provider>
|
||||
),
|
||||
})
|
||||
// Dark theme uses MUI default dark background
|
||||
expect(document.body.style.backgroundColor).toBe('rgb(48, 48, 48)')
|
||||
})
|
||||
it('sets body background for light theme', () => {
|
||||
renderHook(() => useCurrentTheme(), {
|
||||
wrapper: ({ children }) => (
|
||||
<Provider store={createStore(themeReducer, { theme: 'LightTheme' })}>
|
||||
{children}
|
||||
</Provider>
|
||||
),
|
||||
})
|
||||
// Light theme uses MUI default light background
|
||||
expect(document.body.style.backgroundColor).toBe('rgb(250, 250, 250)')
|
||||
})
|
||||
it('sets body background for theme with custom background', () => {
|
||||
renderHook(() => useCurrentTheme(), {
|
||||
wrapper: ({ children }) => (
|
||||
<Provider
|
||||
store={createStore(themeReducer, { theme: 'SpotifyTheme' })}
|
||||
>
|
||||
{children}
|
||||
</Provider>
|
||||
),
|
||||
})
|
||||
// Spotify theme has explicit background.default: #121212
|
||||
expect(document.body.style.backgroundColor).toBe('rgb(18, 18, 18)')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,11 +2,15 @@ package number
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"golang.org/x/exp/constraints"
|
||||
)
|
||||
|
||||
func ParseInt[T constraints.Integer](s string) T {
|
||||
// Integer is a constraint that permits any integer type.
|
||||
type Integer interface {
|
||||
~int | ~int8 | ~int16 | ~int32 | ~int64 |
|
||||
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
|
||||
}
|
||||
|
||||
func ParseInt[T Integer](s string) T {
|
||||
r, _ := strconv.ParseInt(s, 10, 64)
|
||||
return T(r)
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@ import (
|
||||
"encoding/binary"
|
||||
"math/big"
|
||||
|
||||
"golang.org/x/exp/constraints"
|
||||
"github.com/navidrome/navidrome/utils/number"
|
||||
)
|
||||
|
||||
// Int64N returns a random int64 between 0 and max.
|
||||
// This is a reimplementation of math/rand/v2.Int64N using a cryptographically secure random number generator.
|
||||
func Int64N[T constraints.Integer](max T) int64 {
|
||||
func Int64N[T number.Integer](max T) int64 {
|
||||
rnd, _ := rand.Int(rand.Reader, big.NewInt(int64(max)))
|
||||
return rnd.Int64()
|
||||
}
|
||||
|
||||
@@ -6,9 +6,8 @@ import (
|
||||
"cmp"
|
||||
"io"
|
||||
"iter"
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
"golang.org/x/exp/maps"
|
||||
)
|
||||
|
||||
func Map[T any, R any](t []T, mapFunc func(T) R) []R {
|
||||
@@ -49,11 +48,9 @@ func CompactByFrequency[T comparable](list []T) []T {
|
||||
counters[item]++
|
||||
}
|
||||
|
||||
sorted := maps.Keys(counters)
|
||||
slices.SortFunc(sorted, func(i, j T) int {
|
||||
return slices.SortedFunc(maps.Keys(counters), func(i, j T) int {
|
||||
return cmp.Compare(counters[j], counters[i])
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
func MostFrequent[T comparable](list []T) T {
|
||||
|
||||
Reference in New Issue
Block a user