mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-05 21:38:27 -05:00
Compare commits
36 Commits
dependabot
...
v0.58.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
131c0c565c | ||
|
|
53ff33866d | ||
|
|
508670ecfb | ||
|
|
c369224597 | ||
|
|
ff583970f0 | ||
|
|
38ca65726a | ||
|
|
5ce6e16d96 | ||
|
|
69527085db | ||
|
|
9bb933c0d6 | ||
|
|
6f4fa76772 | ||
|
|
9621a40f29 | ||
|
|
df95dffa74 | ||
|
|
a59b59192a | ||
|
|
4f7dc105b0 | ||
|
|
e918e049e2 | ||
|
|
1e8d28ff46 | ||
|
|
a128b3cf98 | ||
|
|
290a9fdeaa | ||
|
|
58b5ed86df | ||
|
|
fe1cee0159 | ||
|
|
3dfaa8cca1 | ||
|
|
0a5abfc1b1 | ||
|
|
c501bc6996 | ||
|
|
0c71842b12 | ||
|
|
e86dc03619 | ||
|
|
775626e037 | ||
|
|
91fab68578 | ||
|
|
0bdd3e6f8b | ||
|
|
465846c1bc | ||
|
|
cce11c5416 | ||
|
|
d021289279 | ||
|
|
aa7f55646d | ||
|
|
925bfafc1f | ||
|
|
e24f7984cc | ||
|
|
ac3e6ae6a5 | ||
|
|
b2019da999 |
@@ -4,10 +4,10 @@
|
||||
"dockerfile": "Dockerfile",
|
||||
"args": {
|
||||
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
|
||||
"VARIANT": "1.24",
|
||||
"VARIANT": "1.25",
|
||||
// Options
|
||||
"INSTALL_NODE": "true",
|
||||
"NODE_VERSION": "v20"
|
||||
"NODE_VERSION": "v24"
|
||||
}
|
||||
},
|
||||
"workspaceMount": "",
|
||||
|
||||
42
.github/workflows/pipeline.yml
vendored
42
.github/workflows/pipeline.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
git_tag: ${{ steps.git-version.outputs.GIT_TAG }}
|
||||
git_sha: ${{ steps.git-version.outputs.GIT_SHA }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
name: Lint Go code
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Download TagLib
|
||||
uses: ./.github/actions/download-taglib
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Download TagLib
|
||||
uses: ./.github/actions/download-taglib
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
- name: Test
|
||||
run: |
|
||||
pkg-config --define-prefix --cflags --libs taglib # for debugging
|
||||
go test -shuffle=on -tags netgo -race -cover ./... -v
|
||||
go test -shuffle=on -tags netgo -race ./... -v
|
||||
|
||||
js:
|
||||
name: Test JS code
|
||||
@@ -114,10 +114,10 @@ jobs:
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
cache-dependency-path: "**/package-lock.json"
|
||||
|
||||
@@ -145,7 +145,7 @@ jobs:
|
||||
name: Lint i18n files
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- run: |
|
||||
set -e
|
||||
for file in resources/i18n/*.json; do
|
||||
@@ -191,7 +191,7 @@ jobs:
|
||||
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
|
||||
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Prepare Docker Buildx
|
||||
uses: ./.github/actions/prepare-docker
|
||||
@@ -217,7 +217,7 @@ jobs:
|
||||
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
|
||||
|
||||
- name: Upload Binaries
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: navidrome-${{ env.PLATFORM }}
|
||||
path: ./output
|
||||
@@ -248,7 +248,7 @@ jobs:
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM }}
|
||||
@@ -264,10 +264,10 @@ jobs:
|
||||
env:
|
||||
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
@@ -318,9 +318,9 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v6
|
||||
with:
|
||||
path: ./binaries
|
||||
pattern: navidrome-windows*
|
||||
@@ -339,7 +339,7 @@ jobs:
|
||||
du -h binaries/msi/*.msi
|
||||
|
||||
- name: Upload MSI files
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: navidrome-windows-installers
|
||||
path: binaries/msi/*.msi
|
||||
@@ -352,12 +352,12 @@ jobs:
|
||||
outputs:
|
||||
package_list: ${{ steps.set-package-list.outputs.package_list }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v6
|
||||
with:
|
||||
path: ./binaries
|
||||
pattern: navidrome-*
|
||||
@@ -383,7 +383,7 @@ jobs:
|
||||
rm ./dist/*.tar.gz ./dist/*.zip
|
||||
|
||||
- name: Upload all-packages artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: packages
|
||||
path: dist/navidrome_0*
|
||||
@@ -406,13 +406,13 @@ jobs:
|
||||
item: ${{ fromJson(needs.release.outputs.package_list) }}
|
||||
steps:
|
||||
- name: Download all-packages artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: packages
|
||||
path: ./dist
|
||||
|
||||
- name: Upload all-packages artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
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
@@ -28,7 +28,7 @@ jobs:
|
||||
This pull request has been automatically locked since there
|
||||
has not been any recent activity after it was closed.
|
||||
Please open a new issue for related bugs.
|
||||
- uses: actions/stale@v10
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
operations-per-run: 999
|
||||
days-before-issue-stale: 180
|
||||
|
||||
2
.github/workflows/update-translations.yml
vendored
2
.github/workflows/update-translations.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'navidrome' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Get updated translations
|
||||
id: poeditor
|
||||
env:
|
||||
|
||||
@@ -31,7 +31,9 @@ ARG TARGETPLATFORM
|
||||
ARG CROSS_TAGLIB_VERSION=2.1.1-1
|
||||
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
|
||||
|
||||
# wget in busybox can't follow redirects
|
||||
RUN <<EOT
|
||||
apk add --no-cache wget
|
||||
PLATFORM=$(echo ${TARGETPLATFORM} | tr '/' '-')
|
||||
FILE=taglib-${PLATFORM}.tar.gz
|
||||
|
||||
@@ -61,7 +63,7 @@ COPY --from=ui /build /build
|
||||
|
||||
########################################################################################################################
|
||||
### Build Navidrome binary
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.24-bookworm AS base
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.25-bookworm AS base
|
||||
RUN apt-get update && apt-get install -y clang lld
|
||||
COPY --from=xx / /
|
||||
WORKDIR /workspace
|
||||
|
||||
18
Makefile
18
Makefile
@@ -16,6 +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.5.0
|
||||
|
||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||
|
||||
@@ -65,7 +66,22 @@ test-i18n: ##@Development Validate all translations files
|
||||
.PHONY: test-i18n
|
||||
|
||||
install-golangci-lint: ##@Development Install golangci-lint if not present
|
||||
@PATH=$$PATH:./bin which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s v2.1.6)
|
||||
@INSTALL=false; \
|
||||
if PATH=$$PATH:./bin which golangci-lint > /dev/null 2>&1; then \
|
||||
CURRENT_VERSION=$$(PATH=$$PATH:./bin golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \
|
||||
REQUIRED_VERSION=$$(echo "$(GOLANGCI_LINT_VERSION)" | sed 's/^v//'); \
|
||||
if [ "$$CURRENT_VERSION" != "$$REQUIRED_VERSION" ]; then \
|
||||
echo "Found golangci-lint $$CURRENT_VERSION, but $$REQUIRED_VERSION is required. Reinstalling..."; \
|
||||
rm -f ./bin/golangci-lint; \
|
||||
INSTALL=true; \
|
||||
fi; \
|
||||
else \
|
||||
INSTALL=true; \
|
||||
fi; \
|
||||
if [ "$$INSTALL" = "true" ]; then \
|
||||
echo "Installing golangci-lint $(GOLANGCI_LINT_VERSION)..."; \
|
||||
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s $(GOLANGCI_LINT_VERSION); \
|
||||
fi
|
||||
.PHONY: install-golangci-lint
|
||||
|
||||
lint: install-golangci-lint ##@Development Lint Go code
|
||||
|
||||
@@ -43,23 +43,21 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
|
||||
|
||||
// Parse audio properties
|
||||
ap := metadata.AudioProperties{}
|
||||
if length, ok := tags["_lengthinmilliseconds"]; ok && len(length) > 0 {
|
||||
millis, _ := strconv.Atoi(length[0])
|
||||
if millis > 0 {
|
||||
ap.Duration = (time.Millisecond * time.Duration(millis)).Round(time.Millisecond * 10)
|
||||
}
|
||||
delete(tags, "_lengthinmilliseconds")
|
||||
}
|
||||
parseProp := func(prop string, target *int) {
|
||||
if value, ok := tags[prop]; ok && len(value) > 0 {
|
||||
*target, _ = strconv.Atoi(value[0])
|
||||
delete(tags, prop)
|
||||
}
|
||||
}
|
||||
parseProp("_bitrate", &ap.BitRate)
|
||||
parseProp("_channels", &ap.Channels)
|
||||
parseProp("_samplerate", &ap.SampleRate)
|
||||
parseProp("_bitspersample", &ap.BitDepth)
|
||||
ap.BitRate = parseProp(tags, "__bitrate")
|
||||
ap.Channels = parseProp(tags, "__channels")
|
||||
ap.SampleRate = parseProp(tags, "__samplerate")
|
||||
ap.BitDepth = parseProp(tags, "__bitspersample")
|
||||
length := parseProp(tags, "__lengthinmilliseconds")
|
||||
ap.Duration = (time.Millisecond * time.Duration(length)).Round(time.Millisecond * 10)
|
||||
|
||||
// Extract basic tags
|
||||
parseBasicTag(tags, "__title", "title")
|
||||
parseBasicTag(tags, "__artist", "artist")
|
||||
parseBasicTag(tags, "__album", "album")
|
||||
parseBasicTag(tags, "__comment", "comment")
|
||||
parseBasicTag(tags, "__genre", "genre")
|
||||
parseBasicTag(tags, "__year", "year")
|
||||
parseBasicTag(tags, "__track", "tracknumber")
|
||||
|
||||
// Parse track/disc totals
|
||||
parseTuple := func(prop string) {
|
||||
@@ -107,6 +105,31 @@ var tiplMapping = map[string]string{
|
||||
"DJ-mix": "djmixer",
|
||||
}
|
||||
|
||||
// parseProp parses a property from the tags map and sets it to the target integer.
|
||||
// It also deletes the property from the tags map after parsing.
|
||||
func parseProp(tags map[string][]string, prop string) int {
|
||||
if value, ok := tags[prop]; ok && len(value) > 0 {
|
||||
v, _ := strconv.Atoi(value[0])
|
||||
delete(tags, prop)
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseBasicTag checks if a basic tag (like __title, __artist, etc.) exists in the tags map.
|
||||
// If it does, it moves the value to a more appropriate tag name (like title, artist, etc.),
|
||||
// and deletes the basic tag from the map. If the target tag already exists, it ignores the basic tag.
|
||||
func parseBasicTag(tags map[string][]string, basicName string, tagName string) {
|
||||
basicValue := tags[basicName]
|
||||
if len(basicValue) == 0 {
|
||||
return
|
||||
}
|
||||
delete(tags, basicName)
|
||||
if len(tags[tagName]) == 0 {
|
||||
tags[tagName] = basicValue
|
||||
}
|
||||
}
|
||||
|
||||
// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format:
|
||||
//
|
||||
// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson".
|
||||
|
||||
@@ -45,31 +45,63 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
|
||||
|
||||
// Add audio properties to the tags
|
||||
const TagLib::AudioProperties *props(f.audioProperties());
|
||||
goPutInt(id, (char *)"_lengthinmilliseconds", props->lengthInMilliseconds());
|
||||
goPutInt(id, (char *)"_bitrate", props->bitrate());
|
||||
goPutInt(id, (char *)"_channels", props->channels());
|
||||
goPutInt(id, (char *)"_samplerate", props->sampleRate());
|
||||
goPutInt(id, (char *)"__lengthinmilliseconds", props->lengthInMilliseconds());
|
||||
goPutInt(id, (char *)"__bitrate", props->bitrate());
|
||||
goPutInt(id, (char *)"__channels", props->channels());
|
||||
goPutInt(id, (char *)"__samplerate", props->sampleRate());
|
||||
|
||||
// Extract bits per sample for supported formats
|
||||
int bitsPerSample = 0;
|
||||
if (const auto* apeProperties{ dynamic_cast<const TagLib::APE::Properties*>(props) })
|
||||
goPutInt(id, (char *)"_bitspersample", apeProperties->bitsPerSample());
|
||||
if (const auto* asfProperties{ dynamic_cast<const TagLib::ASF::Properties*>(props) })
|
||||
goPutInt(id, (char *)"_bitspersample", asfProperties->bitsPerSample());
|
||||
bitsPerSample = apeProperties->bitsPerSample();
|
||||
else if (const auto* asfProperties{ dynamic_cast<const TagLib::ASF::Properties*>(props) })
|
||||
bitsPerSample = asfProperties->bitsPerSample();
|
||||
else if (const auto* flacProperties{ dynamic_cast<const TagLib::FLAC::Properties*>(props) })
|
||||
goPutInt(id, (char *)"_bitspersample", flacProperties->bitsPerSample());
|
||||
bitsPerSample = flacProperties->bitsPerSample();
|
||||
else if (const auto* mp4Properties{ dynamic_cast<const TagLib::MP4::Properties*>(props) })
|
||||
goPutInt(id, (char *)"_bitspersample", mp4Properties->bitsPerSample());
|
||||
bitsPerSample = mp4Properties->bitsPerSample();
|
||||
else if (const auto* wavePackProperties{ dynamic_cast<const TagLib::WavPack::Properties*>(props) })
|
||||
goPutInt(id, (char *)"_bitspersample", wavePackProperties->bitsPerSample());
|
||||
bitsPerSample = wavePackProperties->bitsPerSample();
|
||||
else if (const auto* aiffProperties{ dynamic_cast<const TagLib::RIFF::AIFF::Properties*>(props) })
|
||||
goPutInt(id, (char *)"_bitspersample", aiffProperties->bitsPerSample());
|
||||
bitsPerSample = aiffProperties->bitsPerSample();
|
||||
else if (const auto* wavProperties{ dynamic_cast<const TagLib::RIFF::WAV::Properties*>(props) })
|
||||
goPutInt(id, (char *)"_bitspersample", wavProperties->bitsPerSample());
|
||||
bitsPerSample = wavProperties->bitsPerSample();
|
||||
else if (const auto* dsfProperties{ dynamic_cast<const TagLib::DSF::Properties*>(props) })
|
||||
goPutInt(id, (char *)"_bitspersample", dsfProperties->bitsPerSample());
|
||||
bitsPerSample = dsfProperties->bitsPerSample();
|
||||
|
||||
if (bitsPerSample > 0) {
|
||||
goPutInt(id, (char *)"__bitspersample", bitsPerSample);
|
||||
}
|
||||
|
||||
// Send all properties to the Go map
|
||||
TagLib::PropertyMap tags = f.file()->properties();
|
||||
|
||||
// Make sure at least the basic properties are extracted
|
||||
TagLib::Tag *basic = f.file()->tag();
|
||||
if (!basic->isEmpty()) {
|
||||
if (!basic->title().isEmpty()) {
|
||||
tags.insert("__title", basic->title());
|
||||
}
|
||||
if (!basic->artist().isEmpty()) {
|
||||
tags.insert("__artist", basic->artist());
|
||||
}
|
||||
if (!basic->album().isEmpty()) {
|
||||
tags.insert("__album", basic->album());
|
||||
}
|
||||
if (!basic->comment().isEmpty()) {
|
||||
tags.insert("__comment", basic->comment());
|
||||
}
|
||||
if (!basic->genre().isEmpty()) {
|
||||
tags.insert("__genre", basic->genre());
|
||||
}
|
||||
if (basic->year() > 0) {
|
||||
tags.insert("__year", TagLib::String::number(basic->year()));
|
||||
}
|
||||
if (basic->track() > 0) {
|
||||
tags.insert("__track", TagLib::String::number(basic->track()));
|
||||
}
|
||||
}
|
||||
|
||||
TagLib::ID3v2::Tag *id3Tags = NULL;
|
||||
|
||||
// Get some extended/non-standard ID3-only tags (ex: iTunes extended frames)
|
||||
|
||||
@@ -72,7 +72,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
watcher := scanner.GetWatcher(dataStore, scannerScanner)
|
||||
library := core.NewLibrary(dataStore, scannerScanner, watcher, broker)
|
||||
router := nativeapi.New(dataStore, share, playlists, insights, library)
|
||||
maintenance := core.NewMaintenance(dataStore)
|
||||
router := nativeapi.New(dataStore, share, playlists, insights, library, maintenance)
|
||||
return router
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ var _ = Describe("CacheWarmer", func() {
|
||||
})
|
||||
|
||||
It("deduplicates items in buffer", func() {
|
||||
fc.SetReady(false) // Make cache unavailable so items stay in buffer
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/ioutils"
|
||||
)
|
||||
|
||||
func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||
@@ -27,8 +28,7 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (
|
||||
|
||||
externalLyric := basePath[0:len(basePath)-len(ext)] + suffix
|
||||
|
||||
contents, err := os.ReadFile(externalLyric)
|
||||
|
||||
contents, err := ioutils.UTF8ReadFile(externalLyric)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
log.Trace(ctx, "no lyrics found at path", "path", externalLyric)
|
||||
return nil, nil
|
||||
|
||||
@@ -108,5 +108,39 @@ var _ = Describe("sources", func() {
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
It("should handle LRC files with UTF-8 BOM marker (issue #4631)", func() {
|
||||
// The function looks for <basePath-without-ext><suffix>, so we need to pass
|
||||
// a MediaFile with .mp3 path and look for .lrc suffix
|
||||
mf := model.MediaFile{Path: "tests/fixtures/bom-test.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).ToNot(BeNil())
|
||||
Expect(lyrics).To(HaveLen(1))
|
||||
|
||||
// The critical assertion: even with BOM, synced should be true
|
||||
Expect(lyrics[0].Synced).To(BeTrue(), "Lyrics with BOM marker should be recognized as synced")
|
||||
Expect(lyrics[0].Line).To(HaveLen(1))
|
||||
Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(0))))
|
||||
Expect(lyrics[0].Line[0].Value).To(ContainSubstring("作曲"))
|
||||
})
|
||||
|
||||
It("should handle UTF-16 LE encoded LRC files", func() {
|
||||
mf := model.MediaFile{Path: "tests/fixtures/bom-utf16-test.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).ToNot(BeNil())
|
||||
Expect(lyrics).To(HaveLen(1))
|
||||
|
||||
// UTF-16 should be properly converted to UTF-8
|
||||
Expect(lyrics[0].Synced).To(BeTrue(), "UTF-16 encoded lyrics should be recognized as synced")
|
||||
Expect(lyrics[0].Line).To(HaveLen(2))
|
||||
Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(18800))))
|
||||
Expect(lyrics[0].Line[0].Value).To(Equal("We're no strangers to love"))
|
||||
Expect(lyrics[0].Line[1].Start).To(Equal(gg.P(int64(22801))))
|
||||
Expect(lyrics[0].Line[1].Value).To(Equal("You know the rules and so do I"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
226
core/maintenance.go
Normal file
226
core/maintenance.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
type Maintenance interface {
|
||||
// DeleteMissingFiles deletes specific missing files by their IDs
|
||||
DeleteMissingFiles(ctx context.Context, ids []string) error
|
||||
// DeleteAllMissingFiles deletes all files marked as missing
|
||||
DeleteAllMissingFiles(ctx context.Context) error
|
||||
}
|
||||
|
||||
type maintenanceService struct {
|
||||
ds model.DataStore
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewMaintenance(ds model.DataStore) Maintenance {
|
||||
return &maintenanceService{
|
||||
ds: ds,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *maintenanceService) DeleteMissingFiles(ctx context.Context, ids []string) error {
|
||||
return s.deleteMissing(ctx, ids)
|
||||
}
|
||||
|
||||
func (s *maintenanceService) DeleteAllMissingFiles(ctx context.Context) error {
|
||||
return s.deleteMissing(ctx, nil)
|
||||
}
|
||||
|
||||
// deleteMissing handles the deletion of missing files and triggers necessary cleanup operations
|
||||
func (s *maintenanceService) deleteMissing(ctx context.Context, ids []string) error {
|
||||
// Track affected album IDs before deletion for refresh
|
||||
affectedAlbumIDs, err := s.getAffectedAlbumIDs(ctx, ids)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error tracking affected albums for refresh", err)
|
||||
// Don't fail the operation, just log the warning
|
||||
}
|
||||
|
||||
// Delete missing files within a transaction
|
||||
err = s.ds.WithTx(func(tx model.DataStore) error {
|
||||
if len(ids) == 0 {
|
||||
_, err := tx.MediaFile(ctx).DeleteAllMissing()
|
||||
return err
|
||||
}
|
||||
return tx.MediaFile(ctx).DeleteMissing(ids)
|
||||
})
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Run garbage collection to clean up orphaned records
|
||||
if err := s.ds.GC(ctx); err != nil {
|
||||
log.Error(ctx, "Error running GC after deleting missing tracks", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Refresh statistics in background
|
||||
s.refreshStatsAsync(ctx, affectedAlbumIDs)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshAlbums recalculates album attributes (size, duration, song count, etc.) from media files.
|
||||
// It uses batch queries to minimize database round-trips for efficiency.
|
||||
func (s *maintenanceService) refreshAlbums(ctx context.Context, albumIDs []string) error {
|
||||
if len(albumIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Refreshing albums", "count", len(albumIDs))
|
||||
|
||||
// Process in chunks to avoid query size limits
|
||||
const chunkSize = 100
|
||||
for chunk := range slice.CollectChunks(slices.Values(albumIDs), chunkSize) {
|
||||
if err := s.refreshAlbumChunk(ctx, chunk); err != nil {
|
||||
return fmt.Errorf("refreshing album chunk: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Successfully refreshed albums", "count", len(albumIDs))
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshAlbumChunk processes a single chunk of album IDs
|
||||
func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []string) error {
|
||||
albumRepo := s.ds.Album(ctx)
|
||||
mfRepo := s.ds.MediaFile(ctx)
|
||||
|
||||
// Batch load existing albums
|
||||
albums, err := albumRepo.GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"album.id": albumIDs},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading albums: %w", err)
|
||||
}
|
||||
|
||||
// Create a map for quick lookup
|
||||
albumMap := make(map[string]*model.Album, len(albums))
|
||||
for i := range albums {
|
||||
albumMap[albums[i].ID] = &albums[i]
|
||||
}
|
||||
|
||||
// Batch load all media files for these albums
|
||||
mediaFiles, err := mfRepo.GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"album_id": albumIDs},
|
||||
Sort: "album_id, path",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading media files: %w", err)
|
||||
}
|
||||
|
||||
// Group media files by album ID
|
||||
filesByAlbum := make(map[string]model.MediaFiles)
|
||||
for i := range mediaFiles {
|
||||
albumID := mediaFiles[i].AlbumID
|
||||
filesByAlbum[albumID] = append(filesByAlbum[albumID], mediaFiles[i])
|
||||
}
|
||||
|
||||
// Recalculate each album from its media files
|
||||
for albumID, oldAlbum := range albumMap {
|
||||
mfs, hasTracks := filesByAlbum[albumID]
|
||||
if !hasTracks {
|
||||
// Album has no tracks anymore, skip (will be cleaned up by GC)
|
||||
log.Debug(ctx, "Skipping album with no tracks", "albumID", albumID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Recalculate album from media files
|
||||
newAlbum := mfs.ToAlbum()
|
||||
|
||||
// Only update if something changed (avoid unnecessary writes)
|
||||
if !oldAlbum.Equals(newAlbum) {
|
||||
// Preserve original timestamps
|
||||
newAlbum.UpdatedAt = time.Now()
|
||||
newAlbum.CreatedAt = oldAlbum.CreatedAt
|
||||
|
||||
if err := albumRepo.Put(&newAlbum); err != nil {
|
||||
log.Error(ctx, "Error updating album during refresh", "albumID", albumID, err)
|
||||
// Continue with other albums instead of failing entirely
|
||||
continue
|
||||
}
|
||||
log.Trace(ctx, "Refreshed album", "albumID", albumID, "name", newAlbum.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAffectedAlbumIDs returns distinct album IDs from missing media files
|
||||
func (s *maintenanceService) getAffectedAlbumIDs(ctx context.Context, ids []string) ([]string, error) {
|
||||
var filters squirrel.Sqlizer = squirrel.Eq{"missing": true}
|
||||
if len(ids) > 0 {
|
||||
filters = squirrel.And{
|
||||
squirrel.Eq{"missing": true},
|
||||
squirrel.Eq{"id": ids},
|
||||
}
|
||||
}
|
||||
|
||||
mfs, err := s.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: filters,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Extract unique album IDs
|
||||
albumIDMap := make(map[string]struct{}, len(mfs))
|
||||
for _, mf := range mfs {
|
||||
if mf.AlbumID != "" {
|
||||
albumIDMap[mf.AlbumID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
albumIDs := make([]string, 0, len(albumIDMap))
|
||||
for id := range albumIDMap {
|
||||
albumIDs = append(albumIDs, id)
|
||||
}
|
||||
|
||||
return albumIDs, nil
|
||||
}
|
||||
|
||||
// refreshStatsAsync refreshes artist and album statistics in background goroutines
|
||||
func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbumIDs []string) {
|
||||
// Refresh artist stats in background
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
bgCtx := request.AddValues(context.Background(), ctx)
|
||||
if _, err := s.ds.Artist(bgCtx).RefreshStats(true); err != nil {
|
||||
log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err)
|
||||
} else {
|
||||
log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files")
|
||||
}
|
||||
|
||||
// Refresh album stats in background if we have affected albums
|
||||
if len(affectedAlbumIDs) > 0 {
|
||||
if err := s.refreshAlbums(bgCtx, affectedAlbumIDs); err != nil {
|
||||
log.Error(bgCtx, "Error refreshing album stats after deleting missing files", err)
|
||||
} else {
|
||||
log.Debug(bgCtx, "Successfully refreshed album stats after deleting missing files", "count", len(affectedAlbumIDs))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait waits for all background goroutines to complete.
|
||||
// WARNING: This method is ONLY for testing. Never call this in production code.
|
||||
// Calling Wait() in production will block until ALL background operations complete
|
||||
// and may cause race conditions with new operations starting.
|
||||
func (s *maintenanceService) wait() {
|
||||
s.wg.Wait()
|
||||
}
|
||||
382
core/maintenance_test.go
Normal file
382
core/maintenance_test.go
Normal file
@@ -0,0 +1,382 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var _ = Describe("Maintenance", func() {
|
||||
var ds *extendedDataStore
|
||||
var mfRepo *extendedMediaFileRepo
|
||||
var service Maintenance
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user1", IsAdmin: true})
|
||||
|
||||
ds = createTestDataStore()
|
||||
mfRepo = ds.MockedMediaFile.(*extendedMediaFileRepo)
|
||||
service = NewMaintenance(ds)
|
||||
})
|
||||
|
||||
Describe("DeleteMissingFiles", func() {
|
||||
Context("with specific IDs", func() {
|
||||
It("deletes specific missing files and runs GC", func() {
|
||||
// Setup: mock missing files with album IDs
|
||||
mfRepo.SetData(model.MediaFiles{
|
||||
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||
{ID: "mf2", AlbumID: "album2", Missing: true},
|
||||
})
|
||||
|
||||
err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfRepo.deleteMissingCalled).To(BeTrue())
|
||||
Expect(mfRepo.deletedIDs).To(Equal([]string{"mf1", "mf2"}))
|
||||
Expect(ds.gcCalled).To(BeTrue(), "GC should be called after deletion")
|
||||
})
|
||||
|
||||
It("triggers artist stats refresh and album refresh after deletion", func() {
|
||||
artistRepo := ds.MockedArtist.(*extendedArtistRepo)
|
||||
// Setup: mock missing files with albums
|
||||
albumRepo := ds.MockedAlbum.(*extendedAlbumRepo)
|
||||
albumRepo.SetData(model.Albums{
|
||||
{ID: "album1", Name: "Test Album", SongCount: 5},
|
||||
})
|
||||
mfRepo.SetData(model.MediaFiles{
|
||||
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||
{ID: "mf2", AlbumID: "album1", Missing: false, Size: 1000, Duration: 180},
|
||||
{ID: "mf3", AlbumID: "album1", Missing: false, Size: 2000, Duration: 200},
|
||||
})
|
||||
|
||||
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Wait for background goroutines to complete
|
||||
service.(*maintenanceService).wait()
|
||||
|
||||
// RefreshStats should be called
|
||||
Expect(artistRepo.IsRefreshStatsCalled()).To(BeTrue(), "Artist stats should be refreshed")
|
||||
|
||||
// Album should be updated with new calculated values
|
||||
Expect(albumRepo.GetPutCallCount()).To(BeNumerically(">", 0), "Album.Put() should be called to refresh album data")
|
||||
})
|
||||
|
||||
It("returns error if deletion fails", func() {
|
||||
mfRepo.deleteMissingError = errors.New("delete failed")
|
||||
|
||||
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("delete failed"))
|
||||
})
|
||||
|
||||
It("continues even if album tracking fails", func() {
|
||||
mfRepo.SetError(true)
|
||||
|
||||
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
|
||||
|
||||
// Should not fail, just log warning
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfRepo.deleteMissingCalled).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns error if GC fails", func() {
|
||||
mfRepo.SetData(model.MediaFiles{
|
||||
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||
})
|
||||
|
||||
// Set GC to return error
|
||||
ds.gcError = errors.New("gc failed")
|
||||
|
||||
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("gc failed"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("album ID extraction", func() {
|
||||
It("extracts unique album IDs from missing files", func() {
|
||||
mfRepo.SetData(model.MediaFiles{
|
||||
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||
{ID: "mf2", AlbumID: "album1", Missing: true},
|
||||
{ID: "mf3", AlbumID: "album2", Missing: true},
|
||||
})
|
||||
|
||||
err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2", "mf3"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("skips files without album IDs", func() {
|
||||
mfRepo.SetData(model.MediaFiles{
|
||||
{ID: "mf1", AlbumID: "", Missing: true},
|
||||
{ID: "mf2", AlbumID: "album1", Missing: true},
|
||||
})
|
||||
|
||||
err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DeleteAllMissingFiles", func() {
|
||||
It("deletes all missing files and runs GC", func() {
|
||||
mfRepo.SetData(model.MediaFiles{
|
||||
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||
{ID: "mf2", AlbumID: "album2", Missing: true},
|
||||
{ID: "mf3", AlbumID: "album3", Missing: true},
|
||||
})
|
||||
|
||||
err := service.DeleteAllMissingFiles(ctx)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ds.gcCalled).To(BeTrue(), "GC should be called after deletion")
|
||||
})
|
||||
|
||||
It("returns error if deletion fails", func() {
|
||||
mfRepo.SetError(true)
|
||||
|
||||
err := service.DeleteAllMissingFiles(ctx)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("handles empty result gracefully", func() {
|
||||
mfRepo.SetData(model.MediaFiles{})
|
||||
|
||||
err := service.DeleteAllMissingFiles(ctx)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Album refresh logic", func() {
|
||||
var albumRepo *extendedAlbumRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
albumRepo = ds.MockedAlbum.(*extendedAlbumRepo)
|
||||
})
|
||||
|
||||
Context("when album has no tracks after deletion", func() {
|
||||
It("skips the album without updating it", func() {
|
||||
// Setup album with no remaining tracks
|
||||
albumRepo.SetData(model.Albums{
|
||||
{ID: "album1", Name: "Empty Album", SongCount: 1},
|
||||
})
|
||||
mfRepo.SetData(model.MediaFiles{
|
||||
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||
})
|
||||
|
||||
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Wait for background goroutines to complete
|
||||
service.(*maintenanceService).wait()
|
||||
|
||||
// Album should NOT be updated because it has no tracks left
|
||||
Expect(albumRepo.GetPutCallCount()).To(Equal(0), "Album with no tracks should not be updated")
|
||||
})
|
||||
})
|
||||
|
||||
Context("when Put fails for one album", func() {
|
||||
It("continues processing other albums", func() {
|
||||
albumRepo.SetData(model.Albums{
|
||||
{ID: "album1", Name: "Album 1"},
|
||||
{ID: "album2", Name: "Album 2"},
|
||||
})
|
||||
mfRepo.SetData(model.MediaFiles{
|
||||
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||
{ID: "mf2", AlbumID: "album1", Missing: false, Size: 1000, Duration: 180},
|
||||
{ID: "mf3", AlbumID: "album2", Missing: true},
|
||||
{ID: "mf4", AlbumID: "album2", Missing: false, Size: 2000, Duration: 200},
|
||||
})
|
||||
|
||||
// Make Put fail on first call but succeed on subsequent calls
|
||||
albumRepo.putError = errors.New("put failed")
|
||||
albumRepo.failOnce = true
|
||||
|
||||
err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf3"})
|
||||
|
||||
// Should not fail even if one album's Put fails
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Wait for background goroutines to complete
|
||||
service.(*maintenanceService).wait()
|
||||
|
||||
// Put should have been called multiple times
|
||||
Expect(albumRepo.GetPutCallCount()).To(BeNumerically(">", 0), "Put should be attempted")
|
||||
})
|
||||
})
|
||||
|
||||
Context("when media file loading fails", func() {
|
||||
It("logs warning but continues when tracking affected albums fails", func() {
|
||||
// Set up log capturing
|
||||
hook, cleanup := tests.LogHook()
|
||||
defer cleanup()
|
||||
|
||||
albumRepo.SetData(model.Albums{
|
||||
{ID: "album1", Name: "Album 1"},
|
||||
})
|
||||
mfRepo.SetData(model.MediaFiles{
|
||||
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||
})
|
||||
// Make GetAll fail when loading media files
|
||||
mfRepo.SetError(true)
|
||||
|
||||
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
|
||||
|
||||
// Deletion should succeed despite the tracking error
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfRepo.deleteMissingCalled).To(BeTrue())
|
||||
|
||||
// Verify the warning was logged
|
||||
Expect(hook.LastEntry()).ToNot(BeNil())
|
||||
Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel))
|
||||
Expect(hook.LastEntry().Message).To(Equal("Error tracking affected albums for refresh"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Test helper to create a mock DataStore with controllable behavior
|
||||
func createTestDataStore() *extendedDataStore {
|
||||
// Create extended datastore with GC tracking
|
||||
ds := &extendedDataStore{
|
||||
MockDataStore: &tests.MockDataStore{},
|
||||
}
|
||||
|
||||
// Create extended album repo with Put tracking
|
||||
albumRepo := &extendedAlbumRepo{
|
||||
MockAlbumRepo: tests.CreateMockAlbumRepo(),
|
||||
}
|
||||
ds.MockedAlbum = albumRepo
|
||||
|
||||
// Create extended artist repo with RefreshStats tracking
|
||||
artistRepo := &extendedArtistRepo{
|
||||
MockArtistRepo: tests.CreateMockArtistRepo(),
|
||||
}
|
||||
ds.MockedArtist = artistRepo
|
||||
|
||||
// Create extended media file repo with DeleteMissing support
|
||||
mfRepo := &extendedMediaFileRepo{
|
||||
MockMediaFileRepo: tests.CreateMockMediaFileRepo(),
|
||||
}
|
||||
ds.MockedMediaFile = mfRepo
|
||||
|
||||
return ds
|
||||
}
|
||||
|
||||
// Extension of MockMediaFileRepo to add DeleteMissing method
|
||||
type extendedMediaFileRepo struct {
|
||||
*tests.MockMediaFileRepo
|
||||
deleteMissingCalled bool
|
||||
deletedIDs []string
|
||||
deleteMissingError error
|
||||
}
|
||||
|
||||
func (m *extendedMediaFileRepo) DeleteMissing(ids []string) error {
|
||||
m.deleteMissingCalled = true
|
||||
m.deletedIDs = ids
|
||||
if m.deleteMissingError != nil {
|
||||
return m.deleteMissingError
|
||||
}
|
||||
// Actually delete from the mock data
|
||||
for _, id := range ids {
|
||||
delete(m.Data, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extension of MockAlbumRepo to track Put calls
|
||||
type extendedAlbumRepo struct {
|
||||
*tests.MockAlbumRepo
|
||||
mu sync.RWMutex
|
||||
putCallCount int
|
||||
lastPutData *model.Album
|
||||
putError error
|
||||
failOnce bool
|
||||
}
|
||||
|
||||
func (m *extendedAlbumRepo) Put(album *model.Album) error {
|
||||
m.mu.Lock()
|
||||
m.putCallCount++
|
||||
m.lastPutData = album
|
||||
|
||||
// Handle failOnce behavior
|
||||
var err error
|
||||
if m.putError != nil {
|
||||
if m.failOnce {
|
||||
err = m.putError
|
||||
m.putError = nil // Clear error after first failure
|
||||
m.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
err = m.putError
|
||||
m.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
return m.MockAlbumRepo.Put(album)
|
||||
}
|
||||
|
||||
func (m *extendedAlbumRepo) GetPutCallCount() int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.putCallCount
|
||||
}
|
||||
|
||||
// Extension of MockArtistRepo to track RefreshStats calls
|
||||
type extendedArtistRepo struct {
|
||||
*tests.MockArtistRepo
|
||||
mu sync.RWMutex
|
||||
refreshStatsCalled bool
|
||||
refreshStatsError error
|
||||
}
|
||||
|
||||
func (m *extendedArtistRepo) RefreshStats(allArtists bool) (int64, error) {
|
||||
m.mu.Lock()
|
||||
m.refreshStatsCalled = true
|
||||
err := m.refreshStatsError
|
||||
m.mu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return m.MockArtistRepo.RefreshStats(allArtists)
|
||||
}
|
||||
|
||||
func (m *extendedArtistRepo) IsRefreshStatsCalled() bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.refreshStatsCalled
|
||||
}
|
||||
|
||||
// Extension of MockDataStore to track GC calls
|
||||
type extendedDataStore struct {
|
||||
*tests.MockDataStore
|
||||
gcCalled bool
|
||||
gcError error
|
||||
}
|
||||
|
||||
func (ds *extendedDataStore) GC(ctx context.Context) error {
|
||||
ds.gcCalled = true
|
||||
if ds.gcError != nil {
|
||||
return ds.gcError
|
||||
}
|
||||
return ds.MockDataStore.GC(ctx)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
@@ -160,6 +161,13 @@ var staticData = sync.OnceValue(func() insights.Data {
|
||||
data.Build.Settings, data.Build.GoVersion = buildInfo()
|
||||
data.OS.Containerized = consts.InContainer
|
||||
|
||||
// Install info
|
||||
packageFilename := filepath.Join(conf.Server.DataFolder, ".package")
|
||||
packageFileData, err := os.ReadFile(packageFilename)
|
||||
if err == nil {
|
||||
data.OS.Package = string(packageFileData)
|
||||
}
|
||||
|
||||
// OS info
|
||||
data.OS.Type = runtime.GOOS
|
||||
data.OS.Arch = runtime.GOARCH
|
||||
|
||||
@@ -16,6 +16,7 @@ type Data struct {
|
||||
Containerized bool `json:"containerized"`
|
||||
Arch string `json:"arch"`
|
||||
NumCPU int `json:"numCPU"`
|
||||
Package string `json:"package,omitempty"`
|
||||
} `json:"os"`
|
||||
Mem struct {
|
||||
Alloc uint64 `json:"alloc"`
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/ioutils"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
@@ -97,12 +98,13 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := ioutils.UTF8Reader(file)
|
||||
extension := strings.ToLower(filepath.Ext(playlistFile))
|
||||
switch extension {
|
||||
case ".nsp":
|
||||
err = s.parseNSP(ctx, pls, file)
|
||||
err = s.parseNSP(ctx, pls, reader)
|
||||
default:
|
||||
err = s.parseM3U(ctx, pls, folder, file)
|
||||
err = s.parseM3U(ctx, pls, folder, reader)
|
||||
}
|
||||
return pls, err
|
||||
}
|
||||
|
||||
@@ -74,6 +74,24 @@ var _ = Describe("Playlists", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Tracks).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("parses playlists with UTF-8 BOM marker", func() {
|
||||
pls, err := ps.ImportFile(ctx, folder, "bom-test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.OwnerID).To(Equal("123"))
|
||||
Expect(pls.Name).To(Equal("Test Playlist"))
|
||||
Expect(pls.Tracks).To(HaveLen(1))
|
||||
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
|
||||
})
|
||||
|
||||
It("parses UTF-16 LE encoded playlists with BOM and converts to UTF-8", func() {
|
||||
pls, err := ps.ImportFile(ctx, folder, "bom-test-utf16.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.OwnerID).To(Equal("123"))
|
||||
Expect(pls.Name).To(Equal("UTF-16 Test Playlist"))
|
||||
Expect(pls.Tracks).To(HaveLen(1))
|
||||
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("NSP", func() {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
type Share interface {
|
||||
@@ -119,9 +120,8 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
|
||||
log.Error(r.ctx, "Invalid Resource ID", "id", firstId)
|
||||
return "", model.ErrNotFound
|
||||
}
|
||||
if len(s.Contents) > 30 {
|
||||
s.Contents = s.Contents[:26] + "..."
|
||||
}
|
||||
|
||||
s.Contents = str.TruncateRunes(s.Contents, 30, "...")
|
||||
|
||||
id, err = r.Persistable.Save(s)
|
||||
return id, err
|
||||
|
||||
@@ -38,6 +38,38 @@ var _ = Describe("Share", func() {
|
||||
Expect(id).ToNot(BeEmpty())
|
||||
Expect(entity.ID).To(Equal(id))
|
||||
})
|
||||
|
||||
It("does not truncate ASCII labels shorter than 30 characters", func() {
|
||||
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "Example Media File"})
|
||||
entity := &model.Share{Description: "test", ResourceIDs: "456"}
|
||||
_, err := repo.Save(entity)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(entity.Contents).To(Equal("Example Media File"))
|
||||
})
|
||||
|
||||
It("truncates ASCII labels longer than 30 characters", func() {
|
||||
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "Example Media File But The Title Is Really Long For Testing Purposes"})
|
||||
entity := &model.Share{Description: "test", ResourceIDs: "789"}
|
||||
_, err := repo.Save(entity)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(entity.Contents).To(Equal("Example Media File But The ..."))
|
||||
})
|
||||
|
||||
It("does not truncate CJK labels shorter than 30 runes", func() {
|
||||
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "青春コンプレックス"})
|
||||
entity := &model.Share{Description: "test", ResourceIDs: "456"}
|
||||
_, err := repo.Save(entity)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(entity.Contents).To(Equal("青春コンプレックス"))
|
||||
})
|
||||
|
||||
It("truncates CJK labels longer than 30 runes", func() {
|
||||
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "私の中の幻想的世界観及びその顕現を想起させたある現実での出来事に関する一考察"})
|
||||
entity := &model.Share{Description: "test", ResourceIDs: "789"}
|
||||
_, err := repo.Save(entity)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実で..."))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Update", func() {
|
||||
|
||||
@@ -18,6 +18,7 @@ var Set = wire.NewSet(
|
||||
NewShare,
|
||||
NewPlaylists,
|
||||
NewLibrary,
|
||||
NewMaintenance,
|
||||
agents.GetAgents,
|
||||
external.NewProvider,
|
||||
wire.Bind(new(external.Agents), new(*agents.Agents)),
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE playqueue ADD COLUMN position_int integer;
|
||||
UPDATE playqueue SET position_int = CAST(position as INTEGER) ;
|
||||
ALTER TABLE playqueue DROP COLUMN position;
|
||||
ALTER TABLE playqueue RENAME COLUMN position_int TO position;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
76
go.mod
76
go.mod
@@ -1,15 +1,15 @@
|
||||
module github.com/navidrome/navidrome
|
||||
|
||||
go 1.24.5
|
||||
go 1.25.4
|
||||
|
||||
// Fork to fix https://github.com/navidrome/navidrome/pull/3254
|
||||
// Fork to fix https://github.com/navidrome/navidrome/issues/3254
|
||||
replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
||||
|
||||
require (
|
||||
github.com/Masterminds/squirrel v1.5.4
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0
|
||||
github.com/andybalholm/cascadia v1.3.3
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.0
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1
|
||||
github.com/bradleyjkemp/cupaloy/v2 v2.8.0
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
|
||||
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
|
||||
@@ -22,15 +22,15 @@ require (
|
||||
github.com/djherbis/times v1.6.0
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/fatih/structs v1.1.0
|
||||
github.com/go-chi/chi/v5 v5.2.2
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/go-chi/httprate v0.15.0
|
||||
github.com/go-chi/jwtauth/v5 v5.3.3
|
||||
github.com/go-viper/encoding/ini v0.1.1
|
||||
github.com/gohugoio/hashstructure v0.5.0
|
||||
github.com/gohugoio/hashstructure v0.6.0
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/google/wire v0.6.0
|
||||
github.com/google/wire v0.7.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hashicorp/go-multierror v1.1.1
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0
|
||||
@@ -40,39 +40,40 @@ require (
|
||||
github.com/kr/pretty v0.3.1
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||
github.com/mattn/go-sqlite3 v1.14.29
|
||||
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.23.4
|
||||
github.com/onsi/gomega v1.38.0
|
||||
github.com/onsi/ginkgo/v2 v2.27.2
|
||||
github.com/onsi/gomega v1.38.2
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
github.com/pocketbase/dbx v1.11.0
|
||||
github.com/pressly/goose/v3 v3.24.3
|
||||
github.com/prometheus/client_golang v1.22.0
|
||||
github.com/pressly/goose/v3 v3.26.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/rjeczalik/notify v0.9.3
|
||||
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.9.1
|
||||
github.com/spf13/viper v1.20.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/tetratelabs/wazero v1.9.0
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tetratelabs/wazero v1.10.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-20250718183923-645b1fa84792
|
||||
golang.org/x/image v0.29.0
|
||||
golang.org/x/net v0.42.0
|
||||
golang.org/x/sync v0.16.0
|
||||
golang.org/x/sys v0.34.0
|
||||
golang.org/x/text v0.27.0
|
||||
golang.org/x/time v0.12.0
|
||||
google.golang.org/protobuf v1.36.6
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546
|
||||
golang.org/x/image v0.32.0
|
||||
golang.org/x/net v0.46.0
|
||||
golang.org/x/sync v0.18.0
|
||||
golang.org/x/sys v0.38.0
|
||||
golang.org/x/text v0.30.0
|
||||
golang.org/x/time v0.14.0
|
||||
google.golang.org/protobuf v1.36.10
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/atombender/go-jsonschema v0.20.0 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
@@ -86,9 +87,9 @@ require (
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.17.1 // 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-20250630185457-6e76a2b096b5 // indirect
|
||||
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d // 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
|
||||
@@ -108,27 +109,28 @@ require (
|
||||
github.com/ogier/pflag v0.0.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.62.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.9.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/sanity-io/litter v1.5.8 // indirect
|
||||
github.com/segmentio/asm v1.2.0 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/sosodev/duration v1.3.1 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.14.0 // indirect
|
||||
github.com/spf13/cast v1.9.2 // indirect
|
||||
github.com/spf13/pflag v1.0.7 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/mod v0.26.0 // indirect
|
||||
golang.org/x/tools v0.35.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.43.0 // indirect
|
||||
golang.org/x/mod v0.29.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
||||
)
|
||||
|
||||
191
go.sum
191
go.sum
@@ -2,6 +2,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
|
||||
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0 h1:t527LHHE3HmiHrq74QMpNPZpGCIJzTx+apLkMKt4HC0=
|
||||
@@ -14,8 +16,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.0 h1:DBvuZxjdKkRP/dr4GVV4w2fnmrk5Hxc90T51LZjv0JA=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M=
|
||||
github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
|
||||
github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk=
|
||||
@@ -60,8 +62,14 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
|
||||
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
|
||||
github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
|
||||
github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
|
||||
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
|
||||
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
|
||||
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
|
||||
@@ -71,8 +79,8 @@ github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
|
||||
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-viper/encoding/ini v0.1.1 h1:MVWY7B2XNw7lnOqHutGRc97bF3rP7omOdgjdMPAJgbs=
|
||||
@@ -81,25 +89,24 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
|
||||
github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg=
|
||||
github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg=
|
||||
github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
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-20250630185457-6e76a2b096b5 h1:xhMrHhTJ6zxu3gA4enFM9MLn9AY7613teCdFnlUVbSQ=
|
||||
github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
|
||||
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d h1:KJIErDwbSHjnp/SGzE5ed8Aol7JsKiI5X7yWKAtzhM0=
|
||||
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
|
||||
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=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
|
||||
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
|
||||
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||
github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
@@ -115,6 +122,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY=
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4=
|
||||
github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
|
||||
github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2OgBk=
|
||||
@@ -153,14 +162,18 @@ 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.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
|
||||
github.com/maruel/natural v1.1.1/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=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.29 h1:1O6nRLJKvsi1H2Sj0Hzdfojwt8GiGKm+LOfLaBFaouQ=
|
||||
github.com/mattn/go-sqlite3 v1.14.29/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
|
||||
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
|
||||
@@ -173,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.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
|
||||
github.com/onsi/gomega v1.38.0 h1:c/WX+w8SLAinvuKKQFh77WEucCnPk4j2OTUr7lt7BeY=
|
||||
github.com/onsi/gomega v1.38.0/go.mod h1:OcXcwId0b9QsE7Y49u+BTrL4IdKOBOKnD6VQNTJEB6o=
|
||||
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/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=
|
||||
@@ -188,16 +201,14 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||
github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM=
|
||||
github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E=
|
||||
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
|
||||
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
||||
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
|
||||
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
@@ -212,12 +223,12 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
|
||||
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
|
||||
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
|
||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||
github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg=
|
||||
github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
|
||||
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
@@ -229,19 +240,17 @@ github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIK
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
|
||||
github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
||||
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
||||
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
|
||||
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||
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/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=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
@@ -252,12 +261,20 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
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.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
|
||||
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
|
||||
github.com/tetratelabs/wazero v1.10.0 h1:CXP3zneLDl6J4Zy8N/J+d5JsWKfrjE6GtvVK1fpnDlk=
|
||||
github.com/tetratelabs/wazero v1.10.0/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
|
||||
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=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
|
||||
github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
|
||||
@@ -267,34 +284,34 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
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.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4=
|
||||
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas=
|
||||
golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA=
|
||||
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
|
||||
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
|
||||
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.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
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.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
|
||||
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
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=
|
||||
@@ -303,12 +320,11 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
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.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
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=
|
||||
@@ -316,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.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
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/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=
|
||||
@@ -331,19 +347,19 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
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.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
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/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 h1:LvzTn0GQhWuvKH/kVRS3R3bVAsdQWI7hvfLHGgh9+lU=
|
||||
golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE=
|
||||
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=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
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=
|
||||
@@ -357,24 +373,23 @@ 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.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
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=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
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.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
|
||||
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
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.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/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=
|
||||
@@ -386,11 +401,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y=
|
||||
modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
|
||||
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
|
||||
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||
|
||||
14
log/log.go
14
log/log.go
@@ -11,6 +11,7 @@ import (
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
@@ -70,6 +71,7 @@ type levelPath struct {
|
||||
|
||||
var (
|
||||
currentLevel Level
|
||||
loggerMu sync.RWMutex
|
||||
defaultLogger = logrus.New()
|
||||
logSourceLine = false
|
||||
rootPath string
|
||||
@@ -79,7 +81,9 @@ var (
|
||||
// SetLevel sets the global log level used by the simple logger.
|
||||
func SetLevel(l Level) {
|
||||
currentLevel = l
|
||||
loggerMu.Lock()
|
||||
defaultLogger.Level = logrus.TraceLevel
|
||||
loggerMu.Unlock()
|
||||
logrus.SetLevel(logrus.Level(l))
|
||||
}
|
||||
|
||||
@@ -125,6 +129,8 @@ func SetLogSourceLine(enabled bool) {
|
||||
|
||||
func SetRedacting(enabled bool) {
|
||||
if enabled {
|
||||
loggerMu.Lock()
|
||||
defer loggerMu.Unlock()
|
||||
defaultLogger.AddHook(redacted)
|
||||
}
|
||||
}
|
||||
@@ -133,6 +139,8 @@ func SetOutput(w io.Writer) {
|
||||
if runtime.GOOS == "windows" {
|
||||
w = CRLFWriter(w)
|
||||
}
|
||||
loggerMu.Lock()
|
||||
defer loggerMu.Unlock()
|
||||
defaultLogger.SetOutput(w)
|
||||
}
|
||||
|
||||
@@ -158,6 +166,8 @@ func NewContext(ctx context.Context, keyValuePairs ...interface{}) context.Conte
|
||||
}
|
||||
|
||||
func SetDefaultLogger(l *logrus.Logger) {
|
||||
loggerMu.Lock()
|
||||
defer loggerMu.Unlock()
|
||||
defaultLogger = l
|
||||
}
|
||||
|
||||
@@ -204,6 +214,8 @@ func log(level Level, args ...interface{}) {
|
||||
}
|
||||
|
||||
func Writer() io.Writer {
|
||||
loggerMu.RLock()
|
||||
defer loggerMu.RUnlock()
|
||||
return defaultLogger.Writer()
|
||||
}
|
||||
|
||||
@@ -314,6 +326,8 @@ func extractLogger(ctx interface{}) (*logrus.Entry, error) {
|
||||
func createNewLogger() *logrus.Entry {
|
||||
//logrus.SetFormatter(&logrus.TextFormatter{ForceColors: true, DisableTimestamp: false, FullTimestamp: true})
|
||||
//l.Formatter = &logrus.TextFormatter{ForceColors: true, DisableTimestamp: false, FullTimestamp: true}
|
||||
loggerMu.RLock()
|
||||
defer loggerMu.RUnlock()
|
||||
logger := logrus.NewEntry(defaultLogger)
|
||||
return logger
|
||||
}
|
||||
|
||||
@@ -61,7 +61,12 @@ func (c Criteria) OrderBy() string {
|
||||
if f.order != "" {
|
||||
mapped = f.order
|
||||
} else if f.isTag {
|
||||
mapped = "COALESCE(json_extract(media_file.tags, '$." + sortField + "[0].value'), '')"
|
||||
// Use the actual field name (handles aliases like albumtype -> releasetype)
|
||||
tagName := sortField
|
||||
if f.field != "" {
|
||||
tagName = f.field
|
||||
}
|
||||
mapped = "COALESCE(json_extract(media_file.tags, '$." + tagName + "[0].value'), '')"
|
||||
} else if f.isRole {
|
||||
mapped = "COALESCE(json_extract(media_file.participants, '$." + sortField + "[0].name'), '')"
|
||||
} else {
|
||||
|
||||
@@ -118,6 +118,16 @@ var _ = Describe("Criteria", func() {
|
||||
)
|
||||
})
|
||||
|
||||
It("sorts by albumtype alias (resolves to releasetype)", func() {
|
||||
AddTagNames([]string{"releasetype"})
|
||||
goObj.Sort = "albumtype"
|
||||
gomega.Expect(goObj.OrderBy()).To(
|
||||
gomega.Equal(
|
||||
"COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc",
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
It("sorts by random", func() {
|
||||
newObj := goObj
|
||||
newObj.Sort = "random"
|
||||
|
||||
@@ -32,7 +32,6 @@ var fieldMap = map[string]*mappedField{
|
||||
"sortalbum": {field: "media_file.sort_album_name"},
|
||||
"sortartist": {field: "media_file.sort_artist_name"},
|
||||
"sortalbumartist": {field: "media_file.sort_album_artist_name"},
|
||||
"albumtype": {field: "media_file.mbz_album_type", alias: "releasetype"},
|
||||
"albumcomment": {field: "media_file.mbz_album_comment"},
|
||||
"catalognumber": {field: "media_file.catalog_num"},
|
||||
"filepath": {field: "media_file.path"},
|
||||
@@ -55,6 +54,9 @@ var fieldMap = map[string]*mappedField{
|
||||
"mbz_release_group_id": {field: "media_file.mbz_release_group_id"},
|
||||
"library_id": {field: "media_file.library_id", numeric: true},
|
||||
|
||||
// Backward compatibility: albumtype is an alias for releasetype tag
|
||||
"albumtype": {field: "releasetype", isTag: true},
|
||||
|
||||
// special fields
|
||||
"random": {field: "", order: "random()"}, // pseudo-field for random sorting
|
||||
"value": {field: "value"}, // pseudo-field for tag and roles values
|
||||
@@ -154,13 +156,19 @@ type tagCond struct {
|
||||
func (e tagCond) ToSql() (string, []any, error) {
|
||||
cond, args, err := e.cond.ToSql()
|
||||
|
||||
// Check if this tag is marked as numeric in the fieldMap
|
||||
if fm, ok := fieldMap[e.tag]; ok && fm.numeric {
|
||||
cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
|
||||
// Resolve the actual tag name (handles aliases like albumtype -> releasetype)
|
||||
tagName := e.tag
|
||||
if fm, ok := fieldMap[e.tag]; ok {
|
||||
if fm.field != "" {
|
||||
tagName = fm.field
|
||||
}
|
||||
if fm.numeric {
|
||||
cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
|
||||
}
|
||||
}
|
||||
|
||||
cond = fmt.Sprintf("exists (select 1 from json_tree(tags, '$.%s') where key='value' and %s)",
|
||||
e.tag, cond)
|
||||
tagName, cond)
|
||||
if e.not {
|
||||
cond = "not " + cond
|
||||
}
|
||||
|
||||
@@ -105,6 +105,40 @@ var _ = Describe("Operators", func() {
|
||||
gomega.Expect(sql).To(gomega.BeEmpty())
|
||||
gomega.Expect(args).To(gomega.BeEmpty())
|
||||
})
|
||||
It("supports releasetype as multi-valued tag", func() {
|
||||
AddTagNames([]string{"releasetype"})
|
||||
op := Contains{"releasetype": "soundtrack"}
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value LIKE ?)"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("%soundtrack%"))
|
||||
})
|
||||
It("supports albumtype as alias for releasetype", func() {
|
||||
AddTagNames([]string{"releasetype"})
|
||||
op := Contains{"albumtype": "live"}
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value LIKE ?)"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("%live%"))
|
||||
})
|
||||
It("supports albumtype alias with Is operator", func() {
|
||||
AddTagNames([]string{"releasetype"})
|
||||
op := Is{"albumtype": "album"}
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
// Should query $.releasetype, not $.albumtype
|
||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value = ?)"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("album"))
|
||||
})
|
||||
It("supports albumtype alias with IsNot operator", func() {
|
||||
AddTagNames([]string{"releasetype"})
|
||||
op := IsNot{"albumtype": "compilation"}
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
// Should query $.releasetype, not $.albumtype
|
||||
gomega.Expect(sql).To(gomega.Equal("not exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value = ?)"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("compilation"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Custom Roles", func() {
|
||||
|
||||
@@ -55,6 +55,7 @@ var _ = Describe("AlbumRepository", func() {
|
||||
It("returns all records sorted", func() {
|
||||
Expect(GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{
|
||||
albumAbbeyRoad,
|
||||
albumMultiDisc,
|
||||
albumRadioactivity,
|
||||
albumSgtPeppers,
|
||||
}))
|
||||
@@ -64,6 +65,7 @@ var _ = Describe("AlbumRepository", func() {
|
||||
Expect(GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{
|
||||
albumSgtPeppers,
|
||||
albumRadioactivity,
|
||||
albumMultiDisc,
|
||||
albumAbbeyRoad,
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -400,23 +400,16 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
|
||||
// This now calculates per-library statistics and stores them in library_artist.stats
|
||||
batchUpdateStatsSQL := `
|
||||
WITH artist_role_counters AS (
|
||||
SELECT jt.atom AS artist_id,
|
||||
SELECT mfa.artist_id,
|
||||
mf.library_id,
|
||||
substr(
|
||||
replace(jt.path, '$.', ''),
|
||||
1,
|
||||
CASE WHEN instr(replace(jt.path, '$.', ''), '[') > 0
|
||||
THEN instr(replace(jt.path, '$.', ''), '[') - 1
|
||||
ELSE length(replace(jt.path, '$.', ''))
|
||||
END
|
||||
) AS role,
|
||||
mfa.role,
|
||||
count(DISTINCT mf.album_id) AS album_count,
|
||||
count(mf.id) AS count,
|
||||
count(DISTINCT mf.id) AS count,
|
||||
sum(mf.size) AS size
|
||||
FROM media_file mf
|
||||
JOIN json_tree(mf.participants) jt ON jt.key = 'id' AND jt.atom IS NOT NULL
|
||||
WHERE jt.atom IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders
|
||||
GROUP BY jt.atom, mf.library_id, role
|
||||
FROM media_file_artists mfa
|
||||
JOIN media_file mf ON mfa.media_file_id = mf.id
|
||||
WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders
|
||||
GROUP BY mfa.artist_id, mf.library_id, mfa.role
|
||||
),
|
||||
artist_total_counters AS (
|
||||
SELECT mfa.artist_id,
|
||||
@@ -445,24 +438,24 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
|
||||
),
|
||||
combined_counters AS (
|
||||
SELECT artist_id, library_id, role, album_count, count, size FROM artist_role_counters
|
||||
UNION
|
||||
UNION ALL
|
||||
SELECT artist_id, library_id, role, album_count, count, size FROM artist_total_counters
|
||||
UNION
|
||||
UNION ALL
|
||||
SELECT artist_id, library_id, role, album_count, count, size FROM artist_participant_counter
|
||||
),
|
||||
library_artist_counters AS (
|
||||
SELECT artist_id,
|
||||
library_id,
|
||||
json_group_object(
|
||||
replace(role, '"', ''),
|
||||
role,
|
||||
json_object('a', album_count, 'm', count, 's', size)
|
||||
) AS counters
|
||||
FROM combined_counters
|
||||
GROUP BY artist_id, library_id
|
||||
)
|
||||
UPDATE library_artist
|
||||
SET stats = coalesce((SELECT counters FROM library_artist_counters lac
|
||||
WHERE lac.artist_id = library_artist.artist_id
|
||||
SET stats = coalesce((SELECT counters FROM library_artist_counters lac
|
||||
WHERE lac.artist_id = library_artist.artist_id
|
||||
AND lac.library_id = library_artist.library_id), '{}')
|
||||
WHERE library_artist.artist_id IN (ROLE_IDS_PLACEHOLDER);` // Will replace with actual placeholders
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
})
|
||||
|
||||
It("counts the number of mediafiles in the DB", func() {
|
||||
Expect(mr.CountAll()).To(Equal(int64(6)))
|
||||
Expect(mr.CountAll()).To(Equal(int64(10)))
|
||||
})
|
||||
|
||||
It("returns songs ordered by lyrics with a specific title/artist", func() {
|
||||
|
||||
@@ -69,10 +69,12 @@ var (
|
||||
albumSgtPeppers = al(model.Album{ID: "101", Name: "Sgt Peppers", AlbumArtist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967})
|
||||
albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969})
|
||||
albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("/kraft/radio/radio.mp3"), SongCount: 2})
|
||||
albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("/test/multi/disc1/track1.mp3"), SongCount: 4})
|
||||
testAlbums = model.Albums{
|
||||
albumSgtPeppers,
|
||||
albumAbbeyRoad,
|
||||
albumRadioactivity,
|
||||
albumMultiDisc,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -94,13 +96,22 @@ var (
|
||||
Lyrics: `[{"lang":"xxx","line":[{"value":"This is a set of lyrics"}],"synced":false}]`,
|
||||
})
|
||||
songAntenna2 = mf(model.MediaFile{ID: "1006", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103"})
|
||||
testSongs = model.MediaFiles{
|
||||
// Multi-disc album tracks (intentionally out of order to test sorting)
|
||||
songDisc2Track11 = mf(model.MediaFile{ID: "2001", Title: "Disc 2 Track 11", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 11, Path: p("/test/multi/disc2/track11.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
||||
songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("/test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
||||
songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("/test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
||||
songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("/test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
||||
testSongs = model.MediaFiles{
|
||||
songDayInALife,
|
||||
songComeTogether,
|
||||
songRadioactivity,
|
||||
songAntenna,
|
||||
songAntennaWithLyrics,
|
||||
songAntenna2,
|
||||
songDisc2Track11,
|
||||
songDisc1Track01,
|
||||
songDisc2Track01,
|
||||
songDisc1Track02,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -219,4 +219,37 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Playlist Track Sorting", func() {
|
||||
var testPlaylistID string
|
||||
|
||||
AfterEach(func() {
|
||||
if testPlaylistID != "" {
|
||||
Expect(repo.Delete(testPlaylistID)).To(BeNil())
|
||||
testPlaylistID = ""
|
||||
}
|
||||
})
|
||||
|
||||
It("sorts tracks correctly by album (disc and track number)", func() {
|
||||
By("creating a playlist with multi-disc album tracks in arbitrary order")
|
||||
newPls := model.Playlist{Name: "Multi-Disc Test", OwnerID: "userid"}
|
||||
// Add tracks in intentionally scrambled order
|
||||
newPls.AddMediaFilesByID([]string{"2001", "2002", "2003", "2004"})
|
||||
Expect(repo.Put(&newPls)).To(Succeed())
|
||||
testPlaylistID = newPls.ID
|
||||
|
||||
By("retrieving tracks sorted by album")
|
||||
tracksRepo := repo.Tracks(newPls.ID, false)
|
||||
tracks, err := tracksRepo.GetAll(model.QueryOptions{Sort: "album", Order: "asc"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
By("verifying tracks are sorted by disc number then track number")
|
||||
Expect(tracks).To(HaveLen(4))
|
||||
// Expected order: Disc 1 Track 1, Disc 1 Track 2, Disc 2 Track 1, Disc 2 Track 11
|
||||
Expect(tracks[0].MediaFileID).To(Equal("2002")) // Disc 1, Track 1
|
||||
Expect(tracks[1].MediaFileID).To(Equal("2004")) // Disc 1, Track 2
|
||||
Expect(tracks[2].MediaFileID).To(Equal("2003")) // Disc 2, Track 1
|
||||
Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,7 +55,7 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
|
||||
"id": "playlist_tracks.id",
|
||||
"artist": "order_artist_name",
|
||||
"album_artist": "order_album_artist_name",
|
||||
"album": "order_album_name, order_album_artist_name",
|
||||
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title",
|
||||
"title": "order_title",
|
||||
// To make sure these fields will be whitelisted
|
||||
"duration": "duration",
|
||||
|
||||
@@ -57,6 +57,7 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository
|
||||
r.db = db
|
||||
r.tableName = "user"
|
||||
r.registerModel(&model.User{}, map[string]filterFunc{
|
||||
"id": idFilter(r.tableName),
|
||||
"password": invalidFilter(ctx),
|
||||
"name": r.withTableName(startsWithFilter),
|
||||
})
|
||||
|
||||
@@ -559,4 +559,15 @@ var _ = Describe("UserRepository", func() {
|
||||
Expect(user.Libraries[0].ID).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("filters", func() {
|
||||
It("qualifies id filter with table name", func() {
|
||||
r := repo.(*userRepository)
|
||||
qo := r.parseRestOptions(r.ctx, rest.QueryOptions{Filters: map[string]any{"id": "123"}})
|
||||
sel := r.selectUserWithLibraries(qo)
|
||||
query, _, err := r.toSQL(sel)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(query).To(ContainSubstring("user.id = {:p0}"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -83,6 +83,15 @@ nfpms:
|
||||
owner: navidrome
|
||||
group: navidrome
|
||||
|
||||
- src: release/linux/.package.rpm # contents: "rpm"
|
||||
dst: /var/lib/navidrome/.package
|
||||
type: "config|noreplace"
|
||||
packager: rpm
|
||||
- src: release/linux/.package.deb # contents: "deb"
|
||||
dst: /var/lib/navidrome/.package
|
||||
type: "config|noreplace"
|
||||
packager: deb
|
||||
|
||||
scripts:
|
||||
preinstall: "release/linux/preinstall.sh"
|
||||
postinstall: "release/linux/postinstall.sh"
|
||||
|
||||
1
release/linux/.package.deb
Normal file
1
release/linux/.package.deb
Normal file
@@ -0,0 +1 @@
|
||||
deb
|
||||
1
release/linux/.package.rpm
Normal file
1
release/linux/.package.rpm
Normal file
@@ -0,0 +1 @@
|
||||
rpm
|
||||
@@ -49,6 +49,9 @@ cp "${DOWNLOAD_FOLDER}"/extracted_ffmpeg/${FFMPEG_FILE}/bin/ffmpeg.exe "$MSI_OUT
|
||||
cp "$WORKSPACE"/LICENSE "$WORKSPACE"/README.md "$MSI_OUTPUT_DIR"
|
||||
cp "$BINARY" "$MSI_OUTPUT_DIR"
|
||||
|
||||
# package type indicator file
|
||||
echo "msi" > "$MSI_OUTPUT_DIR/.package"
|
||||
|
||||
# workaround for wixl WixVariable not working to override bmp locations
|
||||
cp "$WORKSPACE"/release/wix/bmp/banner.bmp /usr/share/wixl-*/ext/ui/bitmaps/bannrbmp.bmp
|
||||
cp "$WORKSPACE"/release/wix/bmp/dialogue.bmp /usr/share/wixl-*/ext/ui/bitmaps/dlgbmp.bmp
|
||||
|
||||
@@ -69,6 +69,12 @@
|
||||
|
||||
</Directory>
|
||||
</Directory>
|
||||
|
||||
<Directory Id="ND_DATAFOLDER" name="[ND_DATAFOLDER]">
|
||||
<Component Id='PackageFile' Guid='9eec0697-803c-4629-858f-20dc376c960b' Win64="$(var.Win64)">
|
||||
<File Id='package' Name='.package' DiskId='1' Source='.package' KeyPath='no' />
|
||||
</Component>
|
||||
</Directory>
|
||||
</Directory>
|
||||
|
||||
<InstallUISequence>
|
||||
@@ -81,6 +87,7 @@
|
||||
<ComponentRef Id='Configuration'/>
|
||||
<ComponentRef Id='MainExecutable' />
|
||||
<ComponentRef Id='FFMpegExecutable' />
|
||||
<ComponentRef Id='PackageFile' />
|
||||
</Feature>
|
||||
</Product>
|
||||
</Wix>
|
||||
|
||||
@@ -31,8 +31,12 @@
|
||||
"mood": "Estado",
|
||||
"participants": "Participantes adicionais",
|
||||
"tags": "Etiquetas adicionais",
|
||||
"mappedTags": "",
|
||||
"rawTags": "Etiquetas en cru"
|
||||
"mappedTags": "Etiquetas mapeadas",
|
||||
"rawTags": "Etiquetas en cru",
|
||||
"bitDepth": "Calidade de Bit",
|
||||
"sampleRate": "Taxa de mostra",
|
||||
"missing": "Falta",
|
||||
"libraryName": "Biblioteca"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Ao final da cola",
|
||||
@@ -41,7 +45,8 @@
|
||||
"shuffleAll": "Remexer todo",
|
||||
"download": "Descargar",
|
||||
"playNext": "A continuación",
|
||||
"info": "Obter info"
|
||||
"info": "Obter info",
|
||||
"showInPlaylist": "Mostrar en Lista de reprodución"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -70,7 +75,10 @@
|
||||
"releaseType": "Tipo",
|
||||
"grouping": "Grupos",
|
||||
"media": "Multimedia",
|
||||
"mood": "Estado"
|
||||
"mood": "Estado",
|
||||
"date": "Data de gravación",
|
||||
"missing": "Falta",
|
||||
"libraryName": "Biblioteca"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Reproducir",
|
||||
@@ -102,7 +110,8 @@
|
||||
"rating": "Valoración",
|
||||
"genre": "Xénero",
|
||||
"size": "Tamaño",
|
||||
"role": "Rol"
|
||||
"role": "Rol",
|
||||
"missing": "Falta"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Artista do álbum |||| Artistas do álbum",
|
||||
@@ -117,7 +126,13 @@
|
||||
"mixer": "Mistura |||| Mistura",
|
||||
"remixer": "Remezcla |||| Remezcla",
|
||||
"djmixer": "Mezcla DJs |||| Mezcla DJs",
|
||||
"performer": "Intérprete |||| Intérpretes"
|
||||
"performer": "Intérprete |||| Intérpretes",
|
||||
"maincredit": "Artista do álbum ou Artista |||| Artistas do álbum ou Artistas"
|
||||
},
|
||||
"actions": {
|
||||
"shuffle": "Barallar",
|
||||
"radio": "Radio",
|
||||
"topSongs": "Cancións destacadas"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -134,10 +149,12 @@
|
||||
"currentPassword": "Contrasinal actual",
|
||||
"newPassword": "Novo contrasinal",
|
||||
"token": "Token",
|
||||
"lastAccessAt": "Último acceso"
|
||||
"lastAccessAt": "Último acceso",
|
||||
"libraries": "Bibliotecas"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Os cambios no nome aplicaranse a próxima vez que accedas"
|
||||
"name": "Os cambios no nome aplicaranse a próxima vez que accedas",
|
||||
"libraries": "Selecciona bibliotecas específicas para esta usuaria, ou deixa baleiro para usar as bibliotecas por defecto"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Creouse a usuaria",
|
||||
@@ -146,7 +163,12 @@
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "Escribe o token de usuaria de ListenBrainz",
|
||||
"clickHereForToken": "Preme aquí para obter o token"
|
||||
"clickHereForToken": "Preme aquí para obter o token",
|
||||
"selectAllLibraries": "Seleccionar todas as bibliotecas",
|
||||
"adminAutoLibraries": "As usuarias Admin teñen acceso por defecto a todas as bibliotecas"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "Debes seleccionar polo menos unha biblioteca para usuarias non admins"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -190,11 +212,17 @@
|
||||
"addNewPlaylist": "Crear \"%{name}\"",
|
||||
"export": "Exportar",
|
||||
"makePublic": "Facela Pública",
|
||||
"makePrivate": "Facela Privada"
|
||||
"makePrivate": "Facela Privada",
|
||||
"saveQueue": "Salvar a Cola como Lista de reprodución",
|
||||
"searchOrCreate": "Buscar listas ou escribe para crear nova…",
|
||||
"pressEnterToCreate": "Preme Enter para crear nova lista",
|
||||
"removeFromSelection": "Retirar da selección"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Engadir cancións duplicadas",
|
||||
"song_exist": "Hai duplicadas que serán engadidas á lista de reprodución. Desexas engadir as duplicadas ou omitilas?"
|
||||
"song_exist": "Hai duplicadas que serán engadidas á lista de reprodución. Desexas engadir as duplicadas ou omitilas?",
|
||||
"noPlaylistsFound": "Sen listas de reprodución",
|
||||
"noPlaylists": "Sen listas dispoñibles"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
@@ -232,13 +260,68 @@
|
||||
"fields": {
|
||||
"path": "Ruta",
|
||||
"size": "Tamaño",
|
||||
"updatedAt": "Desapareceu o"
|
||||
"updatedAt": "Desapareceu o",
|
||||
"libraryName": "Biblioteca"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Retirar"
|
||||
"remove": "Retirar",
|
||||
"remove_all": "Retirar todo"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Ficheiro(s) faltantes retirados"
|
||||
},
|
||||
"empty": "Sen ficheiros faltantes"
|
||||
},
|
||||
"library": {
|
||||
"name": "Biblioteca |||| Bibliotecas",
|
||||
"fields": {
|
||||
"name": "Nome",
|
||||
"path": "Ruta",
|
||||
"remotePath": "Ruta remota",
|
||||
"lastScanAt": "Último escaneado",
|
||||
"songCount": "Cancións",
|
||||
"albumCount": "Álbums",
|
||||
"artistCount": "Artistas",
|
||||
"totalSongs": "Cancións",
|
||||
"totalAlbums": "Álbums",
|
||||
"totalArtists": "Artistas",
|
||||
"totalFolders": "Cartafoles",
|
||||
"totalFiles": "Ficheiros",
|
||||
"totalMissingFiles": "Ficheiros que faltan",
|
||||
"totalSize": "Tamaño total",
|
||||
"totalDuration": "Duración",
|
||||
"defaultNewUsers": "Por defecto para novas usuarias",
|
||||
"createdAt": "Creada",
|
||||
"updatedAt": "Actualizada"
|
||||
},
|
||||
"sections": {
|
||||
"basic": "Información básica",
|
||||
"statistics": "Estatísticas"
|
||||
},
|
||||
"actions": {
|
||||
"scan": "Escanear Biblioteca",
|
||||
"manageUsers": "Xestionar acceso das usuarias",
|
||||
"viewDetails": "Ver detalles"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Requírese un nome para a biblioteca",
|
||||
"pathRequired": "Requírese unha ruta para a biblioteca",
|
||||
"pathNotDirectory": "A ruta á biblioteca ten que ser un directorio",
|
||||
"pathNotFound": "Non se atopa a ruta á biblioteca",
|
||||
"pathNotAccessible": "A ruta á biblioteca non é accesible",
|
||||
"pathInvalid": "Ruta non válida á biblioteca"
|
||||
},
|
||||
"messages": {
|
||||
"deleteConfirm": "Tes certeza de querer eliminar esta biblioteca? Isto eliminará todos os datos asociados e accesos de usuarias.",
|
||||
"scanInProgress": "Escaneo en progreso…",
|
||||
"noLibrariesAssigned": "Sen bibliotecas asignadas a esta usuaria"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -419,7 +502,11 @@
|
||||
"downloadDialogTitle": "Descargar %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Copiar ao portapapeis: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Retirar ficheiros que faltan",
|
||||
"remove_missing_content": "Tes certeza de querer retirar da base de datos os ficheiros que faltan? Isto retirará de xeito permanente todas a referencias a eles, incluíndo a conta de reproducións e valoracións."
|
||||
"remove_missing_content": "Tes certeza de querer retirar da base de datos os ficheiros que faltan? Isto retirará de xeito permanente todas a referencias a eles, incluíndo a conta de reproducións e valoracións.",
|
||||
"remove_all_missing_title": "Retirar todos os ficheiros que faltan",
|
||||
"remove_all_missing_content": "Tes certeza de querer retirar da base de datos todos os ficheiros que faltan? Isto eliminará todas as referencias a eles, incluíndo o número de reproducións e valoracións.",
|
||||
"noSimilarSongsFound": "Sen cancións parecidas",
|
||||
"noTopSongsFound": "Sen cancións destacadas"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteca",
|
||||
@@ -448,7 +535,13 @@
|
||||
"albumList": "Álbums",
|
||||
"about": "Acerca de",
|
||||
"playlists": "Listas de reprodución",
|
||||
"sharedPlaylists": "Listas compartidas"
|
||||
"sharedPlaylists": "Listas compartidas",
|
||||
"librarySelector": {
|
||||
"allLibraries": "Todas as bibliotecas (%{count})",
|
||||
"multipleLibraries": "%{selected} de %{total} Bibliotecas",
|
||||
"selectLibraries": "Seleccionar Bibliotecas",
|
||||
"none": "Ningunha"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Reproducir cola",
|
||||
@@ -485,6 +578,21 @@
|
||||
"disabled": "Desactivado",
|
||||
"waiting": "Agardando"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"about": "Sobre",
|
||||
"config": "Configuración"
|
||||
},
|
||||
"config": {
|
||||
"configName": "Nome",
|
||||
"environmentVariable": "Variable de entorno",
|
||||
"currentValue": "Valor actual",
|
||||
"configurationFile": "Ficheiro de configuración",
|
||||
"exportToml": "Exportar configuración (TOML)",
|
||||
"exportSuccess": "Configuración exportada ao portapapeis no formato TOML",
|
||||
"exportFailed": "Fallou a copia da configuración",
|
||||
"devFlagsHeader": "Configuracións de Desenvolvemento (suxeitas a cambio/retirada)",
|
||||
"devFlagsComment": "Son axustes experimentais e poden retirarse en futuras versións"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
@@ -493,7 +601,10 @@
|
||||
"quickScan": "Escaneo rápido",
|
||||
"fullScan": "Escaneo completo",
|
||||
"serverUptime": "Servidor a funcionar",
|
||||
"serverDown": "SEN CONEXIÓN"
|
||||
"serverDown": "SEN CONEXIÓN",
|
||||
"scanType": "Tipo",
|
||||
"status": "Erro de escaneado",
|
||||
"elapsedTime": "Tempo transcurrido"
|
||||
},
|
||||
"help": {
|
||||
"title": "Atallos de Navidrome",
|
||||
@@ -508,5 +619,10 @@
|
||||
"toggle_love": "Engadir canción a favoritas",
|
||||
"current_song": "Ir á Canción actual "
|
||||
}
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "En reprodución",
|
||||
"empty": "Sen reprodución",
|
||||
"minutesAgo": "hai %{smart_count} minuto |||| hai %{smart_count} minutos"
|
||||
}
|
||||
}
|
||||
@@ -400,8 +400,8 @@
|
||||
},
|
||||
"albumList": "Album",
|
||||
"about": "Info",
|
||||
"playlists": "Scalette",
|
||||
"sharedPlaylists": "Scalette Condivise"
|
||||
"playlists": "Playlist",
|
||||
"sharedPlaylists": "Playlist Condivise"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Coda",
|
||||
@@ -457,4 +457,4 @@
|
||||
"current_song": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"artist": "아티스트",
|
||||
"album": "앨범",
|
||||
"path": "파일 경로",
|
||||
"libraryName": "라이브러리",
|
||||
"genre": "장르",
|
||||
"compilation": "컴필레이션",
|
||||
"year": "년",
|
||||
@@ -34,7 +35,8 @@
|
||||
"participants": "추가 참가자",
|
||||
"tags": "추가 태그",
|
||||
"mappedTags": "매핑된 태그",
|
||||
"rawTags": "원시 태그"
|
||||
"rawTags": "원시 태그",
|
||||
"missing": "누락"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "나중에 재생",
|
||||
@@ -56,6 +58,7 @@
|
||||
"playCount": "재생 횟수",
|
||||
"size": "크기",
|
||||
"name": "이름",
|
||||
"libraryName": "라이브러리",
|
||||
"genre": "장르",
|
||||
"compilation": "컴필레이션",
|
||||
"year": "년",
|
||||
@@ -73,7 +76,8 @@
|
||||
"releaseType": "유형",
|
||||
"grouping": "그룹",
|
||||
"media": "미디어",
|
||||
"mood": "분위기"
|
||||
"mood": "분위기",
|
||||
"missing": "누락"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "재생",
|
||||
@@ -105,7 +109,8 @@
|
||||
"playCount": "재생 횟수",
|
||||
"rating": "평가",
|
||||
"genre": "장르",
|
||||
"role": "역할"
|
||||
"role": "역할",
|
||||
"missing": "누락"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "앨범 아티스트 |||| 앨범 아티스트들",
|
||||
@@ -120,7 +125,13 @@
|
||||
"mixer": "믹서 |||| 믹서들",
|
||||
"remixer": "리믹서 |||| 리믹서들",
|
||||
"djmixer": "DJ 믹서 |||| DJ 믹서들",
|
||||
"performer": "공연자 |||| 공연자들"
|
||||
"performer": "공연자 |||| 공연자들",
|
||||
"maincredit": "앨범 아티스트 또는 아티스트 |||| 앨범 아티스트들 또는 아티스트들"
|
||||
},
|
||||
"actions": {
|
||||
"topSongs": "인기곡",
|
||||
"shuffle": "셔플",
|
||||
"radio": "라디오"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -137,19 +148,26 @@
|
||||
"changePassword": "비밀번호를 변경할까요?",
|
||||
"currentPassword": "현재 비밀번호",
|
||||
"newPassword": "새 비밀번호",
|
||||
"token": "토큰"
|
||||
"token": "토큰",
|
||||
"libraries": "라이브러리"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "이름 변경 사항은 다음 로그인 시에만 반영됨"
|
||||
"name": "이름 변경 사항은 다음 로그인 시에만 반영됨",
|
||||
"libraries": "이 사용자에 대한 특정 라이브러리를 선택하거나 기본 라이브러리를 사용하려면 비움"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "사용자 생성됨",
|
||||
"updated": "사용자 업데이트됨",
|
||||
"deleted": "사용자 삭제됨"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "관리자가 아닌 사용자의 경우 최소한 하나의 라이브러리를 선택해야 함"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "ListenBrainz 사용자 토큰을 입력하세요.",
|
||||
"clickHereForToken": "여기를 클릭하여 토큰을 얻으세요"
|
||||
"clickHereForToken": "여기를 클릭하여 토큰을 얻으세요",
|
||||
"selectAllLibraries": "모든 라이브러리 선택",
|
||||
"adminAutoLibraries": "관리자 사용자는 자동으로 모든 라이브러리에 접속할 수 있음"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -192,12 +210,18 @@
|
||||
"selectPlaylist": "재생목록 선택:",
|
||||
"addNewPlaylist": "\"%{name}\" 만들기",
|
||||
"export": "내보내기",
|
||||
"saveQueue": "재생목록에 대기열 저장",
|
||||
"makePublic": "공개 만들기",
|
||||
"makePrivate": "비공개 만들기"
|
||||
"makePrivate": "비공개 만들기",
|
||||
"searchOrCreate": "재생목록을 검색하거나 입력하여 새 재생목록을 만드세요...",
|
||||
"pressEnterToCreate": "새 재생목록을 만드려면 Enter 키를 누름",
|
||||
"removeFromSelection": "선택에서 제거"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "중복된 노래 추가",
|
||||
"song_exist": "이미 재생목록에 존재하는 노래입니다. 중복을 추가할까요 아니면 건너뛸까요?"
|
||||
"song_exist": "이미 재생목록에 존재하는 노래입니다. 중복을 추가할까요 아니면 건너뛸까요?",
|
||||
"noPlaylistsFound": "재생목록을 찾을 수 없음",
|
||||
"noPlaylists": "사용 가능한 재생 목록이 없음"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
@@ -238,14 +262,68 @@
|
||||
"fields": {
|
||||
"path": "경로",
|
||||
"size": "크기",
|
||||
"libraryName": "라이브러리",
|
||||
"updatedAt": "사라짐"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "제거"
|
||||
"remove": "제거",
|
||||
"remove_all": "모두 제거"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "누락된 파일이 제거되었음"
|
||||
}
|
||||
},
|
||||
"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": "상세 보기"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "라이브러리가 성공적으로 생성됨",
|
||||
"updated": "라이브러리가 성공적으로 업데이트됨",
|
||||
"deleted": "라이브러리가 성공적으로 삭제됨",
|
||||
"scanStarted": "라이브러리 스캔 스작됨",
|
||||
"scanCompleted": "라이브러리 스캔 완료됨"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "라이브러리 이름이 필요함",
|
||||
"pathRequired": "라이브러리 경로가 필요함",
|
||||
"pathNotDirectory": "라이브러리 경로는 디렉터리여야 함",
|
||||
"pathNotFound": "라이브러리 경로를 찾을 수 없음",
|
||||
"pathNotAccessible": "라이브러리 경로에 접근할 수 없음",
|
||||
"pathInvalid": "잘못된 라이브러리 경로"
|
||||
},
|
||||
"messages": {
|
||||
"deleteConfirm": "이 라이브러리를 삭제할까요? 삭제하면 연결된 모든 데이터와 사용자 접속 권한이 제거됩니다.",
|
||||
"scanInProgress": "스캔 진행 중...",
|
||||
"noLibrariesAssigned": "이 사용자에게 할당된 라이브러리가 없음"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -398,11 +476,15 @@
|
||||
"transcodingDisabled": "웹 인터페이스를 통한 트랜스코딩 구성 변경은 보안상의 이유로 비활성화되어 있습니다. 트랜스코딩 옵션을 변경(편집 또는 추가)하려면, %{config} 구성 옵션으로 서버를 다시 시작하세요.",
|
||||
"transcodingEnabled": "Navidrome은 현재 %{config}로 실행 중이므로 웹 인터페이스를 사용하여 트랜스코딩 설정에서 시스템 명령을 실행할 수 있습니다. 보안상의 이유로 비활성화하고 트랜스코딩 옵션을 구성할 때만 활성화하는 것이 좋습니다.",
|
||||
"songsAddedToPlaylist": "1 개의 노래를 재생목록에 추가하였음 |||| %{smart_count} 개의 노래를 재생 목록에 추가하였음",
|
||||
"noSimilarSongsFound": "비슷한 노래를 찾을 수 없음",
|
||||
"noTopSongsFound": "인기곡을 찾을 수 없음",
|
||||
"noPlaylistsAvailable": "사용 가능한 노래 없음",
|
||||
"delete_user_title": "사용자 '%{name}' 삭제",
|
||||
"delete_user_content": "이 사용자와 해당 사용자의 모든 데이터(재생 목록 및 환경 설정 포함)를 삭제할까요?",
|
||||
"remove_missing_title": "누락된 파일들 제거",
|
||||
"remove_missing_content": "선택한 누락된 파일을 데이터베이스에서 삭제할까요? 삭제하면 재생 횟수 및 평점을 포함하여 해당 파일에 대한 모든 참조가 영구적으로 삭제됩니다.",
|
||||
"remove_all_missing_title": "누락된 모든 파일 제거",
|
||||
"remove_all_missing_content": "데이터베이스에서 누락된 모든 파일을 제거할까요? 이렇게 하면 해당 게임의 플레이 횟수와 평점을 포함한 모든 참조 내용이 영구적으로 삭제됩니다.",
|
||||
"notifications_blocked": "브라우저 설정에서 이 사이트의 알림을 차단하였음",
|
||||
"notifications_not_available": "이 브라우저는 데스크톱 알림을 지원하지 않거나 https를 통해 Navidrome에 접속하고 있지 않음",
|
||||
"lastfmLinkSuccess": "Last.fm이 성공적으로 연결되었고 스크로블링이 활성화되었음",
|
||||
@@ -429,6 +511,12 @@
|
||||
},
|
||||
"menu": {
|
||||
"library": "라이브러리",
|
||||
"librarySelector": {
|
||||
"allLibraries": "모든 라이브러리 (%{count})",
|
||||
"multipleLibraries": "%{selected} / %{total} 라이브러리",
|
||||
"selectLibraries": "라이브러리 선택",
|
||||
"none": "없음"
|
||||
},
|
||||
"settings": "설정",
|
||||
"version": "버전",
|
||||
"theme": "테마",
|
||||
@@ -491,6 +579,21 @@
|
||||
"disabled": "비활성화",
|
||||
"waiting": "대기중"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"about": "정보",
|
||||
"config": "구성"
|
||||
},
|
||||
"config": {
|
||||
"configName": "구성 이름",
|
||||
"environmentVariable": "환경 변수",
|
||||
"currentValue": "현재 값",
|
||||
"configurationFile": "구성 파일",
|
||||
"exportToml": "구성 내보내기 (TOML)",
|
||||
"exportSuccess": "TOML 형식으로 클립보드로 내보낸 구성",
|
||||
"exportFailed": "구성 복사 실패",
|
||||
"devFlagsHeader": "개발 플래그 (변경/삭제 가능)",
|
||||
"devFlagsComment": "이는 실험적 설정이므로 향후 버전에서 제거될 수 있음"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
@@ -499,7 +602,15 @@
|
||||
"quickScan": "빠른 스캔",
|
||||
"fullScan": "전체 스캔",
|
||||
"serverUptime": "서버 가동 시간",
|
||||
"serverDown": "오프라인"
|
||||
"serverDown": "오프라인",
|
||||
"scanType": "유형",
|
||||
"status": "스캔 오류",
|
||||
"elapsedTime": "경과 시간"
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "현재 재생 중",
|
||||
"empty": "재생 중인 콘텐츠 없음",
|
||||
"minutesAgo": "%{smart_count} 분 전"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome 단축키",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"name": "Nummer |||| Nummers",
|
||||
"fields": {
|
||||
"albumArtist": "Album Artiest",
|
||||
"duration": "Lengte",
|
||||
"duration": "Afspeelduur",
|
||||
"trackNumber": "Nummer #",
|
||||
"playCount": "Aantal keren afgespeeld",
|
||||
"title": "Titel",
|
||||
@@ -35,7 +35,8 @@
|
||||
"rawTags": "Onbewerkte tags",
|
||||
"bitDepth": "Bit diepte",
|
||||
"sampleRate": "Sample waarde",
|
||||
"missing": "Ontbrekend"
|
||||
"missing": "Ontbrekend",
|
||||
"libraryName": "Bibliotheek"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Voeg toe aan wachtrij",
|
||||
@@ -44,7 +45,8 @@
|
||||
"shuffleAll": "Shuffle alles",
|
||||
"download": "Downloaden",
|
||||
"playNext": "Volgende",
|
||||
"info": "Meer info"
|
||||
"info": "Meer info",
|
||||
"showInPlaylist": "Toon in afspeellijst"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -55,7 +57,7 @@
|
||||
"duration": "Afspeelduur",
|
||||
"songCount": "Nummers",
|
||||
"playCount": "Aantal keren afgespeeld",
|
||||
"name": "Naam",
|
||||
"name": "Titel",
|
||||
"genre": "Genre",
|
||||
"compilation": "Compilatie",
|
||||
"year": "Jaar",
|
||||
@@ -65,9 +67,9 @@
|
||||
"createdAt": "Datum toegevoegd",
|
||||
"size": "Grootte",
|
||||
"originalDate": "Origineel",
|
||||
"releaseDate": "Uitgegeven",
|
||||
"releaseDate": "Uitgave",
|
||||
"releases": "Uitgave |||| Uitgaven",
|
||||
"released": "Uitgegeven",
|
||||
"released": "Uitgave",
|
||||
"recordLabel": "Label",
|
||||
"catalogNum": "Catalogus nummer",
|
||||
"releaseType": "Type",
|
||||
@@ -75,7 +77,8 @@
|
||||
"media": "Media",
|
||||
"mood": "Sfeer",
|
||||
"date": "Opnamedatum",
|
||||
"missing": "Ontbrekend"
|
||||
"missing": "Ontbrekend",
|
||||
"libraryName": "Bibliotheek"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Afspelen",
|
||||
@@ -123,7 +126,13 @@
|
||||
"mixer": "Mixer |||| Mixers",
|
||||
"remixer": "Remixer |||| Remixers",
|
||||
"djmixer": "DJ Mixer |||| DJ Mixers",
|
||||
"performer": "Performer |||| Performers"
|
||||
"performer": "Performer |||| Performers",
|
||||
"maincredit": "Album Artiest of Artiest |||| Album Artiesten or Artiesten"
|
||||
},
|
||||
"actions": {
|
||||
"shuffle": "Shuffle",
|
||||
"radio": "Radio",
|
||||
"topSongs": "Beste nummers"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -132,7 +141,7 @@
|
||||
"userName": "Gebruikersnaam",
|
||||
"isAdmin": "Is beheerder",
|
||||
"lastLoginAt": "Laatst ingelogd op",
|
||||
"updatedAt": "Laatst gewijzigd op",
|
||||
"updatedAt": "Laatst bijgewerkt op",
|
||||
"name": "Naam",
|
||||
"password": "Wachtwoord",
|
||||
"createdAt": "Aangemaakt op",
|
||||
@@ -140,19 +149,26 @@
|
||||
"currentPassword": "Huidig wachtwoord",
|
||||
"newPassword": "Nieuw wachtwoord",
|
||||
"token": "Token",
|
||||
"lastAccessAt": "Meest recente toegang"
|
||||
"lastAccessAt": "Meest recente toegang",
|
||||
"libraries": "Bibliotheken"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Naamswijziging wordt pas zichtbaar bij de volgende login"
|
||||
"name": "Naamswijziging wordt pas zichtbaar bij de volgende login",
|
||||
"libraries": "Selecteer specifieke bibliotheken voor deze gebruiker, of laat leeg om de standaardbiblliotheken te gebruiken"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Aangemaakt door gebruiker",
|
||||
"updated": "Gewijzigd door gebruiker",
|
||||
"deleted": "Gewist door gebruiker"
|
||||
"updated": "Bijgewerkt door gebruiker",
|
||||
"deleted": "Gebruiker verwijderd"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "Vul je ListenBrainz gebruikers-token in.",
|
||||
"clickHereForToken": "Klik hier voor je token"
|
||||
"clickHereForToken": "Klik hier voor je token",
|
||||
"selectAllLibraries": "Selecteer alle bibliotheken",
|
||||
"adminAutoLibraries": "Admin gebruikers hebben automatisch toegang tot alle bibliotheken"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "Minstens één bibliotheek moet geselecteerd worden voor niet-admin gebruikers"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -181,10 +197,10 @@
|
||||
"name": "Afspeellijst |||| Afspeellijsten",
|
||||
"fields": {
|
||||
"name": "Titel",
|
||||
"duration": "Lengte",
|
||||
"duration": "Afspeelduur",
|
||||
"ownerName": "Eigenaar",
|
||||
"public": "Publiek",
|
||||
"updatedAt": "Laatst gewijzigd op",
|
||||
"updatedAt": "Laatst bijgewerkt op",
|
||||
"createdAt": "Aangemaakt op",
|
||||
"songCount": "Nummers",
|
||||
"comment": "Commentaar",
|
||||
@@ -197,11 +213,16 @@
|
||||
"export": "Exporteer",
|
||||
"makePublic": "Openbaar maken",
|
||||
"makePrivate": "Privé maken",
|
||||
"saveQueue": "Bewaar wachtrij als playlist"
|
||||
"saveQueue": "Bewaar wachtrij als playlist",
|
||||
"searchOrCreate": "Zoek afspeellijsten of typ om een nieuwe te starten...",
|
||||
"pressEnterToCreate": "Druk Enter om nieuwe afspeellijst te maken",
|
||||
"removeFromSelection": "Verwijder van selectie"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Dubbele nummers toevoegen",
|
||||
"song_exist": "Er komen nummers dubbel in de afspeellijst. Wil je de dubbele nummers toevoegen of overslaan?"
|
||||
"song_exist": "Er komen nummers dubbel in de afspeellijst. Wil je de dubbele nummers toevoegen of overslaan?",
|
||||
"noPlaylistsFound": "Geen playlists gevonden",
|
||||
"noPlaylists": "Geen playlists beschikbaar"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
@@ -210,8 +231,8 @@
|
||||
"name": "Naam",
|
||||
"streamUrl": "Stream URL",
|
||||
"homePageUrl": "Hoofdpagina URL",
|
||||
"updatedAt": "Geüpdate op",
|
||||
"createdAt": "Gecreëerd op"
|
||||
"updatedAt": "Bijgewerkt op",
|
||||
"createdAt": "Aangemaakt op"
|
||||
},
|
||||
"actions": {
|
||||
"playNow": "Speel nu"
|
||||
@@ -229,8 +250,8 @@
|
||||
"visitCount": "Bezocht",
|
||||
"format": "Formaat",
|
||||
"maxBitRate": "Max. bitrate",
|
||||
"updatedAt": "Geüpdatet op",
|
||||
"createdAt": "Gecreëerd op",
|
||||
"updatedAt": "Bijgewerkt op",
|
||||
"createdAt": "Aangemaakt op",
|
||||
"downloadable": "Downloads toestaan?"
|
||||
}
|
||||
},
|
||||
@@ -239,7 +260,8 @@
|
||||
"fields": {
|
||||
"path": "Pad",
|
||||
"size": "Grootte",
|
||||
"updatedAt": "Verdwenen op"
|
||||
"updatedAt": "Verdwenen op",
|
||||
"libraryName": "Bibliotheek"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Verwijder",
|
||||
@@ -249,6 +271,58 @@
|
||||
"removed": "Ontbrekende bestanden verwijderd"
|
||||
},
|
||||
"empty": "Geen ontbrekende bestanden"
|
||||
},
|
||||
"library": {
|
||||
"name": "Bibliotheek |||| Bibliotheken",
|
||||
"fields": {
|
||||
"name": "Naam",
|
||||
"path": "Pad",
|
||||
"remotePath": "Extern pad",
|
||||
"lastScanAt": "Laatste scan",
|
||||
"songCount": "Nummers",
|
||||
"albumCount": "Albums",
|
||||
"artistCount": "Artiesten",
|
||||
"totalSongs": "Nummers",
|
||||
"totalAlbums": "Albums",
|
||||
"totalArtists": "Artiesten",
|
||||
"totalFolders": "Mappen",
|
||||
"totalFiles": "Bestanden",
|
||||
"totalMissingFiles": "Ontbrekende bestanden",
|
||||
"totalSize": "Totale bestandsgrootte",
|
||||
"totalDuration": "Afspeelduur",
|
||||
"defaultNewUsers": "Standaard voor nieuwe gebruikers",
|
||||
"createdAt": "Aangemaakt",
|
||||
"updatedAt": "Bijgewerkt"
|
||||
},
|
||||
"sections": {
|
||||
"basic": "Basisinformatie",
|
||||
"statistics": "Statistieken"
|
||||
},
|
||||
"actions": {
|
||||
"scan": "Scan bibliotheek",
|
||||
"manageUsers": "Beheer gebruikerstoegang",
|
||||
"viewDetails": "Bekijk details"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Bibliotheek succesvol aangemaakt",
|
||||
"updated": "Bibliotheek succesvol bijgewerkt",
|
||||
"deleted": "Bibliotheek succesvol verwijderd",
|
||||
"scanStarted": "Bibliotheekscan is gestart",
|
||||
"scanCompleted": "Bibliotheekscan is voltooid"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Bibliotheek naam is vereist",
|
||||
"pathRequired": "Pad naar bibliotheek is vereist",
|
||||
"pathNotDirectory": "Pad naar bibliotheek moet een map zijn",
|
||||
"pathNotFound": "Pad naar bibliotheek niet gevonden",
|
||||
"pathNotAccessible": "Pad naar bibliotheek is niet toegankelijk",
|
||||
"pathInvalid": "Ongeldig pad naar bibliotheek"
|
||||
},
|
||||
"messages": {
|
||||
"deleteConfirm": "Weet je zeker dat je deze bibliotheek wil verwijderen? Dit verwijdert ook alle gerelateerde data en gebruikerstoegang.",
|
||||
"scanInProgress": "Scan is bezig...",
|
||||
"noLibrariesAssigned": "Geen bibliotheken aan deze gebruiker toegewezen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -430,7 +504,9 @@
|
||||
"remove_missing_title": "Verwijder ontbrekende bestanden",
|
||||
"remove_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen.",
|
||||
"remove_all_missing_title": "Verwijder alle ontbrekende bestanden",
|
||||
"remove_all_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen."
|
||||
"remove_all_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen.",
|
||||
"noSimilarSongsFound": "Geen vergelijkbare nummers gevonden",
|
||||
"noTopSongsFound": "Geen beste nummers gevonden"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliotheek",
|
||||
@@ -459,7 +535,13 @@
|
||||
"albumList": "Albums",
|
||||
"about": "Over",
|
||||
"playlists": "Afspeellijsten",
|
||||
"sharedPlaylists": "Gedeelde afspeellijsten"
|
||||
"sharedPlaylists": "Gedeelde afspeellijsten",
|
||||
"librarySelector": {
|
||||
"allLibraries": "Alle bibliotheken (%{count})",
|
||||
"multipleLibraries": "%{selected} van %{total} bibliotheken",
|
||||
"selectLibraries": "Selecteer bibliotheken",
|
||||
"none": "Geen"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Wachtrij",
|
||||
@@ -468,7 +550,7 @@
|
||||
"notContentText": "Geen muziek",
|
||||
"clickToPlayText": "Klik om af te spelen",
|
||||
"clickToPauseText": "Klik om te pauzeren",
|
||||
"nextTrackText": "Volgende",
|
||||
"nextTrackText": "Volgend nummer",
|
||||
"previousTrackText": "Vorige",
|
||||
"reloadText": "Herladen",
|
||||
"volumeText": "Volume",
|
||||
@@ -496,11 +578,26 @@
|
||||
"disabled": "Uitgeschakeld",
|
||||
"waiting": "Wachten"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"about": "Over",
|
||||
"config": "Configuratie"
|
||||
},
|
||||
"config": {
|
||||
"configName": "Config Naam",
|
||||
"environmentVariable": "Omgevingsvariabele",
|
||||
"currentValue": "Huidige waarde",
|
||||
"configurationFile": "Configuratiebestand",
|
||||
"exportToml": "Exporteer configuratie (TOML)",
|
||||
"exportSuccess": "Configuratie geëxporteerd naar klembord in TOML formaat",
|
||||
"exportFailed": "Kopiëren van configuratie mislukt",
|
||||
"devFlagsHeader": "Ontwikkelaarsinstellingen (onder voorbehoud)",
|
||||
"devFlagsComment": "Dit zijn experimentele instellingen en worden mogelijk in latere versies verwijderd"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "Activiteit",
|
||||
"totalScanned": "Totaal gescande folders",
|
||||
"totalScanned": "Totaal gescande mappen",
|
||||
"quickScan": "Snelle scan",
|
||||
"fullScan": "Volledige scan",
|
||||
"serverUptime": "Server uptime",
|
||||
@@ -522,5 +619,10 @@
|
||||
"toggle_love": "Voeg toe aan favorieten",
|
||||
"current_song": "Ga naar huidig nummer"
|
||||
}
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "Speelt nu",
|
||||
"empty": "Er wordt niets afgespeed",
|
||||
"minutesAgo": "%{smart_count} minuut geleden |||| %{smart_count} minuten geleden"
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,17 @@
|
||||
"bpm": "BPM",
|
||||
"playDate": "เล่นล่าสุด",
|
||||
"channels": "ช่อง",
|
||||
"createdAt": "เพิ่มเมื่อ"
|
||||
"createdAt": "เพิ่มเมื่อ",
|
||||
"grouping": "จัดกลุ่ม",
|
||||
"mood": "อารมณ์",
|
||||
"participants": "ผู้มีส่วนร่วม",
|
||||
"tags": "แทกเพิ่มเติม",
|
||||
"mappedTags": "แมพแทก",
|
||||
"rawTags": "แทกเริ่มต้น",
|
||||
"bitDepth": "Bit depth",
|
||||
"sampleRate": "แซมเปิ้ลเรต",
|
||||
"missing": "หายไป",
|
||||
"libraryName": "ห้องสมุด"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "เพิ่มในคิว",
|
||||
@@ -35,7 +45,8 @@
|
||||
"shuffleAll": "สุ่มทั้งหมด",
|
||||
"download": "ดาวน์โหลด",
|
||||
"playNext": "เล่นถัดไป",
|
||||
"info": "ดูรายละเอียด"
|
||||
"info": "ดูรายละเอียด",
|
||||
"showInPlaylist": "แสดงในเพลย์ลิสต์"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -58,7 +69,16 @@
|
||||
"originalDate": "วันที่เริ่ม",
|
||||
"releaseDate": "เผยแพร่เมื่อ",
|
||||
"releases": "เผยแพร่ |||| เผยแพร่",
|
||||
"released": "เผยแพร่เมื่อ"
|
||||
"released": "เผยแพร่เมื่อ",
|
||||
"recordLabel": "ป้าย",
|
||||
"catalogNum": "หมายเลขแคตาล็อก",
|
||||
"releaseType": "ประเภท",
|
||||
"grouping": "จัดกลุ่ม",
|
||||
"media": "มีเดีย",
|
||||
"mood": "อารมณ์",
|
||||
"date": "บันทึกเมื่อ",
|
||||
"missing": "หายไป",
|
||||
"libraryName": "ห้องสมุด"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "เล่นทั้งหมด",
|
||||
@@ -89,7 +109,30 @@
|
||||
"playCount": "เล่นแล้ว",
|
||||
"rating": "ความนิยม",
|
||||
"genre": "ประเภท",
|
||||
"size": "ขนาด"
|
||||
"size": "ขนาด",
|
||||
"role": "Role",
|
||||
"missing": "หายไป"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "ศิลปินอัลบั้ม |||| ศิลปินอัลบั้ม",
|
||||
"artist": "ศิลปิน |||| ศิลปิน",
|
||||
"composer": "ผู้แต่ง |||| ผู้แต่ง",
|
||||
"conductor": "คอนดักเตอร์ |||| คอนดักเตอร์",
|
||||
"lyricist": "เนื้อเพลง |||| เนื้อเพลง",
|
||||
"arranger": "ผู้ดำเนินการ |||| ผู้ดำเนินการ",
|
||||
"producer": "ผู้จัด |||| ผู้จัด",
|
||||
"director": "ไดเรกเตอร์ |||| ไดเรกเตอร์",
|
||||
"engineer": "วิศวกร |||| วิศวกร",
|
||||
"mixer": "มิกเซอร์ |||| มิกเซอร์",
|
||||
"remixer": "รีมิกเซอร์ |||| รีมิกเซอร์",
|
||||
"djmixer": "ดีเจมิกเซอร์ |||| ดีเจมิกเซอร์",
|
||||
"performer": "ผู้เล่น |||| ผู้เล่น",
|
||||
"maincredit": "ศิลปิน |||| ศิลปิน"
|
||||
},
|
||||
"actions": {
|
||||
"shuffle": "เล่นสุ่ม",
|
||||
"radio": "วิทยุ",
|
||||
"topSongs": "เพลงยอดนิยม"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -106,10 +149,12 @@
|
||||
"currentPassword": "รหัสผ่านปัจจุบัน",
|
||||
"newPassword": "รหัสผ่านใหม่",
|
||||
"token": "โทเคน",
|
||||
"lastAccessAt": "เข้าใช้ล่าสุด"
|
||||
"lastAccessAt": "เข้าใช้ล่าสุด",
|
||||
"libraries": "ห้องสมุด"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "การเปลี่ยนชื่อจะมีผลในการล็อกอินครั้งถัดไป"
|
||||
"name": "การเปลี่ยนชื่อจะมีผลในการล็อกอินครั้งถัดไป",
|
||||
"libraries": "เลือกห้องสมุดสำหรับผู้ใช้นี้หรือปล่อยว่างเพื่อใช้ห้องสมุดเริ่มต้น"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "สร้างชื่อผู้ใช้",
|
||||
@@ -118,7 +163,12 @@
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "ใส่โทเคน ListenBrainz ของคุณ",
|
||||
"clickHereForToken": "กดที่นี่เพื่อรับโทเคนของคุณ"
|
||||
"clickHereForToken": "กดที่นี่เพื่อรับโทเคนของคุณ",
|
||||
"selectAllLibraries": "เลือกห้องสมุดทั้งหมด",
|
||||
"adminAutoLibraries": "ผู้ดูแลเข้าถึงห้องสมุดทั้งหมดโดยอัตโนมัติ"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "ต้องเลือกห้องสมุด 1 ห้อง สำหรับผู้ใช้ที่ไม่ใช่ผู้ดูแล"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -162,11 +212,17 @@
|
||||
"addNewPlaylist": "สร้าง \"%{name}\"",
|
||||
"export": "ส่งออก",
|
||||
"makePublic": "ทำเป็นสาธารณะ",
|
||||
"makePrivate": "ทำเป็นส่วนตัว"
|
||||
"makePrivate": "ทำเป็นส่วนตัว",
|
||||
"saveQueue": "บันทึกคิวลงเพลย์ลิสต์",
|
||||
"searchOrCreate": "ค้นหาเพลย์ลิสต์หรือพิมพ์เพื่อสร้างใหม่",
|
||||
"pressEnterToCreate": "กด Enter เพื่อสร้างเพลย์ลิสต์",
|
||||
"removeFromSelection": "เอาออกจากที่เลือกไว้"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "เพิ่มเพลงซ้ำ",
|
||||
"song_exist": "เพิ่มเพลงซ้ำกันในเพลย์ลิสต์ คุณจะเพิ่มเพลงต่อหรือข้าม"
|
||||
"song_exist": "เพิ่มเพลงซ้ำกันในเพลย์ลิสต์ คุณจะเพิ่มเพลงต่อหรือข้าม",
|
||||
"noPlaylistsFound": "ไม่พบเพลย์ลิสต์",
|
||||
"noPlaylists": "ไม่มีเพลย์ลิสต์อยู่"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
@@ -198,6 +254,75 @@
|
||||
"createdAt": "สร้างเมื่อ",
|
||||
"downloadable": "อนุญาตให้ดาวโหลด?"
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "ไฟล์ที่หายไป |||| ไฟล์ที่หายไป",
|
||||
"fields": {
|
||||
"path": "พาร์ท",
|
||||
"size": "ขนาด",
|
||||
"updatedAt": "หายไปจาก",
|
||||
"libraryName": "ห้องสมุด"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "เอาออก",
|
||||
"remove_all": "เอาออกทั้งหมด"
|
||||
},
|
||||
"notifications": {
|
||||
"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": "ดูรายละเอียด"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "สร้างห้องสมุดเรียบร้อย",
|
||||
"updated": "อัพเดทห้องสมุดเรียบร้อย",
|
||||
"deleted": "ลบห้องสมุดเพลงเรียบร้อยแล้ว",
|
||||
"scanStarted": "เริ่มสแกนห้องสมุด",
|
||||
"scanCompleted": "สแกนห้องสมุดเสร็จแล้ว"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "ต้องใส่ชื่อห้องสมุดเพลง",
|
||||
"pathRequired": "ต้องใส่พาร์ทของห้องสมุด",
|
||||
"pathNotDirectory": "พาร์ทของห้องสมุดต้องเป็นแฟ้ม",
|
||||
"pathNotFound": "ไม่เจอพาร์ทของห้องสมุด",
|
||||
"pathNotAccessible": "ไม่สามารถเข้าพาร์ทของห้องสมุด",
|
||||
"pathInvalid": "พาร์ทห้องสมุดไม่ถูก"
|
||||
},
|
||||
"messages": {
|
||||
"deleteConfirm": "คุณแน่ใจว่าจะลบห้องสมุดนี้? นี่จะลบข้อมูลและการเข้าถึงของผู้ใช้ที่เกี่ยวข้องทั้งหมด",
|
||||
"scanInProgress": "กำลังสแกน...",
|
||||
"noLibrariesAssigned": "ไม่มีห้องสมุดสำหรับผู้ใช้นี้"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -375,7 +500,13 @@
|
||||
"shareSuccess": "คัดลอก URL ไปคลิปบอร์ด: %{url}",
|
||||
"shareFailure": "คัดลอก URL %{url} ไปคลิปบอร์ดผิดพลาด",
|
||||
"downloadDialogTitle": "ดาวโหลด %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "คัดลอกไปคลิปบอร์ด: Ctrl+C, Enter"
|
||||
"shareCopyToClipboard": "คัดลอกไปคลิปบอร์ด: Ctrl+C, Enter",
|
||||
"remove_missing_title": "ลบรายการไฟล์ที่หายไป",
|
||||
"remove_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร",
|
||||
"remove_all_missing_title": "เอารายการไฟล์ที่หายไปออกทั้งหมด",
|
||||
"remove_all_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร",
|
||||
"noSimilarSongsFound": "ไม่มีเพลงคล้ายกัน",
|
||||
"noTopSongsFound": "ไม่พบเพลงยอดนิยม"
|
||||
},
|
||||
"menu": {
|
||||
"library": "ห้องสมุดเพลง",
|
||||
@@ -404,7 +535,13 @@
|
||||
"albumList": "อัลบั้ม",
|
||||
"about": "เกี่ยวกับ",
|
||||
"playlists": "เพลย์ลิสต์",
|
||||
"sharedPlaylists": "เพลย์ลิสต์ที่แบ่งปัน"
|
||||
"sharedPlaylists": "เพลย์ลิสต์ที่แบ่งปัน",
|
||||
"librarySelector": {
|
||||
"allLibraries": "ห้องสมุด (%{count}) ห้อง",
|
||||
"multipleLibraries": "%{selected} ของ %{total} ห้องสมุด",
|
||||
"selectLibraries": "เลือกห้องสมุด",
|
||||
"none": "ไม่มี"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "คิวเล่น",
|
||||
@@ -441,6 +578,21 @@
|
||||
"disabled": "ปิดการทำงาน",
|
||||
"waiting": "รอ"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"about": "เกี่ยวกับ",
|
||||
"config": "การตั้งค่า"
|
||||
},
|
||||
"config": {
|
||||
"configName": "ชื่อการตั้งค่า",
|
||||
"environmentVariable": "ค่าทั่วไป",
|
||||
"currentValue": "ค่าปัจจุบัน",
|
||||
"configurationFile": "ไฟล์การตั้งค่า",
|
||||
"exportToml": "นำออกการตั้งค่า (TOML)",
|
||||
"exportSuccess": "นำออกการตั้งค่าไปยังคลิปบอร์ดในรูปแบบ TOML แล้ว",
|
||||
"exportFailed": "คัดลอกการตั้งค่าล้มเหลว",
|
||||
"devFlagsHeader": "ปักธงการพัฒนา (อาจมีการเปลี่ยน/เอาออก)",
|
||||
"devFlagsComment": "การตั้งค่านี้อยู่ในช่วงทดลองและอาจจะมีการเอาออกในเวอร์ชั่นหลัง"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
@@ -449,7 +601,10 @@
|
||||
"quickScan": "สแกนแบบเร็ว",
|
||||
"fullScan": "สแกนทั้งหมด",
|
||||
"serverUptime": "เซิร์ฟเวอร์ออนไลน์นาน",
|
||||
"serverDown": "ออฟไลน์"
|
||||
"serverDown": "ออฟไลน์",
|
||||
"scanType": "ประเภท",
|
||||
"status": "สแกนผิดพลาด",
|
||||
"elapsedTime": "เวลาที่ใช้"
|
||||
},
|
||||
"help": {
|
||||
"title": "คีย์ลัด Navidrome",
|
||||
@@ -464,5 +619,10 @@
|
||||
"toggle_love": "เพิ่มเพลงนี้ไปยังรายการโปรด",
|
||||
"current_song": "ไปยังเพลงปัจจุบัน"
|
||||
}
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "กำลังเล่น",
|
||||
"empty": "ไม่มีเพลงเล่น",
|
||||
"minutesAgo": "%{smart_count} นาทีที่แล้ว |||| %{smart_count} นาทีที่แล้ว"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,7 @@ var _ = Describe("walk_dir_tree", func() {
|
||||
"root/d/f2.mp3": {},
|
||||
"root/d/f3.mp3": {},
|
||||
"root/e/original/f1.mp3": {},
|
||||
"root/e/symlink": {Mode: fs.ModeSymlink, Data: []byte("root/e/original")},
|
||||
"root/e/symlink": {Mode: fs.ModeSymlink, Data: []byte("original")},
|
||||
},
|
||||
}
|
||||
job = &scanJob{
|
||||
|
||||
@@ -29,7 +29,7 @@ var _ = Describe("Config API", func() {
|
||||
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService())
|
||||
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
|
||||
// Create test users
|
||||
|
||||
@@ -13,11 +13,11 @@ import (
|
||||
)
|
||||
|
||||
// User-library association endpoints (admin only)
|
||||
func (n *Router) addUserLibraryRoute(r chi.Router) {
|
||||
func (api *Router) addUserLibraryRoute(r chi.Router) {
|
||||
r.Route("/user/{id}/library", func(r chi.Router) {
|
||||
r.Use(parseUserIDMiddleware)
|
||||
r.Get("/", getUserLibraries(n.libs))
|
||||
r.Put("/", setUserLibraries(n.libs))
|
||||
r.Get("/", getUserLibraries(api.libs))
|
||||
r.Put("/", setUserLibraries(api.libs))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ var _ = Describe("Library API", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService())
|
||||
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
|
||||
// Create test users
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
@@ -63,45 +63,32 @@ func (r *missingRepository) EntityName() string {
|
||||
return "missing_files"
|
||||
}
|
||||
|
||||
func deleteMissingFiles(ds model.DataStore, w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
p := req.Params(r)
|
||||
ids, _ := p.Strings("id")
|
||||
err := ds.WithTx(func(tx model.DataStore) error {
|
||||
func deleteMissingFiles(maintenance core.Maintenance) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
p := req.Params(r)
|
||||
ids, _ := p.Strings("id")
|
||||
|
||||
var err error
|
||||
if len(ids) == 0 {
|
||||
_, err := tx.MediaFile(ctx).DeleteAllMissing()
|
||||
return err
|
||||
}
|
||||
return tx.MediaFile(ctx).DeleteMissing(ids)
|
||||
})
|
||||
if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "Missing file not found", "id", ids[0])
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
err = ds.GC(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error running GC after deleting missing tracks", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh artist stats in background after deleting missing files
|
||||
go func() {
|
||||
bgCtx := request.AddValues(context.Background(), r.Context())
|
||||
if _, err := ds.Artist(bgCtx).RefreshStats(true); err != nil {
|
||||
log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err)
|
||||
err = maintenance.DeleteAllMissingFiles(ctx)
|
||||
} else {
|
||||
log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files")
|
||||
err = maintenance.DeleteMissingFiles(ctx, ids)
|
||||
}
|
||||
}()
|
||||
|
||||
writeDeleteManyResponse(w, r, ids)
|
||||
if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "Missing file not found", "id", ids[0])
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "failed to delete missing files", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeDeleteManyResponse(w, r, ids)
|
||||
}
|
||||
}
|
||||
|
||||
var _ model.ResourceRepository = &missingRepository{}
|
||||
|
||||
@@ -22,70 +22,71 @@ import (
|
||||
|
||||
type Router struct {
|
||||
http.Handler
|
||||
ds model.DataStore
|
||||
share core.Share
|
||||
playlists core.Playlists
|
||||
insights metrics.Insights
|
||||
libs core.Library
|
||||
ds model.DataStore
|
||||
share core.Share
|
||||
playlists core.Playlists
|
||||
insights metrics.Insights
|
||||
libs core.Library
|
||||
maintenance core.Maintenance
|
||||
}
|
||||
|
||||
func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library) *Router {
|
||||
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService}
|
||||
func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library, maintenance core.Maintenance) *Router {
|
||||
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, maintenance: maintenance}
|
||||
r.Handler = r.routes()
|
||||
return r
|
||||
}
|
||||
|
||||
func (n *Router) routes() http.Handler {
|
||||
func (api *Router) routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Public
|
||||
n.RX(r, "/translation", newTranslationRepository, false)
|
||||
api.RX(r, "/translation", newTranslationRepository, false)
|
||||
|
||||
// Protected
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.Authenticator(n.ds))
|
||||
r.Use(server.Authenticator(api.ds))
|
||||
r.Use(server.JWTRefresher)
|
||||
r.Use(server.UpdateLastAccessMiddleware(n.ds))
|
||||
n.R(r, "/user", model.User{}, true)
|
||||
n.R(r, "/song", model.MediaFile{}, false)
|
||||
n.R(r, "/album", model.Album{}, false)
|
||||
n.R(r, "/artist", model.Artist{}, false)
|
||||
n.R(r, "/genre", model.Genre{}, false)
|
||||
n.R(r, "/player", model.Player{}, true)
|
||||
n.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
|
||||
n.R(r, "/radio", model.Radio{}, true)
|
||||
n.R(r, "/tag", model.Tag{}, true)
|
||||
r.Use(server.UpdateLastAccessMiddleware(api.ds))
|
||||
api.R(r, "/user", model.User{}, true)
|
||||
api.R(r, "/song", model.MediaFile{}, false)
|
||||
api.R(r, "/album", model.Album{}, false)
|
||||
api.R(r, "/artist", model.Artist{}, false)
|
||||
api.R(r, "/genre", model.Genre{}, false)
|
||||
api.R(r, "/player", model.Player{}, true)
|
||||
api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
|
||||
api.R(r, "/radio", model.Radio{}, true)
|
||||
api.R(r, "/tag", model.Tag{}, true)
|
||||
if conf.Server.EnableSharing {
|
||||
n.RX(r, "/share", n.share.NewRepository, true)
|
||||
api.RX(r, "/share", api.share.NewRepository, true)
|
||||
}
|
||||
|
||||
n.addPlaylistRoute(r)
|
||||
n.addPlaylistTrackRoute(r)
|
||||
n.addSongPlaylistsRoute(r)
|
||||
n.addQueueRoute(r)
|
||||
n.addMissingFilesRoute(r)
|
||||
n.addKeepAliveRoute(r)
|
||||
n.addInsightsRoute(r)
|
||||
api.addPlaylistRoute(r)
|
||||
api.addPlaylistTrackRoute(r)
|
||||
api.addSongPlaylistsRoute(r)
|
||||
api.addQueueRoute(r)
|
||||
api.addMissingFilesRoute(r)
|
||||
api.addKeepAliveRoute(r)
|
||||
api.addInsightsRoute(r)
|
||||
|
||||
r.With(adminOnlyMiddleware).Group(func(r chi.Router) {
|
||||
n.addInspectRoute(r)
|
||||
n.addConfigRoute(r)
|
||||
n.addUserLibraryRoute(r)
|
||||
n.RX(r, "/library", n.libs.NewRepository, true)
|
||||
api.addInspectRoute(r)
|
||||
api.addConfigRoute(r)
|
||||
api.addUserLibraryRoute(r)
|
||||
api.RX(r, "/library", api.libs.NewRepository, true)
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (n *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) {
|
||||
func (api *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
return n.ds.Resource(ctx, model)
|
||||
return api.ds.Resource(ctx, model)
|
||||
}
|
||||
n.RX(r, pathPrefix, constructor, persistable)
|
||||
api.RX(r, pathPrefix, constructor, persistable)
|
||||
}
|
||||
|
||||
func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) {
|
||||
func (api *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) {
|
||||
r.Route(pathPrefix, func(r chi.Router) {
|
||||
r.Get("/", rest.GetAll(constructor))
|
||||
if persistable {
|
||||
@@ -102,9 +103,9 @@ func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.Repository
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addPlaylistRoute(r chi.Router) {
|
||||
func (api *Router) addPlaylistRoute(r chi.Router) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
return n.ds.Resource(ctx, model.Playlist{})
|
||||
return api.ds.Resource(ctx, model.Playlist{})
|
||||
}
|
||||
|
||||
r.Route("/playlist", func(r chi.Router) {
|
||||
@@ -114,7 +115,7 @@ func (n *Router) addPlaylistRoute(r chi.Router) {
|
||||
rest.Post(constructor)(w, r)
|
||||
return
|
||||
}
|
||||
createPlaylistFromM3U(n.playlists)(w, r)
|
||||
createPlaylistFromM3U(api.playlists)(w, r)
|
||||
})
|
||||
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
@@ -126,55 +127,53 @@ func (n *Router) addPlaylistRoute(r chi.Router) {
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addPlaylistTrackRoute(r chi.Router) {
|
||||
func (api *Router) addPlaylistTrackRoute(r chi.Router) {
|
||||
r.Route("/playlist/{playlistId}/tracks", func(r chi.Router) {
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
getPlaylist(n.ds)(w, r)
|
||||
getPlaylist(api.ds)(w, r)
|
||||
})
|
||||
r.With(server.URLParamsMiddleware).Route("/", func(r chi.Router) {
|
||||
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
deleteFromPlaylist(n.ds)(w, r)
|
||||
deleteFromPlaylist(api.ds)(w, r)
|
||||
})
|
||||
r.Post("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
addToPlaylist(n.ds)(w, r)
|
||||
addToPlaylist(api.ds)(w, r)
|
||||
})
|
||||
})
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Use(server.URLParamsMiddleware)
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
getPlaylistTrack(n.ds)(w, r)
|
||||
getPlaylistTrack(api.ds)(w, r)
|
||||
})
|
||||
r.Put("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
reorderItem(n.ds)(w, r)
|
||||
reorderItem(api.ds)(w, r)
|
||||
})
|
||||
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
deleteFromPlaylist(n.ds)(w, r)
|
||||
deleteFromPlaylist(api.ds)(w, r)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addSongPlaylistsRoute(r chi.Router) {
|
||||
func (api *Router) addSongPlaylistsRoute(r chi.Router) {
|
||||
r.With(server.URLParamsMiddleware).Get("/song/{id}/playlists", func(w http.ResponseWriter, r *http.Request) {
|
||||
getSongPlaylists(n.ds)(w, r)
|
||||
getSongPlaylists(api.ds)(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addQueueRoute(r chi.Router) {
|
||||
func (api *Router) addQueueRoute(r chi.Router) {
|
||||
r.Route("/queue", func(r chi.Router) {
|
||||
r.Get("/", getQueue(n.ds))
|
||||
r.Post("/", saveQueue(n.ds))
|
||||
r.Put("/", updateQueue(n.ds))
|
||||
r.Delete("/", clearQueue(n.ds))
|
||||
r.Get("/", getQueue(api.ds))
|
||||
r.Post("/", saveQueue(api.ds))
|
||||
r.Put("/", updateQueue(api.ds))
|
||||
r.Delete("/", clearQueue(api.ds))
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addMissingFilesRoute(r chi.Router) {
|
||||
func (api *Router) addMissingFilesRoute(r chi.Router) {
|
||||
r.Route("/missing", func(r chi.Router) {
|
||||
n.RX(r, "/", newMissingRepository(n.ds), false)
|
||||
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
deleteMissingFiles(n.ds, w, r)
|
||||
})
|
||||
api.RX(r, "/", newMissingRepository(api.ds), false)
|
||||
r.Delete("/", deleteMissingFiles(api.maintenance))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -198,7 +197,7 @@ func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []strin
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Router) addInspectRoute(r chi.Router) {
|
||||
func (api *Router) addInspectRoute(r chi.Router) {
|
||||
if conf.Server.Inspect.Enabled {
|
||||
r.Group(func(r chi.Router) {
|
||||
if conf.Server.Inspect.MaxRequests > 0 {
|
||||
@@ -207,26 +206,26 @@ func (n *Router) addInspectRoute(r chi.Router) {
|
||||
conf.Server.Inspect.BacklogTimeout)
|
||||
r.Use(middleware.ThrottleBacklog(conf.Server.Inspect.MaxRequests, conf.Server.Inspect.BacklogLimit, time.Duration(conf.Server.Inspect.BacklogTimeout)))
|
||||
}
|
||||
r.Get("/inspect", inspect(n.ds))
|
||||
r.Get("/inspect", inspect(api.ds))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Router) addConfigRoute(r chi.Router) {
|
||||
func (api *Router) addConfigRoute(r chi.Router) {
|
||||
if conf.Server.DevUIShowConfig {
|
||||
r.Get("/config/*", getConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Router) addKeepAliveRoute(r chi.Router) {
|
||||
func (api *Router) addKeepAliveRoute(r chi.Router) {
|
||||
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`))
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addInsightsRoute(r chi.Router) {
|
||||
func (api *Router) addInsightsRoute(r chi.Router) {
|
||||
r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
last, success := n.insights.LastRun(r.Context())
|
||||
last, success := api.insights.LastRun(r.Context())
|
||||
if conf.Server.EnableInsightsCollector {
|
||||
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`))
|
||||
} else {
|
||||
|
||||
@@ -95,7 +95,7 @@ var _ = Describe("Song Endpoints", func() {
|
||||
mfRepo.SetData(testSongs)
|
||||
|
||||
// Create the native API router and wrap it with the JWTVerifier middleware
|
||||
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService())
|
||||
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
w = httptest.NewRecorder()
|
||||
})
|
||||
|
||||
@@ -148,7 +148,9 @@ func (api *Router) routes() http.Handler {
|
||||
h(r, "createBookmark", api.CreateBookmark)
|
||||
h(r, "deleteBookmark", api.DeleteBookmark)
|
||||
h(r, "getPlayQueue", api.GetPlayQueue)
|
||||
h(r, "getPlayQueueByIndex", api.GetPlayQueueByIndex)
|
||||
h(r, "savePlayQueue", api.SavePlayQueue)
|
||||
h(r, "savePlayQueueByIndex", api.SavePlayQueueByIndex)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
|
||||
@@ -91,7 +91,7 @@ func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) {
|
||||
Current: currentID,
|
||||
Position: pq.Position,
|
||||
Username: user.UserName,
|
||||
Changed: &pq.UpdatedAt,
|
||||
Changed: pq.UpdatedAt,
|
||||
ChangedBy: pq.ChangedBy,
|
||||
}
|
||||
return response, nil
|
||||
@@ -135,3 +135,74 @@ func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) {
|
||||
}
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (api *Router) GetPlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) {
|
||||
user, _ := request.UserFrom(r.Context())
|
||||
|
||||
repo := api.ds.PlayQueue(r.Context())
|
||||
pq, err := repo.RetrieveWithMediaFiles(user.ID)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if pq == nil || len(pq.Items) == 0 {
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
|
||||
var index *int
|
||||
if len(pq.Items) > 0 {
|
||||
index = &pq.Current
|
||||
}
|
||||
|
||||
response.PlayQueueByIndex = &responses.PlayQueueByIndex{
|
||||
Entry: slice.MapWithArg(pq.Items, r.Context(), childFromMediaFile),
|
||||
CurrentIndex: index,
|
||||
Position: pq.Position,
|
||||
Username: user.UserName,
|
||||
Changed: pq.UpdatedAt,
|
||||
ChangedBy: pq.ChangedBy,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) SavePlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
ids, _ := p.Strings("id")
|
||||
|
||||
position := p.Int64Or("position", 0)
|
||||
|
||||
var err error
|
||||
var currentIndex int
|
||||
|
||||
if len(ids) > 0 {
|
||||
currentIndex, err = p.Int("currentIndex")
|
||||
if err != nil || currentIndex < 0 || currentIndex >= len(ids) {
|
||||
return nil, newError(responses.ErrorMissingParameter, "missing parameter index, err: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
items := slice.Map(ids, func(id string) model.MediaFile {
|
||||
return model.MediaFile{ID: id}
|
||||
})
|
||||
|
||||
user, _ := request.UserFrom(r.Context())
|
||||
client, _ := request.ClientFrom(r.Context())
|
||||
|
||||
pq := &model.PlayQueue{
|
||||
UserID: user.ID,
|
||||
Current: currentIndex,
|
||||
Position: position,
|
||||
ChangedBy: client,
|
||||
Items: items,
|
||||
CreatedAt: time.Time{},
|
||||
UpdatedAt: time.Time{},
|
||||
}
|
||||
|
||||
repo := api.ds.PlayQueue(r.Context())
|
||||
err = repo.Store(pq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson
|
||||
{Name: "transcodeOffset", Versions: []int32{1}},
|
||||
{Name: "formPost", Versions: []int32{1}},
|
||||
{Name: "songLyrics", Versions: []int32{1}},
|
||||
{Name: "indexBasedQueue", Versions: []int32{1}},
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -35,10 +35,11 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
|
||||
HaveLen(3),
|
||||
HaveLen(4),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"openSubsonic": true,
|
||||
"playQueue": {
|
||||
"username": "",
|
||||
"changed": "0001-01-01T00:00:00Z",
|
||||
"changedBy": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<playQueue username="" changedBy=""></playQueue>
|
||||
<playQueue username="" changed="0001-01-01T00:00:00Z" changedBy=""></playQueue>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"playQueueByIndex": {
|
||||
"entry": [
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false
|
||||
}
|
||||
],
|
||||
"currentIndex": 0,
|
||||
"position": 243,
|
||||
"username": "user1",
|
||||
"changed": "0001-01-01T00:00:00Z",
|
||||
"changedBy": "a_client"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<playQueueByIndex currentIndex="0" position="243" username="user1" changed="0001-01-01T00:00:00Z" changedBy="a_client">
|
||||
<entry id="1" isDir="false" title="title" isVideo="false"></entry>
|
||||
</playQueueByIndex>
|
||||
</subsonic-response>
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"playQueueByIndex": {
|
||||
"username": "",
|
||||
"changed": "0001-01-01T00:00:00Z",
|
||||
"changedBy": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<playQueueByIndex username="" changed="0001-01-01T00:00:00Z" changedBy=""></playQueueByIndex>
|
||||
</subsonic-response>
|
||||
@@ -60,6 +60,7 @@ type Subsonic struct {
|
||||
// OpenSubsonic extensions
|
||||
OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"`
|
||||
LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"`
|
||||
PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -439,12 +440,21 @@ type TopSongs struct {
|
||||
}
|
||||
|
||||
type PlayQueue struct {
|
||||
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
|
||||
Current string `xml:"current,attr,omitempty" json:"current,omitempty"`
|
||||
Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
|
||||
Username string `xml:"username,attr" json:"username"`
|
||||
Changed *time.Time `xml:"changed,attr,omitempty" json:"changed,omitempty"`
|
||||
ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
|
||||
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
|
||||
Current string `xml:"current,attr,omitempty" json:"current,omitempty"`
|
||||
Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
|
||||
Username string `xml:"username,attr" json:"username"`
|
||||
Changed time.Time `xml:"changed,attr" json:"changed"`
|
||||
ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
|
||||
}
|
||||
|
||||
type PlayQueueByIndex struct {
|
||||
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
|
||||
CurrentIndex *int `xml:"currentIndex,attr,omitempty" json:"currentIndex,omitempty"`
|
||||
Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
|
||||
Username string `xml:"username,attr" json:"username"`
|
||||
Changed time.Time `xml:"changed,attr" json:"changed"`
|
||||
ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
|
||||
}
|
||||
|
||||
type Bookmark struct {
|
||||
|
||||
@@ -768,7 +768,7 @@ var _ = Describe("Responses", func() {
|
||||
response.PlayQueue.Username = "user1"
|
||||
response.PlayQueue.Current = "111"
|
||||
response.PlayQueue.Position = 243
|
||||
response.PlayQueue.Changed = &time.Time{}
|
||||
response.PlayQueue.Changed = time.Time{}
|
||||
response.PlayQueue.ChangedBy = "a_client"
|
||||
child := make([]Child, 1)
|
||||
child[0] = Child{Id: "1", Title: "title", IsDir: false}
|
||||
@@ -783,6 +783,40 @@ var _ = Describe("Responses", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PlayQueueByIndex", func() {
|
||||
BeforeEach(func() {
|
||||
response.PlayQueueByIndex = &PlayQueueByIndex{}
|
||||
})
|
||||
|
||||
Context("without data", func() {
|
||||
It("should match .XML", func() {
|
||||
Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
It("should match .JSON", func() {
|
||||
Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with data", func() {
|
||||
BeforeEach(func() {
|
||||
response.PlayQueueByIndex.Username = "user1"
|
||||
response.PlayQueueByIndex.CurrentIndex = gg.P(0)
|
||||
response.PlayQueueByIndex.Position = 243
|
||||
response.PlayQueueByIndex.Changed = time.Time{}
|
||||
response.PlayQueueByIndex.ChangedBy = "a_client"
|
||||
child := make([]Child, 1)
|
||||
child[0] = Child{Id: "1", Title: "title", IsDir: false}
|
||||
response.PlayQueueByIndex.Entry = child
|
||||
})
|
||||
It("should match .XML", func() {
|
||||
Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
It("should match .JSON", func() {
|
||||
Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Shares", func() {
|
||||
BeforeEach(func() {
|
||||
response.Shares = &Shares{}
|
||||
|
||||
4
tests/fixtures/bom-test.lrc
vendored
Normal file
4
tests/fixtures/bom-test.lrc
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
[00:00.00] 作曲 : 柏大輔
|
||||
NOTE: This file intentionally contains a UTF-8 BOM (Byte Order Mark) at byte 0.
|
||||
This tests BOM handling in lyrics parsing (GitHub issue #4631).
|
||||
The BOM bytes are: 0xEF 0xBB 0xBF
|
||||
BIN
tests/fixtures/bom-utf16-test.lrc
vendored
Normal file
BIN
tests/fixtures/bom-utf16-test.lrc
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/playlists/bom-test-utf16.m3u
vendored
Normal file
BIN
tests/fixtures/playlists/bom-test-utf16.m3u
vendored
Normal file
Binary file not shown.
6
tests/fixtures/playlists/bom-test.m3u
vendored
Normal file
6
tests/fixtures/playlists/bom-test.m3u
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
#EXTM3U
|
||||
# NOTE: This file intentionally contains a UTF-8 BOM (Byte Order Mark) at the beginning
|
||||
# (bytes 0xEF 0xBB 0xBF) to test BOM handling in playlist parsing.
|
||||
#PLAYLIST:Test Playlist
|
||||
#EXTINF:123,Test Artist - Test Song
|
||||
test.mp3
|
||||
@@ -6,7 +6,10 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
)
|
||||
|
||||
type testingT interface {
|
||||
@@ -35,3 +38,23 @@ func ClearDB() error {
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// LogHook sets up a logrus test hook and configures the default logger to use it.
|
||||
// It returns the hook and a cleanup function to restore the default logger.
|
||||
// Example usage:
|
||||
//
|
||||
// hook, cleanup := LogHook()
|
||||
// defer cleanup()
|
||||
// // ... perform logging operations ...
|
||||
// Expect(hook.LastEntry()).ToNot(BeNil())
|
||||
// Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel))
|
||||
// Expect(hook.LastEntry().Message).To(Equal("log message"))
|
||||
func LogHook() (*test.Hook, func()) {
|
||||
l, hook := test.NewNullLogger()
|
||||
log.SetLevel(log.LevelWarn)
|
||||
log.SetDefaultLogger(l)
|
||||
return hook, func() {
|
||||
// Restore default logger after test
|
||||
log.SetDefaultLogger(logrus.New())
|
||||
}
|
||||
}
|
||||
|
||||
1245
ui/package-lock.json
generated
1245
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@
|
||||
"dependencies": {
|
||||
"@material-ui/core": "^4.12.4",
|
||||
"@material-ui/icons": "^4.11.3",
|
||||
"@material-ui/lab": "^4.0.0-alpha.58",
|
||||
"@material-ui/lab": "^4.0.0-alpha.61",
|
||||
"@material-ui/styles": "^4.11.5",
|
||||
"blueimp-md5": "^2.19.0",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -46,8 +46,8 @@
|
||||
"react-redux": "^7.2.9",
|
||||
"react-router-dom": "^5.3.4",
|
||||
"redux": "^4.2.1",
|
||||
"redux-saga": "^1.3.0",
|
||||
"uuid": "^11.1.0",
|
||||
"redux-saga": "^1.4.2",
|
||||
"uuid": "^13.0.0",
|
||||
"workbox-cli": "^7.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -55,27 +55,27 @@
|
||||
"@testing-library/react": "^12.1.5",
|
||||
"@testing-library/react-hooks": "^7.0.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^22.15.21",
|
||||
"@types/react": "^17.0.86",
|
||||
"@types/node": "^24.9.1",
|
||||
"@types/react": "^17.0.89",
|
||||
"@types/react-dom": "^17.0.26",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitejs/plugin-react": "^4.5.0",
|
||||
"@vitest/coverage-v8": "^3.1.4",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"@vitest/coverage-v8": "^4.0.3",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"happy-dom": "^17.4.7",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"happy-dom": "^20.0.8",
|
||||
"jsdom": "^26.1.0",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier": "^3.6.2",
|
||||
"ra-test": "^3.19.12",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-pwa": "^0.21.2",
|
||||
"vitest": "^3.1.4"
|
||||
"vite": "^7.1.12",
|
||||
"vite-plugin-pwa": "^1.1.0",
|
||||
"vitest": "^4.0.3"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": {
|
||||
|
||||
@@ -42,6 +42,9 @@ const useStyles = makeStyles({
|
||||
},
|
||||
})
|
||||
|
||||
const formatReleaseType = (record) =>
|
||||
record?.tagValue ? humanize(record?.tagValue) : '-- None --'
|
||||
|
||||
const AlbumFilter = (props) => {
|
||||
const classes = useStyles()
|
||||
const translate = useTranslate()
|
||||
@@ -142,9 +145,7 @@ const AlbumFilter = (props) => {
|
||||
>
|
||||
<AutocompleteInput
|
||||
emptyText="-- None --"
|
||||
optionText={(record) =>
|
||||
record?.tagValue ? humanize(record?.tagValue) : '-- None --'
|
||||
}
|
||||
optionText={formatReleaseType}
|
||||
/>
|
||||
</ReferenceInput>
|
||||
<NullableBooleanInput source="compilation" />
|
||||
|
||||
@@ -127,6 +127,7 @@ const Player = () => {
|
||||
/>
|
||||
),
|
||||
locale: locale(translate),
|
||||
sortableOptions: { delay: 200, delayOnTouchOnly: true },
|
||||
}),
|
||||
[gainInfo, isDesktop, playerTheme, translate, playerState.mode],
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import Linkify from './Linkify'
|
||||
|
||||
const URL = 'http://www.example.com'
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('startEventStream', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
dispatch = vi.fn()
|
||||
global.EventSource = vi.fn((url) => {
|
||||
global.EventSource = vi.fn().mockImplementation(function (url) {
|
||||
instance = new MockEventSource(url)
|
||||
return instance
|
||||
})
|
||||
|
||||
@@ -40,6 +40,11 @@ export default {
|
||||
color: '#ebdbb2',
|
||||
},
|
||||
},
|
||||
MuiIconButton: {
|
||||
root: {
|
||||
color: '#ebdbb2',
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
clickable: {
|
||||
background: '#49483e',
|
||||
|
||||
@@ -70,7 +70,7 @@ export default {
|
||||
},
|
||||
background: {
|
||||
default: '#f0f2f5',
|
||||
paper: 'inherit',
|
||||
paper: bLight['500'],
|
||||
},
|
||||
text: {
|
||||
secondary: '#232323',
|
||||
@@ -450,13 +450,21 @@ export default {
|
||||
},
|
||||
RaPaginationActions: {
|
||||
button: {
|
||||
backgroundColor: 'inherit',
|
||||
backgroundColor: '#fff',
|
||||
color: '#000',
|
||||
minWidth: 48,
|
||||
margin: '0 4px',
|
||||
border: '1px solid #282828',
|
||||
border: '1px solid #cccccc',
|
||||
'@global': {
|
||||
'> .MuiButton-label': {
|
||||
padding: 0,
|
||||
color: '#656565',
|
||||
'&:hover': {
|
||||
color: '#fff !important',
|
||||
},
|
||||
},
|
||||
'> .MuiButton-label > svg': {
|
||||
color: '#656565',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -95,7 +95,7 @@ export const formatFullDate = (date, locale) => {
|
||||
return new Date(date).toLocaleDateString(locale, options)
|
||||
}
|
||||
|
||||
export const formatNumber = (value) => {
|
||||
export const formatNumber = (value, locale) => {
|
||||
if (value === null || value === undefined) return '0'
|
||||
return value.toLocaleString()
|
||||
return value.toLocaleString(locale)
|
||||
}
|
||||
|
||||
@@ -121,35 +121,35 @@ describe('formatDuration2', () => {
|
||||
|
||||
describe('formatNumber', () => {
|
||||
it('handles null and undefined values', () => {
|
||||
expect(formatNumber(null)).toEqual('0')
|
||||
expect(formatNumber(undefined)).toEqual('0')
|
||||
expect(formatNumber(null, 'en-CA')).toEqual('0')
|
||||
expect(formatNumber(undefined, 'en-CA')).toEqual('0')
|
||||
})
|
||||
|
||||
it('formats integers', () => {
|
||||
expect(formatNumber(0)).toEqual('0')
|
||||
expect(formatNumber(1)).toEqual('1')
|
||||
expect(formatNumber(123)).toEqual('123')
|
||||
expect(formatNumber(1000)).toEqual('1,000')
|
||||
expect(formatNumber(1234567)).toEqual('1,234,567')
|
||||
expect(formatNumber(0, 'en-CA')).toEqual('0')
|
||||
expect(formatNumber(1, 'en-CA')).toEqual('1')
|
||||
expect(formatNumber(123, 'en-CA')).toEqual('123')
|
||||
expect(formatNumber(1000, 'en-CA')).toEqual('1,000')
|
||||
expect(formatNumber(1234567, 'en-CA')).toEqual('1,234,567')
|
||||
})
|
||||
|
||||
it('formats decimal numbers', () => {
|
||||
expect(formatNumber(123.45)).toEqual('123.45')
|
||||
expect(formatNumber(1234.567)).toEqual('1,234.567')
|
||||
expect(formatNumber(123.45, 'en-CA')).toEqual('123.45')
|
||||
expect(formatNumber(1234.567, 'en-CA')).toEqual('1,234.567')
|
||||
})
|
||||
|
||||
it('formats negative numbers', () => {
|
||||
expect(formatNumber(-123)).toEqual('-123')
|
||||
expect(formatNumber(-1234)).toEqual('-1,234')
|
||||
expect(formatNumber(-123.45)).toEqual('-123.45')
|
||||
expect(formatNumber(-123, 'en-CA')).toEqual('-123')
|
||||
expect(formatNumber(-1234, 'en-CA')).toEqual('-1,234')
|
||||
expect(formatNumber(-123.45, 'en-CA')).toEqual('-123.45')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatFullDate', () => {
|
||||
it('format dates', () => {
|
||||
expect(formatFullDate('2011', 'en-US')).toEqual('2011')
|
||||
expect(formatFullDate('2011-06', 'en-US')).toEqual('Jun 2011')
|
||||
expect(formatFullDate('1985-01-01', 'en-US')).toEqual('Jan 1, 1985')
|
||||
expect(formatFullDate('2011', 'en-CA')).toEqual('2011')
|
||||
expect(formatFullDate('2011-06', 'en-CA')).toEqual('Jun 2011')
|
||||
expect(formatFullDate('1985-01-01', 'en-CA')).toEqual('Jan 1, 1985')
|
||||
expect(formatFullDate('199704')).toEqual('')
|
||||
})
|
||||
})
|
||||
|
||||
33
utils/ioutils/ioutils.go
Normal file
33
utils/ioutils/ioutils.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package ioutils
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"golang.org/x/text/encoding/unicode"
|
||||
"golang.org/x/text/transform"
|
||||
)
|
||||
|
||||
// UTF8Reader wraps an io.Reader to handle Byte Order Mark (BOM) properly.
|
||||
// It strips UTF-8 BOM if present, and converts UTF-16 (LE/BE) to UTF-8.
|
||||
// This is particularly useful for reading user-provided text files (like LRC lyrics,
|
||||
// playlists) that may have been created on Windows, which often adds BOM markers.
|
||||
//
|
||||
// Reference: https://en.wikipedia.org/wiki/Byte_order_mark
|
||||
func UTF8Reader(r io.Reader) io.Reader {
|
||||
return transform.NewReader(r, unicode.BOMOverride(unicode.UTF8.NewDecoder()))
|
||||
}
|
||||
|
||||
// UTF8ReadFile reads the named file and returns its contents as a byte slice,
|
||||
// automatically handling BOM markers. It's similar to os.ReadFile but strips
|
||||
// UTF-8 BOM and converts UTF-16 encoded files to UTF-8.
|
||||
func UTF8ReadFile(filename string) ([]byte, error) {
|
||||
file, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := UTF8Reader(file)
|
||||
return io.ReadAll(reader)
|
||||
}
|
||||
117
utils/ioutils/ioutils_test.go
Normal file
117
utils/ioutils/ioutils_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package ioutils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestIOUtils(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "IO Utils Suite")
|
||||
}
|
||||
|
||||
var _ = Describe("UTF8Reader", func() {
|
||||
Context("when reading text with UTF-8 BOM", func() {
|
||||
It("strips the UTF-8 BOM marker", func() {
|
||||
// UTF-8 BOM is EF BB BF
|
||||
input := []byte{0xEF, 0xBB, 0xBF, 'h', 'e', 'l', 'l', 'o'}
|
||||
reader := UTF8Reader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(output)).To(Equal("hello"))
|
||||
})
|
||||
|
||||
It("strips UTF-8 BOM from multi-line text", func() {
|
||||
// Test with the actual LRC file format
|
||||
input := []byte{0xEF, 0xBB, 0xBF, '[', '0', '0', ':', '0', '0', '.', '0', '0', ']', ' ', 't', 'e', 's', 't'}
|
||||
reader := UTF8Reader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(output)).To(Equal("[00:00.00] test"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when reading text without BOM", func() {
|
||||
It("passes through unchanged", func() {
|
||||
input := []byte("hello world")
|
||||
reader := UTF8Reader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(output)).To(Equal("hello world"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when reading UTF-16 LE encoded text", func() {
|
||||
It("converts to UTF-8 and strips BOM", func() {
|
||||
// UTF-16 LE BOM (FF FE) followed by "hi" in UTF-16 LE
|
||||
input := []byte{0xFF, 0xFE, 'h', 0x00, 'i', 0x00}
|
||||
reader := UTF8Reader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(output)).To(Equal("hi"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when reading UTF-16 BE encoded text", func() {
|
||||
It("converts to UTF-8 and strips BOM", func() {
|
||||
// UTF-16 BE BOM (FE FF) followed by "hi" in UTF-16 BE
|
||||
input := []byte{0xFE, 0xFF, 0x00, 'h', 0x00, 'i'}
|
||||
reader := UTF8Reader(bytes.NewReader(input))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(output)).To(Equal("hi"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when reading empty content", func() {
|
||||
It("returns empty string", func() {
|
||||
reader := UTF8Reader(bytes.NewReader([]byte{}))
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(output)).To(Equal(""))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("UTF8ReadFile", func() {
|
||||
Context("when reading a file with UTF-8 BOM", func() {
|
||||
It("strips the BOM marker", func() {
|
||||
// Use the actual fixture from issue #4631
|
||||
contents, err := UTF8ReadFile("../../tests/fixtures/bom-test.lrc")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Should NOT start with BOM
|
||||
Expect(contents[0]).ToNot(Equal(byte(0xEF)))
|
||||
// Should start with '['
|
||||
Expect(contents[0]).To(Equal(byte('[')))
|
||||
Expect(string(contents)).To(HavePrefix("[00:00.00]"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when reading a file without BOM", func() {
|
||||
It("reads the file normally", func() {
|
||||
contents, err := UTF8ReadFile("../../tests/fixtures/test.lrc")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Should contain the expected content
|
||||
Expect(string(contents)).To(ContainSubstring("We're no strangers to love"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when reading a non-existent file", func() {
|
||||
It("returns an error", func() {
|
||||
_, err := UTF8ReadFile("../../tests/fixtures/nonexistent.lrc")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ package str
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var utf8ToAscii = func() *strings.Replacer {
|
||||
@@ -39,3 +40,25 @@ func LongestCommonPrefix(list []string) string {
|
||||
}
|
||||
return list[0]
|
||||
}
|
||||
|
||||
// TruncateRunes truncates a string to a maximum number of runes, adding a suffix if truncated.
|
||||
// The suffix is included in the rune count, so if maxRunes is 30 and suffix is "...", the actual
|
||||
// string content will be truncated to fit within the maxRunes limit including the suffix.
|
||||
func TruncateRunes(s string, maxRunes int, suffix string) string {
|
||||
if utf8.RuneCountInString(s) <= maxRunes {
|
||||
return s
|
||||
}
|
||||
|
||||
suffixRunes := utf8.RuneCountInString(suffix)
|
||||
truncateAt := maxRunes - suffixRunes
|
||||
if truncateAt < 0 {
|
||||
truncateAt = 0
|
||||
}
|
||||
|
||||
runes := []rune(s)
|
||||
if truncateAt >= len(runes) {
|
||||
return s + suffix
|
||||
}
|
||||
|
||||
return string(runes[:truncateAt]) + suffix
|
||||
}
|
||||
|
||||
@@ -31,6 +31,72 @@ var _ = Describe("String Utils", func() {
|
||||
Expect(str.LongestCommonPrefix(albums)).To(Equal("/artist/album"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("TruncateRunes", func() {
|
||||
It("returns string unchanged if under max runes", func() {
|
||||
Expect(str.TruncateRunes("hello", 10, "...")).To(Equal("hello"))
|
||||
})
|
||||
|
||||
It("returns string unchanged if exactly at max runes", func() {
|
||||
Expect(str.TruncateRunes("hello", 5, "...")).To(Equal("hello"))
|
||||
})
|
||||
|
||||
It("truncates and adds suffix when over max runes", func() {
|
||||
Expect(str.TruncateRunes("hello world", 8, "...")).To(Equal("hello..."))
|
||||
})
|
||||
|
||||
It("handles unicode characters correctly", func() {
|
||||
// 6 emoji characters, maxRunes=5, suffix="..." (3 runes)
|
||||
// So content gets 5-3=2 runes
|
||||
Expect(str.TruncateRunes("😀😁😂😃😄😅", 5, "...")).To(Equal("😀😁..."))
|
||||
})
|
||||
|
||||
It("handles multi-byte UTF-8 characters", func() {
|
||||
// Characters like é are single runes
|
||||
Expect(str.TruncateRunes("Café au Lait", 5, "...")).To(Equal("Ca..."))
|
||||
})
|
||||
|
||||
It("works with empty suffix", func() {
|
||||
Expect(str.TruncateRunes("hello world", 5, "")).To(Equal("hello"))
|
||||
})
|
||||
|
||||
It("accounts for suffix length in truncation", func() {
|
||||
// maxRunes=10, suffix="..." (3 runes) -> leaves 7 runes for content
|
||||
result := str.TruncateRunes("hello world this is long", 10, "...")
|
||||
Expect(result).To(Equal("hello w..."))
|
||||
// Verify total rune count is <= maxRunes
|
||||
runeCount := len([]rune(result))
|
||||
Expect(runeCount).To(BeNumerically("<=", 10))
|
||||
})
|
||||
|
||||
It("handles very long suffix gracefully", func() {
|
||||
// If suffix is longer than maxRunes, we still add it
|
||||
// but the content will be truncated to 0
|
||||
result := str.TruncateRunes("hello world", 5, "... (truncated)")
|
||||
// Result will be just the suffix (since truncateAt=0)
|
||||
Expect(result).To(Equal("... (truncated)"))
|
||||
})
|
||||
|
||||
It("handles empty string", func() {
|
||||
Expect(str.TruncateRunes("", 10, "...")).To(Equal(""))
|
||||
})
|
||||
|
||||
It("uses custom suffix", func() {
|
||||
// maxRunes=11, suffix=" [...]" (6 runes) -> content gets 5 runes
|
||||
// "hello world" is 11 runes exactly, so we need a longer string
|
||||
Expect(str.TruncateRunes("hello world extra", 11, " [...]")).To(Equal("hello [...]"))
|
||||
})
|
||||
|
||||
DescribeTable("truncates at rune boundaries (not byte boundaries)",
|
||||
func(input string, maxRunes int, suffix string, expected string) {
|
||||
Expect(str.TruncateRunes(input, maxRunes, suffix)).To(Equal(expected))
|
||||
},
|
||||
Entry("ASCII", "abcdefghij", 5, "...", "ab..."),
|
||||
Entry("Mixed ASCII and Unicode", "ab😀cd", 4, ".", "ab😀."),
|
||||
Entry("All emoji", "😀😁😂😃😄", 3, "…", "😀😁…"),
|
||||
Entry("Japanese", "こんにちは世界", 3, "…", "こん…"),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
var testPaths = []string{
|
||||
|
||||
Reference in New Issue
Block a user