mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Compare commits
65 Commits
4622d25745
...
8ec1f3bad6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ec1f3bad6 | ||
|
|
11f5441eb5 | ||
|
|
13e96a0e81 | ||
|
|
bd9fa1c602 | ||
|
|
7b7721f002 | ||
|
|
63a5954e4f | ||
|
|
56f0518830 | ||
|
|
e6560ccb40 | ||
|
|
fa138afea5 | ||
|
|
06993a8e04 | ||
|
|
9bd3400d0e | ||
|
|
b38054b29c | ||
|
|
21a016742e | ||
|
|
803b385920 | ||
|
|
6486a27634 | ||
|
|
05105e91d9 | ||
|
|
6f7af6650c | ||
|
|
aa5aa731dc | ||
|
|
3a14faa033 | ||
|
|
ecba19a08e | ||
|
|
32ac53dc9f | ||
|
|
6abc2ed517 | ||
|
|
838ceee26d | ||
|
|
f0625ff709 | ||
|
|
08a027dbcc | ||
|
|
c4c70519b5 | ||
|
|
2c90685bc2 | ||
|
|
f3887df334 | ||
|
|
c466f6b612 | ||
|
|
af78bdeb3a | ||
|
|
da56df3160 | ||
|
|
5ec6e6a8d4 | ||
|
|
3b958dd6a7 | ||
|
|
08fd222791 | ||
|
|
37e5a1d248 | ||
|
|
bd3192be0b | ||
|
|
b6fba33b14 | ||
|
|
9cd2cd0a8b | ||
|
|
1b46b97712 | ||
|
|
5c387630ff | ||
|
|
9a2eb483e8 | ||
|
|
cc18bf7329 | ||
|
|
e15896bf32 | ||
|
|
29c123854c | ||
|
|
318ad164df | ||
|
|
1709ce37f6 | ||
|
|
a6451f75d6 | ||
|
|
03841ffe96 | ||
|
|
fb61827ab6 | ||
|
|
1e7996f5d7 | ||
|
|
174621f259 | ||
|
|
cf1f190bb5 | ||
|
|
11640f2e4d | ||
|
|
37908d3cea | ||
|
|
bc107d1cee | ||
|
|
dad4203f9a | ||
|
|
2a43c4683e | ||
|
|
59b6755014 | ||
|
|
833c50adc7 | ||
|
|
74a5c0c6d1 | ||
|
|
fc9cdf39c8 | ||
|
|
823d851b75 | ||
|
|
945d0ba1e2 | ||
|
|
55a31f30b3 | ||
|
|
5cc4ce1ace |
22
.github/workflows/pipeline.yml
vendored
22
.github/workflows/pipeline.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
git_tag: ${{ steps.git-version.outputs.GIT_TAG }}
|
||||
git_sha: ${{ steps.git-version.outputs.GIT_SHA }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
@ -62,7 +62,7 @@ jobs:
|
||||
name: Lint Go code
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
@ -101,7 +101,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
@ -127,7 +127,7 @@ jobs:
|
||||
FFMPEG_VERSION: "7.1"
|
||||
FFMPEG_REPOSITORY: navidrome/ffmpeg-windows-builds
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
@ -199,7 +199,7 @@ jobs:
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
@ -230,7 +230,7 @@ jobs:
|
||||
name: Lint i18n files
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- run: |
|
||||
set -e
|
||||
for file in resources/i18n/*.json; do
|
||||
@ -276,7 +276,7 @@ jobs:
|
||||
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
|
||||
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Prepare Docker Buildx
|
||||
uses: ./.github/actions/prepare-docker
|
||||
@ -350,7 +350,7 @@ jobs:
|
||||
env:
|
||||
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v8
|
||||
@ -384,7 +384,7 @@ jobs:
|
||||
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v8
|
||||
@ -437,7 +437,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
@ -471,7 +471,7 @@ jobs:
|
||||
outputs:
|
||||
package_list: ${{ steps.set-package-list.outputs.package_list }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
2
.github/workflows/push-translations.yml
vendored
2
.github/workflows/push-translations.yml
vendored
@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'navidrome' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
|
||||
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@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Get updated translations
|
||||
id: poeditor
|
||||
env:
|
||||
|
||||
@ -13,6 +13,7 @@ linters:
|
||||
- dogsled
|
||||
- durationcheck
|
||||
- errorlint
|
||||
- forbidigo
|
||||
- gocritic
|
||||
- gocyclo
|
||||
- goprintffuncname
|
||||
@ -36,6 +37,14 @@ linters:
|
||||
- G401
|
||||
- G505
|
||||
- G115
|
||||
forbidigo:
|
||||
forbid:
|
||||
- pattern: 'tx\.Exec$'
|
||||
msg: "use tx.ExecContext(ctx, ...) in migrations to propagate context"
|
||||
- pattern: 'tx\.Query$'
|
||||
msg: "use tx.QueryContext(ctx, ...) in migrations to propagate context"
|
||||
- pattern: 'tx\.QueryRow$'
|
||||
msg: "use tx.QueryRowContext(ctx, ...) in migrations to propagate context"
|
||||
govet:
|
||||
enable:
|
||||
- nilness
|
||||
@ -45,6 +54,9 @@ linters:
|
||||
- gosec
|
||||
path: _test\.go
|
||||
text: "G703"
|
||||
- path-except: 'db/migrations/'
|
||||
linters:
|
||||
- forbidigo
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
@ -56,6 +68,7 @@ linters:
|
||||
- builtin$
|
||||
- examples$
|
||||
- node_modules
|
||||
- _gen\.go$
|
||||
formatters:
|
||||
exclusions:
|
||||
generated: lax
|
||||
|
||||
11
Dockerfile
11
Dockerfile
@ -69,8 +69,16 @@ RUN --mount=type=bind,source=. \
|
||||
set -e
|
||||
xx-go --wrap
|
||||
export CGO_ENABLED=1
|
||||
# Native libwebp (gen2brain/webp) uses ebitengine/purego reverse callbacks,
|
||||
# which purego does not support on 32-bit ARM or x86 and crash with a SIGSEGV
|
||||
# (issue #5597). Build those arches with the "nodynamic" tag so gen2brain/webp
|
||||
# is WASM-only and never links the purego path. 64-bit arches keep native libwebp.
|
||||
BUILD_TAGS=netgo,sqlite_fts5
|
||||
if [ "$(xx-info arch)" = "arm" ] || [ "$(xx-info arch)" = "386" ]; then
|
||||
BUILD_TAGS=${BUILD_TAGS},nodynamic
|
||||
fi
|
||||
# -latomic is required on 32-bit arm (arm/v6, arm/v7) so SQLite's 64-bit atomics resolve.
|
||||
go build -tags=netgo,sqlite_fts5 -ldflags="-w -s \
|
||||
go build -tags=${BUILD_TAGS} -ldflags="-w -s \
|
||||
-linkmode=external -extldflags '-latomic' \
|
||||
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
|
||||
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
|
||||
@ -159,7 +167,6 @@ ENV ND_MUSICFOLDER=/music
|
||||
ENV ND_DATAFOLDER=/data
|
||||
ENV ND_CONFIGFILE=/data/navidrome.toml
|
||||
ENV ND_PORT=4533
|
||||
ENV ND_ENABLEWEBPENCODING=true
|
||||
RUN touch /.nddockerenv
|
||||
|
||||
EXPOSE ${ND_PORT}
|
||||
|
||||
@ -52,6 +52,7 @@ A share of the revenue helps fund the development of Navidrome at no additional
|
||||
- **Multi-platform**, runs on macOS, Linux and Windows. **Docker** images are also provided
|
||||
- Ready to use binaries for all major platforms, including **Raspberry Pi**
|
||||
- Automatically **monitors your library** for changes, importing new files and reloading new metadata
|
||||
- Supports **lyrics** from sidecar .ttml, .yaml/.yml Lyricsfile, .elrc, .lrc, .srt, .txt files and embedded TTML, Enhanced LRC, LRC, SRT, and plain-text tags (via `lyricspriority`)
|
||||
- **Themeable**, modern and responsive **Web interface** based on [Material UI](https://material-ui.com)
|
||||
- **Compatible** with all Subsonic/Madsonic/Airsonic [clients](https://www.navidrome.org/docs/overview/#apps)
|
||||
- **Transcoding** on the fly. Can be set per user/player. **Opus encoding is supported**
|
||||
|
||||
@ -231,10 +231,9 @@ func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, arti
|
||||
res := make([]agents.Song, 0, len(resp))
|
||||
for _, t := range resp {
|
||||
res = append(res, agents.Song{
|
||||
Name: t.Name,
|
||||
MBID: t.MBID,
|
||||
Artist: t.Artist.Name,
|
||||
ArtistMBID: t.Artist.MBID,
|
||||
Name: t.Name,
|
||||
MBID: t.MBID,
|
||||
Artists: []agents.Artist{{Name: t.Artist.Name, MBID: t.Artist.MBID}},
|
||||
})
|
||||
}
|
||||
return res, nil
|
||||
|
||||
@ -309,11 +309,11 @@ var _ = Describe("lastfmAgent", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
Expect(agent.GetSimilarSongsByTrack(ctx, "123", "Just Can't Get Enough", "Depeche Mode", "", 5)).To(Equal([]agents.Song{
|
||||
{Name: "Dreaming of Me", MBID: "027b553e-7c74-3ed4-a95e-1d4fea51f174", Artist: "Depeche Mode", ArtistMBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"},
|
||||
{Name: "Everything Counts", MBID: "5a5a3ca4-bdb8-4641-a674-9b54b9b319a6", Artist: "Depeche Mode", ArtistMBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"},
|
||||
{Name: "Don't You Want Me", MBID: "", Artist: "The Human League", ArtistMBID: "7adaabfb-acfb-47bc-8c7c-59471c2f0db8"},
|
||||
{Name: "Tainted Love", MBID: "", Artist: "Soft Cell", ArtistMBID: "7fb50287-029d-47cc-825a-235ca28024b2"},
|
||||
{Name: "Blue Monday", MBID: "727e84c6-1b56-31dd-a958-a5f46305cec0", Artist: "New Order", ArtistMBID: "f1106b17-dcbb-45f6-b938-199ccfab50cc"},
|
||||
{Name: "Dreaming of Me", MBID: "027b553e-7c74-3ed4-a95e-1d4fea51f174", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}}},
|
||||
{Name: "Everything Counts", MBID: "5a5a3ca4-bdb8-4641-a674-9b54b9b319a6", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}}},
|
||||
{Name: "Don't You Want Me", MBID: "", Artists: []agents.Artist{{Name: "The Human League", MBID: "7adaabfb-acfb-47bc-8c7c-59471c2f0db8"}}},
|
||||
{Name: "Tainted Love", MBID: "", Artists: []agents.Artist{{Name: "Soft Cell", MBID: "7fb50287-029d-47cc-825a-235ca28024b2"}}},
|
||||
{Name: "Blue Monday", MBID: "727e84c6-1b56-31dd-a958-a5f46305cec0", Artists: []agents.Artist{{Name: "New Order", MBID: "f1106b17-dcbb-45f6-b938-199ccfab50cc"}}},
|
||||
}))
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("track")).To(Equal("Just Can't Get Enough"))
|
||||
|
||||
@ -141,24 +141,37 @@ func (l *listenBrainzAgent) GetArtistTopSongs(ctx context.Context, id, artistNam
|
||||
|
||||
res := make([]agents.Song, len(resp))
|
||||
for i, t := range resp {
|
||||
mbid := ""
|
||||
if len(t.ArtistMBIDs) > 0 {
|
||||
mbid = t.ArtistMBIDs[0]
|
||||
}
|
||||
|
||||
res[i] = agents.Song{
|
||||
Album: t.ReleaseName,
|
||||
AlbumMBID: t.ReleaseMBID,
|
||||
Artist: t.ArtistName,
|
||||
ArtistMBID: mbid,
|
||||
Duration: t.DurationMs,
|
||||
Name: t.RecordingName,
|
||||
MBID: t.RecordingMbid,
|
||||
Album: t.ReleaseName,
|
||||
AlbumMBID: t.ReleaseMBID,
|
||||
Artists: topSongArtists(t.ArtistName, t.ArtistMBIDs),
|
||||
Duration: t.DurationMs,
|
||||
Name: t.RecordingName,
|
||||
MBID: t.RecordingMbid,
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// topSongArtists maps the top-recordings response, which carries a single combined display name
|
||||
// (e.g. "X feat. Y") plus a per-artist MBID list, onto agents.Artist. Names and MBIDs are not
|
||||
// positionally pairable, so the display name attaches to the first credit and any further MBIDs
|
||||
// become MBID-only collaborators — still valid identity signals for the matcher.
|
||||
func topSongArtists(name string, mbids []string) []agents.Artist {
|
||||
if len(mbids) == 0 {
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
return []agents.Artist{{Name: name}}
|
||||
}
|
||||
artists := make([]agents.Artist, len(mbids))
|
||||
artists[0] = agents.Artist{Name: name, MBID: mbids[0]}
|
||||
for i, m := range mbids[1:] {
|
||||
artists[i+1] = agents.Artist{MBID: m}
|
||||
}
|
||||
return artists
|
||||
}
|
||||
|
||||
func (l *listenBrainzAgent) GetSimilarArtists(ctx context.Context, id string, name string, mbid string, limit int) ([]agents.Artist, error) {
|
||||
if mbid == "" {
|
||||
return nil, agents.ErrNotFound
|
||||
@ -203,7 +216,7 @@ func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id strin
|
||||
songs[i] = agents.Song{
|
||||
Album: song.ReleaseName,
|
||||
AlbumMBID: song.ReleaseMBID,
|
||||
Artist: song.Artist,
|
||||
Artists: []agents.Artist{{Name: song.Artist}},
|
||||
MBID: song.MBID,
|
||||
Name: song.Name,
|
||||
}
|
||||
|
||||
@ -249,24 +249,22 @@ var _ = Describe("listenBrainzAgent", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(data).To(Equal([]agents.Song{
|
||||
{
|
||||
ID: "",
|
||||
Name: "world.execute(me);",
|
||||
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
|
||||
Artist: "Mili",
|
||||
ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
|
||||
Album: "Miracle Milk",
|
||||
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
|
||||
Duration: 211912,
|
||||
ID: "",
|
||||
Name: "world.execute(me);",
|
||||
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
|
||||
Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}},
|
||||
Album: "Miracle Milk",
|
||||
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
|
||||
Duration: 211912,
|
||||
},
|
||||
{
|
||||
ID: "",
|
||||
Name: "String Theocracy",
|
||||
MBID: "afa2c83d-b17f-4029-b9da-790ea9250cf9",
|
||||
Artist: "Mili",
|
||||
ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
|
||||
Album: "String Theocracy",
|
||||
AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e",
|
||||
Duration: 174000,
|
||||
ID: "",
|
||||
Name: "String Theocracy",
|
||||
MBID: "afa2c83d-b17f-4029-b9da-790ea9250cf9",
|
||||
Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}},
|
||||
Album: "String Theocracy",
|
||||
AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e",
|
||||
Duration: 174000,
|
||||
},
|
||||
}))
|
||||
})
|
||||
@ -278,17 +276,45 @@ var _ = Describe("listenBrainzAgent", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(data).To(Equal([]agents.Song{
|
||||
{
|
||||
ID: "",
|
||||
Name: "world.execute(me);",
|
||||
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
|
||||
Artist: "Mili",
|
||||
ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
|
||||
Album: "Miracle Milk",
|
||||
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
|
||||
Duration: 211912,
|
||||
ID: "",
|
||||
Name: "world.execute(me);",
|
||||
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
|
||||
Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}},
|
||||
Album: "Miracle Milk",
|
||||
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
|
||||
Duration: 211912,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
It("maps a multi-artist top song to one named artist plus MBID-only collaborators", func() {
|
||||
body := `[{
|
||||
"recording_name": "Collab",
|
||||
"recording_mbid": "rec-1",
|
||||
"artist_name": "Drake feat. Future",
|
||||
"artist_mbids": ["mbid-drake", "mbid-future"],
|
||||
"release_name": "Album",
|
||||
"release_mbid": "rel-1",
|
||||
"length": 200000
|
||||
}]`
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(body)), StatusCode: 200}
|
||||
data, err := agent.GetArtistTopSongs(ctx, "", "", "mbid-drake", 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(data).To(HaveLen(1))
|
||||
Expect(data[0].Artists).To(Equal([]agents.Artist{
|
||||
{Name: "Drake feat. Future", MBID: "mbid-drake"},
|
||||
{MBID: "mbid-future"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("leaves Artists nil when the top song carries no name or MBIDs", func() {
|
||||
body := `[{"recording_name": "Anon", "recording_mbid": "rec-1", "artist_name": "", "artist_mbids": []}]`
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(body)), StatusCode: 200}
|
||||
data, err := agent.GetArtistTopSongs(ctx, "", "", "x", 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(data).To(HaveLen(1))
|
||||
Expect(data[0].Artists).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetSimilarArtists", func() {
|
||||
@ -393,26 +419,24 @@ var _ = Describe("listenBrainzAgent", func() {
|
||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
|
||||
Expect(resp).To(Equal([]agents.Song{
|
||||
{
|
||||
ID: "",
|
||||
Name: "Take On Me",
|
||||
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
|
||||
ISRC: "",
|
||||
Artist: "a‐ha",
|
||||
ArtistMBID: "",
|
||||
Album: "Hunting High and Low",
|
||||
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
||||
Duration: 0,
|
||||
ID: "",
|
||||
Name: "Take On Me",
|
||||
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
|
||||
ISRC: "",
|
||||
Artists: []agents.Artist{{Name: "a‐ha"}},
|
||||
Album: "Hunting High and Low",
|
||||
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
||||
Duration: 0,
|
||||
},
|
||||
{
|
||||
ID: "",
|
||||
Name: "Wake Me Up Before You Go‐Go",
|
||||
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
|
||||
ISRC: "",
|
||||
Artist: "Wham!",
|
||||
ArtistMBID: "",
|
||||
Album: "Make It Big",
|
||||
AlbumMBID: "c143d542-48dc-446b-b523-1762da721638",
|
||||
Duration: 0,
|
||||
ID: "",
|
||||
Name: "Wake Me Up Before You Go‐Go",
|
||||
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
|
||||
ISRC: "",
|
||||
Artists: []agents.Artist{{Name: "Wham!"}},
|
||||
Album: "Make It Big",
|
||||
AlbumMBID: "c143d542-48dc-446b-b523-1762da721638",
|
||||
Duration: 0,
|
||||
},
|
||||
}))
|
||||
})
|
||||
@ -427,15 +451,14 @@ var _ = Describe("listenBrainzAgent", func() {
|
||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
|
||||
Expect(resp).To(Equal([]agents.Song{
|
||||
{
|
||||
ID: "",
|
||||
Name: "Take On Me",
|
||||
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
|
||||
ISRC: "",
|
||||
Artist: "a‐ha",
|
||||
ArtistMBID: "",
|
||||
Album: "Hunting High and Low",
|
||||
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
||||
Duration: 0,
|
||||
ID: "",
|
||||
Name: "Take On Me",
|
||||
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
|
||||
ISRC: "",
|
||||
Artists: []agents.Artist{{Name: "a‐ha"}},
|
||||
Album: "Hunting High and Low",
|
||||
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
||||
Duration: 0,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
@ -32,17 +32,17 @@ var inspectCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var marshalers = map[string]func(interface{}) ([]byte, error){
|
||||
var marshalers = map[string]func(any) ([]byte, error){
|
||||
"pretty": prettyMarshal,
|
||||
"toml": toml.Marshal,
|
||||
"yaml": yaml.Marshal,
|
||||
"json": json.Marshal,
|
||||
"jsonindent": func(v interface{}) ([]byte, error) {
|
||||
"jsonindent": func(v any) ([]byte, error) {
|
||||
return json.MarshalIndent(v, "", " ")
|
||||
},
|
||||
}
|
||||
|
||||
func prettyMarshal(v interface{}) ([]byte, error) {
|
||||
func prettyMarshal(v any) ([]byte, error) {
|
||||
out := v.([]core.InspectOutput)
|
||||
var res strings.Builder
|
||||
for i := range out {
|
||||
|
||||
@ -109,7 +109,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
lyricsLyrics := lyrics.NewLyrics(manager)
|
||||
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
||||
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)
|
||||
|
||||
@ -47,7 +47,6 @@ type configOptions struct {
|
||||
UIWelcomeMessage string
|
||||
MaxSidebarPlaylists int
|
||||
EnableTranscodingConfig bool
|
||||
EnableTranscodingCancellation bool
|
||||
EnableDownloads bool
|
||||
EnableExternalServices bool
|
||||
EnableM3UExternalAlbumArt bool
|
||||
@ -113,6 +112,7 @@ type configOptions struct {
|
||||
PID pidOptions `json:",omitzero"`
|
||||
Inspect inspectOptions `json:",omitzero"`
|
||||
Subsonic subsonicOptions `json:",omitzero"`
|
||||
Transcoding transcodingOptions `json:",omitzero"`
|
||||
LastFM lastfmOptions `json:",omitzero"`
|
||||
Deezer deezerOptions `json:",omitzero"`
|
||||
ListenBrainz listenBrainzOptions `json:",omitzero"`
|
||||
@ -162,9 +162,16 @@ type scannerOptions struct {
|
||||
GenreSeparators string // Deprecated: Use Tags.genre.Split instead
|
||||
GroupAlbumReleases bool // Deprecated: Use PID.Album instead
|
||||
FollowSymlinks bool // Whether to follow symlinks when scanning directories
|
||||
IgnoreDotFolders bool // Whether to ignore folders whose name starts with a dot when scanning
|
||||
PurgeMissing string // Values: "never", "always", "full"
|
||||
}
|
||||
|
||||
type transcodingOptions struct {
|
||||
MaxConcurrent int
|
||||
MaxConcurrentPerUser int
|
||||
EnableCancellation bool
|
||||
}
|
||||
|
||||
type subsonicOptions struct {
|
||||
AppendSubtitle bool
|
||||
AppendAlbumVersion bool
|
||||
@ -324,6 +331,7 @@ func Load(noConfigDump bool) {
|
||||
mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||
mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality")
|
||||
mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
|
||||
mapDeprecatedOption("EnableTranscodingCancellation", "Transcoding.EnableCancellation")
|
||||
|
||||
err := viper.Unmarshal(&Server, viper.DecodeHook(
|
||||
mapstructure.ComposeDecodeHookFunc(
|
||||
@ -449,6 +457,7 @@ func Load(noConfigDump bool) {
|
||||
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||
logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality")
|
||||
logDeprecatedOptions("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
|
||||
logDeprecatedOptions("EnableTranscodingCancellation", "Transcoding.EnableCancellation")
|
||||
|
||||
// Removed options
|
||||
logRemovedOptions("Spotify.ID", "Spotify.Secret")
|
||||
@ -737,7 +746,6 @@ func setViperDefaults() {
|
||||
viper.SetDefault("uiwelcomemessage", "")
|
||||
viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists)
|
||||
viper.SetDefault("enabletranscodingconfig", false)
|
||||
viper.SetDefault("enabletranscodingcancellation", false)
|
||||
viper.SetDefault("transcodingcachesize", "100MB")
|
||||
viper.SetDefault("imagecachesize", "100MB")
|
||||
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
|
||||
@ -769,7 +777,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
||||
viper.SetDefault("artistimagefolder", "")
|
||||
viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded")
|
||||
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
|
||||
viper.SetDefault("lyricspriority", ".ttml,.yaml,.yml,.elrc,.lrc,.srt,.txt,embedded")
|
||||
viper.SetDefault("enablegravatar", false)
|
||||
viper.SetDefault("enablefavourites", true)
|
||||
viper.SetDefault("enablestarrating", true)
|
||||
@ -814,6 +822,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("scanner.genreseparators", "")
|
||||
viper.SetDefault("scanner.groupalbumreleases", false)
|
||||
viper.SetDefault("scanner.followsymlinks", true)
|
||||
viper.SetDefault("scanner.ignoredotfolders", true)
|
||||
viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever)
|
||||
viper.SetDefault("subsonic.appendsubtitle", true)
|
||||
viper.SetDefault("subsonic.appendalbumversion", true)
|
||||
@ -822,6 +831,9 @@ func setViperDefaults() {
|
||||
viper.SetDefault("subsonic.enableaveragerating", true)
|
||||
viper.SetDefault("subsonic.legacyclients", "DSub")
|
||||
viper.SetDefault("subsonic.minimalclients", "SubMusic")
|
||||
viper.SetDefault("transcoding.maxconcurrent", 0)
|
||||
viper.SetDefault("transcoding.maxconcurrentperuser", 0)
|
||||
viper.SetDefault("transcoding.enablecancellation", false)
|
||||
viper.SetDefault("agents", "deezer,lastfm,listenbrainz")
|
||||
viper.SetDefault("lastfm.enabled", true)
|
||||
viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage)
|
||||
|
||||
47
conf/dir.go
47
conf/dir.go
@ -1,20 +1,20 @@
|
||||
package conf
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Dir wraps a directory path and lazily creates the directory on first use.
|
||||
// The directory is created at most once; if creation fails, the error is
|
||||
// permanently cached (sync.Once semantics). Dir is not safe for mutation
|
||||
// after Path() has been called.
|
||||
// Dir wraps a directory path and creates the directory on demand. Dir is a
|
||||
// plain value type — safe to copy, compare, and print via reflection-based
|
||||
// formatters (pretty.Sprintf("%# v", ...)) without any concurrency hazards.
|
||||
// Directory creation is delegated to os.MkdirAll on every Path() call;
|
||||
// MkdirAll is idempotent, so repeated calls cost one stat syscall when the
|
||||
// directory already exists.
|
||||
type Dir struct {
|
||||
path string
|
||||
perm os.FileMode
|
||||
once sync.Once
|
||||
err error
|
||||
}
|
||||
|
||||
// NewDir creates a new Dir with the given path and default permissions (os.ModePerm).
|
||||
@ -23,31 +23,32 @@ func NewDir(path string) Dir {
|
||||
}
|
||||
|
||||
// NewDirWithPerm creates a new Dir with the given path and permissions.
|
||||
// A perm of 0 is treated as "default" and resolves to os.ModePerm at
|
||||
// directory-creation time; pass an explicit non-zero mode to constrain the
|
||||
// permissions.
|
||||
func NewDirWithPerm(path string, perm os.FileMode) Dir {
|
||||
return Dir{path: path, perm: perm}
|
||||
}
|
||||
|
||||
// String returns the raw path without creating the directory. Satisfies fmt.Stringer.
|
||||
func (d *Dir) String() string {
|
||||
func (d Dir) String() string {
|
||||
return d.path
|
||||
}
|
||||
|
||||
// Path creates the directory on first call (via sync.Once) and returns the path.
|
||||
func (d *Dir) Path() (string, error) {
|
||||
d.once.Do(func() {
|
||||
if d.path == "" {
|
||||
return
|
||||
}
|
||||
d.err = os.MkdirAll(d.path, d.perm)
|
||||
if d.err != nil {
|
||||
d.err = fmt.Errorf("creating directory %q: %w", d.path, d.err)
|
||||
}
|
||||
})
|
||||
return d.path, d.err
|
||||
// Path ensures the directory exists and returns its path. Safe to call
|
||||
// repeatedly; an empty path is returned as-is with no error.
|
||||
func (d Dir) Path() (string, error) {
|
||||
if d.path == "" {
|
||||
return "", nil
|
||||
}
|
||||
if err := os.MkdirAll(d.path, cmp.Or(d.perm, os.ModePerm)); err != nil {
|
||||
return d.path, fmt.Errorf("creating directory %q: %w", d.path, err)
|
||||
}
|
||||
return d.path, nil
|
||||
}
|
||||
|
||||
// MustPath calls Path() and calls logFatal on error.
|
||||
func (d *Dir) MustPath() string {
|
||||
func (d Dir) MustPath() string {
|
||||
path, err := d.Path()
|
||||
if err != nil {
|
||||
logFatal("creating directory:", err)
|
||||
@ -57,12 +58,12 @@ func (d *Dir) MustPath() string {
|
||||
|
||||
// GoString implements fmt.GoStringer so that %#v (used by pretty.Sprintf)
|
||||
// prints the path string instead of the internal struct fields.
|
||||
func (d Dir) GoString() string { //nolint:govet // uses a value receiver so Dir values satisfy GoStringer
|
||||
func (d Dir) GoString() string {
|
||||
return fmt.Sprintf("%q", d.path)
|
||||
}
|
||||
|
||||
// MarshalText returns the raw path bytes. No side effects.
|
||||
func (d *Dir) MarshalText() ([]byte, error) {
|
||||
func (d Dir) MarshalText() ([]byte, error) {
|
||||
return []byte(d.path), nil
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,9 @@ package conf_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/kr/pretty"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@ -35,9 +37,9 @@ var _ = Describe("Dir", func() {
|
||||
Expect(target).To(BeADirectory())
|
||||
})
|
||||
|
||||
It("returns the same result on subsequent calls (sync.Once)", func() {
|
||||
It("is idempotent on subsequent calls", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
target := dir + "/once"
|
||||
target := dir + "/idempotent"
|
||||
d := conf.NewDir(target)
|
||||
|
||||
path1, err1 := d.Path()
|
||||
@ -45,6 +47,7 @@ var _ = Describe("Dir", func() {
|
||||
Expect(err1).ToNot(HaveOccurred())
|
||||
Expect(err2).ToNot(HaveOccurred())
|
||||
Expect(path1).To(Equal(path2))
|
||||
Expect(target).To(BeADirectory())
|
||||
})
|
||||
|
||||
It("returns an error when directory cannot be created", func() {
|
||||
@ -124,4 +127,38 @@ var _ = Describe("Dir", func() {
|
||||
Expect(d2.String()).To(Equal(d1.String()))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GoString", func() {
|
||||
// Regression: pretty.Sprintf("%# v", ...) is used by the
|
||||
// configuration dump. It must render Dir as a quoted path via
|
||||
// GoString, not dump the internal struct fields.
|
||||
It("renders Dir as a quoted path under pretty.Sprintf", func() {
|
||||
type host struct {
|
||||
DataFolder conf.Dir
|
||||
}
|
||||
h := host{DataFolder: conf.NewDir("./data")}
|
||||
out := pretty.Sprintf("%# v", h)
|
||||
Expect(out).To(ContainSubstring(`DataFolder: "./data"`))
|
||||
Expect(out).ToNot(ContainSubstring("perm:"))
|
||||
Expect(out).ToNot(ContainSubstring("path:"))
|
||||
})
|
||||
|
||||
It("is safe to copy and use concurrently", func() {
|
||||
// Regression for the Windows "sync: unlock of unlocked mutex"
|
||||
// crash that was caused by copying a Dir embedding sync.Once.
|
||||
// Dir is a plain value type now, but keep the concurrent stress
|
||||
// test to lock in the property.
|
||||
dir := GinkgoT().TempDir()
|
||||
d := conf.NewDir(dir + "/race")
|
||||
var wg sync.WaitGroup
|
||||
for range 10 {
|
||||
wg.Go(func() {
|
||||
copy1 := d
|
||||
_ = pretty.Sprintf("%# v", copy1)
|
||||
_, _ = copy1.Path()
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -14,6 +14,9 @@ const (
|
||||
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on&synchronous=normal"
|
||||
InitialSetupFlagKey = "InitialSetup"
|
||||
FullScanAfterMigrationFlagKey = "FullScanAfterMigration"
|
||||
// PlaylistsImportPendingFlagKey marks that playlist import was deferred because
|
||||
// no admin user existed yet; the next scan with an admin imports them.
|
||||
PlaylistsImportPendingFlagKey = "PlaylistsImportPending"
|
||||
LastScanErrorKey = "LastScanError"
|
||||
LastScanTypeKey = "LastScanType"
|
||||
LastScanStartTimeKey = "LastScanStartTime"
|
||||
@ -153,25 +156,25 @@ var (
|
||||
Name: "mp3 audio",
|
||||
TargetFormat: "mp3",
|
||||
DefaultBitRate: 192,
|
||||
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
|
||||
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -f mp3 -",
|
||||
},
|
||||
{
|
||||
Name: "opus audio",
|
||||
TargetFormat: "opus",
|
||||
DefaultBitRate: 128,
|
||||
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
|
||||
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
|
||||
},
|
||||
{
|
||||
Name: "aac audio",
|
||||
TargetFormat: "aac",
|
||||
DefaultBitRate: 256,
|
||||
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
|
||||
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
|
||||
},
|
||||
{
|
||||
Name: "flac audio",
|
||||
TargetFormat: "flac",
|
||||
DefaultBitRate: 0,
|
||||
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
|
||||
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -v 0 -c:a flac -f flac -",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/gohugoio/hashstructure"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
@ -33,15 +34,22 @@ type ExternalImage struct {
|
||||
}
|
||||
|
||||
type Song struct {
|
||||
ID string
|
||||
Name string
|
||||
MBID string
|
||||
ISRC string
|
||||
Artist string
|
||||
ArtistMBID string
|
||||
Album string
|
||||
AlbumMBID string
|
||||
Duration uint32 // Duration in milliseconds, 0 means unknown
|
||||
ID string
|
||||
Name string
|
||||
MBID string
|
||||
ISRC string
|
||||
Artists []Artist
|
||||
Album string
|
||||
AlbumMBID string
|
||||
Duration uint32 // Duration in milliseconds, 0 means unknown
|
||||
}
|
||||
|
||||
// Equals reports strict whole-value equality, used to dedup identical input songs. It hashes
|
||||
// rather than comparing with ==, which the Artists slice makes illegal.
|
||||
func (s Song) Equals(other Song) bool {
|
||||
h1, _ := hashstructure.Hash(s, nil)
|
||||
h2, _ := hashstructure.Hash(other, nil)
|
||||
return h1 == h2
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
27
core/agents/interfaces_test.go
Normal file
27
core/agents/interfaces_test.go
Normal file
@ -0,0 +1,27 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Song.Equals", func() {
|
||||
base := Song{ID: "1", Name: "S", Artists: []Artist{{ID: "x", Name: "A"}}}
|
||||
It("true for identical songs incl Artists", func() {
|
||||
Expect(base.Equals(base)).To(BeTrue())
|
||||
})
|
||||
It("false when Artists differ", func() {
|
||||
other := base
|
||||
other.Artists = []Artist{{ID: "y", Name: "B"}}
|
||||
Expect(base.Equals(other)).To(BeFalse())
|
||||
})
|
||||
It("false when a scalar differs", func() {
|
||||
other := base
|
||||
other.Name = "T"
|
||||
Expect(base.Equals(other)).To(BeFalse())
|
||||
})
|
||||
It("true when both have empty Artists and equal scalars", func() {
|
||||
a := Song{ID: "1", Name: "S"}
|
||||
Expect(a.Equals(a)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
@ -3,6 +3,7 @@ package core
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@ -60,7 +61,15 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr
|
||||
"format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album))
|
||||
for _, mf := range album {
|
||||
file := a.albumFilename(mf, format, isMultiDisc)
|
||||
_ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
|
||||
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
||||
// Stop iterating: continuing would just rack up more
|
||||
// rejections from the limiter. Close finalises whatever
|
||||
// tracks were already written; the rejected one is not
|
||||
// present in the archive (addFileToZip aborts before
|
||||
// writing its entry header).
|
||||
_ = z.Close()
|
||||
return addErr
|
||||
}
|
||||
}
|
||||
}
|
||||
err = z.Close()
|
||||
@ -120,7 +129,12 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st
|
||||
zippedMfs := make(model.MediaFiles, len(mfs))
|
||||
for idx, mf := range mfs {
|
||||
file := a.playlistFilename(mf, format, idx)
|
||||
_ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
|
||||
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
||||
// Abort the whole archive: continuing would silently emit
|
||||
// empty zip entries since the headers are already written.
|
||||
_ = z.Close()
|
||||
return addErr
|
||||
}
|
||||
mf.Path = file
|
||||
zippedMfs[idx] = mf
|
||||
}
|
||||
@ -162,6 +176,27 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int)
|
||||
|
||||
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error {
|
||||
path := mf.AbsolutePath()
|
||||
|
||||
// Open the source before writing the zip entry header so a rejection
|
||||
// (limiter, missing file, etc.) does not leave an empty entry in the
|
||||
// archive.
|
||||
var r io.ReadCloser
|
||||
var err error
|
||||
if format != "raw" && format != "" {
|
||||
r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
|
||||
} else {
|
||||
r, err = os.Open(path)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err)
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
|
||||
log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err)
|
||||
}
|
||||
}()
|
||||
|
||||
w, err := z.CreateHeader(&zip.FileHeader{
|
||||
Name: filename,
|
||||
Modified: mf.UpdatedAt,
|
||||
@ -172,23 +207,6 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
||||
return err
|
||||
}
|
||||
|
||||
var r io.ReadCloser
|
||||
if format != "raw" && format != "" {
|
||||
r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
|
||||
} else {
|
||||
r, err = os.Open(path)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err)
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
|
||||
log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = io.Copy(w, r)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error zipping file", "file", path, err)
|
||||
|
||||
@ -89,6 +89,32 @@ var _ = Describe("Archiver", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Context("when the transcode limiter rejects a file", func() {
|
||||
It("aborts the archive instead of continuing with empty entries", func() {
|
||||
mfs := model.MediaFiles{
|
||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1},
|
||||
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1},
|
||||
}
|
||||
|
||||
mfRepo := &mockMediaFileRepository{}
|
||||
mfRepo.On("GetAll", []model.QueryOptions{{
|
||||
Filters: squirrel.Eq{"album_id": "1"},
|
||||
Sort: "album",
|
||||
}}).Return(mfs, nil)
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).
|
||||
Return(nil, stream.ErrTooManyTranscodes).Once()
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
|
||||
Expect(err).To(MatchError(stream.ErrTooManyTranscodes))
|
||||
// NewStream should only have been called once: the loop must bail
|
||||
// out on the rejection instead of trying every remaining track.
|
||||
ms.AssertNumberOfCalls(GinkgoT(), "NewStream", 1)
|
||||
})
|
||||
})
|
||||
|
||||
Context("ZipShare", func() {
|
||||
It("zips a share correctly", func() {
|
||||
mfs := model.MediaFiles{
|
||||
|
||||
@ -169,7 +169,7 @@ func BenchmarkArtworkGetE2EConcurrent(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(n)
|
||||
for g := 0; g < n; g++ {
|
||||
for range n {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
r, _, err := aw.Get(context.Background(), artID, 300, true)
|
||||
|
||||
@ -35,8 +35,8 @@ func generatePNG(t testing.TB, width, height int) []byte {
|
||||
// generateGradientImage creates an RGBA image with a diagonal gradient pattern.
|
||||
func generateGradientImage(width, height int) *image.RGBA {
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
for y := range height {
|
||||
for x := range width {
|
||||
r := uint8((x * 255) / width)
|
||||
g := uint8((y * 255) / height)
|
||||
b := uint8(((x + y) * 255) / (width + height))
|
||||
|
||||
@ -357,6 +357,98 @@ var _ = Describe("Album artwork resolution", func() {
|
||||
})
|
||||
})
|
||||
|
||||
// Regression introduced in v0.62.0 (#5451 + #5457): the parent-folder
|
||||
// fallback can pick up images from the ARTIST folder, serving the artist
|
||||
// thumbnail as album art for any album without its own image files.
|
||||
When("an album has no images and the artist folder has folder.jpg", func() {
|
||||
// Artist/
|
||||
// ├── folder.jpg ← artist thumbnail, must NOT become album art
|
||||
// ├── Album A/
|
||||
// │ └── 01 - Track.mp3 (no images)
|
||||
// └── Album B/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── cover.jpg
|
||||
It("does not use the artist image as album art", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/folder.jpg": imageFile("artist-thumbnail"),
|
||||
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||
"Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}),
|
||||
"Artist/Album B/cover.jpg": imageFile("album-b"),
|
||||
})
|
||||
scan()
|
||||
|
||||
alA := albumByName("Album A")
|
||||
_, err := readArtworkOrErr(alA.CoverArtID())
|
||||
Expect(err).To(HaveOccurred(),
|
||||
"Album A has no images of its own, so it must fall through to the placeholder "+
|
||||
"instead of inheriting the artist folder's folder.jpg")
|
||||
|
||||
alB := albumByName("Album B")
|
||||
Expect(readArtwork(alB.CoverArtID())).To(Equal(imageBytes("album-b")))
|
||||
})
|
||||
})
|
||||
|
||||
When("a single-disc album is spread across sibling folders under the artist folder", func() {
|
||||
// Artist/
|
||||
// ├── folder.jpg ← artist thumbnail, must NOT become album art
|
||||
// ├── Album A/
|
||||
// │ └── 01 - Track.mp3 (album: "Album A")
|
||||
// ├── Album A bonus/
|
||||
// │ └── 02 - Track.mp3 (album: "Album A" — same album, second folder)
|
||||
// └── Album B/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── cover.jpg
|
||||
It("does not use the artist image as album art for the spread album", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/folder.jpg": imageFile("artist-thumbnail"),
|
||||
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||
"Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||
"Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}),
|
||||
"Artist/Album B/cover.jpg": imageFile("album-b"),
|
||||
})
|
||||
scan()
|
||||
|
||||
alA := albumByName("Album A")
|
||||
Expect(alA.FolderIDs).To(HaveLen(2),
|
||||
"sanity check: scanner should treat the two sibling folders as one spread album")
|
||||
_, err := readArtworkOrErr(alA.CoverArtID())
|
||||
Expect(err).To(HaveOccurred(),
|
||||
"the spread album has no images of its own, so it must fall through to the "+
|
||||
"placeholder instead of inheriting the artist folder's folder.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
When("a spread album has its own front.jpg but the artist folder has cover.jpg", func() {
|
||||
// Artist/
|
||||
// ├── cover.jpg ← artist image; matches cover.* (first pattern),
|
||||
// │ must NOT shadow the album's own front.jpg
|
||||
// ├── Album A/
|
||||
// │ ├── 01 - Track.mp3 (album: "Album A")
|
||||
// │ └── front.jpg ← should win
|
||||
// ├── Album A bonus/
|
||||
// │ └── 02 - Track.mp3 (album: "Album A")
|
||||
// └── Album B/
|
||||
// └── 01 - Track.mp3
|
||||
It("prefers the album's own art over the artist image", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/cover.jpg": imageFile("artist-image"),
|
||||
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||
"Artist/Album A/front.jpg": imageFile("album-a-front"),
|
||||
"Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||
"Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}),
|
||||
})
|
||||
scan()
|
||||
|
||||
alA := albumByName("Album A")
|
||||
Expect(alA.FolderIDs).To(HaveLen(2),
|
||||
"sanity check: scanner should treat the two sibling folders as one spread album")
|
||||
Expect(readArtwork(alA.CoverArtID())).To(Equal(imageBytes("album-a-front")))
|
||||
})
|
||||
})
|
||||
|
||||
When("embedded is first in CoverArtPriority but the track has no embedded art", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
|
||||
@ -2,6 +2,7 @@ package artworke2e_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
@ -104,3 +105,16 @@ func firstAlbum() model.Album {
|
||||
Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums))
|
||||
return albums[0]
|
||||
}
|
||||
|
||||
func albumByName(name string) model.Album {
|
||||
GinkgoHelper()
|
||||
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
for _, al := range albums {
|
||||
if al.Name == name {
|
||||
return al
|
||||
}
|
||||
}
|
||||
Fail(fmt.Sprintf("album %q not found among %d albums", name, len(albums)))
|
||||
return model.Album{}
|
||||
}
|
||||
|
||||
@ -113,28 +113,12 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
folderIDSet := make(map[string]bool, len(folderIDs))
|
||||
for _, id := range folderIDs {
|
||||
folderIDSet[id] = true
|
||||
parent, err := albumRootParent(ctx, ds, folders, folderIDs)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
// Check if all folders share a common parent that is not already included.
|
||||
// This finds cover art in the album root folder (e.g., "Artist/Album/cover.jpg"
|
||||
// when tracks are in disc subfolders like "Artist/Album/CD1/" and "Artist/Album/CD2/").
|
||||
// For single-folder albums, the parent is only included when the folder has no
|
||||
// images of its own (indicating a disc subfolder needing parent artwork).
|
||||
if commonParentID := commonParentFolder(folders, folderIDSet); commonParentID != "" {
|
||||
if len(folders) >= 2 || !anyFolderHasImages(folders) {
|
||||
parentFolder, err := ds.Folder(ctx).Get(commonParentID)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID)
|
||||
} else if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if parentFolder != nil && parentFolder.ParentID != "" {
|
||||
folders = append(folders, *parentFolder)
|
||||
}
|
||||
}
|
||||
if parent != nil {
|
||||
folders = append(folders, *parent)
|
||||
}
|
||||
|
||||
var paths []string
|
||||
@ -159,6 +143,50 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
|
||||
return paths, imgFiles, &updatedAt, nil
|
||||
}
|
||||
|
||||
// albumRootParent returns the common parent of the album's folders when it
|
||||
// qualifies as the album's root folder (e.g. "Artist/Album" above disc
|
||||
// subfolders), or nil when there is no such parent. This finds cover art in
|
||||
// the album root folder when tracks live in disc subfolders, like
|
||||
// "Artist/Album/cover.jpg" with tracks in "Artist/Album/CD1/" and
|
||||
// "Artist/Album/CD2/". The parent must look like an album root, not an
|
||||
// artist-level folder — it qualifies only when it holds no audio belonging to
|
||||
// other albums — so artist images are never served as album art.
|
||||
func albumRootParent(ctx context.Context, ds model.DataStore, folders []model.Folder, folderIDs []string) (*model.Folder, error) {
|
||||
folderIDSet := make(map[string]bool, len(folderIDs))
|
||||
for _, id := range folderIDs {
|
||||
folderIDSet[id] = true
|
||||
}
|
||||
commonParentID := commonParentFolder(folders, folderIDSet)
|
||||
if commonParentID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
// Single-folder albums only use the parent when the folder has no images
|
||||
// of its own (indicating a disc subfolder needing parent artwork).
|
||||
if len(folders) < 2 && anyFolderHasImages(folders) {
|
||||
return nil, nil
|
||||
}
|
||||
parent, err := ds.Folder(ctx).Get(commonParentID)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID)
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parent.ParentID == "" {
|
||||
// The library root can never be an album root
|
||||
return nil, nil
|
||||
}
|
||||
hasOtherAudio, err := ds.Folder(ctx).HasAudioOutsideFolders(*parent, folderIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hasOtherAudio {
|
||||
return nil, nil
|
||||
}
|
||||
return parent, nil
|
||||
}
|
||||
|
||||
func anyFolderHasImages(folders []model.Folder) bool {
|
||||
for _, f := range folders {
|
||||
if len(f.ImageFiles) > 0 {
|
||||
|
||||
@ -339,6 +339,61 @@ var _ = Describe("Album Artwork Reader", func() {
|
||||
Expect(repo.getCallCount).To(Equal(1))
|
||||
})
|
||||
|
||||
It("does not include parent images when other albums' audio lives under the parent", func() {
|
||||
// Simulates: Artist/folder.jpg with Artist/Album (no images) and
|
||||
// another album's tracks elsewhere under the artist folder
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: "Artist",
|
||||
Name: "Album",
|
||||
ParentID: "artistFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{},
|
||||
},
|
||||
}
|
||||
repo.parentResult = &model.Folder{
|
||||
ID: "artistFolder",
|
||||
Path: ".",
|
||||
Name: "Artist",
|
||||
ParentID: "libraryRoot",
|
||||
ImagesUpdatedAt: expectedAt,
|
||||
ImageFiles: []string{"folder.jpg"},
|
||||
}
|
||||
repo.hasOtherAudio = true
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("propagates errors from the album-root check", func() {
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: "Artist/Album",
|
||||
Name: "disc1",
|
||||
ParentID: "albumFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{},
|
||||
},
|
||||
}
|
||||
repo.parentResult = &model.Folder{
|
||||
ID: "albumFolder",
|
||||
Path: "Artist",
|
||||
Name: "Album",
|
||||
ParentID: "artistFolder",
|
||||
ImagesUpdatedAt: expectedAt,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}
|
||||
repo.otherAudioErr = errors.New("db connection failed")
|
||||
|
||||
_, _, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).To(MatchError("db connection failed"))
|
||||
})
|
||||
|
||||
It("propagates non-ErrNotFound errors from parent folder lookup", func() {
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
|
||||
@ -702,12 +702,20 @@ type fakeFolderRepo struct {
|
||||
getErr error
|
||||
getCallCount int
|
||||
err error
|
||||
// hasOtherAudio is returned by HasAudioOutsideFolders (the album-root
|
||||
// check). False means the parent qualifies as an album root.
|
||||
hasOtherAudio bool
|
||||
otherAudioErr error
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) {
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) {
|
||||
return f.hasOtherAudio, f.otherAudioErr
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) Get(id string) (*model.Folder, error) {
|
||||
f.getCallCount++
|
||||
if f.getErr != nil {
|
||||
|
||||
@ -21,6 +21,12 @@ import (
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
// gen2brain/webp selects native (purego/libwebp) vs WASM in its own
|
||||
// package init() and exposes the result only via webp.Dynamic(); there is
|
||||
// no runtime way to switch back. On 32-bit ARM/x86 the purego callback path
|
||||
// crashes (issue #5597), so those builds must be compiled with the
|
||||
// "nodynamic" tag (see Dockerfile), which makes webp.Dynamic() report an
|
||||
// error here and forces the safe WASM path.
|
||||
if err := webp.Dynamic(); err != nil {
|
||||
log.Debug("Using WASM WebP encoder/decoder", "reason", err)
|
||||
} else {
|
||||
@ -126,6 +132,22 @@ func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader
|
||||
return resizeStaticImage(data, a.size, a.square)
|
||||
}
|
||||
|
||||
// toFastScaleType converts images whose concrete type has no optimized scaler
|
||||
// in x/image/draw (e.g. *image.NYCbCrA from WebP, *image.Paletted from indexed
|
||||
// PNGs) into *image.RGBA, which has a fast path. Without this, CatmullRom.Scale
|
||||
// falls back to a generic per-pixel At()/RGBA() loop that is several times
|
||||
// slower. Fast-path types are returned unchanged.
|
||||
func toFastScaleType(img image.Image) image.Image {
|
||||
switch img.(type) {
|
||||
case *image.RGBA, *image.NRGBA, *image.Gray, *image.YCbCr:
|
||||
return img
|
||||
default:
|
||||
rgba := image.NewRGBA(img.Bounds())
|
||||
draw.Draw(rgba, rgba.Bounds(), img, img.Bounds().Min, draw.Src)
|
||||
return rgba
|
||||
}
|
||||
}
|
||||
|
||||
func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) {
|
||||
original, format, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
@ -163,6 +185,7 @@ func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, erro
|
||||
dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
|
||||
dstRect = dst.Bounds()
|
||||
}
|
||||
original = toFastScaleType(original)
|
||||
xdraw.CatmullRom.Scale(dst, dstRect, original, bounds, draw.Src, nil)
|
||||
|
||||
buf := bufPool.Get().(*bytes.Buffer)
|
||||
|
||||
18
core/external/provider.go
vendored
18
core/external/provider.go
vendored
@ -471,13 +471,19 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT
|
||||
return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, err)
|
||||
}
|
||||
|
||||
// Enrich songs with artist info if not already present (for top songs, we know the artist)
|
||||
// Enrich top songs with the queried artist. A song with no artists, or whose first credit the
|
||||
// agent left unnamed, is attributed to the queried artist. A first credit that already names an
|
||||
// artist is left as-is: it may be a different (e.g. featured) artist, so stamping the queried
|
||||
// MBID onto it would create a false name+MBID pairing.
|
||||
for i := range songs {
|
||||
if songs[i].Artist == "" {
|
||||
songs[i].Artist = artistName
|
||||
}
|
||||
if songs[i].ArtistMBID == "" {
|
||||
songs[i].ArtistMBID = artist.MbzArtistID
|
||||
switch {
|
||||
case len(songs[i].Artists) == 0:
|
||||
songs[i].Artists = []agents.Artist{{Name: artistName, MBID: artist.MbzArtistID}}
|
||||
case songs[i].Artists[0].Name == "":
|
||||
songs[i].Artists[0].Name = artistName
|
||||
if songs[i].Artists[0].MBID == "" {
|
||||
songs[i].Artists[0].MBID = artist.MbzArtistID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
40
core/external/provider_similarsongs_test.go
vendored
40
core/external/provider_similarsongs_test.go
vendored
@ -3,6 +3,7 @@ package external_test
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
@ -56,7 +57,14 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
||||
Context("when ID is a MediaFile (track)", func() {
|
||||
It("calls GetSimilarSongsByTrack and returns matched songs", func() {
|
||||
track := model.MediaFile{ID: "track-1", Title: "Just Can't Get Enough", Artist: "Depeche Mode", MbzRecordingID: "track-mbid"}
|
||||
matchedSong := model.MediaFile{ID: "matched-1", Title: "Dreaming of Me", Artist: "Depeche Mode"}
|
||||
|
||||
// Depeche Mode artist row used by matcher artist resolution and track-fetch back-mapping.
|
||||
dmArtist := model.Artist{ID: "dm-1", Name: "Depeche Mode", OrderArtistName: "depeche mode", MbzArtistID: "artist-mbid"}
|
||||
dmParticipant := model.Participant{Artist: dmArtist}
|
||||
matchedSong := model.MediaFile{
|
||||
ID: "matched-1", Title: "Dreaming of Me", Artist: "Depeche Mode",
|
||||
Participants: model.Participants{model.RoleArtist: model.ParticipantList{dmParticipant}},
|
||||
}
|
||||
|
||||
// GetEntityByID tries Artist, Album, Playlist, then MediaFile
|
||||
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
||||
@ -65,16 +73,19 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
||||
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Just Can't Get Enough", "Depeche Mode", "track-mbid", 5).
|
||||
Return([]agents.Song{
|
||||
{Name: "Dreaming of Me", MBID: "", Artist: "Depeche Mode", ArtistMBID: "artist-mbid"},
|
||||
{Name: "Dreaming of Me", MBID: "", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "artist-mbid"}}},
|
||||
}, nil).Once()
|
||||
|
||||
// Mock loadTracksByID - no ID matches
|
||||
// Matcher artist resolution: resolve Depeche Mode in the artist table.
|
||||
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{dmArtist}, nil).Maybe()
|
||||
|
||||
// ID phase: no IDs → squirrel.And with media_file.id; won't be called but guard it.
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
_, ok := opt.Filters.(squirrel.Eq)
|
||||
return ok
|
||||
})).Return(model.MediaFiles{}, nil).Once()
|
||||
})).Return(model.MediaFiles{}, nil).Maybe()
|
||||
|
||||
// Mock loadTracksByMBID - no MBID matches (empty MBID means this won't be called)
|
||||
// MBID phase: won't fire (empty MBID).
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
and, ok := opt.Filters.(squirrel.And)
|
||||
if !ok || len(and) < 1 {
|
||||
@ -88,18 +99,19 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
||||
return hasMBID
|
||||
})).Return(model.MediaFiles{}, nil).Maybe()
|
||||
|
||||
// Mock loadTracksByTitleAndArtist - queries by artist name
|
||||
// Matcher track-fetch: subquery returns the matched song with participants.
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
and, ok := opt.Filters.(squirrel.And)
|
||||
if !ok || len(and) < 2 {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
eq, hasEq := and[0].(squirrel.Eq)
|
||||
if !hasEq {
|
||||
return false
|
||||
for _, f := range and {
|
||||
sql, _, err := f.ToSql()
|
||||
if err == nil && strings.Contains(sql, "media_file_artists") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
_, hasArtist := eq["order_artist_name"]
|
||||
return hasArtist
|
||||
return false
|
||||
})).Return(model.MediaFiles{matchedSong}, nil).Maybe()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
@ -165,7 +177,7 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
||||
|
||||
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Speak & Spell", "Depeche Mode", "album-mbid", 5).
|
||||
Return([]agents.Song{
|
||||
{Name: "New Life", MBID: "song-mbid", Artist: "Depeche Mode"},
|
||||
{Name: "New Life", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}},
|
||||
}, nil).Once()
|
||||
|
||||
// Mock loadTracksByID - no ID matches
|
||||
@ -242,7 +254,7 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
||||
artistRepo.On("Get", "artist-1").Return(&artist, nil).Once()
|
||||
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Depeche Mode", "artist-mbid", 5).
|
||||
Return([]agents.Song{
|
||||
{Name: "Enjoy the Silence", MBID: "song-mbid", Artist: "Depeche Mode"},
|
||||
{Name: "Enjoy the Silence", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}},
|
||||
}, nil).Once()
|
||||
|
||||
// Mock loadTracksByID - no ID matches
|
||||
|
||||
87
core/external/provider_topsongs_test.go
vendored
87
core/external/provider_topsongs_test.go
vendored
@ -76,6 +76,63 @@ var _ = Describe("Provider - TopSongs", func() {
|
||||
mediaFileRepo.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("backfills name and MBID onto an unnamed primary credit (the queried artist) and matches", func() {
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil)
|
||||
|
||||
// Agent leaves the first credit unnamed (e.g. an MBID-less collaborator slot). That blank
|
||||
// credit IS the queried artist, so enrichment fills both name and MBID; the song then matches
|
||||
// the queried artist's track via the backfilled identity.
|
||||
agentSongs := []agents.Song{
|
||||
{Name: "Song One", Artists: []agents.Artist{{}}},
|
||||
}
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once()
|
||||
|
||||
track := model.MediaFile{
|
||||
ID: "song-1", Title: "Song One", ArtistID: "artist-1",
|
||||
Participants: model.Participants{model.RoleArtist: model.ParticipantList{
|
||||
{Artist: model.Artist{ID: "artist-1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-artist-1"}},
|
||||
}},
|
||||
}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{track}, nil)
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 1)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
})
|
||||
|
||||
It("does not stamp the queried MBID onto an already-named different first credit", func() {
|
||||
// The queried artist (One) appears only as a featured collaborator; the displayed first credit
|
||||
// is a DIFFERENT artist (Two) returned without an MBID. Enrichment must NOT assign One's MBID
|
||||
// to Two — only Two's name match (which fails here) or One's own credit may resolve the track.
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil)
|
||||
|
||||
agentSongs := []agents.Song{
|
||||
{Name: "Collab Song", Artists: []agents.Artist{{Name: "Artist Two"}}},
|
||||
}
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once()
|
||||
|
||||
// Library track is credited to Artist One (the queried artist) under a same title. If the
|
||||
// queried MBID were wrongly stamped onto the "Artist Two" credit, that mismatched name+MBID
|
||||
// could mis-resolve. With the guard, "Artist Two" stays MBID-less and does not match One's track.
|
||||
track := model.MediaFile{
|
||||
ID: "one-track", Title: "Collab Song", ArtistID: "artist-1",
|
||||
Participants: model.Participants{model.RoleArtist: model.ParticipantList{
|
||||
{Artist: model.Artist{ID: "artist-1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-artist-1"}},
|
||||
}},
|
||||
}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{track}, nil)
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 1)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// "Artist Two" (named, MBID-less, not in the library) does not resolve to One's track.
|
||||
Expect(songs).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns nil for an unknown artist", func() {
|
||||
// Mock artist not found
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{}, nil).Once()
|
||||
@ -148,6 +205,8 @@ var _ = Describe("Provider - TopSongs", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
// Matcher artist resolution for the title-match path (song2 falls through).
|
||||
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist1}, nil).Maybe()
|
||||
|
||||
// Mock agent response
|
||||
agentSongs := []agents.Song{
|
||||
@ -159,7 +218,7 @@ var _ = Describe("Provider - TopSongs", func() {
|
||||
// Mock finding matching tracks (only find song 1 on bulk query)
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() // bulk MBID query
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // title fallback for song2
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // title track-fetch for song2: no match
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 2)
|
||||
|
||||
@ -195,6 +254,8 @@ var _ = Describe("Provider - TopSongs", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
// Matcher artist resolution for both title-fallback songs.
|
||||
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist1}, nil).Maybe()
|
||||
|
||||
// Mock agent response with songs that have NO MBID (empty string)
|
||||
agentSongs := []agents.Song{
|
||||
@ -203,10 +264,16 @@ var _ = Describe("Provider - TopSongs", func() {
|
||||
}
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once()
|
||||
|
||||
// Since there are no MBIDs, loadTracksByMBID should not make any database call
|
||||
// loadTracksByTitle should make a database call for title matching
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song one"}
|
||||
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"}
|
||||
// Title track-fetch: tracks must carry RoleArtist participants so back-mapping routes them.
|
||||
participant1 := model.Participant{Artist: model.Artist{ID: "artist-1", Name: "Artist One", OrderArtistName: "artist one"}}
|
||||
song1 := model.MediaFile{
|
||||
ID: "song-1", Title: "Song One", Artist: "Artist One", ArtistID: "artist-1",
|
||||
Participants: model.Participants{model.RoleArtist: model.ParticipantList{participant1}},
|
||||
}
|
||||
song2 := model.MediaFile{
|
||||
ID: "song-2", Title: "Song Two", Artist: "Artist One", ArtistID: "artist-1",
|
||||
Participants: model.Participants{model.RoleArtist: model.ParticipantList{participant1}},
|
||||
}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 2)
|
||||
@ -224,6 +291,8 @@ var _ = Describe("Provider - TopSongs", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
// Matcher artist resolution for song2's title-fallback path.
|
||||
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist1}, nil).Maybe()
|
||||
|
||||
// Mock agent response with mixed MBID availability
|
||||
agentSongs := []agents.Song{
|
||||
@ -236,8 +305,12 @@ var _ = Describe("Provider - TopSongs", func() {
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1", OrderTitle: "song one"}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
|
||||
|
||||
// Mock the title fallback query (finds song2 by title)
|
||||
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"}
|
||||
// Title track-fetch: song2 must carry RoleArtist participants for back-mapping.
|
||||
participant1 := model.Participant{Artist: model.Artist{ID: "artist-1", Name: "Artist One", OrderArtistName: "artist one"}}
|
||||
song2 := model.MediaFile{
|
||||
ID: "song-2", Title: "Song Two", Artist: "Artist One", ArtistID: "artist-1",
|
||||
Participants: model.Participants{model.RoleArtist: model.ParticipantList{participant1}},
|
||||
}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song2}, nil).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 2)
|
||||
|
||||
@ -403,6 +403,14 @@ func buildDynamicArgs(opts TranscodeOptions) []string {
|
||||
args = append(args, "-i", opts.FilePath)
|
||||
args = append(args, "-map", "0:a:0")
|
||||
|
||||
// Preserve source tags. -map_metadata 0 copies format-level tags (MP3/FLAC);
|
||||
// -map_metadata 0:s:a:0 copies tags from the first audio stream (OPUS/OGG).
|
||||
// Both are needed because the two source families store tags at different
|
||||
// levels. Targeting the audio stream explicitly (s:a:0 rather than s:0) avoids
|
||||
// pulling metadata from an embedded cover-art/video stream at index 0. Note:
|
||||
// adts (AAC) output cannot hold tags, so these are a no-op there.
|
||||
args = append(args, "-map_metadata", "0", "-map_metadata", "0:s:a:0")
|
||||
|
||||
if codec, ok := formatCodecMap[opts.Format]; ok {
|
||||
args = append(args, "-c:a", codec)
|
||||
}
|
||||
@ -496,8 +504,8 @@ func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
|
||||
// Pre-input seeking: ffmpeg seeks at the demuxer level (fast)
|
||||
// instead of decoding all frames up to the offset (slow).
|
||||
insertAt := len(args)
|
||||
for i := len(args) - 1; i >= 0; i-- {
|
||||
if args[i] == "-i" {
|
||||
for i, arg := range slices.Backward(args) {
|
||||
if arg == "-i" {
|
||||
insertAt = i
|
||||
break
|
||||
}
|
||||
|
||||
@ -82,16 +82,16 @@ var _ = Describe("ffmpeg", func() {
|
||||
|
||||
Describe("isDefaultCommand", func() {
|
||||
It("returns true for known default mp3 command", func() {
|
||||
Expect(isDefaultCommand("mp3", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue())
|
||||
Expect(isDefaultCommand("mp3", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue())
|
||||
})
|
||||
It("returns true for known default opus command", func() {
|
||||
Expect(isDefaultCommand("opus", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue())
|
||||
Expect(isDefaultCommand("opus", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue())
|
||||
})
|
||||
It("returns true for known default aac command", func() {
|
||||
Expect(isDefaultCommand("aac", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue())
|
||||
Expect(isDefaultCommand("aac", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue())
|
||||
})
|
||||
It("returns true for known default flac command", func() {
|
||||
Expect(isDefaultCommand("flac", "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue())
|
||||
Expect(isDefaultCommand("flac", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue())
|
||||
})
|
||||
It("returns false for a custom command", func() {
|
||||
Expect(isDefaultCommand("mp3", "ffmpeg -i %s -b:a %bk -custom-flag -f mp3 -")).To(BeFalse())
|
||||
@ -113,6 +113,7 @@ var _ = Describe("ffmpeg", func() {
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.flac",
|
||||
"-map", "0:a:0",
|
||||
"-map_metadata", "0", "-map_metadata", "0:s:a:0",
|
||||
"-c:a", "libmp3lame",
|
||||
"-b:a", "256k",
|
||||
"-ar", "48000",
|
||||
@ -132,6 +133,7 @@ var _ = Describe("ffmpeg", func() {
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.dsf",
|
||||
"-map", "0:a:0",
|
||||
"-map_metadata", "0", "-map_metadata", "0:s:a:0",
|
||||
"-c:a", "flac",
|
||||
"-ar", "48000",
|
||||
"-v", "0",
|
||||
@ -149,6 +151,7 @@ var _ = Describe("ffmpeg", func() {
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.flac",
|
||||
"-map", "0:a:0",
|
||||
"-map_metadata", "0", "-map_metadata", "0:s:a:0",
|
||||
"-c:a", "libopus",
|
||||
"-b:a", "128k",
|
||||
"-v", "0",
|
||||
@ -169,6 +172,7 @@ var _ = Describe("ffmpeg", func() {
|
||||
"-ss", "30",
|
||||
"-i", "/music/file.mp3",
|
||||
"-map", "0:a:0",
|
||||
"-map_metadata", "0", "-map_metadata", "0:s:a:0",
|
||||
"-c:a", "libmp3lame",
|
||||
"-b:a", "192k",
|
||||
"-v", "0",
|
||||
@ -186,6 +190,7 @@ var _ = Describe("ffmpeg", func() {
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.flac",
|
||||
"-map", "0:a:0",
|
||||
"-map_metadata", "0", "-map_metadata", "0:s:a:0",
|
||||
"-c:a", "aac",
|
||||
"-b:a", "256k",
|
||||
"-v", "0",
|
||||
@ -203,6 +208,7 @@ var _ = Describe("ffmpeg", func() {
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.dsf",
|
||||
"-map", "0:a:0",
|
||||
"-map_metadata", "0", "-map_metadata", "0:s:a:0",
|
||||
"-c:a", "flac",
|
||||
"-sample_fmt", "s32",
|
||||
"-v", "0",
|
||||
|
||||
@ -253,7 +253,11 @@ func (r *libraryRepositoryWrapper) Delete(id string) error {
|
||||
return r.mapError(err)
|
||||
}
|
||||
|
||||
err = r.LibraryRepository.Delete(libID)
|
||||
// Run the deletion in a transaction so the cascade delete and the orphaned-artist
|
||||
// reconciliation it triggers (see libraryRepository.Delete) commit atomically.
|
||||
err = r.ds.WithTx(func(tx model.DataStore) error {
|
||||
return tx.Library(r.ctx).Delete(libID)
|
||||
}, "delete library")
|
||||
if err != nil {
|
||||
return r.mapError(err)
|
||||
}
|
||||
|
||||
@ -4,56 +4,122 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
)
|
||||
|
||||
// Lyrics can fetch lyrics for a media file.
|
||||
type Lyrics interface {
|
||||
// maxLegacyLyricsCandidates bounds the duplicate window scanned by the legacy
|
||||
// artist/title lookup, so source-priority resolution can still reach older
|
||||
// matches without turning it into an unbounded table scan.
|
||||
const maxLegacyLyricsCandidates = 10
|
||||
|
||||
// Provider fetches lyrics for a single media file. It is the contract
|
||||
// implemented by individual lyrics sources, such as plugins.
|
||||
type Provider interface {
|
||||
GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error)
|
||||
}
|
||||
|
||||
// Lyrics resolves lyrics for media files, honoring the configured source
|
||||
// priority.
|
||||
type Lyrics interface {
|
||||
Provider
|
||||
GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error)
|
||||
}
|
||||
|
||||
// PluginLoader discovers and loads lyrics provider plugins.
|
||||
type PluginLoader interface {
|
||||
LoadLyricsProvider(name string) (Lyrics, bool)
|
||||
LoadLyricsProvider(name string) (Provider, bool)
|
||||
}
|
||||
|
||||
type lyricsService struct {
|
||||
ds model.DataStore
|
||||
pluginLoader PluginLoader
|
||||
}
|
||||
|
||||
// NewLyrics creates a new lyrics service. pluginLoader may be nil if no plugin
|
||||
// system is available.
|
||||
func NewLyrics(pluginLoader PluginLoader) Lyrics {
|
||||
return &lyricsService{pluginLoader: pluginLoader}
|
||||
func NewLyrics(ds model.DataStore, pluginLoader PluginLoader) Lyrics {
|
||||
return &lyricsService{ds: ds, pluginLoader: pluginLoader}
|
||||
}
|
||||
|
||||
// GetLyrics returns lyrics for the given media file, trying sources in the
|
||||
// order specified by conf.Server.LyricsPriority.
|
||||
func (l *lyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||
var lyricsList model.LyricList
|
||||
var err error
|
||||
return l.getLyricsForCandidates(ctx, []*model.MediaFile{mf})
|
||||
}
|
||||
|
||||
// GetLyricsByArtistTitle resolves lyrics for the legacy artist/title lookup,
|
||||
// scanning a bounded window of duplicate matches so source priority still wins
|
||||
// across them.
|
||||
func (l *lyricsService) GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error) {
|
||||
opts := songsByArtistTitleWithLyricsFirst(artist, title)
|
||||
opts.Max = maxLegacyLyricsCandidates
|
||||
mediaFiles, err := l.ds.MediaFile(ctx).GetAll(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(mediaFiles) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
candidates := make([]*model.MediaFile, 0, len(mediaFiles))
|
||||
for i := range mediaFiles {
|
||||
candidates = append(candidates, &mediaFiles[i])
|
||||
}
|
||||
return l.getLyricsForCandidates(ctx, candidates)
|
||||
}
|
||||
|
||||
func songsByArtistTitleWithLyricsFirst(artist, title string) model.QueryOptions {
|
||||
return model.QueryOptions{
|
||||
Sort: "lyrics, updated_at",
|
||||
Order: "desc",
|
||||
Filters: And{
|
||||
Eq{"missing": false},
|
||||
Eq{"title": title},
|
||||
Or{
|
||||
persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artist}),
|
||||
persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artist}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (l *lyricsService) getLyricsForCandidates(ctx context.Context, mediaFiles []*model.MediaFile) (model.LyricList, error) {
|
||||
for pattern := range strings.SplitSeq(conf.Server.LyricsPriority, ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case strings.EqualFold(pattern, "embedded"):
|
||||
lyricsList, err = fromEmbedded(ctx, mf)
|
||||
case strings.HasPrefix(pattern, "."):
|
||||
lyricsList, err = fromExternalFile(ctx, mf, strings.ToLower(pattern))
|
||||
default:
|
||||
lyricsList, err = l.fromPlugin(ctx, mf, pattern)
|
||||
if pattern == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error(ctx, "error getting lyrics", "source", pattern, err)
|
||||
}
|
||||
for _, mf := range mediaFiles {
|
||||
if mf == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(lyricsList) > 0 {
|
||||
return lyricsList, nil
|
||||
lyricsList, err := l.getLyricsFromSource(ctx, mf, pattern)
|
||||
if err != nil {
|
||||
log.Error(ctx, "error getting lyrics", "source", pattern, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(lyricsList) > 0 {
|
||||
return lyricsList, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (l *lyricsService) getLyricsFromSource(ctx context.Context, mf *model.MediaFile, pattern string) (model.LyricList, error) {
|
||||
switch {
|
||||
case strings.EqualFold(pattern, "embedded"):
|
||||
return fromEmbedded(ctx, mf)
|
||||
case strings.HasPrefix(pattern, "."):
|
||||
return fromExternalFile(ctx, mf, pattern)
|
||||
default:
|
||||
return l.fromPlugin(ctx, mf, pattern)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
package lyrics_test
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/storage/local"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@ -15,3 +19,17 @@ func TestLyrics(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Lyrics Suite")
|
||||
}
|
||||
|
||||
// core/storage/local calls log.Fatal if the default scanner extractor is unregistered
|
||||
// when constructing any localStorage. Register a no-op so storage.For("file://...") works
|
||||
// in tests without importing the real extractor.
|
||||
var _ = BeforeSuite(func() {
|
||||
local.RegisterExtractor(consts.DefaultScannerExtractor, func(fs.FS, string) local.Extractor {
|
||||
return &noopExtractor{}
|
||||
})
|
||||
})
|
||||
|
||||
type noopExtractor struct{}
|
||||
|
||||
func (e *noopExtractor) Parse(_ ...string) (map[string]metadata.Info, error) { return nil, nil }
|
||||
func (e *noopExtractor) Version() string { return "noop" }
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
@ -16,13 +17,14 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("sources", func() {
|
||||
var _ = Describe("Lyrics", func() {
|
||||
var mf model.MediaFile
|
||||
var ctx context.Context
|
||||
|
||||
const badLyrics = "This is a set of lyrics\nThat is not good"
|
||||
unsynced, _ := model.ToLyrics("xxx", badLyrics)
|
||||
embeddedLyrics := model.LyricList{*unsynced}
|
||||
unsyncedList, _ := model.ParseLyrics(".lrc", "xxx", []byte(badLyrics))
|
||||
unsynced, _ := unsyncedList.Main()
|
||||
embeddedLyrics := model.LyricList{unsynced}
|
||||
|
||||
syncedLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
@ -44,6 +46,71 @@ var _ = Describe("sources", func() {
|
||||
},
|
||||
}
|
||||
|
||||
elrcLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
DisplayArtist: "ELRC Artist",
|
||||
DisplayTitle: "ELRC Song",
|
||||
Lang: "eng",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: new(int64(1000)),
|
||||
End: new(int64(3000)),
|
||||
Value: "Lead words",
|
||||
Cue: []model.Cue{
|
||||
{
|
||||
Start: new(int64(1000)),
|
||||
End: new(int64(1500)),
|
||||
Value: "Lead ",
|
||||
ByteStart: 0,
|
||||
ByteEnd: 4,
|
||||
},
|
||||
{
|
||||
Start: new(int64(1500)),
|
||||
End: new(int64(3000)),
|
||||
Value: "words",
|
||||
ByteStart: 5,
|
||||
ByteEnd: 9,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Start: new(int64(3000)),
|
||||
Value: "Fallback line",
|
||||
},
|
||||
},
|
||||
Synced: true,
|
||||
},
|
||||
}
|
||||
|
||||
ttmlLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
Kind: "main",
|
||||
Lang: "eng",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: new(int64(18800)),
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: new(int64(22800)),
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Synced: true,
|
||||
},
|
||||
model.Lyrics{
|
||||
Kind: "main",
|
||||
Lang: "por",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: new(int64(18800)),
|
||||
Value: "Nao somos estranhos ao amor",
|
||||
},
|
||||
},
|
||||
Synced: true,
|
||||
},
|
||||
}
|
||||
|
||||
unsyncedLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
Lang: "xxx",
|
||||
@ -59,6 +126,25 @@ var _ = Describe("sources", func() {
|
||||
},
|
||||
}
|
||||
|
||||
srtLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
Lang: "xxx",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: new(int64(18800)),
|
||||
End: new(int64(22800)),
|
||||
Value: "We're from subtitles",
|
||||
},
|
||||
{
|
||||
Start: new(int64(22801)),
|
||||
End: new(int64(26000)),
|
||||
Value: "Another subtitle line",
|
||||
},
|
||||
},
|
||||
Synced: true,
|
||||
},
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
||||
@ -68,19 +154,104 @@ var _ = Describe("sources", func() {
|
||||
Lyrics: string(lyricsJson),
|
||||
Path: "tests/fixtures/test.mp3",
|
||||
}
|
||||
ctx = context.Background()
|
||||
ctx = GinkgoT().Context()
|
||||
})
|
||||
|
||||
DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) {
|
||||
conf.Server.LyricsPriority = priority
|
||||
svc := lyrics.NewLyrics(nil)
|
||||
svc := lyrics.NewLyrics(nil, nil)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(expected))
|
||||
},
|
||||
Entry("embedded > lrc > txt", "embedded,.lrc,.txt", embeddedLyrics),
|
||||
Entry("lrc > embedded > txt", ".lrc,embedded,.txt", syncedLyrics),
|
||||
Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics))
|
||||
Entry("elrc > lrc > embedded", ".elrc,.lrc,embedded", elrcLyrics),
|
||||
Entry("srt > txt > embedded", ".srt,.txt,embedded", srtLyrics),
|
||||
Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics),
|
||||
Entry("ttml > elrc > lrc > srt > embedded", ".ttml,.elrc,.lrc,.srt,embedded", ttmlLyrics))
|
||||
|
||||
It("resolves source priority across duplicate media files", func() {
|
||||
conf.Server.LyricsPriority = ".ttml,embedded"
|
||||
embeddedJSON, err := json.Marshal(embeddedLyrics)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
repo := &tests.MockMediaFileRepo{}
|
||||
repo.SetData(model.MediaFiles{
|
||||
{
|
||||
Lyrics: string(embeddedJSON),
|
||||
Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3",
|
||||
},
|
||||
{
|
||||
Lyrics: "[]",
|
||||
Path: "tests/fixtures/test.mp3",
|
||||
},
|
||||
})
|
||||
svc := lyrics.NewLyrics(&tests.MockDataStore{MockedMediaFile: repo}, nil)
|
||||
|
||||
list, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(ttmlLyrics))
|
||||
})
|
||||
|
||||
It("preserves configured sidecar suffix casing on case-sensitive filesystems", func() {
|
||||
dir, err := os.MkdirTemp("", "lyrics-case-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
DeferCleanup(func() {
|
||||
Expect(os.RemoveAll(dir)).To(Succeed())
|
||||
})
|
||||
|
||||
probe := filepath.Join(dir, "CASECHECK")
|
||||
Expect(os.WriteFile(probe, []byte("probe"), 0600)).To(Succeed())
|
||||
_, err = os.Stat(filepath.Join(dir, "casecheck"))
|
||||
if err == nil {
|
||||
Skip("filesystem is case-insensitive")
|
||||
}
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
|
||||
conf.Server.LyricsPriority = ".LRC"
|
||||
Expect(os.WriteFile(filepath.Join(dir, "song.LRC"), []byte("[00:01.00]Upper suffix"), 0600)).To(Succeed())
|
||||
|
||||
svc := lyrics.NewLyrics(nil, nil)
|
||||
list, err := svc.GetLyrics(ctx, &model.MediaFile{
|
||||
LibraryPath: dir,
|
||||
Path: "song.mp3",
|
||||
})
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Line).To(Equal([]model.Line{
|
||||
{Start: new(int64(1000)), Value: "Upper suffix"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("returns a non-Lyricsfile YAML sidecar as plain text, shadowing lower-priority sources", func() {
|
||||
dir, err := os.MkdirTemp("", "lyrics-yaml-fallback-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
DeferCleanup(func() {
|
||||
Expect(os.RemoveAll(dir)).To(Succeed())
|
||||
})
|
||||
|
||||
Expect(os.WriteFile(filepath.Join(dir, "song.yaml"), []byte("title: not lyricsfile\n"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(dir, "song.lrc"), []byte("[00:01.00]Fallback line"), 0600)).To(Succeed())
|
||||
|
||||
conf.Server.LyricsPriority = ".yaml,.lrc"
|
||||
svc := lyrics.NewLyrics(nil, nil)
|
||||
list, err := svc.GetLyrics(ctx, &model.MediaFile{
|
||||
LibraryPath: dir,
|
||||
Path: "song.mp3",
|
||||
})
|
||||
|
||||
// ParseLyrics falls back to plain text for any suffix when the content
|
||||
// doesn't match the structured format, so the .yaml hit is non-empty and
|
||||
// shadows the lower-priority .lrc entirely.
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Synced).To(BeFalse())
|
||||
Expect(list[0].Line).To(Equal([]model.Line{
|
||||
{Value: "title: not lyricsfile"},
|
||||
}))
|
||||
})
|
||||
|
||||
Context("Errors", func() {
|
||||
var RegularUserContext = XContext
|
||||
@ -110,7 +281,7 @@ var _ = Describe("sources", func() {
|
||||
It("should fallback to embedded if an error happens when parsing file", func() {
|
||||
conf.Server.LyricsPriority = ".mp3,embedded"
|
||||
|
||||
svc := lyrics.NewLyrics(nil)
|
||||
svc := lyrics.NewLyrics(nil, nil)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics))
|
||||
@ -119,7 +290,7 @@ var _ = Describe("sources", func() {
|
||||
It("should return nothing if error happens when trying to parse file", func() {
|
||||
conf.Server.LyricsPriority = ".mp3"
|
||||
|
||||
svc := lyrics.NewLyrics(nil)
|
||||
svc := lyrics.NewLyrics(nil, nil)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(BeEmpty())
|
||||
@ -137,7 +308,7 @@ var _ = Describe("sources", func() {
|
||||
It("should return lyrics from a plugin", func() {
|
||||
conf.Server.LyricsPriority = "test-lyrics-plugin"
|
||||
mockLoader.lyrics = unsyncedLyrics
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(unsyncedLyrics))
|
||||
@ -147,7 +318,7 @@ var _ = Describe("sources", func() {
|
||||
conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
|
||||
mf.Lyrics = "" // No embedded lyrics
|
||||
mockLoader.lyrics = unsyncedLyrics
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(unsyncedLyrics))
|
||||
@ -156,7 +327,7 @@ var _ = Describe("sources", func() {
|
||||
It("should skip plugin if embedded has lyrics", func() {
|
||||
conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
|
||||
mockLoader.lyrics = unsyncedLyrics
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics)) // embedded wins
|
||||
@ -165,7 +336,7 @@ var _ = Describe("sources", func() {
|
||||
It("should skip unknown plugin names gracefully", func() {
|
||||
conf.Server.LyricsPriority = "nonexistent-plugin,embedded"
|
||||
mockLoader.notFound = true
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
|
||||
@ -175,7 +346,7 @@ var _ = Describe("sources", func() {
|
||||
conf.Server.LyricsPriority = "MyLyricsPlugin"
|
||||
mockLoader.pluginName = "MyLyricsPlugin"
|
||||
mockLoader.lyrics = unsyncedLyrics
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(unsyncedLyrics))
|
||||
@ -184,12 +355,56 @@ var _ = Describe("sources", func() {
|
||||
It("should handle plugin error gracefully", func() {
|
||||
conf.Server.LyricsPriority = "test-lyrics-plugin,embedded"
|
||||
mockLoader.err = fmt.Errorf("plugin error")
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("GetLyricsByArtistTitle", func() {
|
||||
var svc lyrics.Lyrics
|
||||
var repo *tests.MockMediaFileRepo
|
||||
var ds *tests.MockDataStore
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.LyricsPriority = "embedded"
|
||||
repo = &tests.MockMediaFileRepo{}
|
||||
ds = &tests.MockDataStore{MockedMediaFile: repo}
|
||||
svc = lyrics.NewLyrics(ds, nil)
|
||||
})
|
||||
|
||||
It("bounds the query to a duplicate window", func() {
|
||||
repo.SetData(model.MediaFiles{})
|
||||
_, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(repo.Options.Max).To(Equal(10))
|
||||
})
|
||||
|
||||
It("returns nil when no media file matches", func() {
|
||||
repo.SetData(model.MediaFiles{})
|
||||
list, err := svc.GetLyricsByArtistTitle(ctx, "Nobody", "No Song")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(BeNil())
|
||||
})
|
||||
|
||||
It("resolves lyrics from the matched media files", func() {
|
||||
embeddedList, err := model.ParseLyrics(".lrc", "eng", []byte("Embedded lyrics line"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
embedded, _ := embeddedList.Main()
|
||||
embeddedJSON, err := json.Marshal(model.LyricList{embedded})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
repo.SetData(model.MediaFiles{
|
||||
{ID: "1", Title: "Never Gonna Give You Up", Lyrics: string(embeddedJSON)},
|
||||
})
|
||||
|
||||
list, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Line[0].Value).To(Equal("Embedded lyrics line"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockPluginLoader struct {
|
||||
@ -206,7 +421,7 @@ func (m *mockPluginLoader) PluginNames(_ string) []string {
|
||||
return []string{"test-lyrics-plugin"}
|
||||
}
|
||||
|
||||
func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) {
|
||||
func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Provider, bool) {
|
||||
if m.notFound {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@ -3,9 +3,12 @@ package lyrics
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"path"
|
||||
|
||||
"github.com/navidrome/navidrome/core/storage"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/ioutils"
|
||||
@ -23,31 +26,45 @@ func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, er
|
||||
}
|
||||
|
||||
func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (model.LyricList, error) {
|
||||
basePath := mf.AbsolutePath()
|
||||
ext := path.Ext(basePath)
|
||||
ext := path.Ext(mf.Path)
|
||||
sidecarRelPath := mf.Path[0:len(mf.Path)-len(ext)] + suffix
|
||||
|
||||
externalLyric := basePath[0:len(basePath)-len(ext)] + suffix
|
||||
store, err := storage.For(mf.LibraryPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting storage for library: %w", err)
|
||||
}
|
||||
fsys, err := store.FS()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening library filesystem: %w", err)
|
||||
}
|
||||
|
||||
contents, err := ioutils.UTF8ReadFile(externalLyric)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
log.Trace(ctx, "no lyrics found at path", "path", externalLyric)
|
||||
f, err := fsys.Open(sidecarRelPath)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
log.Trace(ctx, "no lyrics found at path", "path", sidecarRelPath)
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
lyrics, err := model.ToLyrics("xxx", string(contents))
|
||||
contents, err := io.ReadAll(ioutils.UTF8Reader(f))
|
||||
if err != nil {
|
||||
log.Error(ctx, "error parsing lyric external file", "path", externalLyric, err)
|
||||
return nil, err
|
||||
} else if lyrics == nil {
|
||||
log.Trace(ctx, "empty lyrics from external file", "path", externalLyric)
|
||||
}
|
||||
|
||||
list, err := model.ParseLyrics(suffix, "xxx", contents)
|
||||
if err != nil {
|
||||
log.Error(ctx, "error parsing external lyric file", "path", sidecarRelPath, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(list) == 0 {
|
||||
log.Trace(ctx, "empty lyrics from external file", "path", sidecarRelPath)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
log.Trace(ctx, "retrieved lyrics from external file", "path", externalLyric)
|
||||
|
||||
return model.LyricList{*lyrics}, nil
|
||||
log.Trace(ctx, "retrieved lyrics from external file", "path", sidecarRelPath)
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// fromPlugin attempts to load lyrics from a plugin with the given name.
|
||||
|
||||
@ -3,6 +3,7 @@ package lyrics
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@ -25,10 +26,12 @@ var _ = Describe("sources", func() {
|
||||
const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I"
|
||||
const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I"
|
||||
|
||||
synced, _ := model.ToLyrics("eng", syncedLyrics)
|
||||
unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics)
|
||||
syncedList, _ := model.ParseLyrics(".lrc", "eng", []byte(syncedLyrics))
|
||||
unsyncedList, _ := model.ParseLyrics(".lrc", "xxx", []byte(unsyncedLyrics))
|
||||
synced, _ := syncedList.Main()
|
||||
unsynced, _ := unsyncedList.Main()
|
||||
|
||||
expectedList := model.LyricList{*synced, *unsynced}
|
||||
expectedList := model.LyricList{synced, unsynced}
|
||||
lyricsJson, err := json.Marshal(expectedList)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@ -53,72 +56,51 @@ var _ = Describe("sources", func() {
|
||||
})
|
||||
|
||||
Describe("fromExternalFile", func() {
|
||||
var fixturesDir string
|
||||
|
||||
BeforeEach(func() {
|
||||
// tests.Init sets CWD to the repo root, so "tests/fixtures" resolves correctly.
|
||||
abs, err := filepath.Abs("tests/fixtures")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
fixturesDir = abs
|
||||
})
|
||||
|
||||
mf := func(name string) *model.MediaFile {
|
||||
return &model.MediaFile{LibraryPath: fixturesDir, Path: name}
|
||||
}
|
||||
|
||||
It("should return nil for lyrics that don't exist", func() {
|
||||
mf := model.MediaFile{Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
|
||||
lyrics, err := fromExternalFile(ctx, mf("01 Invisible (RED) Edit Version.mp3"), ".lrc")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("should return synchronized lyrics from a file", func() {
|
||||
mf := model.MediaFile{Path: "tests/fixtures/test.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
|
||||
// fromExternalFile delegates format parsing to model.ParseLyrics; the
|
||||
// per-format parser output is covered exhaustively in the model package.
|
||||
// Here we only verify each suffix is read from the library FS and routed.
|
||||
DescribeTable("should read the sidecar file and route its suffix to a parser",
|
||||
func(name, suffix string, expectSynced bool) {
|
||||
lyrics, err := fromExternalFile(ctx, mf(name), suffix)
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(Equal(model.LyricList{
|
||||
model.Lyrics{
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "That one song",
|
||||
Lang: "eng",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: new(int64(18800)),
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: new(int64(22801)),
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Offset: new(int64(-100)),
|
||||
Synced: true,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
It("should return unsynchronized lyrics from a file", func() {
|
||||
mf := model.MediaFile{Path: "tests/fixtures/test.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".txt")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(Equal(model.LyricList{
|
||||
model.Lyrics{
|
||||
Lang: "xxx",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Synced: false,
|
||||
},
|
||||
}))
|
||||
})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).ToNot(BeEmpty())
|
||||
Expect(lyrics[0].Line).ToNot(BeEmpty())
|
||||
Expect(lyrics[0].Synced).To(Equal(expectSynced))
|
||||
},
|
||||
Entry(".lrc synced", "test.mp3", ".lrc", true),
|
||||
Entry(".elrc enhanced", "test.mp3", ".elrc", true),
|
||||
Entry(".txt plain", "test.mp3", ".txt", false),
|
||||
Entry(".srt subtitles", "test.mp3", ".srt", true),
|
||||
Entry(".ttml multilingual", "test.mp3", ".ttml", true),
|
||||
Entry(".yaml lyricsfile", "test.mp3", ".yaml", true),
|
||||
)
|
||||
|
||||
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")
|
||||
lyrics, err := fromExternalFile(ctx, mf("bom-test.mp3"), ".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(new(int64(0))))
|
||||
@ -126,14 +108,10 @@ var _ = Describe("sources", func() {
|
||||
})
|
||||
|
||||
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")
|
||||
lyrics, err := fromExternalFile(ctx, mf("bom-utf16-test.mp3"), ".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(new(int64(18800))))
|
||||
@ -141,5 +119,31 @@ var _ = Describe("sources", func() {
|
||||
Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801))))
|
||||
Expect(lyrics[0].Line[1].Value).To(Equal("You know the rules and so do I"))
|
||||
})
|
||||
|
||||
It("should handle TTML files with UTF-8 BOM marker", func() {
|
||||
lyrics, err := fromExternalFile(ctx, mf("bom-test.mp3"), ".ttml")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(HaveLen(1))
|
||||
Expect(lyrics[0].Kind).To(Equal("main"))
|
||||
Expect(lyrics[0].Synced).To(BeTrue())
|
||||
Expect(lyrics[0].Line).To(HaveLen(1))
|
||||
Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0))))
|
||||
Expect(lyrics[0].Line[0].Value).To(Equal("BOM test line"))
|
||||
})
|
||||
|
||||
It("should handle UTF-16 BE encoded TTML files", func() {
|
||||
lyrics, err := fromExternalFile(ctx, mf("bom-utf16-test.mp3"), ".ttml")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(HaveLen(1))
|
||||
Expect(lyrics[0].Kind).To(Equal("main"))
|
||||
Expect(lyrics[0].Synced).To(BeTrue())
|
||||
Expect(lyrics[0].Line).To(HaveLen(2))
|
||||
Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800))))
|
||||
Expect(lyrics[0].Line[0].Value).To(Equal("UTF16 line one"))
|
||||
Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801))))
|
||||
Expect(lyrics[0].Line[1].Value).To(Equal("UTF16 line two"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
124
core/matcher/doc.go
Normal file
124
core/matcher/doc.go
Normal file
@ -0,0 +1,124 @@
|
||||
// Package matcher matches song results from external agents (Last.fm, Deezer,
|
||||
// etc.) to tracks in the local music library, prioritizing accuracy over recall.
|
||||
//
|
||||
// It exposes a single [Matcher] type with two entry points that share the same
|
||||
// matching algorithm:
|
||||
//
|
||||
// - [Matcher.MatchSongs] returns an ordered, deduplicated slice of library
|
||||
// tracks, capped at a requested count. Use it when presenting "similar
|
||||
// songs" results to a client.
|
||||
// - [Matcher.MatchSongsIndexed] returns a map from input-song index to matched
|
||||
// track, with no deduplication. Use it when the caller needs to correlate
|
||||
// each result back to its input position (e.g. to attach a per-song
|
||||
// similarity score).
|
||||
//
|
||||
// # Algorithm Overview
|
||||
//
|
||||
// Each input song is resolved to its best-matching library track using four
|
||||
// strategies, applied in priority order. A song matched by a higher-priority
|
||||
// strategy is never reconsidered by a lower-priority one:
|
||||
//
|
||||
// 1. Direct ID match: songs with an ID are matched to a MediaFile by ID.
|
||||
// 2. MusicBrainz Recording ID (MBID) match: songs with an MBID are matched to
|
||||
// tracks with the same mbz_recording_id.
|
||||
// 3. ISRC match: songs with an ISRC are matched to tracks carrying that ISRC tag.
|
||||
// 4. Title+Artist fuzzy match: remaining songs are matched by fuzzy string
|
||||
// comparison with metadata-specificity scoring (see below).
|
||||
//
|
||||
// Priority order is ID > MBID > ISRC > Title+Artist, so more reliable
|
||||
// identifiers always take precedence over fuzzy text matching. Missing tracks
|
||||
// (those no longer present on disk) are never matched.
|
||||
//
|
||||
// # Fuzzy Matching Details
|
||||
//
|
||||
// Title+artist matching uses Jaro-Winkler similarity, with a threshold
|
||||
// configurable via conf.Server.Matcher.FuzzyThreshold (default 85%). A library
|
||||
// track must clear the title threshold to be considered. Candidates that clear
|
||||
// it are ranked by, in order:
|
||||
//
|
||||
// 1. Title similarity (Jaro-Winkler score, 0.0–1.0)
|
||||
// 2. Duration proximity (closer duration scores higher; 1.0 when the agent
|
||||
// reports no duration)
|
||||
// 3. Specificity level (0–5, based on metadata precision; higher is better)
|
||||
// 4. Artist overlap (how many of the song's artists the track credits; more
|
||||
// shared artists is better)
|
||||
// 5. Preferred-track flag (enabled by conf.Server.Matcher.PreferStarred;
|
||||
// prioritizes tracks that are starred or rated >= 4, but only among
|
||||
// candidates of equal specificity and overlap)
|
||||
// 6. Album similarity (Jaro-Winkler, as the final tiebreaker)
|
||||
//
|
||||
// The specificity levels, from most to least specific, are:
|
||||
//
|
||||
// Level 5: Title + Artist identity + Album MBID
|
||||
// Level 4: Title + Artist identity + Album name (fuzzy)
|
||||
// Level 3: Title + Artist name + Album name (fuzzy)
|
||||
// Level 2: Title + Artist identity
|
||||
// Level 1: Title + Artist name
|
||||
// Level 0: Title only
|
||||
//
|
||||
// "Artist identity" is a match on the artist's Navidrome ID (the strongest signal,
|
||||
// when a source supplies one) or its MBID. A plain name match is the weaker fallback
|
||||
// used for an artist with no identity match (e.g. a cover credited to a different
|
||||
// artist of the same name).
|
||||
//
|
||||
// The title phase always requires an agent artist to scope the library query, so
|
||||
// Level 0 does not mean "no artist": it applies when a candidate matches on title
|
||||
// but its own artist differs from the query's (e.g. a cover or a featured-artist
|
||||
// credit), leaving the title as the only shared field.
|
||||
//
|
||||
// A song may carry several artists, and the title phase scopes candidate tracks by
|
||||
// ANY of them: a track credited to at least one shared artist is considered. When a
|
||||
// source supplies a Navidrome artist ID, that artist is matched directly, skipping
|
||||
// name/MBID resolution. Among equally specific candidates, the one sharing more of
|
||||
// the song's artists wins, so a track crediting every collaborator outranks one
|
||||
// crediting only a single artist.
|
||||
//
|
||||
// Each input song is scored independently, so two songs with the same title and
|
||||
// artist but different durations can resolve to different library tracks (each
|
||||
// matches the track closest to its own duration).
|
||||
//
|
||||
// # Examples
|
||||
//
|
||||
// All examples below exercise the title+artist phase, where the interesting
|
||||
// behavior lives. (Identifier phases — ID, MBID, ISRC — are exact lookups that
|
||||
// always win over fuzzy matching; they need no illustration.)
|
||||
//
|
||||
// Title threshold — a near-miss title still matches; an exact-only threshold
|
||||
// rejects it:
|
||||
//
|
||||
// Agent returns: {Name: "Bohemian Rhapsody", Artist: "Queen"}
|
||||
// Library has: {ID: "t1", Title: "Bohemian Rhapsody - Remastered", Artist: "Queen"}
|
||||
// With threshold 85%: match succeeds (similarity ~0.87)
|
||||
// With threshold 100%: no match (not an exact title)
|
||||
//
|
||||
// Specificity ranking — among candidates that clear the title threshold, a
|
||||
// better album match wins:
|
||||
//
|
||||
// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}
|
||||
// Library has:
|
||||
// {ID: "t1", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101"} // Level 1
|
||||
// {ID: "t2", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} // Level 3
|
||||
// Result: t2 (Level 3 beats Level 1 on the album match)
|
||||
//
|
||||
// Duration tiebreak — with title and artist equal, the closest duration wins,
|
||||
// so two near-identical input songs can resolve to different tracks:
|
||||
//
|
||||
// Agent returns:
|
||||
// {Name: "Untitled", Artist: "Interpol", Duration: 245000} // 4:05
|
||||
// {Name: "Untitled", Artist: "Interpol", Duration: 600000} // 10:00 (a live take)
|
||||
// Library has:
|
||||
// {ID: "studio", Title: "Untitled", Artist: "Interpol", Duration: 248} // 4:08
|
||||
// {ID: "live", Title: "Untitled", Artist: "Interpol", Duration: 602} // 10:02
|
||||
// Result: studio for the first song, live for the second
|
||||
//
|
||||
// Preferred track — when conf.Server.Matcher.PreferStarred is enabled, a
|
||||
// starred (or rating >= 4) track is preferred, but only when specificity and
|
||||
// artist overlap are equal. A more specific match always wins regardless of the
|
||||
// preferred flag:
|
||||
//
|
||||
// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}
|
||||
// Library has:
|
||||
// {ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} // Level 3
|
||||
// {ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Starred: true} // Level 1, starred
|
||||
// Result: exact (specificity outranks the starred flag; preferred only breaks ties of equal identity)
|
||||
package matcher
|
||||
@ -3,12 +3,16 @@ package matcher
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"slices"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
"github.com/xrash/smetrics"
|
||||
)
|
||||
@ -23,176 +27,73 @@ func New(ds model.DataStore) *Matcher {
|
||||
return &Matcher{ds: ds}
|
||||
}
|
||||
|
||||
// MatchSongs matches agent song results to local library tracks using a multi-phase
|
||||
// matching algorithm that prioritizes accuracy over recall.
|
||||
// MatchSongs matches agent songs to library tracks and returns up to count
|
||||
// tracks in the input's order. See the package documentation for the matching
|
||||
// algorithm.
|
||||
//
|
||||
// # Algorithm Overview
|
||||
//
|
||||
// The algorithm matches songs from external agents (Last.fm, Deezer, etc.) to tracks in the
|
||||
// local music library using four matching strategies in priority order:
|
||||
//
|
||||
// 1. Direct ID match: Songs with an ID field are matched directly to MediaFiles by ID
|
||||
// 2. MusicBrainz Recording ID (MBID) match: Songs with MBID are matched to tracks with
|
||||
// matching mbz_recording_id
|
||||
// 3. ISRC match: Songs with ISRC are matched to tracks with matching ISRC tag
|
||||
// 4. Title+Artist fuzzy match: Remaining songs are matched using fuzzy string comparison
|
||||
// with metadata specificity scoring
|
||||
//
|
||||
// # Matching Priority
|
||||
//
|
||||
// When selecting the final result, matches are prioritized in order: ID > MBID > ISRC > Title+Artist.
|
||||
// This ensures that more reliable identifiers take precedence over fuzzy text matching.
|
||||
//
|
||||
// # Fuzzy Matching Details
|
||||
//
|
||||
// For title+artist matching, the algorithm uses Jaro-Winkler similarity (threshold configurable
|
||||
// via Matcher.FuzzyThreshold, default 85%). Matches are ranked by:
|
||||
//
|
||||
// 1. Title similarity (Jaro-Winkler score, 0.0-1.0)
|
||||
// 2. Duration proximity (closer duration = higher score, 1.0 if unknown)
|
||||
// 3. Preferred track flag (enabled by Matcher.PreferStarred; prioritized when the track is
|
||||
// starred or has rating >= 4)
|
||||
// 4. Specificity level (0-5, based on metadata precision):
|
||||
// - Level 5: Title + Artist MBID + Album MBID (most specific)
|
||||
// - Level 4: Title + Artist MBID + Album name (fuzzy)
|
||||
// - Level 3: Title + Artist name + Album name (fuzzy)
|
||||
// - Level 2: Title + Artist MBID
|
||||
// - Level 1: Title + Artist name
|
||||
// - Level 0: Title only
|
||||
// 5. Album similarity (Jaro-Winkler, as final tiebreaker)
|
||||
//
|
||||
// # Examples
|
||||
//
|
||||
// Example 1 - MBID Priority:
|
||||
//
|
||||
// Agent returns: {Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"}
|
||||
// Library has: [
|
||||
// {ID: "t1", Title: "Paranoid Android", MbzRecordingID: "abc-123"},
|
||||
// {ID: "t2", Title: "Paranoid Android", Artist: "Radiohead"},
|
||||
// ]
|
||||
// Result: t1 (MBID match takes priority over title+artist)
|
||||
//
|
||||
// Example 2 - ISRC Priority:
|
||||
//
|
||||
// Agent returns: {Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"}
|
||||
// Library has: [
|
||||
// {ID: "t1", Title: "Paranoid Android", Tags: {isrc: ["GBAYE0000351"]}},
|
||||
// {ID: "t2", Title: "Paranoid Android", Artist: "Radiohead"},
|
||||
// ]
|
||||
// Result: t1 (ISRC match takes priority over title+artist)
|
||||
//
|
||||
// Example 3 - Specificity Ranking:
|
||||
//
|
||||
// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}
|
||||
// Library has: [
|
||||
// {ID: "t1", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101"}, // Level 1
|
||||
// {ID: "t2", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, // Level 3
|
||||
// ]
|
||||
// Result: t2 (Level 3 beats Level 1 due to album match)
|
||||
//
|
||||
// Example 4 - Fuzzy Title Matching:
|
||||
//
|
||||
// Agent returns: {Name: "Bohemian Rhapsody", Artist: "Queen"}
|
||||
// Library has: {ID: "t1", Title: "Bohemian Rhapsody - Remastered", Artist: "Queen"}
|
||||
// With threshold=85%: Match succeeds (similarity ~0.87)
|
||||
// With threshold=100%: No match (not exact)
|
||||
//
|
||||
// # Parameters
|
||||
//
|
||||
// - ctx: Context for database operations
|
||||
// - songs: Slice of agent.Song results from external providers
|
||||
// - count: Maximum number of matches to return
|
||||
//
|
||||
// # Returns
|
||||
//
|
||||
// Returns up to 'count' MediaFiles from the library that best match the input songs,
|
||||
// preserving the original order from the agent. Songs that cannot be matched are skipped.
|
||||
// Each library track appears at most once, unless the same input song is
|
||||
// repeated: identical input songs intentionally yield repeated output tracks,
|
||||
// while distinct songs that resolve to the same track are deduplicated. Songs
|
||||
// that cannot be matched are skipped.
|
||||
func (m *Matcher) MatchSongs(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) {
|
||||
if len(songs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs)
|
||||
matches, err := m.resolveMatches(ctx, songs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.selectBestMatchingSongs(songs, byID, byMBID, byISRC, byTitle, count), nil
|
||||
return orderAndDedup(songs, matches, count), nil
|
||||
}
|
||||
|
||||
// MatchSongsIndexed matches agent song results to local library tracks and returns a map
|
||||
// from input song index to matched MediaFile. Songs that cannot be matched are omitted from the map.
|
||||
// This preserves original indices, allowing callers to correlate results back to the input slice.
|
||||
// MatchSongsIndexed matches agent songs to library tracks and returns a map from
|
||||
// input-song index to matched track, letting callers correlate results back to
|
||||
// the input slice. Unmatched songs are omitted from the map. Unlike MatchSongs,
|
||||
// results are not deduplicated. See the package documentation for the matching
|
||||
// algorithm.
|
||||
func (m *Matcher) MatchSongsIndexed(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) {
|
||||
if len(songs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return m.resolveMatches(ctx, songs)
|
||||
}
|
||||
|
||||
byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// resolveMatches resolves each input song to its best-matching library track,
|
||||
// keyed by the song's index. Loaders run in priority order (ID > MBID > ISRC >
|
||||
// Title); each only fills indices not already matched by a higher-priority loader.
|
||||
func (m *Matcher) resolveMatches(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) {
|
||||
result := make(map[int]model.MediaFile, len(songs))
|
||||
for i, t := range songs {
|
||||
if mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitle); found {
|
||||
result[i] = mf
|
||||
if err := m.matchByID(ctx, songs, result); err != nil {
|
||||
return nil, fmt.Errorf("failed to match tracks by ID: %w", err)
|
||||
}
|
||||
if err := m.matchByMBID(ctx, songs, result); err != nil {
|
||||
return nil, fmt.Errorf("failed to match tracks by MBID: %w", err)
|
||||
}
|
||||
if err := m.matchByISRC(ctx, songs, result); err != nil {
|
||||
return nil, fmt.Errorf("failed to match tracks by ISRC: %w", err)
|
||||
}
|
||||
// The title phase is best-effort: a DB failure there must not discard the exact
|
||||
// matches already found by the higher-priority phases. Only surface it as fatal
|
||||
// when nothing matched at all.
|
||||
if err := m.matchByTitle(ctx, songs, result); err != nil {
|
||||
if len(result) == 0 {
|
||||
return nil, fmt.Errorf("failed to match tracks by title: %w", err)
|
||||
}
|
||||
log.Warn(ctx, "Title matching failed; returning matches from exact phases only", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Matcher) loadAllMatches(ctx context.Context, songs []agents.Song) (byID, byMBID, byISRC, byTitle map[string]model.MediaFile, err error) {
|
||||
byID, err = m.loadTracksByID(ctx, songs)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ID: %w", err)
|
||||
}
|
||||
byMBID, err = m.loadTracksByMBID(ctx, songs, byID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
|
||||
}
|
||||
byISRC, err = m.loadTracksByISRC(ctx, songs, byID, byMBID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ISRC: %w", err)
|
||||
}
|
||||
byTitle, err = m.loadTracksByTitleAndArtist(ctx, songs, byID, byMBID, byISRC)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by title: %w", err)
|
||||
}
|
||||
return byID, byMBID, byISRC, byTitle, nil
|
||||
}
|
||||
|
||||
// songMatchedIn checks if a song has already been matched in any of the provided match maps.
|
||||
func songMatchedIn(s agents.Song, priorMatches ...map[string]model.MediaFile) bool {
|
||||
_, found := lookupByIdentifiers(s, priorMatches...)
|
||||
return found
|
||||
}
|
||||
|
||||
// lookupByIdentifiers searches for a song's identifiers (ID, MBID, ISRC) in the provided maps.
|
||||
func lookupByIdentifiers(s agents.Song, maps ...map[string]model.MediaFile) (model.MediaFile, bool) {
|
||||
keys := []string{s.ID, s.MBID, s.ISRC}
|
||||
for _, m := range maps {
|
||||
for _, key := range keys {
|
||||
if key != "" {
|
||||
if mf, ok := m[key]; ok && mf.ID != "" {
|
||||
return mf, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return model.MediaFile{}, false
|
||||
}
|
||||
|
||||
// loadTracksByID fetches MediaFiles from the library using direct ID matching.
|
||||
func (m *Matcher) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
|
||||
// matchByID fills result with direct ID matches.
|
||||
func (m *Matcher) matchByID(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error {
|
||||
var ids []string
|
||||
for _, s := range songs {
|
||||
if s.ID != "" {
|
||||
ids = append(ids, s.ID)
|
||||
}
|
||||
}
|
||||
matches := map[string]model.MediaFile{}
|
||||
if len(ids) == 0 {
|
||||
return matches, nil
|
||||
return nil
|
||||
}
|
||||
res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
@ -201,27 +102,37 @@ func (m *Matcher) loadTracksByID(ctx context.Context, songs []agents.Song) (map[
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return matches, err
|
||||
return err
|
||||
}
|
||||
byID := make(map[string]model.MediaFile, len(res))
|
||||
for _, mf := range res {
|
||||
if _, ok := matches[mf.ID]; !ok {
|
||||
matches[mf.ID] = mf
|
||||
byID[mf.ID] = mf // media_file.id is unique, so no dedup needed
|
||||
}
|
||||
for i, s := range songs {
|
||||
if s.ID == "" {
|
||||
continue
|
||||
}
|
||||
if mf, ok := byID[s.ID]; ok {
|
||||
result[i] = mf
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadTracksByMBID fetches MediaFiles from the library using MusicBrainz Recording IDs.
|
||||
func (m *Matcher) loadTracksByMBID(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
// matchByMBID fills result with MusicBrainz Recording ID matches, skipping
|
||||
// songs already matched by a higher-priority loader.
|
||||
func (m *Matcher) matchByMBID(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error {
|
||||
var mbids []string
|
||||
for _, s := range songs {
|
||||
if s.MBID != "" && !songMatchedIn(s, priorMatches...) {
|
||||
for i, s := range songs {
|
||||
if _, done := result[i]; done {
|
||||
continue
|
||||
}
|
||||
if s.MBID != "" {
|
||||
mbids = append(mbids, s.MBID)
|
||||
}
|
||||
}
|
||||
matches := map[string]model.MediaFile{}
|
||||
if len(mbids) == 0 {
|
||||
return matches, nil
|
||||
return nil
|
||||
}
|
||||
res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
@ -230,52 +141,86 @@ func (m *Matcher) loadTracksByMBID(ctx context.Context, songs []agents.Song, pri
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return matches, err
|
||||
return err
|
||||
}
|
||||
byMBID := make(map[string]model.MediaFile, len(res))
|
||||
for _, mf := range res {
|
||||
if id := mf.MbzRecordingID; id != "" {
|
||||
if _, ok := matches[id]; !ok {
|
||||
matches[id] = mf
|
||||
if _, ok := byMBID[id]; !ok {
|
||||
byMBID[id] = mf
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
for i, s := range songs {
|
||||
if _, done := result[i]; done {
|
||||
continue
|
||||
}
|
||||
if s.MBID == "" {
|
||||
continue
|
||||
}
|
||||
if mf, ok := byMBID[s.MBID]; ok {
|
||||
result[i] = mf
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadTracksByISRC fetches MediaFiles from the library using ISRC matching.
|
||||
func (m *Matcher) loadTracksByISRC(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
// matchByISRC fills result with ISRC tag matches, skipping songs already
|
||||
// matched by a higher-priority loader.
|
||||
func (m *Matcher) matchByISRC(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error {
|
||||
var isrcs []string
|
||||
for _, s := range songs {
|
||||
if s.ISRC != "" && !songMatchedIn(s, priorMatches...) {
|
||||
for i, s := range songs {
|
||||
if _, done := result[i]; done {
|
||||
continue
|
||||
}
|
||||
if s.ISRC != "" {
|
||||
isrcs = append(isrcs, s.ISRC)
|
||||
}
|
||||
}
|
||||
matches := map[string]model.MediaFile{}
|
||||
if len(isrcs) == 0 {
|
||||
return matches, nil
|
||||
return nil
|
||||
}
|
||||
res, err := m.ds.MediaFile(ctx).GetAllByTags(model.TagISRC, isrcs, model.QueryOptions{
|
||||
Filters: squirrel.Eq{"missing": false},
|
||||
Sort: "starred desc, rating desc, year asc, compilation asc",
|
||||
})
|
||||
if err != nil {
|
||||
return matches, err
|
||||
return err
|
||||
}
|
||||
byISRC := make(map[string]model.MediaFile, len(res))
|
||||
for _, mf := range res {
|
||||
for _, isrc := range mf.Tags.Values(model.TagISRC) {
|
||||
if _, ok := matches[isrc]; !ok {
|
||||
matches[isrc] = mf
|
||||
if _, ok := byISRC[isrc]; !ok {
|
||||
byISRC[isrc] = mf
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
for i, s := range songs {
|
||||
if _, done := result[i]; done {
|
||||
continue
|
||||
}
|
||||
if s.ISRC == "" {
|
||||
continue
|
||||
}
|
||||
if mf, ok := byISRC[s.ISRC]; ok {
|
||||
result[i] = mf
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// queryArtist is one of a song's artists. A non-empty id is matched directly, skipping name/MBID
|
||||
// resolution; name is pre-sanitized (article-stripped).
|
||||
type queryArtist struct {
|
||||
id string
|
||||
name string
|
||||
mbid string
|
||||
}
|
||||
|
||||
// songQuery represents a normalized query for matching a song to library tracks.
|
||||
type songQuery struct {
|
||||
title string
|
||||
artist string
|
||||
artistMBID string
|
||||
artists []queryArtist
|
||||
album string
|
||||
albumMBID string
|
||||
durationMs uint32
|
||||
@ -286,11 +231,13 @@ type matchScore struct {
|
||||
titleSimilarity float64
|
||||
durationProximity float64
|
||||
preferredMatch bool
|
||||
albumSimilarity float64
|
||||
specificityLevel int
|
||||
artistOverlap int
|
||||
albumSimilarity float64
|
||||
}
|
||||
|
||||
// betterThan returns true if this score beats another.
|
||||
// Identity signals (specificity, overlap) outrank the taste signal (preferred).
|
||||
func (s matchScore) betterThan(other matchScore) bool {
|
||||
if s.titleSimilarity != other.titleSimilarity {
|
||||
return s.titleSimilarity > other.titleSimilarity
|
||||
@ -298,106 +245,313 @@ func (s matchScore) betterThan(other matchScore) bool {
|
||||
if s.durationProximity != other.durationProximity {
|
||||
return s.durationProximity > other.durationProximity
|
||||
}
|
||||
if s.preferredMatch != other.preferredMatch {
|
||||
return s.preferredMatch
|
||||
}
|
||||
if s.specificityLevel != other.specificityLevel {
|
||||
return s.specificityLevel > other.specificityLevel
|
||||
}
|
||||
if s.artistOverlap != other.artistOverlap {
|
||||
return s.artistOverlap > other.artistOverlap
|
||||
}
|
||||
if s.preferredMatch != other.preferredMatch {
|
||||
return s.preferredMatch
|
||||
}
|
||||
return s.albumSimilarity > other.albumSimilarity
|
||||
}
|
||||
|
||||
// sanitizedTrack holds pre-sanitized fields for a media file, avoiding redundant sanitization
|
||||
// when the same track is scored against multiple queries in the inner loop. The `mf` field
|
||||
// is a pointer to avoid copying the large MediaFile struct into each entry of the per-artist
|
||||
// sanitized slice.
|
||||
// when the same track is scored against multiple queries. The `mf` field is a pointer to avoid
|
||||
// copying the large MediaFile struct into each entry of the sanitized slice.
|
||||
type sanitizedTrack struct {
|
||||
mf *model.MediaFile
|
||||
title string
|
||||
artist string
|
||||
album string
|
||||
mf *model.MediaFile
|
||||
title string
|
||||
artist string
|
||||
album string
|
||||
artistIDs map[string]struct{} // query's owned artist IDs this track credits; an ID match is the strongest identity signal
|
||||
artistMBIDs map[string]struct{} // MBIDs of those artists (artist table; mf.MbzArtistID is not populated on the bulk path)
|
||||
}
|
||||
|
||||
func newSanitizedTrack(mf *model.MediaFile) sanitizedTrack {
|
||||
func newSanitizedTrack(mf *model.MediaFile, artistIDs, artistMBIDs map[string]struct{}) sanitizedTrack {
|
||||
return sanitizedTrack{
|
||||
mf: mf,
|
||||
title: str.SanitizeFieldForSorting(mf.Title),
|
||||
artist: str.SanitizeFieldForSortingNoArticle(mf.Artist),
|
||||
album: str.SanitizeFieldForSorting(mf.Album),
|
||||
mf: mf,
|
||||
title: str.SanitizeFieldForSorting(mf.Title),
|
||||
artist: str.SanitizeFieldForSortingNoArticle(mf.Artist),
|
||||
album: str.SanitizeFieldForSorting(mf.Album),
|
||||
artistIDs: artistIDs,
|
||||
artistMBIDs: artistMBIDs,
|
||||
}
|
||||
}
|
||||
|
||||
// computeSpecificityLevel determines how well query metadata matches a track (0-5).
|
||||
// The track's title, artist, and album fields must be pre-sanitized.
|
||||
// computeSpecificityLevel determines how well query metadata matches a track (0-5), taking the best
|
||||
// level achievable across any of the query's artists. Fields must be pre-sanitized.
|
||||
//
|
||||
// A query artist counts as an identity match when the track credits its resolved Navidrome ID (the
|
||||
// strongest signal, our own primary key) or its MBID; that identity then unlocks the album tiers.
|
||||
// Name matching is the lowest fallback for an artist with no identity match (e.g. a cover credited
|
||||
// to a different artist by the same name).
|
||||
func computeSpecificityLevel(q songQuery, t sanitizedTrack, albumThreshold float64) int {
|
||||
if q.artistMBID != "" && q.albumMBID != "" &&
|
||||
t.mf.MbzArtistID == q.artistMBID && t.mf.MbzAlbumID == q.albumMBID {
|
||||
return 5
|
||||
}
|
||||
if q.artistMBID != "" && q.album != "" &&
|
||||
t.mf.MbzArtistID == q.artistMBID && similarityRatio(t.album, q.album) >= albumThreshold {
|
||||
return 4
|
||||
}
|
||||
if q.artist != "" && q.album != "" &&
|
||||
t.artist == q.artist && similarityRatio(t.album, q.album) >= albumThreshold {
|
||||
return 3
|
||||
}
|
||||
if q.artistMBID != "" && t.mf.MbzArtistID == q.artistMBID {
|
||||
return 2
|
||||
}
|
||||
if q.artist != "" && t.artist == q.artist {
|
||||
return 1
|
||||
}
|
||||
if t.title == q.title {
|
||||
return 0
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// loadTracksByTitleAndArtist loads tracks matching by title with optional artist/album filtering.
|
||||
func (m *Matcher) loadTracksByTitleAndArtist(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
queries := m.buildTitleQueries(songs, priorMatches...)
|
||||
if len(queries) == 0 {
|
||||
return map[string]model.MediaFile{}, nil
|
||||
}
|
||||
|
||||
threshold := float64(conf.Server.Matcher.FuzzyThreshold) / 100.0
|
||||
|
||||
byArtist := map[string][]songQuery{}
|
||||
for _, q := range queries {
|
||||
if q.artist != "" {
|
||||
byArtist[q.artist] = append(byArtist[q.artist], q)
|
||||
best := 0
|
||||
albumOK := q.album != "" && similarityRatio(t.album, q.album) >= albumThreshold
|
||||
for _, a := range q.artists {
|
||||
_, idMember := t.artistIDs[a.id]
|
||||
_, mbidMember := t.artistMBIDs[a.mbid]
|
||||
identity := (a.id != "" && idMember) || (a.mbid != "" && mbidMember)
|
||||
level := 0
|
||||
switch {
|
||||
case identity && q.albumMBID != "" && t.mf.MbzAlbumID == q.albumMBID:
|
||||
level = 5
|
||||
case identity && q.album != "" && albumOK:
|
||||
level = 4
|
||||
case a.name != "" && q.album != "" && t.artist == a.name && albumOK:
|
||||
level = 3
|
||||
case identity:
|
||||
level = 2
|
||||
case a.name != "" && t.artist == a.name:
|
||||
level = 1
|
||||
}
|
||||
if level > best {
|
||||
best = level
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
matches := map[string]model.MediaFile{}
|
||||
for artist, artistQueries := range byArtist {
|
||||
tracks, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"order_artist_name": artist},
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
Sort: "starred desc, rating desc, year asc, compilation asc",
|
||||
})
|
||||
if err != nil {
|
||||
// indexedQuery pairs a normalized songQuery with the index of the input song
|
||||
// it came from, so title matches can be written back to result by index.
|
||||
type indexedQuery struct {
|
||||
index int
|
||||
query songQuery
|
||||
}
|
||||
|
||||
// matchByTitle fills result with fuzzy title+artist matches, skipping songs
|
||||
// already matched by a higher-priority loader.
|
||||
func (m *Matcher) matchByTitle(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error {
|
||||
queries := groupQueries(songs, result)
|
||||
if len(queries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
resolved, err := m.resolveArtists(ctx, queries)
|
||||
if err != nil || len(resolved.allIDs) == 0 {
|
||||
return err
|
||||
}
|
||||
|
||||
tracks, err := m.fetchTracksCreditedTo(ctx, resolved.allIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tracksByQuery := resolved.bucketTracks(tracks)
|
||||
threshold := float64(conf.Server.Matcher.FuzzyThreshold) / 100.0
|
||||
for _, iq := range queries {
|
||||
sanitized := tracksByQuery[iq.index]
|
||||
if mf, found := m.findBestMatch(iq.query, sanitized, threshold); found {
|
||||
result[iq.index] = mf
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// groupQueries builds one normalized title query per still-unmatched song, carrying its full
|
||||
// artist set. An artist is usable if it carries a Navidrome ID or a non-empty sanitized name;
|
||||
// songs with no usable artist are skipped (the title phase needs at least one to scope the query).
|
||||
func groupQueries(songs []agents.Song, result map[int]model.MediaFile) []indexedQuery {
|
||||
var queries []indexedQuery
|
||||
for i, s := range songs {
|
||||
if _, done := result[i]; done {
|
||||
continue
|
||||
}
|
||||
|
||||
sanitized := make([]sanitizedTrack, len(tracks))
|
||||
for i := range tracks {
|
||||
sanitized[i] = newSanitizedTrack(&tracks[i])
|
||||
var artists []queryArtist
|
||||
for _, a := range s.Artists {
|
||||
name := str.SanitizeFieldForSortingNoArticle(a.Name)
|
||||
if a.ID == "" && name == "" && a.MBID == "" {
|
||||
continue
|
||||
}
|
||||
artists = append(artists, queryArtist{id: a.ID, name: name, mbid: a.MBID})
|
||||
}
|
||||
if len(artists) == 0 {
|
||||
continue
|
||||
}
|
||||
queries = append(queries, indexedQuery{index: i, query: songQuery{
|
||||
title: str.SanitizeFieldForSorting(s.Name),
|
||||
artists: artists,
|
||||
album: str.SanitizeFieldForSorting(s.Album),
|
||||
albumMBID: s.AlbumMBID,
|
||||
durationMs: s.Duration,
|
||||
}})
|
||||
}
|
||||
return queries
|
||||
}
|
||||
|
||||
for _, q := range artistQueries {
|
||||
if mf, found := m.findBestMatch(q, sanitized, threshold); found {
|
||||
key := q.title + "|" + q.artist
|
||||
if _, exists := matches[key]; !exists {
|
||||
matches[key] = mf
|
||||
// resolvedArtists holds the agent artists resolved to artist-table rows, keyed by the query index
|
||||
// that owns them. Routing is always by stable artist ID, never by name.
|
||||
type resolvedArtists struct {
|
||||
byQuery map[int]map[string]struct{} // query index -> set of resolved artist IDs
|
||||
mbid map[string]string // artist ID -> its MBID (from the artist table)
|
||||
allIDs []string // every resolved artist ID, for the track lookup
|
||||
}
|
||||
|
||||
// resolveArtists resolves every artist of every query to artist-table rows. Artists that carry a
|
||||
// Navidrome ID are owned directly (no name/MBID lookup). The remaining names/MBIDs are resolved in
|
||||
// one batched query. Ownership is recorded per query index.
|
||||
func (m *Matcher) resolveArtists(ctx context.Context, queries []indexedQuery) (resolvedArtists, error) {
|
||||
res := resolvedArtists{
|
||||
byQuery: make(map[int]map[string]struct{}, len(queries)),
|
||||
mbid: make(map[string]string),
|
||||
}
|
||||
allIDs := map[string]struct{}{} // de-dupe across fast-path + resolved
|
||||
|
||||
// One pending entry per non-ID artist (carrying the query that owns it). ID-bearing artists
|
||||
// take the fast-path and are owned directly.
|
||||
type pendingArtist struct {
|
||||
name, mbid string
|
||||
query int
|
||||
}
|
||||
var pending []pendingArtist
|
||||
for _, iq := range queries {
|
||||
for _, a := range iq.query.artists {
|
||||
if a.id != "" {
|
||||
addToSet(res.byQuery, iq.index, a.id) // ID fast-path: own directly
|
||||
allIDs[a.id] = struct{}{}
|
||||
continue
|
||||
}
|
||||
pending = append(pending, pendingArtist{name: a.name, mbid: a.mbid, query: iq.index})
|
||||
}
|
||||
}
|
||||
// query indices that supplied each order name / each MBID (skip the empty key — an artist may
|
||||
// have only one of name/mbid).
|
||||
nameToQueries := map[string][]int{}
|
||||
mbidToQueries := map[string][]int{}
|
||||
for _, p := range pending {
|
||||
if p.name != "" {
|
||||
nameToQueries[p.name] = append(nameToQueries[p.name], p.query)
|
||||
}
|
||||
if p.mbid != "" {
|
||||
mbidToQueries[p.mbid] = append(mbidToQueries[p.mbid], p.query)
|
||||
}
|
||||
}
|
||||
|
||||
// Query the artist table for name/MBID artists AND for the fast-path IDs (so their MBIDs are
|
||||
// available for specificity scoring).
|
||||
var filter squirrel.Or
|
||||
if len(nameToQueries) > 0 {
|
||||
filter = append(filter, squirrel.Eq{"order_artist_name": slices.Collect(maps.Keys(nameToQueries))})
|
||||
}
|
||||
if len(mbidToQueries) > 0 {
|
||||
filter = append(filter, squirrel.Eq{"mbz_artist_id": slices.Collect(maps.Keys(mbidToQueries))})
|
||||
}
|
||||
if len(allIDs) > 0 {
|
||||
filter = append(filter, squirrel.Eq{"id": slices.Collect(maps.Keys(allIDs))})
|
||||
}
|
||||
if len(filter) > 0 {
|
||||
artists, err := m.ds.Artist(ctx).GetAll(model.QueryOptions{Filters: filter})
|
||||
if err != nil {
|
||||
return resolvedArtists{}, err
|
||||
}
|
||||
for _, a := range artists {
|
||||
res.mbid[a.ID] = a.MbzArtistID
|
||||
allIDs[a.ID] = struct{}{}
|
||||
for _, idx := range nameToQueries[a.OrderArtistName] {
|
||||
addToSet(res.byQuery, idx, a.ID)
|
||||
}
|
||||
if a.MbzArtistID != "" {
|
||||
for _, idx := range mbidToQueries[a.MbzArtistID] {
|
||||
addToSet(res.byQuery, idx, a.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
|
||||
res.allIDs = slices.Collect(maps.Keys(allIDs))
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func addToSet(m map[int]map[string]struct{}, k int, v string) {
|
||||
if m[k] == nil {
|
||||
m[k] = map[string]struct{}{}
|
||||
}
|
||||
m[k][v] = struct{}{}
|
||||
}
|
||||
|
||||
// scoredTrack is a candidate track for a query; overlap is how many of the query's distinct
|
||||
// artist IDs the track credits.
|
||||
type scoredTrack struct {
|
||||
sanitizedTrack
|
||||
overlap int
|
||||
}
|
||||
|
||||
// queryAccum tallies, for one track against one query, the overlap count, the credited artists'
|
||||
// owned IDs, and their MBIDs.
|
||||
type queryAccum struct {
|
||||
overlap int
|
||||
ids map[string]struct{}
|
||||
mbids map[string]struct{}
|
||||
}
|
||||
|
||||
func (r resolvedArtists) bucketTracks(tracks []model.MediaFile) map[int][]scoredTrack {
|
||||
queriesByArtist := make(map[string][]int)
|
||||
for idx, ids := range r.byQuery {
|
||||
for id := range ids {
|
||||
queriesByArtist[id] = append(queriesByArtist[id], idx)
|
||||
}
|
||||
}
|
||||
|
||||
byQuery := make(map[int][]scoredTrack, len(r.byQuery))
|
||||
for i := range tracks {
|
||||
acc := map[int]*queryAccum{}
|
||||
credited := map[string]struct{}{}
|
||||
for _, p := range tracks[i].Participants[model.RoleArtist] {
|
||||
if _, dup := credited[p.ID]; dup {
|
||||
continue
|
||||
}
|
||||
owners, owned := queriesByArtist[p.ID]
|
||||
if !owned {
|
||||
continue
|
||||
}
|
||||
credited[p.ID] = struct{}{}
|
||||
mbid := r.mbid[p.ID] // "" if not in the artist-table result
|
||||
for _, idx := range owners {
|
||||
a := acc[idx]
|
||||
if a == nil {
|
||||
a = &queryAccum{ids: map[string]struct{}{}, mbids: map[string]struct{}{}}
|
||||
acc[idx] = a
|
||||
}
|
||||
a.overlap++
|
||||
a.ids[p.ID] = struct{}{}
|
||||
if mbid != "" {
|
||||
a.mbids[mbid] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
for idx, a := range acc {
|
||||
byQuery[idx] = append(byQuery[idx], scoredTrack{
|
||||
sanitizedTrack: newSanitizedTrack(&tracks[i], a.ids, a.mbids),
|
||||
overlap: a.overlap,
|
||||
})
|
||||
}
|
||||
}
|
||||
return byQuery
|
||||
}
|
||||
|
||||
// fetchTracksCreditedTo fetches every non-missing track credited to any of the given artists as
|
||||
// the main artist (role='artist', not albumartist — that avoids tribute/compilation false
|
||||
// positives). The non-correlated id IN (subquery) materializes the matching ids once from the
|
||||
// media_file_artists(artist_id) covering index, far cheaper than a correlated EXISTS that re-runs
|
||||
// per row. That form isn't expressible via the repository's role filters, so the raw squirrel.Expr
|
||||
// keeps the media_file_artists schema knowledge here; a dedicated repository method would be the
|
||||
// cleaner home if this is reused.
|
||||
func (m *Matcher) fetchTracksCreditedTo(ctx context.Context, artistIDs []string) (model.MediaFiles, error) {
|
||||
if len(artistIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
args := slice.Map(artistIDs, func(id string) any { return id })
|
||||
return m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Expr(
|
||||
"media_file.id IN (SELECT media_file_id FROM media_file_artists "+
|
||||
"WHERE role = 'artist' AND artist_id IN ("+squirrel.Placeholders(len(artistIDs))+"))", args...),
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
Sort: "starred desc, rating desc, year asc, compilation asc",
|
||||
})
|
||||
}
|
||||
|
||||
// durationProximity returns a score from 0.0 to 1.0 indicating how close the track's duration
|
||||
@ -412,34 +566,32 @@ func durationProximity(durationMs uint32, mediaFileDurationSec float32) float64
|
||||
}
|
||||
|
||||
// findBestMatch finds the best matching track using combined title/album similarity and specificity scoring.
|
||||
func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, threshold float64) (model.MediaFile, bool) {
|
||||
func (m *Matcher) findBestMatch(q songQuery, candidates []scoredTrack, threshold float64) (model.MediaFile, bool) {
|
||||
var bestMatch model.MediaFile
|
||||
bestScore := matchScore{titleSimilarity: -1}
|
||||
found := false
|
||||
|
||||
for _, t := range sanitizedTracks {
|
||||
titleSim := similarityRatio(q.title, t.title)
|
||||
|
||||
preferStarred := conf.Server.Matcher.PreferStarred
|
||||
for _, c := range candidates {
|
||||
titleSim := similarityRatio(q.title, c.title)
|
||||
if titleSim < threshold {
|
||||
continue
|
||||
}
|
||||
|
||||
var albumSim float64
|
||||
if q.album != "" {
|
||||
albumSim = similarityRatio(q.album, t.album)
|
||||
albumSim = similarityRatio(q.album, c.album)
|
||||
}
|
||||
|
||||
score := matchScore{
|
||||
titleSimilarity: titleSim,
|
||||
durationProximity: durationProximity(q.durationMs, t.mf.Duration),
|
||||
preferredMatch: conf.Server.Matcher.PreferStarred && isPreferredTrack(t.mf),
|
||||
durationProximity: durationProximity(q.durationMs, c.mf.Duration),
|
||||
preferredMatch: preferStarred && isPreferredTrack(c.mf),
|
||||
albumSimilarity: albumSim,
|
||||
specificityLevel: computeSpecificityLevel(q, t, threshold),
|
||||
specificityLevel: computeSpecificityLevel(q, c.sanitizedTrack, threshold),
|
||||
artistOverlap: c.overlap,
|
||||
}
|
||||
|
||||
if score.betterThan(bestScore) {
|
||||
bestScore = score
|
||||
bestMatch = *t.mf
|
||||
bestMatch = *c.mf
|
||||
found = true
|
||||
}
|
||||
}
|
||||
@ -450,66 +602,34 @@ func isPreferredTrack(mf *model.MediaFile) bool {
|
||||
return mf.Starred || mf.Rating >= 4
|
||||
}
|
||||
|
||||
// buildTitleQueries converts agent songs into normalized songQuery structs for title+artist matching.
|
||||
func (m *Matcher) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery {
|
||||
var queries []songQuery
|
||||
for _, s := range songs {
|
||||
if songMatchedIn(s, priorMatches...) {
|
||||
continue
|
||||
}
|
||||
queries = append(queries, songQuery{
|
||||
title: str.SanitizeFieldForSorting(s.Name),
|
||||
artist: str.SanitizeFieldForSortingNoArticle(s.Artist),
|
||||
artistMBID: s.ArtistMBID,
|
||||
album: str.SanitizeFieldForSorting(s.Album),
|
||||
albumMBID: s.AlbumMBID,
|
||||
durationMs: s.Duration,
|
||||
})
|
||||
}
|
||||
return queries
|
||||
}
|
||||
|
||||
// selectBestMatchingSongs assembles the final result by mapping input songs to their best matching
|
||||
// library tracks using priority order: ID > MBID > ISRC > title+artist.
|
||||
func (m *Matcher) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile, count int) model.MediaFiles {
|
||||
// orderAndDedup builds the final ordered result from the per-index matches,
|
||||
// applying the count limit and deduplication. A library track is added at most
|
||||
// once unless the same input song appears more than once (callers rely on that
|
||||
// 1:1 positional behavior for identical duplicate inputs).
|
||||
func orderAndDedup(songs []agents.Song, matches map[int]model.MediaFile, count int) model.MediaFiles {
|
||||
mfs := make(model.MediaFiles, 0, len(songs))
|
||||
addedBy := make(map[string]agents.Song, len(songs))
|
||||
addedBy := make(map[string]int, len(songs))
|
||||
|
||||
for _, t := range songs {
|
||||
for i, s := range songs {
|
||||
if len(mfs) == count {
|
||||
break
|
||||
}
|
||||
|
||||
mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitleArtist)
|
||||
mf, found := matches[i]
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
if prevSong, alreadyAdded := addedBy[mf.ID]; alreadyAdded {
|
||||
if t != prevSong {
|
||||
if prevIdx, alreadyAdded := addedBy[mf.ID]; alreadyAdded {
|
||||
if !s.Equals(songs[prevIdx]) {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
addedBy[mf.ID] = t
|
||||
addedBy[mf.ID] = i
|
||||
}
|
||||
|
||||
mfs = append(mfs, mf)
|
||||
}
|
||||
return mfs
|
||||
}
|
||||
|
||||
// findMatchingTrack looks up a song in the match maps using priority order.
|
||||
func findMatchingTrack(t agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile) (model.MediaFile, bool) {
|
||||
if mf, found := lookupByIdentifiers(t, byID, byMBID, byISRC); found {
|
||||
return mf, true
|
||||
}
|
||||
key := str.SanitizeFieldForSorting(t.Name) + "|" + str.SanitizeFieldForSortingNoArticle(t.Artist)
|
||||
if mf, ok := byTitleArtist[key]; ok {
|
||||
return mf, true
|
||||
}
|
||||
return model.MediaFile{}, false
|
||||
}
|
||||
|
||||
// similarityRatio calculates the similarity between two strings using Jaro-Winkler algorithm.
|
||||
func similarityRatio(a, b string) float64 {
|
||||
if a == b {
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
package matcher
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -51,3 +54,205 @@ var _ = Describe("similarityRatio", func() {
|
||||
Expect(ratio1).To(Equal(ratio2))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("matcher internals", func() {
|
||||
It("computeSpecificityLevel uses sanitizedTrack.artistMBIDs for artist-MBID levels", func() {
|
||||
q := songQuery{
|
||||
title: "song",
|
||||
artists: []queryArtist{{mbid: "artist-mbid-1"}},
|
||||
albumMBID: "album-mbid-1",
|
||||
}
|
||||
mf := model.MediaFile{Title: "Song", MbzAlbumID: "album-mbid-1"} // note: mf.MbzArtistID intentionally empty
|
||||
t := newSanitizedTrack(&mf, nil, map[string]struct{}{"artist-mbid-1": {}})
|
||||
Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(5))
|
||||
})
|
||||
|
||||
It("computeSpecificityLevel maximizes the level over all query artists", func() {
|
||||
// First artist does not match; second matches by name with a matching album → level 3.
|
||||
q := songQuery{
|
||||
title: "song",
|
||||
album: "violator",
|
||||
artists: []queryArtist{
|
||||
{name: "no match"},
|
||||
{name: "depeche mode"},
|
||||
},
|
||||
}
|
||||
mf := model.MediaFile{Title: "Song", Artist: "Depeche Mode", Album: "Violator"}
|
||||
t := newSanitizedTrack(&mf, nil, nil)
|
||||
Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(3))
|
||||
})
|
||||
|
||||
It("scores MBID specificity for any credited artist, not just the last", func() {
|
||||
q := songQuery{
|
||||
title: "song",
|
||||
artists: []queryArtist{
|
||||
{name: "drake", mbid: "mbz-drake"},
|
||||
{name: "future", mbid: "mbz-future"},
|
||||
},
|
||||
album: "wrong album", // force album mismatch so only MBID-level (2) is reachable, not 3+
|
||||
}
|
||||
// Track credits BOTH MBIDs; with the old last-wins string this would only match one.
|
||||
t := sanitizedTrack{
|
||||
mf: &model.MediaFile{},
|
||||
title: "song",
|
||||
artist: "drake",
|
||||
album: "some other album",
|
||||
artistMBIDs: map[string]struct{}{"mbz-drake": {}, "mbz-future": {}},
|
||||
}
|
||||
// Either artist's MBID matching yields level 2 (MBID, no album match). The point: it is
|
||||
// reached via mbz-future too, which the old code would have dropped.
|
||||
Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(2))
|
||||
})
|
||||
|
||||
It("treats an ID-only artist (no name/MBID) as an identity match, unlocking album tiers", func() {
|
||||
// A plugin that supplies only a Navidrome artist ID: name and mbid are empty. The ID is the
|
||||
// strongest identity signal, so the track's album still elevates specificity above 0.
|
||||
q := songQuery{
|
||||
title: "song",
|
||||
artists: []queryArtist{{id: "artist-1"}},
|
||||
album: "violator",
|
||||
albumMBID: "album-mbid-1",
|
||||
}
|
||||
// Track credits the owned artist ID; no MBID anywhere (untagged library / ID-only plugin).
|
||||
mf := model.MediaFile{Title: "Song", Album: "Violator", MbzAlbumID: "album-mbid-1"}
|
||||
t := newSanitizedTrack(&mf, map[string]struct{}{"artist-1": {}}, nil)
|
||||
// Album MBID matches → level 5 via the ID identity, where the old code scored 0.
|
||||
Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(5))
|
||||
|
||||
// Same artist, album name matches but no album MBID → level 4 via the ID identity.
|
||||
q.albumMBID = ""
|
||||
mf2 := model.MediaFile{Title: "Song", Album: "Violator"}
|
||||
t2 := newSanitizedTrack(&mf2, map[string]struct{}{"artist-1": {}}, nil)
|
||||
Expect(computeSpecificityLevel(q, t2, 0.85)).To(Equal(4))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("groupQueries", func() {
|
||||
It("builds one query per unmatched song carrying all artists", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Song A", Artists: []agents.Artist{{Name: "Drake"}, {Name: "Future"}}},
|
||||
}
|
||||
queries := groupQueries(songs, map[int]model.MediaFile{})
|
||||
Expect(queries).To(HaveLen(1))
|
||||
Expect(queries[0].index).To(Equal(0))
|
||||
Expect(queries[0].query.title).To(Equal("song a"))
|
||||
Expect(queries[0].query.artists).To(HaveLen(2))
|
||||
Expect(queries[0].query.artists[0].name).To(Equal("drake"))
|
||||
Expect(queries[0].query.artists[1].name).To(Equal("future"))
|
||||
})
|
||||
|
||||
It("strips leading articles from artist names", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Song A", Artists: []agents.Artist{{Name: "The Drake"}}},
|
||||
}
|
||||
queries := groupQueries(songs, map[int]model.MediaFile{})
|
||||
Expect(queries).To(HaveLen(1))
|
||||
Expect(queries[0].query.artists).To(HaveLen(1))
|
||||
Expect(queries[0].query.artists[0].name).To(Equal("drake"))
|
||||
})
|
||||
|
||||
It("keeps an artist that carries only an ID (empty name)", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Song A", Artists: []agents.Artist{{ID: "ar-x"}}},
|
||||
}
|
||||
queries := groupQueries(songs, map[int]model.MediaFile{})
|
||||
Expect(queries).To(HaveLen(1))
|
||||
Expect(queries[0].query.artists).To(HaveLen(1))
|
||||
Expect(queries[0].query.artists[0].id).To(Equal("ar-x"))
|
||||
Expect(queries[0].query.artists[0].name).To(Equal(""))
|
||||
})
|
||||
|
||||
It("keeps an artist that carries only an MBID (empty id and name)", func() {
|
||||
// ListenBrainz collaborators arrive as MBID-only when the API supplies a combined display
|
||||
// name; the MBID is a usable identity signal and must not be dropped.
|
||||
songs := []agents.Song{
|
||||
{Name: "Song A", Artists: []agents.Artist{{MBID: "mbz-future"}}},
|
||||
}
|
||||
queries := groupQueries(songs, map[int]model.MediaFile{})
|
||||
Expect(queries).To(HaveLen(1))
|
||||
Expect(queries[0].query.artists).To(HaveLen(1))
|
||||
Expect(queries[0].query.artists[0].id).To(Equal(""))
|
||||
Expect(queries[0].query.artists[0].name).To(Equal(""))
|
||||
Expect(queries[0].query.artists[0].mbid).To(Equal("mbz-future"))
|
||||
})
|
||||
|
||||
It("drops a fully-empty artist (no id, name, or mbid) but keeps usable ones", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Song A", Artists: []agents.Artist{{}, {Name: "Future"}}},
|
||||
}
|
||||
queries := groupQueries(songs, map[int]model.MediaFile{})
|
||||
Expect(queries).To(HaveLen(1))
|
||||
Expect(queries[0].query.artists).To(HaveLen(1))
|
||||
Expect(queries[0].query.artists[0].name).To(Equal("future"))
|
||||
})
|
||||
|
||||
It("skips already-matched songs and songs with no usable artist", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Already Matched", Artists: []agents.Artist{{Name: "Drake"}}},
|
||||
{Name: "No Artist"},
|
||||
{Name: "Song C", Artists: []agents.Artist{{Name: "Future"}}},
|
||||
}
|
||||
queries := groupQueries(songs, map[int]model.MediaFile{0: {ID: "done"}})
|
||||
Expect(queries).To(HaveLen(1))
|
||||
Expect(queries[0].index).To(Equal(2))
|
||||
Expect(queries[0].query.artists[0].name).To(Equal("future"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("bucketTracks", func() {
|
||||
It("scores tracks by how many of the query's artists they credit (overlap)", func() {
|
||||
r := resolvedArtists{
|
||||
byQuery: map[int]map[string]struct{}{
|
||||
0: {"ar-1": {}, "ar-2": {}},
|
||||
},
|
||||
mbid: map[string]string{},
|
||||
}
|
||||
trackA := model.MediaFile{ID: "a", Title: "A",
|
||||
Participants: artistParticipants(
|
||||
model.Artist{ID: "ar-1", OrderArtistName: "one"},
|
||||
model.Artist{ID: "ar-2", OrderArtistName: "two"},
|
||||
),
|
||||
}
|
||||
trackB := model.MediaFile{ID: "b", Title: "B",
|
||||
Participants: artistParticipants(model.Artist{ID: "ar-1", OrderArtistName: "one"}),
|
||||
}
|
||||
byQuery := r.bucketTracks(model.MediaFiles{trackA, trackB})
|
||||
Expect(byQuery[0]).To(HaveLen(2))
|
||||
overlaps := map[string]int{}
|
||||
for _, st := range byQuery[0] {
|
||||
overlaps[st.mf.ID] = st.overlap
|
||||
}
|
||||
Expect(overlaps["a"]).To(Equal(2))
|
||||
Expect(overlaps["b"]).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("resolveArtists ID fast-path", func() {
|
||||
It("owns an artist supplied by ID without a name match", func() {
|
||||
ctx := GinkgoT().Context()
|
||||
artistRepo := tests.CreateMockArtistRepo()
|
||||
artistRepo.SetData(model.Artists{
|
||||
{ID: "ar-x", Name: "Some Artist", OrderArtistName: "some artist", MbzArtistID: "mbz-x"},
|
||||
})
|
||||
ds := &tests.MockDataStore{MockedArtist: artistRepo}
|
||||
m := New(ds)
|
||||
|
||||
queries := []indexedQuery{
|
||||
{index: 0, query: songQuery{title: "song", artists: []queryArtist{{id: "ar-x"}}}},
|
||||
}
|
||||
res, err := m.resolveArtists(ctx, queries)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.byQuery[0]).To(HaveKey("ar-x"))
|
||||
Expect(res.allIDs).To(ContainElement("ar-x"))
|
||||
Expect(res.mbid["ar-x"]).To(Equal("mbz-x"))
|
||||
})
|
||||
})
|
||||
|
||||
// artistParticipants builds a Participants map crediting the given artists under RoleArtist.
|
||||
func artistParticipants(artists ...model.Artist) model.Participants {
|
||||
list := make(model.ParticipantList, len(artists))
|
||||
for i, a := range artists {
|
||||
list[i] = model.Participant{Artist: a}
|
||||
}
|
||||
return model.Participants{model.RoleArtist: list}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -4,11 +4,13 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
// --- REST adapter (follows Share/Library pattern) ---
|
||||
@ -34,8 +36,8 @@ func (r *playlistRepositoryWrapper) Save(entity any) (string, error) {
|
||||
return r.service.savePlaylist(r.ctx, entity.(*model.Playlist))
|
||||
}
|
||||
|
||||
func (r *playlistRepositoryWrapper) Update(id string, entity any, _ ...string) error {
|
||||
return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist))
|
||||
func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string) error {
|
||||
return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist), cols...)
|
||||
}
|
||||
|
||||
func (r *playlistRepositoryWrapper) Delete(id string) error {
|
||||
@ -79,7 +81,15 @@ func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (stri
|
||||
|
||||
// updatePlaylistEntity updates playlist metadata with permission checks.
|
||||
// Used by the REST API wrapper.
|
||||
func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist) error {
|
||||
//
|
||||
// cols names the fields the client actually sent in the JSON body (extracted by
|
||||
// rest.Put). When non-empty, fields outside cols are not considered changed and
|
||||
// are left untouched — this prevents partial requests like bulk "Make Public"
|
||||
// (body: {"public": true}) from wiping fields that just happen to be zero in
|
||||
// the deserialized entity (see issue #5541). An empty cols means "treat the
|
||||
// entity as a complete record" — preserved for callers that don't use the REST
|
||||
// wrapper.
|
||||
func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error {
|
||||
current, err := s.checkWritable(ctx, id)
|
||||
if err != nil {
|
||||
switch {
|
||||
@ -91,41 +101,92 @@ func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
sent := sentFields(cols)
|
||||
|
||||
usr, _ := request.UserFrom(ctx)
|
||||
if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID {
|
||||
ownerChanged := sent("ownerId") && entity.OwnerID != "" && entity.OwnerID != current.OwnerID
|
||||
if !usr.IsAdmin && ownerChanged {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
|
||||
contentChanged := entity.Name != current.Name ||
|
||||
entity.Comment != current.Comment ||
|
||||
(entity.OwnerID != "" && entity.OwnerID != current.OwnerID) ||
|
||||
!rulesEqual(current.Rules, entity.Rules)
|
||||
nameChanged := sent("name") && entity.Name != current.Name
|
||||
commentChanged := sent("comment") && entity.Comment != current.Comment
|
||||
rulesChanged := sent("rules") && !rulesEqual(current.Rules, entity.Rules)
|
||||
|
||||
if contentChanged {
|
||||
if entity.OwnerID != "" {
|
||||
current.OwnerID = entity.OwnerID
|
||||
}
|
||||
if nameChanged || commentChanged || ownerChanged || rulesChanged {
|
||||
return s.applyContentUpdate(ctx, current, entity, sent,
|
||||
nameChanged, commentChanged, ownerChanged, rulesChanged)
|
||||
}
|
||||
return s.applyFlagsOnly(ctx, current, entity, sent)
|
||||
}
|
||||
|
||||
// applyContentUpdate handles updates that change at least one of name/comment/
|
||||
// owner/rules. It goes through updateMetadata, which always bumps updatedAt
|
||||
// (invalidating cached cover-art URLs). namePtr/commentPtr are nil when the
|
||||
// field is absent from the request OR present-but-unchanged (so updateMetadata
|
||||
// skips them); publicPtr is nil only when public is absent from the request
|
||||
// (an idempotent public value is still forwarded).
|
||||
func (s *playlists) applyContentUpdate(ctx context.Context, current, entity *model.Playlist,
|
||||
sent func(string) bool, nameChanged, commentChanged, ownerChanged, rulesChanged bool,
|
||||
) error {
|
||||
if ownerChanged {
|
||||
current.OwnerID = entity.OwnerID
|
||||
}
|
||||
if rulesChanged {
|
||||
current.Rules = entity.Rules
|
||||
if current.Path != "" && current.Sync != entity.Sync {
|
||||
current.Sync = entity.Sync
|
||||
}
|
||||
return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public)
|
||||
}
|
||||
|
||||
// Only sync/public changed — skip updatedAt so cover art URLs stay stable
|
||||
var cols []string
|
||||
if current.Path != "" && current.Sync != entity.Sync {
|
||||
if sent("sync") && current.Path != "" && current.Sync != entity.Sync {
|
||||
current.Sync = entity.Sync
|
||||
cols = append(cols, "sync")
|
||||
}
|
||||
if current.Public != entity.Public {
|
||||
var namePtr, commentPtr *string
|
||||
var publicPtr *bool
|
||||
if nameChanged {
|
||||
namePtr = &entity.Name
|
||||
}
|
||||
if commentChanged {
|
||||
commentPtr = &entity.Comment
|
||||
}
|
||||
if sent("public") {
|
||||
publicPtr = &entity.Public
|
||||
}
|
||||
return s.updateMetadata(ctx, s.ds, current, namePtr, commentPtr, publicPtr)
|
||||
}
|
||||
|
||||
// applyFlagsOnly handles updates that only toggle sync/public — skips
|
||||
// updatedAt so cover art URLs stay stable.
|
||||
func (s *playlists) applyFlagsOnly(ctx context.Context, current, entity *model.Playlist,
|
||||
sent func(string) bool,
|
||||
) error {
|
||||
var updateCols []string
|
||||
if sent("sync") && current.Path != "" && current.Sync != entity.Sync {
|
||||
current.Sync = entity.Sync
|
||||
updateCols = append(updateCols, "sync")
|
||||
}
|
||||
if sent("public") && current.Public != entity.Public {
|
||||
current.Public = entity.Public
|
||||
cols = append(cols, "public")
|
||||
updateCols = append(updateCols, "public")
|
||||
}
|
||||
if len(cols) == 0 {
|
||||
if len(updateCols) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.ds.Playlist(ctx).Put(current, cols...)
|
||||
return s.ds.Playlist(ctx).Put(current, updateCols...)
|
||||
}
|
||||
|
||||
// sentFields returns a predicate that reports whether a JSON field was present
|
||||
// in the request body. Matching is case-insensitive to mirror Go's json
|
||||
// decoder, which populates struct fields from case-variant keys like
|
||||
// {"Name":"x"} or {"OWNERID":"y"}. An empty cols list means "treat the entity
|
||||
// as a full record" — every field is considered sent.
|
||||
func sentFields(cols []string) func(string) bool {
|
||||
if len(cols) == 0 {
|
||||
return func(string) bool { return true }
|
||||
}
|
||||
set := slice.ToMap(cols, func(c string) (string, struct{}) { return strings.ToLower(c), struct{}{} })
|
||||
return func(field string) bool {
|
||||
_, ok := set[strings.ToLower(field)]
|
||||
return ok
|
||||
}
|
||||
}
|
||||
|
||||
func rulesEqual(a, b *criteria.Criteria) bool {
|
||||
|
||||
@ -125,6 +125,25 @@ var _ = Describe("REST Adapter", func() {
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
|
||||
DescribeTable("denies regular user from changing ownership under any case-variant JSON key",
|
||||
func(colName string) {
|
||||
// rest.Put's field-name extraction is case-sensitive, but Go's
|
||||
// json decoder is case-insensitive on struct fields, so any
|
||||
// {"OwnerId":"x"} / {"OWNERID":"x"} / {"ownerid":"x"} populates
|
||||
// entity.OwnerID. sentFields normalizes both sides so the
|
||||
// permission gate fires regardless of casing.
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{OwnerID: "other-user"}
|
||||
err := repo.Update("pls-1", pls, colName)
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
},
|
||||
Entry("canonical camelCase", "ownerId"),
|
||||
Entry("PascalCase", "OwnerId"),
|
||||
Entry("all upper", "OWNERID"),
|
||||
Entry("all lower", "ownerid"),
|
||||
)
|
||||
|
||||
It("updates smart playlist rules", func() {
|
||||
mockPlsRepo.Data["smart-1"] = &model.Playlist{
|
||||
ID: "smart-1",
|
||||
@ -218,6 +237,156 @@ var _ = Describe("REST Adapter", func() {
|
||||
err := repo.Update("nonexistent", pls)
|
||||
Expect(err).To(Equal(rest.ErrNotFound))
|
||||
})
|
||||
|
||||
// Regression tests for #5541: partial REST updates (e.g. bulk "Make Public")
|
||||
// must only touch the fields the client actually sent. The cols list from
|
||||
// rest.Put names those fields; fields outside it must be left alone, even
|
||||
// when the deserialized entity has zero values for them.
|
||||
Context("with partial updates (cols)", func() {
|
||||
BeforeEach(func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
mockPlsRepo.Data["partial"] = &model.Playlist{
|
||||
ID: "partial",
|
||||
Name: "Original Name",
|
||||
Comment: "Original comment",
|
||||
OwnerID: "user-1",
|
||||
Public: false,
|
||||
}
|
||||
})
|
||||
|
||||
It("preserves name and comment when only public is sent (bulk Make Public)", func() {
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Update("partial", &model.Playlist{Public: true}, "public")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("Original Name"))
|
||||
Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment"))
|
||||
Expect(mockPlsRepo.Last.Public).To(BeTrue())
|
||||
})
|
||||
|
||||
It("preserves name when only sync is sent for a file-backed playlist", func() {
|
||||
mockPlsRepo.Data["file-partial"] = &model.Playlist{
|
||||
ID: "file-partial",
|
||||
Name: "Keep Me",
|
||||
OwnerID: "user-1",
|
||||
Path: "/music/p.m3u",
|
||||
Sync: true,
|
||||
}
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Update("file-partial", &model.Playlist{Sync: false}, "sync")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("Keep Me"))
|
||||
Expect(mockPlsRepo.Last.Sync).To(BeFalse())
|
||||
})
|
||||
|
||||
It("renames the playlist when only name is sent", func() {
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Update("partial", &model.Playlist{Name: "Renamed"}, "name")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("Renamed"))
|
||||
Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment"))
|
||||
Expect(mockPlsRepo.Last.Public).To(BeFalse())
|
||||
})
|
||||
|
||||
It("clears the comment when an empty comment is sent explicitly", func() {
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Update("partial", &model.Playlist{Comment: ""}, "comment")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.Comment).To(BeEmpty())
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("Original Name"))
|
||||
})
|
||||
|
||||
It("updates rules-only on a smart playlist (Feishin-style edit)", func() {
|
||||
mockPlsRepo.Data["smart-partial"] = &model.Playlist{
|
||||
ID: "smart-partial",
|
||||
Name: "Smart Original",
|
||||
Comment: "smart comment",
|
||||
OwnerID: "user-1",
|
||||
Public: true,
|
||||
Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}},
|
||||
}
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
newRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Jazz"}, Sort: "year DESC"}
|
||||
err := repo.Update("smart-partial", &model.Playlist{Rules: newRules}, "rules")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.Rules).To(Equal(newRules))
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("Smart Original"))
|
||||
Expect(mockPlsRepo.Last.Comment).To(Equal("smart comment"))
|
||||
Expect(mockPlsRepo.Last.Public).To(BeTrue())
|
||||
})
|
||||
|
||||
It("updates name and rules together (smart-playlist Edit form)", func() {
|
||||
mockPlsRepo.Data["smart-edit"] = &model.Playlist{
|
||||
ID: "smart-edit",
|
||||
Name: "Smart Original",
|
||||
Comment: "smart comment",
|
||||
OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}},
|
||||
}
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
newRules := &criteria.Criteria{Expression: criteria.Is{"artist": "Miles Davis"}, Sort: "album"}
|
||||
err := repo.Update("smart-edit",
|
||||
&model.Playlist{Name: "Smart Renamed", Rules: newRules},
|
||||
"name", "rules")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("Smart Renamed"))
|
||||
Expect(mockPlsRepo.Last.Rules).To(Equal(newRules))
|
||||
Expect(mockPlsRepo.Last.Comment).To(Equal("smart comment"))
|
||||
})
|
||||
|
||||
It("does not bump the saved rules on an idempotent rules-only PUT", func() {
|
||||
rules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}
|
||||
mockPlsRepo.Data["smart-idempotent"] = &model.Playlist{
|
||||
ID: "smart-idempotent",
|
||||
Name: "Smart Idempotent",
|
||||
OwnerID: "user-1",
|
||||
Rules: rules,
|
||||
}
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
// Same rules sent back — rulesEqual should report no change and
|
||||
// the request should no-op (no Put call).
|
||||
sameRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}
|
||||
err := repo.Update("smart-idempotent", &model.Playlist{Rules: sameRules}, "rules")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last).To(BeNil()) // no Put happened
|
||||
})
|
||||
|
||||
It("preserves rules when only public is sent (smart playlist + bulk Make Public)", func() {
|
||||
rules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}
|
||||
mockPlsRepo.Data["smart-public"] = &model.Playlist{
|
||||
ID: "smart-public",
|
||||
Name: "Smart Public",
|
||||
OwnerID: "user-1",
|
||||
Public: false,
|
||||
Rules: rules,
|
||||
}
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Update("smart-public", &model.Playlist{Public: true}, "public")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.Public).To(BeTrue())
|
||||
Expect(mockPlsRepo.Last.Rules).To(Equal(rules))
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("Smart Public"))
|
||||
})
|
||||
|
||||
It("does not treat a missing ownerId as an ownership transfer attempt", func() {
|
||||
// A non-admin user sending only {public:true} should not be blocked
|
||||
// just because OwnerID is the zero value in the deserialized entity.
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Update("partial", &model.Playlist{Public: true}, "public")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("matches cols case-insensitively (mirrors json decoder behavior)", func() {
|
||||
// Go's json decoder populates struct fields from case-variant keys
|
||||
// like {"Name":"x"}, but rest.Put's field-name extraction is
|
||||
// case-sensitive. sentFields normalizes both sides so a request
|
||||
// with {"Name":"Renamed"} is honored, not silently ignored.
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Update("partial", &model.Playlist{Name: "Renamed"}, "Name")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("Renamed"))
|
||||
Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Delete", func() {
|
||||
|
||||
@ -371,7 +371,14 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
|
||||
p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()})
|
||||
}
|
||||
|
||||
if !params.IgnoreScrobble && player.ScrobbleEnabled &&
|
||||
// NowPlaying gating, by design distinct from scrobble submission:
|
||||
// - IgnoreScrobble=true -> still send NowPlaying (suppresses only the
|
||||
// scrobble submission/play-count above), mirroring the legacy scrobble
|
||||
// endpoint's submission=false behavior.
|
||||
// - player.ScrobbleEnabled=false -> never send NowPlaying.
|
||||
// External agents here are the active scrobblers (Last.fm, ListenBrainz, and
|
||||
// scrobbler plugins) returned by getActiveScrobblers; see dispatchNowPlaying.
|
||||
if player.ScrobbleEnabled &&
|
||||
(params.State == StateStarting || params.State == StatePlaying) {
|
||||
if info, err := p.playMap.Get(clientId); err == nil {
|
||||
p.enqueueNowPlaying(ctx, clientId, user.ID, &info.MediaFile, int(params.PositionMs/1000))
|
||||
|
||||
@ -521,6 +521,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
})
|
||||
|
||||
It("does NOT scrobble when ignoreScrobble=true even if threshold met", func() {
|
||||
fake.ScrobbleCalled.Store(false)
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
|
||||
})
|
||||
@ -531,6 +532,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(track.PlayCount).To(Equal(int64(0)))
|
||||
Consistently(func() bool { return fake.ScrobbleCalled.Load() }).Should(BeFalse())
|
||||
})
|
||||
|
||||
It("does NOT scrobble when player ScrobbleEnabled=false even if threshold met", func() {
|
||||
@ -715,14 +717,14 @@ var _ = Describe("PlayTracker", func() {
|
||||
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
|
||||
})
|
||||
|
||||
It("does NOT dispatch when ignoreScrobble=true", func() {
|
||||
It("still dispatches NowPlaying when ignoreScrobble=true", func() {
|
||||
fake.nowPlayingCalled.Store(false)
|
||||
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
|
||||
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
|
||||
IgnoreScrobble: true,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
|
||||
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
|
||||
})
|
||||
|
||||
It("does NOT dispatch when ScrobbleEnabled=false", func() {
|
||||
|
||||
@ -98,7 +98,7 @@ func (r *shareRepositoryWrapper) Save(entity any) (string, error) {
|
||||
s.ExpiresAt = new(time.Now().Add(conf.Server.DefaultShareExpiration))
|
||||
}
|
||||
|
||||
firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0]
|
||||
firstId, _, _ := strings.Cut(s.ResourceIDs, ",")
|
||||
v, err := model.GetEntityByID(r.ctx, r.ds, firstId)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@ -101,7 +101,7 @@ var _ = Describe("Sonic", func() {
|
||||
|
||||
provider := &mockProvider{
|
||||
similarResults: []sonic.SimilarResult{
|
||||
{Song: agents.Song{ID: "song-2", Name: "Similar Song", Artist: "Test Artist"}, Similarity: 0.85},
|
||||
{Song: agents.Song{ID: "song-2", Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}}, Similarity: 0.85},
|
||||
},
|
||||
}
|
||||
loader.names = []string{"test-plugin"}
|
||||
|
||||
@ -13,7 +13,6 @@ import (
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/storage"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -45,16 +44,13 @@ var _ = Describe("LocalStorage", func() {
|
||||
})
|
||||
|
||||
Describe("newLocalStorage", func() {
|
||||
BeforeEach(func() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
|
||||
})
|
||||
|
||||
Context("with valid path", func() {
|
||||
It("should create a localStorage instance with correct path", func() {
|
||||
u, err := url.Parse("file://" + tempDir)
|
||||
u, err := storage.LocalPathToURL(tempDir)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
storage := newLocalStorage(*u)
|
||||
storage := newLocalStorage(u)
|
||||
localStorage := storage.(*localStorage)
|
||||
|
||||
Expect(localStorage.u.Scheme).To(Equal("file"))
|
||||
@ -94,10 +90,10 @@ var _ = Describe("LocalStorage", func() {
|
||||
err = os.Symlink(realDir, linkDir)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
u, err := url.Parse("file://" + linkDir)
|
||||
u, err := storage.LocalPathToURL(linkDir)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
storage := newLocalStorage(*u)
|
||||
storage := newLocalStorage(u)
|
||||
localStorage, ok := storage.(*localStorage)
|
||||
Expect(ok).To(BeTrue())
|
||||
|
||||
@ -110,10 +106,10 @@ var _ = Describe("LocalStorage", func() {
|
||||
// Use a non-existent path to trigger symlink resolution failure
|
||||
nonExistentPath := filepath.Join(tempDir, "non-existent")
|
||||
|
||||
u, err := url.Parse("file://" + nonExistentPath)
|
||||
u, err := storage.LocalPathToURL(nonExistentPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
storage := newLocalStorage(*u)
|
||||
storage := newLocalStorage(u)
|
||||
localStorage, ok := storage.(*localStorage)
|
||||
Expect(ok).To(BeTrue())
|
||||
|
||||
@ -137,7 +133,9 @@ var _ = Describe("LocalStorage", func() {
|
||||
localStorage, ok := storage.(*localStorage)
|
||||
Expect(ok).To(BeTrue())
|
||||
|
||||
Expect(localStorage.u.Path).To(Equal("C:/music"))
|
||||
// newLocalStorage re-joins the drive letter (u.Host) with u.Path via
|
||||
// filepath.Join, which yields an OS-native (backslash) path on Windows.
|
||||
Expect(localStorage.u.Path).To(Equal(filepath.Join("C:", "/music")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -159,10 +157,10 @@ var _ = Describe("LocalStorage", func() {
|
||||
It("falls back to the default extractor instead of crashing", func() {
|
||||
conf.Server.Scanner.Extractor = "nonexistent-extractor"
|
||||
|
||||
u, err := url.Parse("file://" + tempDir)
|
||||
u, err := storage.LocalPathToURL(tempDir)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
storage := newLocalStorage(*u)
|
||||
storage := newLocalStorage(u)
|
||||
ls, ok := storage.(*localStorage)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(ls.extractor).To(BeIdenticalTo(defaultExtractor))
|
||||
@ -171,16 +169,13 @@ var _ = Describe("LocalStorage", func() {
|
||||
})
|
||||
|
||||
Describe("localStorage.FS", func() {
|
||||
BeforeEach(func() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
|
||||
})
|
||||
|
||||
Context("with existing directory", func() {
|
||||
It("should return a localFS instance", func() {
|
||||
u, err := url.Parse("file://" + tempDir)
|
||||
u, err := storage.LocalPathToURL(tempDir)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
storage := newLocalStorage(*u)
|
||||
storage := newLocalStorage(u)
|
||||
musicFS, err := storage.FS()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(musicFS).ToNot(BeNil())
|
||||
@ -193,10 +188,10 @@ var _ = Describe("LocalStorage", func() {
|
||||
Context("with non-existent directory", func() {
|
||||
It("should return an error", func() {
|
||||
nonExistentPath := filepath.Join(tempDir, "non-existent")
|
||||
u, err := url.Parse("file://" + nonExistentPath)
|
||||
u, err := storage.LocalPathToURL(nonExistentPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
storage := newLocalStorage(*u)
|
||||
storage := newLocalStorage(u)
|
||||
_, err = storage.FS()
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring(nonExistentPath))
|
||||
@ -208,7 +203,6 @@ var _ = Describe("LocalStorage", func() {
|
||||
var testFile string
|
||||
|
||||
BeforeEach(func() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
|
||||
// Create a test file
|
||||
testFile = filepath.Join(tempDir, "test.mp3")
|
||||
err := os.WriteFile(testFile, []byte("test data"), 0600)
|
||||
@ -235,9 +229,9 @@ var _ = Describe("LocalStorage", func() {
|
||||
|
||||
testExtractor.results["test.mp3"] = expectedInfo
|
||||
|
||||
u, err := url.Parse("file://" + tempDir)
|
||||
u, err := storage.LocalPathToURL(tempDir)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
storage := newLocalStorage(*u)
|
||||
storage := newLocalStorage(u)
|
||||
musicFS, err := storage.FS()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@ -259,9 +253,9 @@ var _ = Describe("LocalStorage", func() {
|
||||
|
||||
testExtractor.results["test.mp3"] = incompleteInfo
|
||||
|
||||
u, err := url.Parse("file://" + tempDir)
|
||||
u, err := storage.LocalPathToURL(tempDir)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
storage := newLocalStorage(*u)
|
||||
storage := newLocalStorage(u)
|
||||
musicFS, err := storage.FS()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@ -288,9 +282,9 @@ var _ = Describe("LocalStorage", func() {
|
||||
|
||||
testExtractor.results["non-existent.mp3"] = incompleteInfo
|
||||
|
||||
u, err := url.Parse("file://" + tempDir)
|
||||
u, err := storage.LocalPathToURL(tempDir)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
storage := newLocalStorage(*u)
|
||||
storage := newLocalStorage(u)
|
||||
musicFS, err := storage.FS()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@ -303,9 +297,9 @@ var _ = Describe("LocalStorage", func() {
|
||||
It("should return the extractor error", func() {
|
||||
testExtractor.err = &extractorError{message: "extractor failed"}
|
||||
|
||||
u, err := url.Parse("file://" + tempDir)
|
||||
u, err := storage.LocalPathToURL(tempDir)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
storage := newLocalStorage(*u)
|
||||
storage := newLocalStorage(u)
|
||||
musicFS, err := storage.FS()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@ -334,9 +328,9 @@ var _ = Describe("LocalStorage", func() {
|
||||
testExtractor.results["test.mp3"] = info1
|
||||
testExtractor.results["test2.mp3"] = info2
|
||||
|
||||
u, err := url.Parse("file://" + tempDir)
|
||||
u, err := storage.LocalPathToURL(tempDir)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
storage := newLocalStorage(*u)
|
||||
storage := newLocalStorage(u)
|
||||
musicFS, err := storage.FS()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@ -390,9 +384,8 @@ var _ = Describe("LocalStorage", func() {
|
||||
|
||||
Describe("Storage registration", func() {
|
||||
It("should register localStorage for file scheme", func() {
|
||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
|
||||
// This tests the init() function indirectly
|
||||
storage, err := storage.For("file://" + tempDir)
|
||||
storage, err := storage.For(tempDir)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(storage).To(BeAssignableToTypeOf(&localStorage{}))
|
||||
})
|
||||
|
||||
@ -25,6 +25,27 @@ func Register(schema string, c constructor) {
|
||||
registry[schema] = c
|
||||
}
|
||||
|
||||
// LocalPathToURL converts a bare OS filesystem path into an absolute file:// URL,
|
||||
// applying the same slash-normalisation and per-component escaping the scanner
|
||||
// relies on. It is the single source of truth for how a local path becomes a
|
||||
// storage URL, shared by For and by storage tests.
|
||||
func LocalPathToURL(osPath string) (url.URL, error) {
|
||||
abs, _ := filepath.Abs(osPath)
|
||||
abs = filepath.ToSlash(abs)
|
||||
|
||||
// Properly escape each path component using URL standards
|
||||
pathParts := strings.Split(abs, "/")
|
||||
escapedParts := slice.Map(pathParts, func(s string) string {
|
||||
return url.PathEscape(s)
|
||||
})
|
||||
|
||||
u, err := url.Parse(LocalSchemaID + "://" + strings.Join(escapedParts, "/"))
|
||||
if err != nil {
|
||||
return url.URL{}, err
|
||||
}
|
||||
return *u, nil
|
||||
}
|
||||
|
||||
// For returns a Storage implementation for the given URI.
|
||||
// It uses the schema part of the URI to find the correct registered
|
||||
// Storage constructor.
|
||||
@ -34,24 +55,22 @@ func For(uri string) (Storage, error) {
|
||||
defer lock.RUnlock()
|
||||
parts := strings.Split(uri, "://")
|
||||
|
||||
var u *url.URL
|
||||
// Paths without schema are treated as file:// and use the default LocalStorage implementation
|
||||
if len(parts) < 2 {
|
||||
uri, _ = filepath.Abs(uri)
|
||||
uri = filepath.ToSlash(uri)
|
||||
|
||||
// Properly escape each path component using URL standards
|
||||
pathParts := strings.Split(uri, "/")
|
||||
escapedParts := slice.Map(pathParts, func(s string) string {
|
||||
return url.PathEscape(s)
|
||||
})
|
||||
|
||||
uri = LocalSchemaID + "://" + strings.Join(escapedParts, "/")
|
||||
parsed, err := LocalPathToURL(uri)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u = &parsed
|
||||
} else {
|
||||
parsed, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u = parsed
|
||||
}
|
||||
|
||||
u, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, ok := registry[u.Scheme]
|
||||
if !ok {
|
||||
return nil, errors.New("schema '" + u.Scheme + "' not registered")
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
@ -83,6 +84,43 @@ var _ = Describe("Storage", func() {
|
||||
Entry("multiple special chars", "/tmp/Song #1 & More?.mp3"),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("LocalPathToURL", func() {
|
||||
It("builds a file:// URL from an absolute path", func() {
|
||||
u, err := LocalPathToURL("/tmp/music")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(u.Scheme).To(Equal("file"))
|
||||
Expect(u.Path).To(Equal("/tmp/music"))
|
||||
})
|
||||
|
||||
It("escapes special characters and decodes them back in Path", func() {
|
||||
u, err := LocalPathToURL("/tmp/Song #1 & More?.mp3")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(u.Path).To(Equal("/tmp/Song #1 & More?.mp3"))
|
||||
})
|
||||
|
||||
It("produces the same url.URL that For uses for a bare path", func() {
|
||||
registry = map[string]constructor{}
|
||||
Register("file", func(url url.URL) Storage { return &fakeLocalStorage{u: url} })
|
||||
s, err := For("/tmp/music")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
direct, err := LocalPathToURL("/tmp/music")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.(*fakeLocalStorage).u).To(Equal(direct))
|
||||
})
|
||||
|
||||
// On Windows, library paths are drive-lettered (e.g. C:\Music). This
|
||||
// exercises the real conversion to confirm a drive-letter path yields a
|
||||
// usable file:// URL on the actual platform (no-op on Unix).
|
||||
It("handles a Windows drive-letter path", func() {
|
||||
if runtime.GOOS != "windows" {
|
||||
Skip("Windows-specific path handling")
|
||||
}
|
||||
u, err := LocalPathToURL(`C:\Music`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(u.Scheme).To(Equal("file"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type fakeLocalStorage struct {
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
)
|
||||
|
||||
const fallbackBitrate = 256 // kbps
|
||||
@ -142,7 +143,7 @@ func buildSourceStream(mf *model.MediaFile, probe *ffmpeg.AudioProbeResult) Deta
|
||||
sd.Codec = mf.AudioCodec()
|
||||
sd.Bitrate = mf.BitRate
|
||||
sd.SampleRate = mf.SampleRate
|
||||
sd.BitDepth = mf.BitDepth
|
||||
sd.BitDepth = gg.V(mf.BitDepth)
|
||||
sd.Channels = mf.Channels
|
||||
}
|
||||
sd.IsLossless = isLosslessFormat(sd.Codec)
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -23,7 +24,7 @@ func withProbe(mf *model.MediaFile) *model.MediaFile {
|
||||
Codec: mf.AudioCodec(),
|
||||
BitRate: mf.BitRate,
|
||||
SampleRate: mf.SampleRate,
|
||||
BitDepth: mf.BitDepth,
|
||||
BitDepth: gg.V(mf.BitDepth),
|
||||
Channels: mf.Channels,
|
||||
}
|
||||
data, _ := json.Marshal(probe)
|
||||
@ -243,7 +244,7 @@ var _ = Describe("Decider", func() {
|
||||
|
||||
Context("Transcoding", func() {
|
||||
It("selects transcoding when direct play isn't possible", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 256, // kbps
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
@ -278,7 +279,7 @@ var _ = Describe("Decider", func() {
|
||||
})
|
||||
|
||||
It("uses default bitrate when client doesn't specify", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, BitDepth: 16})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, BitDepth: new(16)})
|
||||
ci := &ClientInfo{
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "mp3", Protocol: ProtocolHTTP},
|
||||
@ -331,7 +332,7 @@ var _ = Describe("Decider", func() {
|
||||
})
|
||||
|
||||
It("selects first valid transcoding profile in order", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: new(16)})
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 320,
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
@ -351,7 +352,7 @@ var _ = Describe("Decider", func() {
|
||||
|
||||
Context("Lossless to lossless transcoding", func() {
|
||||
It("allows lossless to lossless when samplerate needs downsampling", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 176400, BitDepth: 1})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 176400, BitDepth: new(1)})
|
||||
ci := &ClientInfo{
|
||||
MaxAudioBitrate: 1000,
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
@ -369,7 +370,7 @@ var _ = Describe("Decider", func() {
|
||||
|
||||
It("sets IsLossless=true on transcoded stream when target is lossless", func() {
|
||||
// Transcoding to mp3 (lossy) should result in IsLossless=false.
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 320,
|
||||
TranscodingProfiles: []Profile{
|
||||
@ -526,7 +527,7 @@ var _ = Describe("Decider", func() {
|
||||
})
|
||||
|
||||
It("rejects direct play due to samplerate limitation", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
|
||||
@ -573,7 +574,7 @@ var _ = Describe("Decider", func() {
|
||||
})
|
||||
|
||||
It("applies channel limitation to transcoded stream", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 48000, BitDepth: 16})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 48000, BitDepth: new(16)})
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 320,
|
||||
TranscodingProfiles: []Profile{
|
||||
@ -596,7 +597,7 @@ var _ = Describe("Decider", func() {
|
||||
})
|
||||
|
||||
It("applies samplerate limitation to transcoded stream", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 320,
|
||||
TranscodingProfiles: []Profile{
|
||||
@ -619,7 +620,7 @@ var _ = Describe("Decider", func() {
|
||||
})
|
||||
|
||||
It("applies bitdepth limitation to transcoded stream", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
|
||||
ci := &ClientInfo{
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
|
||||
@ -642,7 +643,7 @@ var _ = Describe("Decider", func() {
|
||||
})
|
||||
|
||||
It("preserves source bit depth when no limitation applies", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 24})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(24)})
|
||||
ci := &ClientInfo{
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
|
||||
@ -656,7 +657,7 @@ var _ = Describe("Decider", func() {
|
||||
})
|
||||
|
||||
It("rejects transcoding profile when GreaterThanEqual cannot be satisfied", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 320,
|
||||
TranscodingProfiles: []Profile{
|
||||
@ -680,7 +681,7 @@ var _ = Describe("Decider", func() {
|
||||
|
||||
Context("DSD sample rate conversion", func() {
|
||||
It("converts DSD sample rate to PCM-equivalent in decision", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)})
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 320,
|
||||
TranscodingProfiles: []Profile{
|
||||
@ -700,7 +701,7 @@ var _ = Describe("Decider", func() {
|
||||
})
|
||||
|
||||
It("converts DSD sample rate for FLAC target without codec limit", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)})
|
||||
ci := &ClientInfo{
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
|
||||
@ -719,7 +720,7 @@ var _ = Describe("Decider", func() {
|
||||
})
|
||||
|
||||
It("applies codec profile limit to DSD-converted FLAC sample rate", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)})
|
||||
ci := &ClientInfo{
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
|
||||
@ -746,7 +747,7 @@ var _ = Describe("Decider", func() {
|
||||
})
|
||||
|
||||
It("applies audioBitdepth limitation to DSD-converted bit depth", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)})
|
||||
ci := &ClientInfo{
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
|
||||
@ -775,7 +776,7 @@ var _ = Describe("Decider", func() {
|
||||
// Regression test for #5336: ffmpeg's mp3 encoder rejects >2 channels.
|
||||
// The decider must clamp to the codec's hard limit even when no
|
||||
// transcoding profile MaxAudioChannels is configured.
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)})
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 320,
|
||||
TranscodingProfiles: []Profile{
|
||||
@ -791,7 +792,7 @@ var _ = Describe("Decider", func() {
|
||||
})
|
||||
|
||||
It("honors a stricter profile MaxAudioChannels over the codec clamp", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)})
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 320,
|
||||
TranscodingProfiles: []Profile{
|
||||
@ -806,7 +807,7 @@ var _ = Describe("Decider", func() {
|
||||
})
|
||||
|
||||
It("applies the codec clamp when the profile limit is looser", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)})
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 320,
|
||||
TranscodingProfiles: []Profile{
|
||||
@ -821,7 +822,7 @@ var _ = Describe("Decider", func() {
|
||||
})
|
||||
|
||||
It("passes channels through unchanged for codecs with no hard limit", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)})
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 320,
|
||||
TranscodingProfiles: []Profile{
|
||||
@ -840,7 +841,7 @@ var _ = Describe("Decider", func() {
|
||||
Context("Probe-based lossless detection", func() {
|
||||
It("uses probe codec name for lossless detection", func() {
|
||||
// WavPack files: ffprobe reports codec as "wavpack", suffix is ".wv"
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "wv", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "wv", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}
|
||||
probe := ffmpeg.AudioProbeResult{
|
||||
Codec: "wavpack", BitRate: 1000, SampleRate: 44100, BitDepth: 16, Channels: 2,
|
||||
}
|
||||
@ -884,7 +885,7 @@ var _ = Describe("Decider", func() {
|
||||
|
||||
Context("Opus fixed sample rate", func() {
|
||||
It("sets Opus output to 48000Hz regardless of input", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 128,
|
||||
TranscodingProfiles: []Profile{
|
||||
@ -901,7 +902,7 @@ var _ = Describe("Decider", func() {
|
||||
})
|
||||
|
||||
It("sets Opus output to 48000Hz even for 96kHz input", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 128,
|
||||
TranscodingProfiles: []Profile{
|
||||
@ -917,7 +918,7 @@ var _ = Describe("Decider", func() {
|
||||
|
||||
Context("Container vs format separation", func() {
|
||||
It("preserves mp4 container when falling back to aac format", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 256,
|
||||
TranscodingProfiles: []Profile{
|
||||
@ -935,7 +936,7 @@ var _ = Describe("Decider", func() {
|
||||
})
|
||||
|
||||
It("uses container as format when container matches transcoding config", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 256,
|
||||
TranscodingProfiles: []Profile{
|
||||
@ -952,7 +953,7 @@ var _ = Describe("Decider", func() {
|
||||
|
||||
Context("MP3 max sample rate", func() {
|
||||
It("caps sample rate at 48000 for MP3", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 320,
|
||||
TranscodingProfiles: []Profile{
|
||||
@ -966,7 +967,7 @@ var _ = Describe("Decider", func() {
|
||||
})
|
||||
|
||||
It("preserves sample rate at 44100 for MP3", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 320,
|
||||
TranscodingProfiles: []Profile{
|
||||
@ -982,7 +983,7 @@ var _ = Describe("Decider", func() {
|
||||
|
||||
Context("AAC max sample rate", func() {
|
||||
It("caps sample rate at 96000 for AAC", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)})
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 320,
|
||||
TranscodingProfiles: []Profile{
|
||||
@ -1025,7 +1026,7 @@ var _ = Describe("Decider", func() {
|
||||
|
||||
Context("Source stream details", func() {
|
||||
It("populates source stream correctly with kbps bitrate", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24, Duration: 300.5, Size: 50000000})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24), Duration: 300.5, Size: 50000000})
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
|
||||
@ -1058,7 +1059,7 @@ var _ = Describe("Decider", func() {
|
||||
})
|
||||
|
||||
It("ignores player MaxBitRate in context", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
|
||||
ci := &ClientInfo{
|
||||
Name: "TestClient",
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
@ -1074,7 +1075,7 @@ var _ = Describe("Decider", func() {
|
||||
|
||||
Context("Format-aware default bitrate", func() {
|
||||
It("uses opus default bitrate from DB", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: new(16)})
|
||||
ci := &ClientInfo{
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP},
|
||||
@ -1087,7 +1088,7 @@ var _ = Describe("Decider", func() {
|
||||
})
|
||||
|
||||
It("uses aac default bitrate from DB", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
|
||||
ci := &ClientInfo{
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "aac", AudioCodec: "aac", Protocol: ProtocolHTTP},
|
||||
|
||||
@ -12,7 +12,7 @@ import (
|
||||
|
||||
// buildLegacyClientInfo translates legacy Subsonic stream/download parameters
|
||||
// into a ClientInfo for use with MakeDecision.
|
||||
func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int) *ClientInfo {
|
||||
func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int, playerMaxBitRate int) *ClientInfo {
|
||||
ci := &ClientInfo{Name: "legacy"}
|
||||
|
||||
// Determine target format for transcoding
|
||||
@ -22,6 +22,10 @@ func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int
|
||||
targetFormat = reqFormat
|
||||
case reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "":
|
||||
targetFormat = conf.Server.DefaultDownsamplingFormat
|
||||
case playerMaxBitRate > 0 && playerMaxBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "":
|
||||
// Server-side player MaxBitRate alone forces downsampling, even when the
|
||||
// client sent no format/bitrate params (issue #5583, legacy /stream path).
|
||||
targetFormat = conf.Server.DefaultDownsamplingFormat
|
||||
}
|
||||
|
||||
if targetFormat != "" {
|
||||
@ -63,15 +67,19 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile
|
||||
return req
|
||||
}
|
||||
|
||||
clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate)
|
||||
playerMaxBitRate := 0
|
||||
if player, ok := request.PlayerFrom(ctx); ok {
|
||||
playerMaxBitRate = player.MaxBitRate
|
||||
}
|
||||
|
||||
clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate, playerMaxBitRate)
|
||||
|
||||
// Apply server-side player transcoding override before making the decision
|
||||
if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" {
|
||||
clientInfo = applyServerOverride(ctx, clientInfo, &trc)
|
||||
} else if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 {
|
||||
if clientInfo.MaxAudioBitrate == 0 || player.MaxBitRate < clientInfo.MaxAudioBitrate {
|
||||
modified := *clientInfo
|
||||
modified.MaxAudioBitrate = player.MaxBitRate
|
||||
} else if player, ok := request.PlayerFrom(ctx); ok {
|
||||
modified := *clientInfo
|
||||
if modified.CapBitrate(player.MaxBitRate) {
|
||||
clientInfo = &modified
|
||||
log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name)
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@ var _ = Describe("buildLegacyClientInfo", func() {
|
||||
})
|
||||
|
||||
It("sets transcoding profile for explicit format without bitrate", func() {
|
||||
ci := buildLegacyClientInfo(mf, "mp3", 0)
|
||||
ci := buildLegacyClientInfo(mf, "mp3", 0, 0)
|
||||
|
||||
Expect(ci.Name).To(Equal("legacy"))
|
||||
Expect(ci.TranscodingProfiles).To(HaveLen(1))
|
||||
@ -34,7 +34,7 @@ var _ = Describe("buildLegacyClientInfo", func() {
|
||||
})
|
||||
|
||||
It("does not add direct play profile when explicit format differs from source (no bitrate)", func() {
|
||||
ci := buildLegacyClientInfo(mf, "opus", 0)
|
||||
ci := buildLegacyClientInfo(mf, "opus", 0, 0)
|
||||
|
||||
Expect(ci.TranscodingProfiles).To(HaveLen(1))
|
||||
Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus"))
|
||||
@ -42,7 +42,7 @@ var _ = Describe("buildLegacyClientInfo", func() {
|
||||
})
|
||||
|
||||
It("adds direct play profile when explicit format matches source format", func() {
|
||||
ci := buildLegacyClientInfo(mf, "flac", 0)
|
||||
ci := buildLegacyClientInfo(mf, "flac", 0, 0)
|
||||
|
||||
Expect(ci.TranscodingProfiles).To(HaveLen(1))
|
||||
Expect(ci.TranscodingProfiles[0].Container).To(Equal("flac"))
|
||||
@ -52,7 +52,7 @@ var _ = Describe("buildLegacyClientInfo", func() {
|
||||
})
|
||||
|
||||
It("sets transcoding profile and bitrate for explicit format with bitrate", func() {
|
||||
ci := buildLegacyClientInfo(mf, "mp3", 192)
|
||||
ci := buildLegacyClientInfo(mf, "mp3", 192, 0)
|
||||
|
||||
Expect(ci.TranscodingProfiles).To(HaveLen(1))
|
||||
Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3"))
|
||||
@ -63,7 +63,7 @@ var _ = Describe("buildLegacyClientInfo", func() {
|
||||
})
|
||||
|
||||
It("returns direct play profile when no format and no bitrate", func() {
|
||||
ci := buildLegacyClientInfo(mf, "", 0)
|
||||
ci := buildLegacyClientInfo(mf, "", 0, 0)
|
||||
|
||||
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
|
||||
Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
|
||||
@ -77,7 +77,7 @@ var _ = Describe("buildLegacyClientInfo", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultDownsamplingFormat = "opus"
|
||||
|
||||
ci := buildLegacyClientInfo(mf, "", 128)
|
||||
ci := buildLegacyClientInfo(mf, "", 128, 0)
|
||||
|
||||
Expect(ci.TranscodingProfiles).To(HaveLen(1))
|
||||
Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus"))
|
||||
@ -91,7 +91,7 @@ var _ = Describe("buildLegacyClientInfo", func() {
|
||||
})
|
||||
|
||||
It("returns direct play when bitrate >= source bitrate", func() {
|
||||
ci := buildLegacyClientInfo(mf, "", 960)
|
||||
ci := buildLegacyClientInfo(mf, "", 960, 0)
|
||||
|
||||
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
|
||||
Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
|
||||
@ -100,6 +100,51 @@ var _ = Describe("buildLegacyClientInfo", func() {
|
||||
Expect(ci.TranscodingProfiles).To(BeEmpty())
|
||||
Expect(ci.MaxAudioBitrate).To(BeZero())
|
||||
})
|
||||
|
||||
It("uses default downsampling format when player MaxBitRate is below source and no format/bitrate", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultDownsamplingFormat = "opus"
|
||||
|
||||
ci := buildLegacyClientInfo(mf, "", 0, 256)
|
||||
|
||||
Expect(ci.TranscodingProfiles).To(HaveLen(1))
|
||||
Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus"))
|
||||
Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
|
||||
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
|
||||
Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"}))
|
||||
})
|
||||
|
||||
It("does not downsample when player MaxBitRate is >= source bitrate", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultDownsamplingFormat = "opus"
|
||||
|
||||
ci := buildLegacyClientInfo(mf, "", 0, 960)
|
||||
|
||||
Expect(ci.TranscodingProfiles).To(BeEmpty())
|
||||
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
|
||||
Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("does not downsample when DefaultDownsamplingFormat is empty even with player cap", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultDownsamplingFormat = ""
|
||||
|
||||
ci := buildLegacyClientInfo(mf, "", 0, 256)
|
||||
|
||||
Expect(ci.TranscodingProfiles).To(BeEmpty())
|
||||
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
|
||||
Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("prefers explicit request format over player cap", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultDownsamplingFormat = "opus"
|
||||
|
||||
ci := buildLegacyClientInfo(mf, "mp3", 0, 256)
|
||||
|
||||
Expect(ci.TranscodingProfiles).To(HaveLen(1))
|
||||
Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("ResolveRequest", func() {
|
||||
@ -138,7 +183,7 @@ var _ = Describe("ResolveRequest", func() {
|
||||
})
|
||||
|
||||
It("transcodes to requested format", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
|
||||
|
||||
decider := svc.(*deciderService)
|
||||
req := decider.ResolveRequest(ctx, mf, "opus", 0, 0)
|
||||
@ -147,7 +192,7 @@ var _ = Describe("ResolveRequest", func() {
|
||||
})
|
||||
|
||||
It("transcodes to requested format with bitrate limit", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
|
||||
|
||||
decider := svc.(*deciderService)
|
||||
req := decider.ResolveRequest(ctx, mf, "mp3", 128, 0)
|
||||
@ -169,7 +214,7 @@ var _ = Describe("ResolveRequest", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultDownsamplingFormat = "opus"
|
||||
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
|
||||
|
||||
decider := svc.(*deciderService)
|
||||
req := decider.ResolveRequest(ctx, mf, "", 128, 0)
|
||||
@ -179,7 +224,7 @@ var _ = Describe("ResolveRequest", func() {
|
||||
})
|
||||
|
||||
It("passes offset through", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
|
||||
|
||||
decider := svc.(*deciderService)
|
||||
req := decider.ResolveRequest(ctx, mf, "opus", 128, 30)
|
||||
@ -259,7 +304,7 @@ var _ = Describe("ResolveRequest", func() {
|
||||
|
||||
Context("Player MaxBitRate cap", func() {
|
||||
It("applies player MaxBitRate cap when client has no limit", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
|
||||
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320})
|
||||
|
||||
decider := svc.(*deciderService)
|
||||
@ -270,7 +315,7 @@ var _ = Describe("ResolveRequest", func() {
|
||||
})
|
||||
|
||||
It("uses client limit when it is more restrictive than player MaxBitRate", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
|
||||
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500})
|
||||
|
||||
decider := svc.(*deciderService)
|
||||
@ -289,6 +334,33 @@ var _ = Describe("ResolveRequest", func() {
|
||||
|
||||
Expect(req.Format).To(Equal("raw"))
|
||||
})
|
||||
|
||||
It("downsamples using DefaultDownsamplingFormat when only player MaxBitRate is set (no format/bitrate)", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultDownsamplingFormat = "opus"
|
||||
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
|
||||
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 256})
|
||||
|
||||
decider := svc.(*deciderService)
|
||||
req := decider.ResolveRequest(playerCtx, mf, "", 0, 0)
|
||||
|
||||
Expect(req.Format).To(Equal("opus"))
|
||||
Expect(req.BitRate).To(Equal(256))
|
||||
})
|
||||
|
||||
It("serves raw when only player MaxBitRate is set but no DefaultDownsamplingFormat", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultDownsamplingFormat = ""
|
||||
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
|
||||
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 256})
|
||||
|
||||
decider := svc.(*deciderService)
|
||||
req := decider.ResolveRequest(playerCtx, mf, "", 0, 0)
|
||||
|
||||
Expect(req.Format).To(Equal("raw"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("fallback for unknown format", func() {
|
||||
@ -332,7 +404,7 @@ var _ = Describe("ResolveRequest", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultDownsamplingFormat = "opus"
|
||||
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
|
||||
|
||||
decider := svc.(*deciderService)
|
||||
req := decider.ResolveRequest(ctx, mf, "xyz", 128, 0)
|
||||
|
||||
135
core/stream/limiter.go
Normal file
135
core/stream/limiter.go
Normal file
@ -0,0 +1,135 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// ErrTooManyTranscodes is returned by TranscodeLimiter.Acquire when the
|
||||
// configured concurrency cap has been reached. Callers should translate this
|
||||
// into an HTTP 429 response so well-behaved clients back off and retry.
|
||||
var ErrTooManyTranscodes = errors.New("too many concurrent transcodes")
|
||||
|
||||
// RetryAfterSeconds is the value returned in the HTTP Retry-After header when
|
||||
// a request is rejected with ErrTooManyTranscodes. Most transcodes finish well
|
||||
// within this window, so retrying after this delay typically succeeds.
|
||||
const RetryAfterSeconds = 5
|
||||
|
||||
// TranscodeLimiter gates the number of concurrent ffmpeg transcodes. It enforces
|
||||
// both a global cap (to protect the host from process exhaustion) and an optional
|
||||
// per-user cap (to keep one client from starving the others). Acquire never
|
||||
// blocks: it either reserves a slot or returns ErrTooManyTranscodes immediately.
|
||||
type TranscodeLimiter interface {
|
||||
// Acquire reserves a slot for the given user. On success it returns a release
|
||||
// function that must be called exactly once when the transcode is done.
|
||||
// Calling release more than once is safe and idempotent.
|
||||
Acquire(ctx context.Context, user string) (release func(), err error)
|
||||
|
||||
// Enabled reports whether the limiter actually enforces any cap. Callers
|
||||
// can use it to decide whether to bind ffmpeg's lifetime to the request
|
||||
// context so disconnects free slots quickly, rather than letting the
|
||||
// process drain to completion in the background.
|
||||
Enabled() bool
|
||||
}
|
||||
|
||||
// NewTranscodeLimiter returns a limiter enforcing the given caps. Each cap is
|
||||
// independent: a value of zero or less disables that cap. When both caps are
|
||||
// disabled the limiter is a no-op.
|
||||
func NewTranscodeLimiter(maxConcurrent, maxPerUser int) TranscodeLimiter {
|
||||
if maxConcurrent <= 0 && maxPerUser <= 0 {
|
||||
return noopLimiter{}
|
||||
}
|
||||
l := &transcodeLimiter{maxPerUser: maxPerUser}
|
||||
if maxConcurrent > 0 {
|
||||
l.global = make(chan struct{}, maxConcurrent)
|
||||
}
|
||||
if maxPerUser > 0 {
|
||||
l.perUser = make(map[string]int)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// releasingReadCloser wraps an io.ReadCloser so that closing it also releases
|
||||
// the limiter slot exactly once. release must be the function returned by
|
||||
// TranscodeLimiter.Acquire; its own idempotency makes double-Close safe too.
|
||||
type releasingReadCloser struct {
|
||||
io.ReadCloser
|
||||
release func()
|
||||
}
|
||||
|
||||
func (r *releasingReadCloser) Close() error {
|
||||
err := r.ReadCloser.Close()
|
||||
r.release()
|
||||
return err
|
||||
}
|
||||
|
||||
type noopLimiter struct{}
|
||||
|
||||
func (noopLimiter) Acquire(context.Context, string) (func(), error) {
|
||||
return func() {}, nil
|
||||
}
|
||||
|
||||
func (noopLimiter) Enabled() bool { return false }
|
||||
|
||||
type transcodeLimiter struct {
|
||||
maxPerUser int
|
||||
global chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
perUser map[string]int
|
||||
}
|
||||
|
||||
func (*transcodeLimiter) Enabled() bool { return true }
|
||||
|
||||
func (l *transcodeLimiter) Acquire(_ context.Context, user string) (func(), error) {
|
||||
// Reserve a per-user slot first so a noisy user can't burn through
|
||||
// global slots only to be rejected later. An empty user key means
|
||||
// "anonymous" (e.g. public share viewers); we skip the per-user cap
|
||||
// entirely so unrelated anonymous clients do not share a bucket.
|
||||
perUserActive := l.maxPerUser > 0 && user != ""
|
||||
if perUserActive {
|
||||
l.mu.Lock()
|
||||
if l.perUser[user] >= l.maxPerUser {
|
||||
l.mu.Unlock()
|
||||
return nil, ErrTooManyTranscodes
|
||||
}
|
||||
l.perUser[user]++
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
if l.global != nil {
|
||||
select {
|
||||
case l.global <- struct{}{}:
|
||||
default:
|
||||
if perUserActive {
|
||||
l.releasePerUser(user)
|
||||
}
|
||||
return nil, ErrTooManyTranscodes
|
||||
}
|
||||
}
|
||||
|
||||
var released atomic.Bool
|
||||
return func() {
|
||||
if !released.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
if l.global != nil {
|
||||
<-l.global
|
||||
}
|
||||
if perUserActive {
|
||||
l.releasePerUser(user)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (l *transcodeLimiter) releasePerUser(user string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.perUser[user]--
|
||||
if l.perUser[user] <= 0 {
|
||||
delete(l.perUser, user)
|
||||
}
|
||||
}
|
||||
186
core/stream/limiter_test.go
Normal file
186
core/stream/limiter_test.go
Normal file
@ -0,0 +1,186 @@
|
||||
package stream_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("TranscodeLimiter", func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
|
||||
Describe("Disabled (both caps <= 0)", func() {
|
||||
It("never blocks and never returns ErrTooManyTranscodes", func() {
|
||||
lim := stream.NewTranscodeLimiter(0, 0)
|
||||
for range 100 {
|
||||
rel, err := lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(rel).ToNot(BeNil())
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Per-user cap only (no global cap)", func() {
|
||||
It("still enforces the per-user limit when MaxConcurrent is disabled", func() {
|
||||
lim := stream.NewTranscodeLimiter(0, 2)
|
||||
|
||||
rel1, err := lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rel2, err := lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = lim.Acquire(ctx, "alice")
|
||||
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
|
||||
|
||||
// Other users have their own buckets.
|
||||
rel3, err := lim.Acquire(ctx, "bob")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
rel1()
|
||||
_, err = lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
rel2()
|
||||
rel3()
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Global cap", func() {
|
||||
It("rejects requests beyond MaxConcurrent with ErrTooManyTranscodes", func() {
|
||||
lim := stream.NewTranscodeLimiter(2, 0)
|
||||
|
||||
rel1, err := lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rel2, err := lim.Acquire(ctx, "bob")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = lim.Acquire(ctx, "carol")
|
||||
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
|
||||
|
||||
rel1()
|
||||
_, err = lim.Acquire(ctx, "carol")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
rel2()
|
||||
})
|
||||
|
||||
It("releases a slot only once even if release is called multiple times", func() {
|
||||
lim := stream.NewTranscodeLimiter(1, 0)
|
||||
|
||||
rel, err := lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
rel()
|
||||
rel()
|
||||
rel()
|
||||
|
||||
// After releases, exactly one slot should be available.
|
||||
_, err = lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = lim.Acquire(ctx, "alice")
|
||||
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Per-user cap", func() {
|
||||
It("rejects a user beyond MaxConcurrentPerUser even if global slots remain", func() {
|
||||
lim := stream.NewTranscodeLimiter(10, 2)
|
||||
|
||||
rel1, err := lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rel2, err := lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = lim.Acquire(ctx, "alice")
|
||||
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
|
||||
|
||||
// A different user is unaffected.
|
||||
rel3, err := lim.Acquire(ctx, "bob")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
rel1()
|
||||
_, err = lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
rel2()
|
||||
rel3()
|
||||
})
|
||||
|
||||
It("skips the per-user cap for anonymous users (empty key)", func() {
|
||||
// Anonymous requests (e.g. public share viewers) deliberately
|
||||
// bypass the per-user cap so unrelated anonymous clients are not
|
||||
// collapsed into a single shared bucket. The global cap remains
|
||||
// the only ceiling on anonymous traffic.
|
||||
lim := stream.NewTranscodeLimiter(10, 1)
|
||||
|
||||
rels := make([]func(), 0, 5)
|
||||
for range 5 {
|
||||
rel, err := lim.Acquire(ctx, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rels = append(rels, rel)
|
||||
}
|
||||
for _, rel := range rels {
|
||||
rel()
|
||||
}
|
||||
})
|
||||
|
||||
It("still applies the global cap to anonymous users", func() {
|
||||
lim := stream.NewTranscodeLimiter(2, 1)
|
||||
|
||||
rel1, err := lim.Acquire(ctx, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rel2, err := lim.Acquire(ctx, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = lim.Acquire(ctx, "")
|
||||
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
|
||||
|
||||
rel1()
|
||||
rel2()
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Concurrent safety", func() {
|
||||
It("survives parallel Acquire/release with consistent counts", func() {
|
||||
lim := stream.NewTranscodeLimiter(5, 0)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var acquired int64
|
||||
var rejected int64
|
||||
var mu sync.Mutex
|
||||
|
||||
for i := range 50 {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
rel, err := lim.Acquire(ctx, "alice")
|
||||
mu.Lock()
|
||||
if err == nil {
|
||||
acquired++
|
||||
mu.Unlock()
|
||||
rel()
|
||||
} else {
|
||||
rejected++
|
||||
mu.Unlock()
|
||||
}
|
||||
_ = i
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
Expect(acquired + rejected).To(Equal(int64(50)))
|
||||
// After all releases, all 5 slots should be free again.
|
||||
for range 5 {
|
||||
_, err := lim.Acquire(ctx, "alice")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
_, err := lim.Acquire(ctx, "alice")
|
||||
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -2,6 +2,7 @@ package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
@ -28,13 +29,19 @@ type MediaStreamer interface {
|
||||
type TranscodingCache cache.FileCache
|
||||
|
||||
func NewMediaStreamer(ds model.DataStore, t ffmpeg.FFmpeg, cache TranscodingCache) MediaStreamer {
|
||||
return &mediaStreamer{ds: ds, transcoder: t, cache: cache}
|
||||
return &mediaStreamer{
|
||||
ds: ds,
|
||||
transcoder: t,
|
||||
cache: cache,
|
||||
limiter: NewTranscodeLimiter(conf.Server.Transcoding.MaxConcurrent, conf.Server.Transcoding.MaxConcurrentPerUser),
|
||||
}
|
||||
}
|
||||
|
||||
type mediaStreamer struct {
|
||||
ds model.DataStore
|
||||
transcoder ffmpeg.FFmpeg
|
||||
cache cache.FileCache
|
||||
limiter TranscodeLimiter
|
||||
}
|
||||
|
||||
type streamJob struct {
|
||||
@ -104,7 +111,12 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req
|
||||
}
|
||||
r, err := ms.cache.Get(ctx, job)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err)
|
||||
// Rate-limit rejections are already logged at warn level by the
|
||||
// producer; treating them as cache failures here would both
|
||||
// double-log and mask actual cache problems.
|
||||
if !errors.Is(err, ErrTooManyTranscodes) {
|
||||
log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
cached = r.Cached
|
||||
@ -217,15 +229,31 @@ func NewTranscodingCache() TranscodingCache {
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
|
||||
// Choose the appropriate context based on EnableTranscodingCancellation configuration.
|
||||
// This is where we decide whether transcoding processes should be cancellable or not.
|
||||
release, err := job.ms.limiter.Acquire(ctx, limiterKey(ctx))
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Refusing transcode: concurrent transcode limit reached",
|
||||
"id", job.mf.ID, "user", userName(ctx),
|
||||
"maxConcurrent", conf.Server.Transcoding.MaxConcurrent,
|
||||
"maxPerUser", conf.Server.Transcoding.MaxConcurrentPerUser)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Choose the context that drives the ffmpeg process.
|
||||
//
|
||||
// When the limiter is enabled, force the request context so a
|
||||
// client disconnect cancels ffmpeg and frees the slot promptly.
|
||||
// Otherwise a client could open many transcodes, disconnect
|
||||
// immediately, and still leave the configured cap's worth of
|
||||
// ffmpeg processes draining in the background — which is exactly
|
||||
// the DoS the limiter is meant to prevent.
|
||||
//
|
||||
// When the limiter is disabled, preserve the legacy behavior
|
||||
// governed by Transcoding.EnableCancellation so unchanged configs
|
||||
// keep their previous observable behavior.
|
||||
var transcodingCtx context.Context
|
||||
if conf.Server.EnableTranscodingCancellation {
|
||||
// Use the request context directly, allowing cancellation when client disconnects
|
||||
if job.ms.limiter.Enabled() || conf.Server.Transcoding.EnableCancellation {
|
||||
transcodingCtx = ctx
|
||||
} else {
|
||||
// Use background context with request values preserved.
|
||||
// This prevents cancellation but maintains request metadata (user, client, etc.)
|
||||
transcodingCtx = request.AddValues(context.Background(), ctx)
|
||||
}
|
||||
|
||||
@ -240,10 +268,14 @@ func NewTranscodingCache() TranscodingCache {
|
||||
Offset: job.offset,
|
||||
})
|
||||
if err != nil {
|
||||
release()
|
||||
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
return out, nil
|
||||
// Tie the slot to the ffmpeg process: copyAndClose calls Close
|
||||
// on this reader after io.Copy returns, which is exactly when
|
||||
// ffmpeg has exited (either EOF or context cancellation).
|
||||
return &releasingReadCloser{ReadCloser: out, release: release}, nil
|
||||
})
|
||||
}
|
||||
|
||||
@ -255,3 +287,16 @@ func userName(ctx context.Context) string {
|
||||
return user.UserName
|
||||
}
|
||||
}
|
||||
|
||||
// limiterKey returns the per-user bucket key used by the transcode limiter.
|
||||
// For anonymous requests (e.g. public shares) it returns the empty string,
|
||||
// which signals the limiter to skip the per-user cap entirely — otherwise
|
||||
// every anonymous viewer of a public share would collide on the same key
|
||||
// and starve each other within MaxConcurrentPerUser slots. The global cap
|
||||
// still applies and remains the protection against runaway anonymous load.
|
||||
func limiterKey(ctx context.Context) string {
|
||||
if user, ok := request.UserFrom(ctx); ok {
|
||||
return user.UserName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package stream_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
@ -10,6 +11,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"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"
|
||||
@ -61,6 +63,70 @@ var _ = Describe("MediaStreamer", func() {
|
||||
Expect(s.Seekable()).To(BeFalse())
|
||||
Expect(s.Duration()).To(Equal(float32(257.0)))
|
||||
})
|
||||
It("rejects transcode requests beyond MaxConcurrent with ErrTooManyTranscodes", func() {
|
||||
// Use an ffmpeg whose Read blocks indefinitely so the cache's
|
||||
// background copy can't drain the source and release the slot —
|
||||
// keeping the single transcode slot pinned for this test.
|
||||
pr, pw := io.Pipe()
|
||||
DeferCleanup(func() { _ = pw.Close() })
|
||||
blockingFFmpeg := tests.NewMockFFmpeg("")
|
||||
blockingFFmpeg.Reader = pr
|
||||
|
||||
conf.Server.Transcoding.MaxConcurrent = 1
|
||||
conf.Server.Transcoding.MaxConcurrentPerUser = 0
|
||||
tightCache := stream.NewTranscodingCache()
|
||||
Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue())
|
||||
tightStreamer := stream.NewMediaStreamer(ds, blockingFFmpeg, tightCache)
|
||||
|
||||
userCtx := request.WithUsername(ctx, "alice")
|
||||
s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer s1.Close()
|
||||
|
||||
// Different cache key so it doesn't dedupe with the first request.
|
||||
_, err = tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 96})
|
||||
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("releases the slot once the stream is closed", func() {
|
||||
conf.Server.Transcoding.MaxConcurrent = 1
|
||||
conf.Server.Transcoding.MaxConcurrentPerUser = 0
|
||||
tightCache := stream.NewTranscodingCache()
|
||||
Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue())
|
||||
tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache)
|
||||
|
||||
userCtx := request.WithUsername(ctx, "alice")
|
||||
s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, _ = io.ReadAll(s1)
|
||||
_ = s1.Close()
|
||||
Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue())
|
||||
|
||||
// Slot should now be free for a different transcode.
|
||||
s2, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 96})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer s2.Close()
|
||||
})
|
||||
|
||||
It("does not consume a slot for raw streams", func() {
|
||||
conf.Server.Transcoding.MaxConcurrent = 1
|
||||
conf.Server.Transcoding.MaxConcurrentPerUser = 0
|
||||
tightCache := stream.NewTranscodingCache()
|
||||
Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue())
|
||||
tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache)
|
||||
|
||||
userCtx := request.WithUsername(ctx, "alice")
|
||||
// First, saturate the single transcode slot.
|
||||
s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer s1.Close()
|
||||
|
||||
// Raw stream must still succeed.
|
||||
s2, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "raw"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer s2.Close()
|
||||
})
|
||||
|
||||
It("returns a seekable stream if the file is complete in the cache", func() {
|
||||
s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 32})
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
@ -92,17 +92,10 @@ func paramsFromToken(token jwt.Token) (*params, error) {
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// getIntClaim extracts an int claim from a JWT token, handling the case where
|
||||
// the value may be stored as int64 or float64 (common in JSON-based JWT libraries).
|
||||
// getIntClaim extracts a numeric claim from a JWT token. Numeric claims in a
|
||||
// parsed token are always deserialized as float64, regardless of the type used
|
||||
// when encoding.
|
||||
func getIntClaim(token jwt.Token, key string) int {
|
||||
var v int
|
||||
if err := token.Get(key, &v); err == nil {
|
||||
return v
|
||||
}
|
||||
var v64 int64
|
||||
if err := token.Get(key, &v64); err == nil {
|
||||
return int(v64)
|
||||
}
|
||||
var f float64
|
||||
if err := token.Get(key, &f); err == nil {
|
||||
return int(f)
|
||||
|
||||
@ -40,6 +40,50 @@ type ClientInfo struct {
|
||||
CodecProfiles []CodecProfile
|
||||
}
|
||||
|
||||
// CapBitrate lowers the client's declared audio bitrate limits to maxKbps,
|
||||
// never raising them. A zero limit means "unlimited" and is set to maxKbps.
|
||||
// Returns true if anything changed. No-op when maxKbps <= 0.
|
||||
func (ci *ClientInfo) CapBitrate(maxKbps int) bool {
|
||||
if maxKbps <= 0 {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
if ci.MaxAudioBitrate == 0 || maxKbps < ci.MaxAudioBitrate {
|
||||
ci.MaxAudioBitrate = maxKbps
|
||||
changed = true
|
||||
}
|
||||
if ci.MaxTranscodingAudioBitrate == 0 || maxKbps < ci.MaxTranscodingAudioBitrate {
|
||||
ci.MaxTranscodingAudioBitrate = maxKbps
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// ForceFormat narrows the client to transcoding to targetFormat and suppresses
|
||||
// direct play, but only if the client already declares a profile for that
|
||||
// format. All matching profiles are kept so negotiation can still pick among
|
||||
// them (e.g. by protocol). Returns false (no-op) when targetFormat is empty or
|
||||
// unsupported.
|
||||
func (ci *ClientInfo) ForceFormat(targetFormat string) bool {
|
||||
if targetFormat == "" {
|
||||
return false
|
||||
}
|
||||
var matched []Profile
|
||||
for i := range ci.TranscodingProfiles {
|
||||
// matchesContainer is alias-aware, so a forced "oga" (legacy Opus
|
||||
// target_format) still matches a resolved "opus" profile.
|
||||
if _, format := resolveTargetFormat(&ci.TranscodingProfiles[i]); matchesContainer(format, []string{targetFormat}) {
|
||||
matched = append(matched, ci.TranscodingProfiles[i])
|
||||
}
|
||||
}
|
||||
if len(matched) == 0 {
|
||||
return false
|
||||
}
|
||||
ci.TranscodingProfiles = matched
|
||||
ci.DirectPlayProfiles = nil
|
||||
return true
|
||||
}
|
||||
|
||||
// DirectPlayProfile describes a format the client can play directly
|
||||
type DirectPlayProfile struct {
|
||||
Containers []string
|
||||
|
||||
133
core/stream/types_test.go
Normal file
133
core/stream/types_test.go
Normal file
@ -0,0 +1,133 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ClientInfo", func() {
|
||||
Describe("CapBitrate", func() {
|
||||
It("is a no-op when maxKbps is zero", func() {
|
||||
ci := &ClientInfo{MaxAudioBitrate: 320, MaxTranscodingAudioBitrate: 320}
|
||||
Expect(ci.CapBitrate(0)).To(BeFalse())
|
||||
Expect(ci.MaxAudioBitrate).To(Equal(320))
|
||||
Expect(ci.MaxTranscodingAudioBitrate).To(Equal(320))
|
||||
})
|
||||
|
||||
It("is a no-op when maxKbps is negative", func() {
|
||||
ci := &ClientInfo{MaxAudioBitrate: 320, MaxTranscodingAudioBitrate: 320}
|
||||
Expect(ci.CapBitrate(-1)).To(BeFalse())
|
||||
Expect(ci.MaxAudioBitrate).To(Equal(320))
|
||||
Expect(ci.MaxTranscodingAudioBitrate).To(Equal(320))
|
||||
})
|
||||
|
||||
It("sets both limits when both are zero (unlimited)", func() {
|
||||
ci := &ClientInfo{}
|
||||
Expect(ci.CapBitrate(256)).To(BeTrue())
|
||||
Expect(ci.MaxAudioBitrate).To(Equal(256))
|
||||
Expect(ci.MaxTranscodingAudioBitrate).To(Equal(256))
|
||||
})
|
||||
|
||||
It("lowers limits higher than maxKbps", func() {
|
||||
ci := &ClientInfo{MaxAudioBitrate: 320, MaxTranscodingAudioBitrate: 500}
|
||||
Expect(ci.CapBitrate(192)).To(BeTrue())
|
||||
Expect(ci.MaxAudioBitrate).To(Equal(192))
|
||||
Expect(ci.MaxTranscodingAudioBitrate).To(Equal(192))
|
||||
})
|
||||
|
||||
It("does not raise limits lower than maxKbps", func() {
|
||||
ci := &ClientInfo{MaxAudioBitrate: 128, MaxTranscodingAudioBitrate: 96}
|
||||
Expect(ci.CapBitrate(320)).To(BeFalse())
|
||||
Expect(ci.MaxAudioBitrate).To(Equal(128))
|
||||
Expect(ci.MaxTranscodingAudioBitrate).To(Equal(96))
|
||||
})
|
||||
|
||||
It("reports changed when only one limit is lowered", func() {
|
||||
ci := &ClientInfo{MaxAudioBitrate: 320, MaxTranscodingAudioBitrate: 128}
|
||||
Expect(ci.CapBitrate(192)).To(BeTrue())
|
||||
Expect(ci.MaxAudioBitrate).To(Equal(192))
|
||||
Expect(ci.MaxTranscodingAudioBitrate).To(Equal(128))
|
||||
})
|
||||
|
||||
It("caps only the zero (unlimited) limit", func() {
|
||||
ci := &ClientInfo{MaxAudioBitrate: 128, MaxTranscodingAudioBitrate: 0}
|
||||
Expect(ci.CapBitrate(192)).To(BeTrue())
|
||||
Expect(ci.MaxAudioBitrate).To(Equal(128))
|
||||
Expect(ci.MaxTranscodingAudioBitrate).To(Equal(192))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ForceFormat", func() {
|
||||
It("restricts to the forced format and clears direct play when supported", func() {
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}},
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
|
||||
{Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP},
|
||||
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
||||
},
|
||||
}
|
||||
ok := ci.ForceFormat("opus")
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(ci.TranscodingProfiles).To(HaveLen(1))
|
||||
Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
|
||||
Expect(ci.DirectPlayProfiles).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("matches a container-only forced format (mp3)", func() {
|
||||
ci := &ClientInfo{
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP},
|
||||
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
||||
},
|
||||
}
|
||||
ok := ci.ForceFormat("mp3")
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(ci.TranscodingProfiles).To(HaveLen(1))
|
||||
Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3"))
|
||||
})
|
||||
|
||||
It("matches the forced format against codec aliases (oga/opus)", func() {
|
||||
ci := &ClientInfo{
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP},
|
||||
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
||||
},
|
||||
}
|
||||
// Legacy DBs may store the Opus transcoding as target_format "oga".
|
||||
ok := ci.ForceFormat("oga")
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(ci.TranscodingProfiles).To(HaveLen(1))
|
||||
Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
|
||||
})
|
||||
|
||||
It("is a no-op when the forced format is not supported by the client", func() {
|
||||
original := []Profile{{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}}},
|
||||
TranscodingProfiles: original,
|
||||
}
|
||||
ok := ci.ForceFormat("opus")
|
||||
Expect(ok).To(BeFalse())
|
||||
Expect(ci.TranscodingProfiles).To(Equal(original))
|
||||
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("is a no-op for an empty target format", func() {
|
||||
ci := &ClientInfo{TranscodingProfiles: []Profile{{Container: "mp3", AudioCodec: "mp3"}}}
|
||||
Expect(ci.ForceFormat("")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("keeps all matching profiles when multiple resolve to the forced format", func() {
|
||||
first := Profile{Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP}
|
||||
second := Profile{Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP, MaxAudioChannels: 2}
|
||||
other := Profile{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}
|
||||
ci := &ClientInfo{TranscodingProfiles: []Profile{first, other, second}}
|
||||
|
||||
ok := ci.ForceFormat("opus")
|
||||
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(ci.TranscodingProfiles).To(ConsistOf(first, second))
|
||||
})
|
||||
})
|
||||
})
|
||||
1
db/db.go
1
db/db.go
@ -51,7 +51,6 @@ func Db() *sql.DB {
|
||||
_, err = db.Exec("PRAGMA optimize=0x10002")
|
||||
if err != nil {
|
||||
log.Error("Error applying PRAGMA optimize", err)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return db
|
||||
|
||||
@ -12,9 +12,9 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200130083147, Down20200130083147)
|
||||
}
|
||||
|
||||
func Up20200130083147(_ context.Context, tx *sql.Tx) error {
|
||||
func Up20200130083147(ctx context.Context, tx *sql.Tx) error {
|
||||
log.Info("Creating DB Schema")
|
||||
_, err := tx.Exec(`
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table if not exists album
|
||||
(
|
||||
id varchar(255) not null
|
||||
@ -179,6 +179,6 @@ create table if not exists user
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200130083147(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200130083147(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200131183653, Down20200131183653)
|
||||
}
|
||||
|
||||
func Up20200131183653(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20200131183653(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table search_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
@ -37,8 +37,8 @@ update annotation set item_type = 'media_file' where item_type = 'mediaFile';
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200131183653(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Down20200131183653(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table search_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200208222418, Down20200208222418)
|
||||
}
|
||||
|
||||
func Up20200208222418(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20200208222418(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
update annotation set play_count = 0 where play_count is null;
|
||||
update annotation set rating = 0 where rating is null;
|
||||
create table annotation_dg_tmp
|
||||
@ -51,6 +51,6 @@ create index annotation_starred
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200208222418(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200208222418(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,9 +11,9 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200220143731, Down20200220143731)
|
||||
}
|
||||
|
||||
func Up20200220143731(_ context.Context, tx *sql.Tx) error {
|
||||
notice(tx, "This migration will force the next scan to be a full rescan!")
|
||||
_, err := tx.Exec(`
|
||||
func Up20200220143731(ctx context.Context, tx *sql.Tx) error {
|
||||
notice(ctx, tx, "This migration will force the next scan to be a full rescan!")
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table media_file_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
@ -125,6 +125,6 @@ update media_file set updated_at = '0001-01-01';
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200220143731(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200220143731(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,11 +11,11 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200310171621, Down20200310171621)
|
||||
}
|
||||
|
||||
func Up20200310171621(_ context.Context, tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to enable search by Album Artist!")
|
||||
return forceFullRescan(tx)
|
||||
func Up20200310171621(ctx context.Context, tx *sql.Tx) error {
|
||||
notice(ctx, tx, "A full rescan will be performed to enable search by Album Artist!")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func Down20200310171621(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200310171621(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200310181627, Down20200310181627)
|
||||
}
|
||||
|
||||
func Up20200310181627(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20200310181627(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table transcoding
|
||||
(
|
||||
id varchar(255) not null primary key,
|
||||
@ -45,8 +45,8 @@ create table player
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200310181627(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Down20200310181627(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
drop table transcoding;
|
||||
drop table player;
|
||||
`)
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200319211049, Down20200319211049)
|
||||
}
|
||||
|
||||
func Up20200319211049(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20200319211049(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table media_file
|
||||
add full_text varchar(255) default '';
|
||||
create index if not exists media_file_full_text
|
||||
@ -33,10 +33,10 @@ drop table if exists search;
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(tx)
|
||||
notice(ctx, tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func Down20200319211049(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200319211049(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200325185135, Down20200325185135)
|
||||
}
|
||||
|
||||
func Up20200325185135(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20200325185135(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table album
|
||||
add album_artist_id varchar(255) default '';
|
||||
create index album_artist_album_id
|
||||
@ -26,10 +26,10 @@ create index media_file_artist_album_id
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(tx)
|
||||
notice(ctx, tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func Down20200325185135(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200325185135(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,11 +11,11 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200326090707, Down20200326090707)
|
||||
}
|
||||
|
||||
func Up20200326090707(_ context.Context, tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(tx)
|
||||
func Up20200326090707(ctx context.Context, tx *sql.Tx) error {
|
||||
notice(ctx, tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func Down20200326090707(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200326090707(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200327193744, Down20200327193744)
|
||||
}
|
||||
|
||||
func Up20200327193744(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20200327193744(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table album_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
@ -72,10 +72,10 @@ create index album_max_year
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(tx)
|
||||
notice(ctx, tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func Down20200327193744(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200327193744(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200404214704, Down20200404214704)
|
||||
}
|
||||
|
||||
func Up20200404214704(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20200404214704(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create index if not exists media_file_year
|
||||
on media_file (year);
|
||||
|
||||
@ -25,6 +25,6 @@ create index if not exists media_file_track_number
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200404214704(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200404214704(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,11 +11,11 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200409002249, Down20200409002249)
|
||||
}
|
||||
|
||||
func Up20200409002249(_ context.Context, tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to enable search by individual Artist in an Album!")
|
||||
return forceFullRescan(tx)
|
||||
func Up20200409002249(ctx context.Context, tx *sql.Tx) error {
|
||||
notice(ctx, tx, "A full rescan will be performed to enable search by individual Artist in an Album!")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func Down20200409002249(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200409002249(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200411164603, Down20200411164603)
|
||||
}
|
||||
|
||||
func Up20200411164603(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20200411164603(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table playlist
|
||||
add created_at datetime;
|
||||
alter table playlist
|
||||
@ -23,6 +23,6 @@ update playlist
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200411164603(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200411164603(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,11 +11,11 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200418110522, Down20200418110522)
|
||||
}
|
||||
|
||||
func Up20200418110522(_ context.Context, tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to fix search Albums by year")
|
||||
return forceFullRescan(tx)
|
||||
func Up20200418110522(ctx context.Context, tx *sql.Tx) error {
|
||||
notice(ctx, tx, "A full rescan will be performed to fix search Albums by year")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func Down20200418110522(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200418110522(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,11 +11,11 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200419222708, Down20200419222708)
|
||||
}
|
||||
|
||||
func Up20200419222708(_ context.Context, tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to change the search behaviour")
|
||||
return forceFullRescan(tx)
|
||||
func Up20200419222708(ctx context.Context, tx *sql.Tx) error {
|
||||
notice(ctx, tx, "A full rescan will be performed to change the search behaviour")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func Down20200419222708(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200419222708(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200423204116, Down20200423204116)
|
||||
}
|
||||
|
||||
func Up20200423204116(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20200423204116(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table artist
|
||||
add order_artist_name varchar(255) collate nocase;
|
||||
alter table artist
|
||||
@ -57,10 +57,10 @@ create index if not exists media_file_order_artist_name
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed to change the search behaviour")
|
||||
return forceFullRescan(tx)
|
||||
notice(ctx, tx, "A full rescan will be performed to change the search behaviour")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func Down20200423204116(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200423204116(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,18 +11,18 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200508093059, Down20200508093059)
|
||||
}
|
||||
|
||||
func Up20200508093059(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20200508093059(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table artist
|
||||
add song_count integer default 0 not null;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed to calculate artists' song counts")
|
||||
return forceFullRescan(tx)
|
||||
notice(ctx, tx, "A full rescan will be performed to calculate artists' song counts")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func Down20200508093059(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200508093059(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,18 +11,18 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200512104202, Down20200512104202)
|
||||
}
|
||||
|
||||
func Up20200512104202(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20200512104202(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table media_file
|
||||
add disc_subtitle varchar(255);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed to import disc subtitles")
|
||||
return forceFullRescan(tx)
|
||||
notice(ctx, tx, "A full rescan will be performed to import disc subtitles")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func Down20200512104202(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200512104202(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -13,8 +13,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200516140647, Down20200516140647)
|
||||
}
|
||||
|
||||
func Up20200516140647(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20200516140647(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table if not exists playlist_tracks
|
||||
(
|
||||
id integer default 0 not null,
|
||||
@ -28,7 +28,7 @@ create unique index if not exists playlist_tracks_pos
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := tx.Query("select id, tracks from playlist")
|
||||
rows, err := tx.QueryContext(ctx, "select id, tracks from playlist")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -49,7 +49,7 @@ create unique index if not exists playlist_tracks_pos
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
create table playlist_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
@ -96,6 +96,6 @@ func Up20200516140647UpdatePlaylistTracks(tx *sql.Tx, id string, tracks string)
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down20200516140647(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200516140647(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,46 +11,46 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200608153717, Down20200608153717)
|
||||
}
|
||||
|
||||
func Up20200608153717(_ context.Context, tx *sql.Tx) error {
|
||||
func Up20200608153717(ctx context.Context, tx *sql.Tx) error {
|
||||
// First delete dangling players
|
||||
_, err := tx.Exec(`
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
delete from player where user_name not in (select user_name from user)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Also delete dangling players
|
||||
_, err = tx.Exec(`
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
delete from playlist where owner not in (select user_name from user)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Also delete dangling playlist tracks
|
||||
_, err = tx.Exec(`
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
delete from playlist_tracks where playlist_id not in (select id from playlist)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add foreign key to player table
|
||||
err = updatePlayer_20200608153717(tx)
|
||||
err = updatePlayer_20200608153717(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add foreign key to playlist table
|
||||
err = updatePlaylist_20200608153717(tx)
|
||||
err = updatePlaylist_20200608153717(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add foreign keys to playlist_tracks table
|
||||
return updatePlaylistTracks_20200608153717(tx)
|
||||
return updatePlaylistTracks_20200608153717(ctx, tx)
|
||||
}
|
||||
|
||||
func updatePlayer_20200608153717(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func updatePlayer_20200608153717(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table player_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
@ -77,8 +77,8 @@ alter table player_dg_tmp rename to player;
|
||||
return err
|
||||
}
|
||||
|
||||
func updatePlaylist_20200608153717(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func updatePlaylist_20200608153717(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table playlist_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
@ -108,8 +108,8 @@ create index playlist_name
|
||||
return err
|
||||
}
|
||||
|
||||
func updatePlaylistTracks_20200608153717(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func updatePlaylistTracks_20200608153717(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table playlist_tracks_dg_tmp
|
||||
(
|
||||
id integer default 0 not null,
|
||||
@ -133,6 +133,6 @@ create unique index playlist_tracks_pos
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200608153717(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200608153717(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -13,8 +13,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddDefaultTranscodings, downAddDefaultTranscodings)
|
||||
}
|
||||
|
||||
func upAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error {
|
||||
row := tx.QueryRow("SELECT COUNT(*) FROM transcoding")
|
||||
func upAddDefaultTranscodings(ctx context.Context, tx *sql.Tx) error {
|
||||
row := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM transcoding")
|
||||
var count int
|
||||
err := row.Scan(&count)
|
||||
if err != nil {
|
||||
@ -38,6 +38,6 @@ func upAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func downAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddDefaultTranscodings(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddPlaylistPath, downAddPlaylistPath)
|
||||
}
|
||||
|
||||
func upAddPlaylistPath(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddPlaylistPath(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table playlist
|
||||
add path string default '' not null;
|
||||
|
||||
@ -23,6 +23,6 @@ alter table playlist
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddPlaylistPath(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddPlaylistPath(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upCreatePlayQueuesTable, downCreatePlayQueuesTable)
|
||||
}
|
||||
|
||||
func upCreatePlayQueuesTable(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upCreatePlayQueuesTable(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table playqueue
|
||||
(
|
||||
id varchar(255) not null primary key,
|
||||
@ -32,6 +32,6 @@ create table playqueue
|
||||
return err
|
||||
}
|
||||
|
||||
func downCreatePlayQueuesTable(_ context.Context, tx *sql.Tx) error {
|
||||
func downCreatePlayQueuesTable(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upCreateBookmarkTable, downCreateBookmarkTable)
|
||||
}
|
||||
|
||||
func upCreateBookmarkTable(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upCreateBookmarkTable(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table bookmark
|
||||
(
|
||||
user_id varchar(255) not null
|
||||
@ -49,6 +49,6 @@ alter table playqueue_dg_tmp rename to playqueue;
|
||||
return err
|
||||
}
|
||||
|
||||
func downCreateBookmarkTable(_ context.Context, tx *sql.Tx) error {
|
||||
func downCreateBookmarkTable(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upDropEmailUniqueConstraint, downDropEmailUniqueConstraint)
|
||||
}
|
||||
|
||||
func upDropEmailUniqueConstraint(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upDropEmailUniqueConstraint(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table user_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
@ -38,6 +38,6 @@ alter table user_dg_tmp rename to user;
|
||||
return err
|
||||
}
|
||||
|
||||
func downDropEmailUniqueConstraint(_ context.Context, tx *sql.Tx) error {
|
||||
func downDropEmailUniqueConstraint(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,14 +11,14 @@ func init() {
|
||||
goose.AddMigrationContext(Up20201003111749, Down20201003111749)
|
||||
}
|
||||
|
||||
func Up20201003111749(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20201003111749(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create index if not exists annotation_starred_at
|
||||
on annotation (starred_at);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20201003111749(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20201003111749(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20201010162350, Down20201010162350)
|
||||
}
|
||||
|
||||
func Up20201010162350(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20201010162350(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table album
|
||||
add size integer default 0 not null;
|
||||
create index if not exists album_size
|
||||
@ -28,7 +28,7 @@ where id not null;`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20201010162350(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20201010162350(ctx context.Context, tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20201012210022, Down20201012210022)
|
||||
}
|
||||
|
||||
func Up20201012210022(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20201012210022(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table artist
|
||||
add size integer default 0 not null;
|
||||
create index if not exists artist_size
|
||||
@ -40,6 +40,6 @@ update playlist set size = ifnull((
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20201012210022(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20201012210022(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20201021085410, Down20201021085410)
|
||||
}
|
||||
|
||||
func Up20201021085410(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20201021085410(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table media_file
|
||||
add mbz_track_id varchar(255);
|
||||
alter table media_file
|
||||
@ -49,11 +49,11 @@ alter table artist
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan needs to be performed to import more tags")
|
||||
return forceFullRescan(tx)
|
||||
notice(ctx, tx, "A full rescan needs to be performed to import more tags")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func Down20201021085410(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20201021085410(ctx context.Context, tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20201021093209, Down20201021093209)
|
||||
}
|
||||
|
||||
func Up20201021093209(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20201021093209(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create index if not exists media_file_artist
|
||||
on media_file (artist);
|
||||
create index if not exists media_file_album_artist
|
||||
@ -23,6 +23,6 @@ create index if not exists media_file_mbz_track_id
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20201021093209(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20201021093209(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,14 +11,14 @@ func init() {
|
||||
goose.AddMigrationContext(Up20201021135455, Down20201021135455)
|
||||
}
|
||||
|
||||
func Up20201021135455(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20201021135455(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create index if not exists media_file_artist_id
|
||||
on media_file (artist_id);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20201021135455(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20201021135455(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddArtistImageUrl, downAddArtistImageUrl)
|
||||
}
|
||||
|
||||
func upAddArtistImageUrl(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddArtistImageUrl(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table artist
|
||||
add biography varchar(255) default '' not null;
|
||||
alter table artist
|
||||
@ -31,6 +31,6 @@ alter table artist
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddArtistImageUrl(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddArtistImageUrl(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20201110205344, Down20201110205344)
|
||||
}
|
||||
|
||||
func Up20201110205344(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20201110205344(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table media_file
|
||||
add comment varchar;
|
||||
alter table media_file
|
||||
@ -24,10 +24,10 @@ alter table album
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed to import comments and lyrics")
|
||||
return forceFullRescan(tx)
|
||||
notice(ctx, tx, "A full rescan will be performed to import comments and lyrics")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func Down20201110205344(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20201110205344(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,14 +11,14 @@ func init() {
|
||||
goose.AddMigrationContext(Up20201128100726, Down20201128100726)
|
||||
}
|
||||
|
||||
func Up20201128100726(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20201128100726(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table player
|
||||
add report_real_path bool default FALSE not null;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20201128100726(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20201128100726(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -13,8 +13,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20201213124814, Down20201213124814)
|
||||
}
|
||||
|
||||
func Up20201213124814(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20201213124814(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table album
|
||||
add all_artist_ids varchar;
|
||||
|
||||
@ -25,11 +25,11 @@ create index if not exists album_all_artist_ids
|
||||
return err
|
||||
}
|
||||
|
||||
return updateAlbums20201213124814(tx)
|
||||
return updateAlbums20201213124814(ctx, tx)
|
||||
}
|
||||
|
||||
func updateAlbums20201213124814(tx *sql.Tx) error {
|
||||
rows, err := tx.Query(`
|
||||
func updateAlbums20201213124814(ctx context.Context, tx *sql.Tx) error {
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
select a.id, a.name, a.artist_id, a.album_artist_id, group_concat(mf.artist_id, ' ')
|
||||
from album a left join media_file mf on a.id = mf.album_id group by a.id
|
||||
`)
|
||||
@ -59,6 +59,6 @@ select a.id, a.name, a.artist_id, a.album_artist_id, group_concat(mf.artist_id,
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func Down20201213124814(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20201213124814(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddTimestampIndexesGo, downAddTimestampIndexesGo)
|
||||
}
|
||||
|
||||
func upAddTimestampIndexesGo(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddTimestampIndexesGo(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create index if not exists album_updated_at
|
||||
on album (updated_at);
|
||||
create index if not exists album_created_at
|
||||
@ -29,6 +29,6 @@ create index if not exists media_file_updated_at
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddTimestampIndexesGo(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddTimestampIndexesGo(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user