mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
Merge branch 'master' into feat/support-playlist-paths
# Conflicts: # persistence/criteria_sql_test.go
This commit is contained in:
commit
e55719267f
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
|
||||
|
||||
9
Makefile
9
Makefile
@ -113,10 +113,11 @@ wire: check_go_env ##@Development Update Dependency Injection
|
||||
|
||||
gen: check_go_env ##@Development Run go generate for code generation
|
||||
go generate ./...
|
||||
cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host
|
||||
cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -python -rust
|
||||
cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust
|
||||
cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities
|
||||
cd plugins/cmd/ndpgen && go run . -shared-types -input=../../types -output=../../pdk -go -rust
|
||||
cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host -shared=../../types
|
||||
cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -rust -shared=../../types
|
||||
cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust -shared=../../types
|
||||
cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities -shared=../../types
|
||||
go mod tidy -C plugins/pdk/go
|
||||
.PHONY: gen
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
558
cmd/plugin.go
Normal file
558
cmd/plugin.go
Normal file
@ -0,0 +1,558 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/plugins"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// pluginManager is the subset of *plugins.Manager the CLI needs.
|
||||
type pluginManager interface {
|
||||
EnablePlugin(ctx context.Context, id string) error
|
||||
DisablePlugin(ctx context.Context, id string) error
|
||||
ValidatePluginConfig(ctx context.Context, id, configJSON string) error
|
||||
UpdatePluginConfig(ctx context.Context, id, configJSON string) error
|
||||
UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error
|
||||
UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error
|
||||
RescanPlugins(ctx context.Context) error
|
||||
}
|
||||
|
||||
var (
|
||||
pluginListFormat string
|
||||
pluginInfoFormat string
|
||||
)
|
||||
|
||||
var (
|
||||
editConfig string
|
||||
editConfigFile string
|
||||
editUsers string
|
||||
editAllUsers bool
|
||||
editLibraries string
|
||||
editAllLibs bool
|
||||
editWriteAccess bool
|
||||
editNoWrite bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(pluginRoot)
|
||||
|
||||
pluginListCmd.Flags().StringVarP(&pluginListFormat, "format", "f", "table", "output format [supported values: table, csv, json]")
|
||||
pluginRoot.AddCommand(pluginListCmd)
|
||||
pluginRoot.AddCommand(pluginEnableCmd)
|
||||
pluginRoot.AddCommand(pluginDisableCmd)
|
||||
|
||||
pluginEditCmd.Flags().StringVar(&editConfig, "config", "", "plugin config as JSON")
|
||||
pluginEditCmd.Flags().StringVar(&editConfigFile, "config-file", "", "read plugin config JSON from a file ('-' for stdin)")
|
||||
pluginEditCmd.MarkFlagsMutuallyExclusive("config", "config-file")
|
||||
pluginEditCmd.Flags().StringVar(&editUsers, "users", "", `usernames the plugin may access: comma-separated (alice,bob) or a JSON array (["alice","bob"])`)
|
||||
pluginEditCmd.Flags().BoolVar(&editAllUsers, "all-users", false, "grant the plugin access to all users")
|
||||
pluginEditCmd.MarkFlagsMutuallyExclusive("users", "all-users")
|
||||
pluginEditCmd.Flags().StringVar(&editLibraries, "libraries", "", `library IDs the plugin may access: comma-separated (1,2) or a JSON array ([1,2])`)
|
||||
pluginEditCmd.Flags().BoolVar(&editAllLibs, "all-libraries", false, "grant the plugin access to all libraries")
|
||||
pluginEditCmd.MarkFlagsMutuallyExclusive("libraries", "all-libraries")
|
||||
pluginEditCmd.Flags().BoolVar(&editWriteAccess, "write-access", false, "allow the plugin write access to libraries")
|
||||
pluginEditCmd.Flags().BoolVar(&editNoWrite, "no-write-access", false, "deny the plugin write access to libraries")
|
||||
pluginEditCmd.MarkFlagsMutuallyExclusive("write-access", "no-write-access")
|
||||
pluginRoot.AddCommand(pluginEditCmd)
|
||||
|
||||
pluginInfoCmd.Flags().StringVarP(&pluginInfoFormat, "format", "f", "text", "output format [supported values: text, json]")
|
||||
pluginRoot.AddCommand(pluginInfoCmd)
|
||||
pluginRoot.AddCommand(pluginValidateCmd)
|
||||
pluginRoot.AddCommand(pluginRescanCmd)
|
||||
}
|
||||
|
||||
var (
|
||||
pluginRoot = &cobra.Command{
|
||||
Use: "plugin",
|
||||
Short: "Manage and inspect plugins",
|
||||
Long: "List, inspect, enable, disable, configure, rescan, and validate plugins",
|
||||
}
|
||||
|
||||
pluginListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List installed plugins",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runPluginList(cmd.Context())
|
||||
},
|
||||
}
|
||||
|
||||
pluginEnableCmd = &cobra.Command{
|
||||
Use: "enable <id>",
|
||||
Short: "Enable a plugin",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
requirePluginsEnabled(cmd.Context())
|
||||
_, ctx := getAdminContext(cmd.Context())
|
||||
mgr := GetPluginManager(ctx)
|
||||
if err := enablePlugin(ctx, mgr, args[0]); err != nil {
|
||||
log.Fatal(ctx, "Failed to enable plugin", "id", args[0], err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
pluginDisableCmd = &cobra.Command{
|
||||
Use: "disable <id>",
|
||||
Short: "Disable a plugin",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
requirePluginsEnabled(cmd.Context())
|
||||
_, ctx := getAdminContext(cmd.Context())
|
||||
mgr := GetPluginManager(ctx)
|
||||
if err := disablePlugin(ctx, mgr, args[0]); err != nil {
|
||||
log.Fatal(ctx, "Failed to disable plugin", "id", args[0], err)
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
pluginInfoCmd = &cobra.Command{
|
||||
Use: "info <id|file.ndp>",
|
||||
Short: "Show details for an installed plugin or a .ndp package",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runPluginInfo(cmd.Context(), args[0])
|
||||
},
|
||||
}
|
||||
|
||||
pluginValidateCmd = &cobra.Command{
|
||||
Use: "validate <id|file.ndp>",
|
||||
Short: "Validate an installed plugin or a .ndp package manifest",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runPluginValidate(cmd.Context(), args[0])
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// isPackagePath checks only the extension (not existence) so a mistyped path
|
||||
// still routes to ReadManifest, which reports a precise "no such file" error.
|
||||
func isPackagePath(arg string) bool {
|
||||
return strings.HasSuffix(arg, plugins.PackageExtension)
|
||||
}
|
||||
|
||||
func formatPluginInfo(p *model.Plugin, format string) (string, error) {
|
||||
switch format {
|
||||
case "json":
|
||||
b, err := json.MarshalIndent(p, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
case "text":
|
||||
default:
|
||||
return "", fmt.Errorf("invalid output format %q (supported: text, json)", format)
|
||||
}
|
||||
name, version := manifestSummary(*p)
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "ID: %s\n", p.ID)
|
||||
fmt.Fprintf(&sb, "Name: %s\n", name)
|
||||
fmt.Fprintf(&sb, "Version: %s\n", version)
|
||||
fmt.Fprintf(&sb, "Enabled: %t\n", p.Enabled)
|
||||
fmt.Fprintf(&sb, "Path: %s\n", p.Path)
|
||||
fmt.Fprintf(&sb, "SHA256: %s\n", p.SHA256)
|
||||
fmt.Fprintf(&sb, "All users: %t\n", p.AllUsers)
|
||||
fmt.Fprintf(&sb, "All libs: %t\n", p.AllLibraries)
|
||||
fmt.Fprintf(&sb, "Write access: %t\n", p.AllowWriteAccess)
|
||||
if p.Users != "" {
|
||||
fmt.Fprintf(&sb, "Users: %s\n", p.Users)
|
||||
}
|
||||
if p.Libraries != "" {
|
||||
fmt.Fprintf(&sb, "Libraries: %s\n", p.Libraries)
|
||||
}
|
||||
if m, err := plugins.ParseManifest([]byte(p.Manifest)); err == nil {
|
||||
if perms := m.Permissions.DeclaredNames(); len(perms) > 0 {
|
||||
fmt.Fprintf(&sb, "Permissions: %s\n", strings.Join(perms, ", "))
|
||||
}
|
||||
}
|
||||
if !p.CreatedAt.IsZero() {
|
||||
fmt.Fprintf(&sb, "Created: %s\n", p.CreatedAt.Format(time.RFC3339))
|
||||
}
|
||||
if !p.UpdatedAt.IsZero() {
|
||||
fmt.Fprintf(&sb, "Updated: %s\n", p.UpdatedAt.Format(time.RFC3339))
|
||||
}
|
||||
if p.Config != "" {
|
||||
fmt.Fprintf(&sb, "Config: %s\n", p.Config)
|
||||
}
|
||||
if p.LastError != "" {
|
||||
fmt.Fprintf(&sb, "Last error: %s\n", p.LastError)
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func formatManifestInfo(m *plugins.Manifest, sha256, format string) (string, error) {
|
||||
switch format {
|
||||
case "json":
|
||||
b, err := json.MarshalIndent(struct {
|
||||
*plugins.Manifest
|
||||
SHA256 string `json:"sha256"`
|
||||
}{m, sha256}, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
case "text":
|
||||
default:
|
||||
return "", fmt.Errorf("invalid output format %q (supported: text, json)", format)
|
||||
}
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "Name: %s\n", m.Name)
|
||||
fmt.Fprintf(&sb, "Version: %s\n", m.Version)
|
||||
fmt.Fprintf(&sb, "Author: %s\n", m.Author)
|
||||
if m.Description != nil {
|
||||
fmt.Fprintf(&sb, "Description: %s\n", *m.Description)
|
||||
}
|
||||
if m.Website != nil {
|
||||
fmt.Fprintf(&sb, "Website: %s\n", *m.Website)
|
||||
}
|
||||
if perms := m.Permissions.DeclaredNames(); len(perms) > 0 {
|
||||
fmt.Fprintf(&sb, "Permissions: %s\n", strings.Join(perms, ", "))
|
||||
}
|
||||
fmt.Fprintf(&sb, "SHA256: %s\n", sha256)
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func runPluginInfo(ctx context.Context, arg string) {
|
||||
if isPackagePath(arg) {
|
||||
m, err := plugins.ReadManifest(arg)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to read package", "path", arg, err)
|
||||
}
|
||||
sha, err := plugins.ComputeFileSHA256(arg)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to hash package", "path", arg, err)
|
||||
}
|
||||
out, err := formatManifestInfo(m, sha, pluginInfoFormat)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to format output", err)
|
||||
}
|
||||
fmt.Print(out)
|
||||
return
|
||||
}
|
||||
requirePluginsEnabled(ctx)
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
p, err := ds.Plugin(ctx).Get(arg)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
||||
}
|
||||
out, err := formatPluginInfo(p, pluginInfoFormat)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to format output", err)
|
||||
}
|
||||
fmt.Print(out)
|
||||
}
|
||||
|
||||
func runPluginValidate(ctx context.Context, arg string) {
|
||||
if isPackagePath(arg) {
|
||||
if _, err := plugins.ReadManifest(arg); err != nil {
|
||||
log.Fatal(ctx, "Validation failed", "path", arg, err)
|
||||
}
|
||||
fmt.Printf("%s: OK\n", arg)
|
||||
return
|
||||
}
|
||||
requirePluginsEnabled(ctx)
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
p, err := ds.Plugin(ctx).Get(arg)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
||||
}
|
||||
if _, err := plugins.ParseManifest([]byte(p.Manifest)); err != nil {
|
||||
log.Fatal(ctx, "Validation failed", "id", arg, err)
|
||||
}
|
||||
if p.Config != "" {
|
||||
mgr := GetPluginManager(ctx)
|
||||
if err := mgr.ValidatePluginConfig(ctx, arg, p.Config); err != nil {
|
||||
log.Fatal(ctx, "Config validation failed", "id", arg, err)
|
||||
}
|
||||
}
|
||||
fmt.Printf("%s: OK\n", arg)
|
||||
}
|
||||
|
||||
// manifestSummary extracts the display name and version from a stored manifest JSON, falling
|
||||
// back to the plugin ID when the manifest can't be parsed.
|
||||
func manifestSummary(p model.Plugin) (name, version string) {
|
||||
var m struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(p.Manifest), &m); err != nil {
|
||||
return p.ID, ""
|
||||
}
|
||||
return m.Name, m.Version
|
||||
}
|
||||
|
||||
func formatPluginList(list model.Plugins, format string) (string, error) {
|
||||
switch format {
|
||||
case "json":
|
||||
b, err := json.MarshalIndent(list, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
case "csv":
|
||||
var sb strings.Builder
|
||||
w := csv.NewWriter(&sb)
|
||||
_ = w.Write([]string{"id", "name", "version", "enabled", "last error"})
|
||||
for _, p := range list {
|
||||
name, version := manifestSummary(p)
|
||||
_ = w.Write([]string{p.ID, name, version, fmt.Sprintf("%t", p.Enabled), p.LastError})
|
||||
}
|
||||
w.Flush()
|
||||
return sb.String(), w.Error()
|
||||
case "table":
|
||||
var sb strings.Builder
|
||||
w := tabwriter.NewWriter(&sb, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ID\tNAME\tVERSION\tENABLED\tLAST ERROR")
|
||||
for _, p := range list {
|
||||
name, version := manifestSummary(p)
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%t\t%s\n", p.ID, name, version, p.Enabled, p.LastError)
|
||||
}
|
||||
w.Flush()
|
||||
return sb.String(), nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid output format %q (supported: table, csv, json)", format)
|
||||
}
|
||||
}
|
||||
|
||||
func runPluginList(ctx context.Context) {
|
||||
requirePluginsEnabled(ctx)
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
list, err := ds.Plugin(ctx).GetAll()
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to list plugins", err)
|
||||
}
|
||||
out, err := formatPluginList(list, pluginListFormat)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to format output", err)
|
||||
}
|
||||
fmt.Print(out)
|
||||
}
|
||||
|
||||
// requirePluginsEnabled gates DB/manager-backed commands; off-disk .ndp
|
||||
// inspection deliberately skips this so it works without a configured server.
|
||||
func requirePluginsEnabled(ctx context.Context) {
|
||||
if !conf.Server.Plugins.Enabled {
|
||||
log.Fatal(ctx, "Plugin system is disabled (set Plugins.Enabled to use this command)")
|
||||
}
|
||||
}
|
||||
|
||||
func enablePlugin(ctx context.Context, mgr pluginManager, id string) error {
|
||||
return mgr.EnablePlugin(ctx, id)
|
||||
}
|
||||
|
||||
func disablePlugin(ctx context.Context, mgr pluginManager, id string) error {
|
||||
return mgr.DisablePlugin(ctx, id)
|
||||
}
|
||||
|
||||
type pluginEditOptions struct {
|
||||
config *string // nil = leave unchanged
|
||||
users *string
|
||||
allUsers *bool
|
||||
libraries *string
|
||||
allLibraries *bool
|
||||
writeAccess *bool
|
||||
}
|
||||
|
||||
var pluginEditCmd = &cobra.Command{
|
||||
Use: "edit <id>",
|
||||
Short: "Update a plugin's config and/or permissions",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
requirePluginsEnabled(cmd.Context())
|
||||
ds, ctx := getAdminContext(cmd.Context())
|
||||
cur, err := ds.Plugin(ctx).Get(args[0])
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Plugin not found", "id", args[0], err)
|
||||
}
|
||||
mgr := GetPluginManager(ctx)
|
||||
opts := buildEditOptionsFromFlags(ctx, cmd)
|
||||
if err := applyPluginEdit(ctx, mgr, cur, opts); err != nil {
|
||||
log.Fatal(ctx, "Failed to edit plugin", "id", args[0], err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func buildEditOptionsFromFlags(ctx context.Context, cmd *cobra.Command) pluginEditOptions {
|
||||
var opts pluginEditOptions
|
||||
switch {
|
||||
case cmd.Flags().Changed("config"):
|
||||
c := editConfig
|
||||
opts.config = &c
|
||||
case cmd.Flags().Changed("config-file"):
|
||||
c := readConfigFile(ctx, editConfigFile)
|
||||
opts.config = &c
|
||||
}
|
||||
if cmd.Flags().Changed("users") {
|
||||
u := editUsers
|
||||
opts.users = &u
|
||||
}
|
||||
if cmd.Flags().Changed("all-users") {
|
||||
v := editAllUsers
|
||||
opts.allUsers = &v
|
||||
}
|
||||
if cmd.Flags().Changed("libraries") {
|
||||
l := editLibraries
|
||||
opts.libraries = &l
|
||||
}
|
||||
if cmd.Flags().Changed("all-libraries") {
|
||||
v := editAllLibs
|
||||
opts.allLibraries = &v
|
||||
}
|
||||
if cmd.Flags().Changed("write-access") || cmd.Flags().Changed("no-write-access") {
|
||||
// write-access is part of the library-permission group, so it is updated
|
||||
// alongside the (preserved) library list rather than on its own.
|
||||
wa := editWriteAccess && !editNoWrite
|
||||
opts.writeAccess = &wa
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func readConfigFile(ctx context.Context, path string) string {
|
||||
var data []byte
|
||||
var err error
|
||||
if path == "-" {
|
||||
data, err = io.ReadAll(os.Stdin)
|
||||
} else {
|
||||
data, err = os.ReadFile(path)
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to read config file", "path", path, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// applyPluginEdit applies the requested changes on top of the plugin's current
|
||||
// state. Like the native API, it reads the existing users/libraries before
|
||||
// updating so that flipping one flag (e.g. --write-access) does not wipe
|
||||
// unspecified fields, and rejects non-JSON users/libraries values.
|
||||
func applyPluginEdit(ctx context.Context, mgr pluginManager, cur *model.Plugin, opts pluginEditOptions) error {
|
||||
if opts.config == nil && opts.users == nil && opts.allUsers == nil &&
|
||||
opts.libraries == nil && opts.allLibraries == nil && opts.writeAccess == nil {
|
||||
return fmt.Errorf("nothing to update: provide at least one of --config/--users/--libraries/--write-access")
|
||||
}
|
||||
id := cur.ID
|
||||
if opts.config != nil {
|
||||
if err := mgr.ValidatePluginConfig(ctx, id, *opts.config); err != nil {
|
||||
return fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
if err := mgr.UpdatePluginConfig(ctx, id, *opts.config); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if opts.users != nil || opts.allUsers != nil {
|
||||
users, allUsers := cur.Users, cur.AllUsers
|
||||
if opts.users != nil {
|
||||
parsed, err := usersToJSON(*opts.users)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users = parsed
|
||||
allUsers = false // an explicit list means "restrict to these users"
|
||||
}
|
||||
if opts.allUsers != nil {
|
||||
allUsers = *opts.allUsers
|
||||
}
|
||||
if err := mgr.UpdatePluginUsers(ctx, id, users, allUsers); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if opts.libraries != nil || opts.allLibraries != nil || opts.writeAccess != nil {
|
||||
libs, allLibs, writeAccess := cur.Libraries, cur.AllLibraries, cur.AllowWriteAccess
|
||||
if opts.libraries != nil {
|
||||
parsed, err := librariesToJSON(*opts.libraries)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
libs = parsed
|
||||
allLibs = false // an explicit list means "restrict to these libraries"
|
||||
}
|
||||
if opts.allLibraries != nil {
|
||||
allLibs = *opts.allLibraries
|
||||
}
|
||||
if opts.writeAccess != nil {
|
||||
writeAccess = *opts.writeAccess
|
||||
}
|
||||
if err := mgr.UpdatePluginLibraries(ctx, id, libs, allLibs, writeAccess); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// usersToJSON accepts either a JSON array (starts with '[') or a comma-separated
|
||||
// list and returns the JSON-array form the manager stores.
|
||||
func usersToJSON(value string) (string, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "[]", nil
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(value), "[") {
|
||||
if !json.Valid([]byte(value)) {
|
||||
return "", fmt.Errorf("invalid JSON in --users")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
var names []string
|
||||
for _, u := range strings.Split(value, ",") {
|
||||
if u = strings.TrimSpace(u); u != "" {
|
||||
names = append(names, u)
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(names)
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// librariesToJSON accepts either a JSON array (starts with '[') or a
|
||||
// comma-separated list of integer IDs and returns the JSON-array form stored.
|
||||
func librariesToJSON(value string) (string, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "[]", nil
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(value), "[") {
|
||||
if !json.Valid([]byte(value)) {
|
||||
return "", fmt.Errorf("invalid JSON in --libraries")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
ids := []int{}
|
||||
for _, l := range strings.Split(value, ",") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
id, err := strconv.Atoi(l)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid library ID %q: must be an integer", l)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(ids)
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
var pluginRescanCmd = &cobra.Command{
|
||||
Use: "rescan",
|
||||
Short: "Re-discover plugins in the plugins folder",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
requirePluginsEnabled(cmd.Context())
|
||||
_, ctx := getAdminContext(cmd.Context())
|
||||
mgr := GetPluginManager(ctx)
|
||||
if err := rescanPlugins(ctx, mgr); err != nil {
|
||||
log.Fatal(ctx, "Failed to rescan plugins", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func rescanPlugins(ctx context.Context, mgr pluginManager) error {
|
||||
return mgr.RescanPlugins(ctx)
|
||||
}
|
||||
360
cmd/plugin_test.go
Normal file
360
cmd/plugin_test.go
Normal file
@ -0,0 +1,360 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/plugins"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var samplePlugins = model.Plugins{
|
||||
{ID: "alpha", Manifest: `{"name":"Alpha","version":"1.0.0","author":"me"}`, Enabled: true},
|
||||
{ID: "beta", Manifest: `{"name":"Beta","version":"2.1.0","author":"me"}`, Enabled: false, LastError: "boom"},
|
||||
}
|
||||
|
||||
var _ = Describe("plugin command format flags", func() {
|
||||
// Regression: list and info must not share a format variable. Binding the
|
||||
// same var on both commands makes the last init() registration clobber the
|
||||
// other's default, breaking `plugin list` with no -f flag.
|
||||
It("defaults `list -f` to table", func() {
|
||||
Expect(pluginListCmd.Flags().Lookup("format").DefValue).To(Equal("table"))
|
||||
})
|
||||
|
||||
It("defaults `info -f` to text", func() {
|
||||
Expect(pluginInfoCmd.Flags().Lookup("format").DefValue).To(Equal("text"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("formatPluginList", func() {
|
||||
It("renders csv with a header and one row per plugin", func() {
|
||||
out, err := formatPluginList(samplePlugins, "csv")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
Expect(lines).To(HaveLen(3)) // header + 2 rows
|
||||
Expect(lines[0]).To(ContainSubstring("id"))
|
||||
Expect(out).To(ContainSubstring("alpha"))
|
||||
Expect(out).To(ContainSubstring("beta"))
|
||||
})
|
||||
|
||||
It("renders valid json", func() {
|
||||
out, err := formatPluginList(samplePlugins, "json")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
var got []map[string]any
|
||||
Expect(json.Unmarshal([]byte(out), &got)).To(Succeed())
|
||||
Expect(got).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("renders a human table by default", func() {
|
||||
out, err := formatPluginList(samplePlugins, "table")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring("Alpha"))
|
||||
Expect(out).To(ContainSubstring("1.0.0"))
|
||||
})
|
||||
|
||||
It("errors on an unknown format", func() {
|
||||
_, err := formatPluginList(samplePlugins, "yaml")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("enable/disable plugin", func() {
|
||||
It("calls EnablePlugin on the manager", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
err := enablePlugin(context.Background(), mgr, "alpha")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.EnablePluginCalls).To(Equal([]string{"alpha"}))
|
||||
})
|
||||
|
||||
It("calls DisablePlugin on the manager", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
err := disablePlugin(context.Background(), mgr, "beta")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.DisablePluginCalls).To(Equal([]string{"beta"}))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("applyPluginEdit", func() {
|
||||
var cur *model.Plugin
|
||||
BeforeEach(func() {
|
||||
cur = &model.Plugin{ID: "alpha", Users: `["bob"]`, Libraries: `[1,2]`, AllowWriteAccess: true}
|
||||
})
|
||||
|
||||
It("validates then updates config when config is provided", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
cfg := `{"key":"val"}`
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{config: &cfg})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.ValidatePluginConfigCalls).To(HaveLen(1))
|
||||
Expect(mgr.ValidatePluginConfigCalls[0].ConfigJSON).To(Equal(cfg))
|
||||
Expect(mgr.UpdatePluginConfigCalls).To(HaveLen(1))
|
||||
Expect(mgr.UpdatePluginConfigCalls[0].ConfigJSON).To(Equal(cfg))
|
||||
})
|
||||
|
||||
It("updates users with allUsers flag", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
all := true
|
||||
err := applyPluginEdit(context.Background(), mgr, cur,
|
||||
pluginEditOptions{allUsers: &all})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginUsersCalls).To(HaveLen(1))
|
||||
Expect(mgr.UpdatePluginUsersCalls[0].AllUsers).To(BeTrue())
|
||||
})
|
||||
|
||||
It("updates libraries with allLibraries and write access", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
all := true
|
||||
wr := true
|
||||
err := applyPluginEdit(context.Background(), mgr, cur,
|
||||
pluginEditOptions{allLibraries: &all, writeAccess: &wr})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginLibrariesCalls).To(HaveLen(1))
|
||||
Expect(mgr.UpdatePluginLibrariesCalls[0].AllLibraries).To(BeTrue())
|
||||
Expect(mgr.UpdatePluginLibrariesCalls[0].AllowWriteAccess).To(BeTrue())
|
||||
})
|
||||
|
||||
It("preserves existing fields when only the write-access flag changes", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
no := false
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{writeAccess: &no})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginLibrariesCalls).To(HaveLen(1))
|
||||
Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1,2]`)) // not wiped
|
||||
Expect(mgr.UpdatePluginLibrariesCalls[0].AllowWriteAccess).To(BeFalse())
|
||||
})
|
||||
|
||||
It("preserves existing users when only the all-users flag changes", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
all := true
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{allUsers: &all})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["bob"]`)) // not wiped
|
||||
})
|
||||
|
||||
It("parses a comma-separated users value into a JSON array", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
users := "alice, bob"
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice","bob"]`))
|
||||
})
|
||||
|
||||
It("passes a JSON-array users value through unchanged", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
users := `["alice","bob"]`
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice","bob"]`))
|
||||
})
|
||||
|
||||
It("rejects a malformed JSON-array users value", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
users := `["alice"`
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginUsersCalls).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("parses a comma-separated libraries value into a JSON array of ints", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
libs := "1, 2"
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1,2]`))
|
||||
})
|
||||
|
||||
It("rejects a non-integer library ID", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
libs := "1,abc"
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginLibrariesCalls).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("clears allUsers when an explicit users list is set", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
cur.AllUsers = true
|
||||
users := "alice"
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice"]`))
|
||||
Expect(mgr.UpdatePluginUsersCalls[0].AllUsers).To(BeFalse())
|
||||
})
|
||||
|
||||
It("clears allLibraries when an explicit libraries list is set", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
cur.AllLibraries = true
|
||||
libs := "1"
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1]`))
|
||||
Expect(mgr.UpdatePluginLibrariesCalls[0].AllLibraries).To(BeFalse())
|
||||
})
|
||||
|
||||
It("does nothing and errors when no fields are set", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{})
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("aborts the config update when validation fails", func() {
|
||||
mgr := &tests.MockPluginManager{ValidateError: errors.New("bad config")}
|
||||
cfg := `{"key":"val"}`
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{config: &cfg})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(mgr.ValidatePluginConfigCalls).To(HaveLen(1))
|
||||
Expect(mgr.UpdatePluginConfigCalls).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("isPackagePath", func() {
|
||||
It("is true for any .ndp path", func() {
|
||||
Expect(isPackagePath("/some/dir/x.ndp")).To(BeTrue())
|
||||
})
|
||||
It("is true for a non-existent .ndp path (so ReadManifest reports the error)", func() {
|
||||
Expect(isPackagePath("/nope/x.ndp")).To(BeTrue())
|
||||
})
|
||||
It("is false for a bare plugin id", func() {
|
||||
Expect(isPackagePath("my-plugin")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("formatPluginInfo", func() {
|
||||
It("renders installed plugin details as text", func() {
|
||||
p := &model.Plugin{ID: "alpha", Manifest: `{"name":"Alpha","version":"1.0.0","author":"me"}`, Enabled: true}
|
||||
out, err := formatPluginInfo(p, "text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring("alpha"))
|
||||
Expect(out).To(ContainSubstring("Alpha"))
|
||||
})
|
||||
It("renders json", func() {
|
||||
p := &model.Plugin{ID: "alpha", Manifest: `{}`}
|
||||
out, err := formatPluginInfo(p, "json")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring("alpha"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("formatManifestInfo", func() {
|
||||
It("renders text with name, version, author", func() {
|
||||
m := &plugins.Manifest{Name: "My Plugin", Version: "2.0.0", Author: "me"}
|
||||
out, err := formatManifestInfo(m, "abc123", "text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring("My Plugin"))
|
||||
Expect(out).To(ContainSubstring("2.0.0"))
|
||||
Expect(out).To(ContainSubstring("me"))
|
||||
})
|
||||
|
||||
It("omits Description and Website when nil", func() {
|
||||
m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a"}
|
||||
out, err := formatManifestInfo(m, "abc123", "text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).ToNot(ContainSubstring("Description:"))
|
||||
Expect(out).ToNot(ContainSubstring("Website:"))
|
||||
})
|
||||
|
||||
It("includes Description and Website when set", func() {
|
||||
desc := "a cool plugin"
|
||||
site := "https://example.com"
|
||||
m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a", Description: &desc, Website: &site}
|
||||
out, err := formatManifestInfo(m, "abc123", "text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring("a cool plugin"))
|
||||
Expect(out).To(ContainSubstring("https://example.com"))
|
||||
})
|
||||
|
||||
It("renders valid json", func() {
|
||||
m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a"}
|
||||
out, err := formatManifestInfo(m, "abc123", "json")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring("\"name\""))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("formatPluginInfo enriched text", func() {
|
||||
var fixedTime time.Time
|
||||
|
||||
BeforeEach(func() {
|
||||
fixedTime = time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC)
|
||||
})
|
||||
|
||||
It("includes Users, Libraries, Permissions, Created, Updated in text output", func() {
|
||||
p := &model.Plugin{
|
||||
ID: "myplugin",
|
||||
Manifest: `{"name":"My Plugin","version":"1.0.0","author":"me","permissions":{"users":{},"subsonicapi":{}}}`,
|
||||
Enabled: true,
|
||||
Users: "alice,bob",
|
||||
Libraries: "1,2",
|
||||
CreatedAt: fixedTime,
|
||||
UpdatedAt: fixedTime.Add(time.Hour),
|
||||
}
|
||||
out, err := formatPluginInfo(p, "text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring("alice,bob"))
|
||||
Expect(out).To(ContainSubstring("1,2"))
|
||||
Expect(out).To(ContainSubstring("subsonicapi"))
|
||||
Expect(out).To(ContainSubstring("users"))
|
||||
Expect(out).To(ContainSubstring("2025-01-15T12:00:00Z"))
|
||||
})
|
||||
|
||||
It("omits Users and Libraries lines when empty", func() {
|
||||
p := &model.Plugin{
|
||||
ID: "myplugin",
|
||||
Manifest: `{"name":"X","version":"1.0.0","author":"me"}`,
|
||||
CreatedAt: fixedTime,
|
||||
UpdatedAt: fixedTime,
|
||||
}
|
||||
out, err := formatPluginInfo(p, "text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).ToNot(ContainSubstring("Users:"))
|
||||
Expect(out).ToNot(ContainSubstring("Libraries:"))
|
||||
})
|
||||
|
||||
It("does not alter json output", func() {
|
||||
p := &model.Plugin{ID: "x", Manifest: `{}`, Users: "alice"}
|
||||
out, err := formatPluginInfo(p, "json")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring(`"x"`))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("formatManifestInfo enriched text", func() {
|
||||
It("includes Permissions when declared", func() {
|
||||
p := &plugins.Permissions{Http: &plugins.HTTPPermission{}}
|
||||
m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a", Permissions: p}
|
||||
out, err := formatManifestInfo(m, "abc123", "text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring("http"))
|
||||
Expect(out).To(ContainSubstring("Permissions:"))
|
||||
})
|
||||
|
||||
It("omits Permissions line when no permissions declared", func() {
|
||||
m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a"}
|
||||
out, err := formatManifestInfo(m, "abc123", "text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).ToNot(ContainSubstring("Permissions:"))
|
||||
})
|
||||
|
||||
It("does not alter json output", func() {
|
||||
p := &plugins.Permissions{Http: &plugins.HTTPPermission{}}
|
||||
m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a", Permissions: p}
|
||||
out, err := formatManifestInfo(m, "abc123", "json")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring(`"name"`))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("rescanPlugins", func() {
|
||||
It("calls RescanPlugins on the manager", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
err := rescanPlugins(context.Background(), mgr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.RescanPluginsCalls).To(Equal(1))
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
|
||||
@ -162,6 +162,7 @@ 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"
|
||||
}
|
||||
|
||||
@ -776,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)
|
||||
@ -821,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)
|
||||
|
||||
@ -156,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())
|
||||
})
|
||||
})
|
||||
@ -132,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 {
|
||||
@ -169,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)
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
@ -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,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
|
||||
}
|
||||
|
||||
@ -14,10 +14,10 @@ func init() {
|
||||
goose.AddMigrationContext(upFixAlbumComments, downFixAlbumComments)
|
||||
}
|
||||
|
||||
func upFixAlbumComments(_ context.Context, tx *sql.Tx) error {
|
||||
func upFixAlbumComments(ctx context.Context, tx *sql.Tx) error {
|
||||
//nolint:gosec
|
||||
rows, err := tx.Query(`
|
||||
SELECT album.id, group_concat(media_file.comment, '` + consts.Zwsp + `') FROM album, media_file WHERE media_file.album_id = album.id GROUP BY album.id;
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT album.id, group_concat(media_file.comment, '`+consts.Zwsp+`') FROM album, media_file WHERE media_file.album_id = album.id GROUP BY album.id;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -49,7 +49,7 @@ func upFixAlbumComments(_ context.Context, tx *sql.Tx) error {
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func downFixAlbumComments(_ context.Context, tx *sql.Tx) error {
|
||||
func downFixAlbumComments(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddBpmMetadata, downAddBpmMetadata)
|
||||
}
|
||||
|
||||
func upAddBpmMetadata(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddBpmMetadata(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table media_file
|
||||
add bpm integer;
|
||||
|
||||
@ -22,10 +22,10 @@ create index if not exists media_file_bpm
|
||||
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 downAddBpmMetadata(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddBpmMetadata(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upCreateSharesTable, downCreateSharesTable)
|
||||
}
|
||||
|
||||
func upCreateSharesTable(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upCreateSharesTable(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table share
|
||||
(
|
||||
id varchar(255) not null primary key,
|
||||
@ -30,6 +30,6 @@ create table share
|
||||
return err
|
||||
}
|
||||
|
||||
func downCreateSharesTable(_ context.Context, tx *sql.Tx) error {
|
||||
func downCreateSharesTable(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upUpdateShareFieldNames, downUpdateShareFieldNames)
|
||||
}
|
||||
|
||||
func upUpdateShareFieldNames(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upUpdateShareFieldNames(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table share rename column expires to expires_at;
|
||||
alter table share rename column created to created_at;
|
||||
alter table share rename column last_visited to last_visited_at;
|
||||
@ -21,6 +21,6 @@ alter table share rename column last_visited to last_visited_at;
|
||||
return err
|
||||
}
|
||||
|
||||
func downUpdateShareFieldNames(_ context.Context, tx *sql.Tx) error {
|
||||
func downUpdateShareFieldNames(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -16,7 +16,7 @@ func init() {
|
||||
}
|
||||
|
||||
func upEncodeAllPasswords(ctx context.Context, tx *sql.Tx) error {
|
||||
rows, err := tx.Query(`SELECT id, user_name, password from user;`)
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id, user_name, password from user;`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -51,6 +51,6 @@ func upEncodeAllPasswords(ctx context.Context, tx *sql.Tx) error {
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func downEncodeAllPasswords(_ context.Context, tx *sql.Tx) error {
|
||||
func downEncodeAllPasswords(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upDropPlayerNameUniqueConstraint, downDropPlayerNameUniqueConstraint)
|
||||
}
|
||||
|
||||
func upDropPlayerNameUniqueConstraint(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upDropPlayerNameUniqueConstraint(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table player_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
@ -43,6 +43,6 @@ create index if not exists player_name
|
||||
return err
|
||||
}
|
||||
|
||||
func downDropPlayerNameUniqueConstraint(_ context.Context, tx *sql.Tx) error {
|
||||
func downDropPlayerNameUniqueConstraint(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,16 +11,16 @@ func init() {
|
||||
goose.AddMigrationContext(upAddUserPrefsPlayerScrobblerEnabled, downAddUserPrefsPlayerScrobblerEnabled)
|
||||
}
|
||||
|
||||
func upAddUserPrefsPlayerScrobblerEnabled(_ context.Context, tx *sql.Tx) error {
|
||||
err := upAddUserPrefs(tx)
|
||||
func upAddUserPrefsPlayerScrobblerEnabled(ctx context.Context, tx *sql.Tx) error {
|
||||
err := upAddUserPrefs(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return upPlayerScrobblerEnabled(tx)
|
||||
return upPlayerScrobblerEnabled(ctx, tx)
|
||||
}
|
||||
|
||||
func upAddUserPrefs(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddUserPrefs(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table user_props
|
||||
(
|
||||
user_id varchar not null,
|
||||
@ -33,13 +33,13 @@ create table user_props
|
||||
return err
|
||||
}
|
||||
|
||||
func upPlayerScrobblerEnabled(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upPlayerScrobblerEnabled(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table player add scrobble_enabled bool default true;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddUserPrefsPlayerScrobblerEnabled(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddUserPrefsPlayerScrobblerEnabled(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddReferentialIntegrityToUserProps, downAddReferentialIntegrityToUserProps)
|
||||
}
|
||||
|
||||
func upAddReferentialIntegrityToUserProps(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddReferentialIntegrityToUserProps(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table user_props_dg_tmp
|
||||
(
|
||||
user_id varchar not null
|
||||
@ -34,6 +34,6 @@ alter table user_props_dg_tmp rename to user_props;
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddReferentialIntegrityToUserProps(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddReferentialIntegrityToUserProps(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddScrobbleBuffer, downAddScrobbleBuffer)
|
||||
}
|
||||
|
||||
func upAddScrobbleBuffer(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddScrobbleBuffer(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table if not exists scrobble_buffer
|
||||
(
|
||||
user_id varchar not null
|
||||
@ -34,6 +34,6 @@ create table if not exists scrobble_buffer
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddScrobbleBuffer(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddScrobbleBuffer(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,9 +11,9 @@ func init() {
|
||||
goose.AddMigrationContext(upAddGenreTables, downAddGenreTables)
|
||||
}
|
||||
|
||||
func upAddGenreTables(_ context.Context, tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to import multiple genres!")
|
||||
_, err := tx.Exec(`
|
||||
func upAddGenreTables(ctx context.Context, tx *sql.Tx) error {
|
||||
notice(ctx, tx, "A full rescan will be performed to import multiple genres!")
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table if not exists genre
|
||||
(
|
||||
id varchar not null primary key,
|
||||
@ -61,9 +61,9 @@ create table if not exists artist_genres
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return forceFullRescan(tx)
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func downAddGenreTables(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddGenreTables(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddMediafileChannels, downAddMediafileChannels)
|
||||
}
|
||||
|
||||
func upAddMediafileChannels(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddMediafileChannels(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table media_file
|
||||
add channels integer;
|
||||
|
||||
@ -22,10 +22,10 @@ create index if not exists media_file_channels
|
||||
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 downAddMediafileChannels(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddMediafileChannels(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddSmartPlaylist, downAddSmartPlaylist)
|
||||
}
|
||||
|
||||
func upAddSmartPlaylist(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddSmartPlaylist(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table playlist
|
||||
add column rules varchar null;
|
||||
alter table playlist
|
||||
@ -33,6 +33,6 @@ create unique index playlist_fields_idx
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddSmartPlaylist(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddSmartPlaylist(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -14,8 +14,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddOrderTitleToMediaFile, downAddOrderTitleToMediaFile)
|
||||
}
|
||||
|
||||
func upAddOrderTitleToMediaFile(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddOrderTitleToMediaFile(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table main.media_file
|
||||
add order_title varchar null collate NOCASE;
|
||||
create index if not exists media_file_order_title
|
||||
@ -25,12 +25,12 @@ create index if not exists media_file_order_title
|
||||
return err
|
||||
}
|
||||
|
||||
return upAddOrderTitleToMediaFile_populateOrderTitle(tx)
|
||||
return upAddOrderTitleToMediaFile_populateOrderTitle(ctx, tx)
|
||||
}
|
||||
|
||||
//goland:noinspection GoSnakeCaseUsage
|
||||
func upAddOrderTitleToMediaFile_populateOrderTitle(tx *sql.Tx) error {
|
||||
rows, err := tx.Query(`select id, title from media_file`)
|
||||
func upAddOrderTitleToMediaFile_populateOrderTitle(ctx context.Context, tx *sql.Tx) error {
|
||||
rows, err := tx.QueryContext(ctx, `select id, title from media_file`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -57,6 +57,6 @@ func upAddOrderTitleToMediaFile_populateOrderTitle(tx *sql.Tx) error {
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func downAddOrderTitleToMediaFile(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddOrderTitleToMediaFile(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -13,8 +13,8 @@ func init() {
|
||||
goose.AddMigrationContext(upUnescapeLyricsAndComments, downUnescapeLyricsAndComments)
|
||||
}
|
||||
|
||||
func upUnescapeLyricsAndComments(_ context.Context, tx *sql.Tx) error {
|
||||
rows, err := tx.Query(`select id, comment, lyrics, title from media_file`)
|
||||
func upUnescapeLyricsAndComments(ctx context.Context, tx *sql.Tx) error {
|
||||
rows, err := tx.QueryContext(ctx, `select id, comment, lyrics, title from media_file`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -43,6 +43,6 @@ func upUnescapeLyricsAndComments(_ context.Context, tx *sql.Tx) error {
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func downUnescapeLyricsAndComments(_ context.Context, tx *sql.Tx) error {
|
||||
func downUnescapeLyricsAndComments(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddUseridToPlaylist, downAddUseridToPlaylist)
|
||||
}
|
||||
|
||||
func upAddUseridToPlaylist(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddUseridToPlaylist(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table playlist_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
@ -56,6 +56,6 @@ create index playlist_updated_at
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddUseridToPlaylist(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddUseridToPlaylist(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,14 +11,14 @@ func init() {
|
||||
goose.AddMigrationContext(upAddAlphabeticalByArtistIndex, downAddAlphabeticalByArtistIndex)
|
||||
}
|
||||
|
||||
func upAddAlphabeticalByArtistIndex(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddAlphabeticalByArtistIndex(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create index album_alphabetical_by_artist
|
||||
ON album(compilation, order_album_artist_name, order_album_name)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddAlphabeticalByArtistIndex(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddAlphabeticalByArtistIndex(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,13 +11,13 @@ func init() {
|
||||
goose.AddMigrationContext(upRemoveInvalidArtistIds, downRemoveInvalidArtistIds)
|
||||
}
|
||||
|
||||
func upRemoveInvalidArtistIds(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upRemoveInvalidArtistIds(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
update media_file set artist_id = '' where not exists(select 1 from artist where id = artist_id)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downRemoveInvalidArtistIds(_ context.Context, tx *sql.Tx) error {
|
||||
func downRemoveInvalidArtistIds(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,19 +11,19 @@ func init() {
|
||||
goose.AddMigrationContext(upAddMusicbrainzReleaseTrackId, downAddMusicbrainzReleaseTrackId)
|
||||
}
|
||||
|
||||
func upAddMusicbrainzReleaseTrackId(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddMusicbrainzReleaseTrackId(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table media_file
|
||||
add mbz_release_track_id varchar(255);
|
||||
`)
|
||||
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 downAddMusicbrainzReleaseTrackId(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddMusicbrainzReleaseTrackId(ctx context.Context, tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,17 +11,17 @@ func init() {
|
||||
goose.AddMigrationContext(upAddAlbumImagePaths, downAddAlbumImagePaths)
|
||||
}
|
||||
|
||||
func upAddAlbumImagePaths(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddAlbumImagePaths(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table main.album add image_files varchar;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan needs to be performed to import all album images")
|
||||
return forceFullRescan(tx)
|
||||
notice(ctx, tx, "A full rescan needs to be performed to import all album images")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func downAddAlbumImagePaths(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddAlbumImagePaths(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,18 +11,18 @@ func init() {
|
||||
goose.AddMigrationContext(upRemoveCoverArtId, downRemoveCoverArtId)
|
||||
}
|
||||
|
||||
func upRemoveCoverArtId(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upRemoveCoverArtId(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table album drop column cover_art_id;
|
||||
alter table album rename column cover_art_path to embed_art_path
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan needs to be performed to import all album images")
|
||||
return forceFullRescan(tx)
|
||||
notice(ctx, tx, "A full rescan needs to be performed to import all album images")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func downRemoveCoverArtId(_ context.Context, tx *sql.Tx) error {
|
||||
func downRemoveCoverArtId(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -16,15 +16,15 @@ func init() {
|
||||
goose.AddMigrationContext(upAddAlbumPaths, downAddAlbumPaths)
|
||||
}
|
||||
|
||||
func upAddAlbumPaths(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`alter table album add paths varchar;`)
|
||||
func upAddAlbumPaths(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `alter table album add paths varchar;`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
//nolint:gosec
|
||||
rows, err := tx.Query(`
|
||||
select album_id, group_concat(path, '` + consts.Zwsp + `') from media_file group by album_id
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
select album_id, group_concat(path, '`+consts.Zwsp+`') from media_file group by album_id
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -63,6 +63,6 @@ func upAddAlbumPathsDirs(filePaths string) string {
|
||||
return strings.Join(dirs, string(filepath.ListSeparator))
|
||||
}
|
||||
|
||||
func downAddAlbumPaths(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddAlbumPaths(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,11 +11,11 @@ func init() {
|
||||
goose.AddMigrationContext(upTouchPlaylists, downTouchPlaylists)
|
||||
}
|
||||
|
||||
func upTouchPlaylists(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`update playlist set updated_at = datetime('now');`)
|
||||
func upTouchPlaylists(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `update playlist set updated_at = datetime('now');`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downTouchPlaylists(_ context.Context, tx *sql.Tx) error {
|
||||
func downTouchPlaylists(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upCreateInternetRadio, downCreateInternetRadio)
|
||||
}
|
||||
|
||||
func upCreateInternetRadio(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upCreateInternetRadio(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table if not exists radio
|
||||
(
|
||||
id varchar(255) not null primary key,
|
||||
@ -26,6 +26,6 @@ create table if not exists radio
|
||||
return err
|
||||
}
|
||||
|
||||
func downCreateInternetRadio(_ context.Context, tx *sql.Tx) error {
|
||||
func downCreateInternetRadio(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddReplaygainMetadata, downAddReplaygainMetadata)
|
||||
}
|
||||
|
||||
func upAddReplaygainMetadata(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddReplaygainMetadata(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table media_file add
|
||||
rg_album_gain real;
|
||||
alter table media_file add
|
||||
@ -26,10 +26,10 @@ alter table media_file add
|
||||
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 downAddReplaygainMetadata(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddReplaygainMetadata(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddAlbumInfo, downAddAlbumInfo)
|
||||
}
|
||||
|
||||
func upAddAlbumInfo(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddAlbumInfo(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table album
|
||||
add description varchar(255) default '' not null;
|
||||
alter table album
|
||||
@ -29,6 +29,6 @@ alter table album
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddAlbumInfo(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddAlbumInfo(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddMissingShareInfo, downAddMissingShareInfo)
|
||||
}
|
||||
|
||||
func upAddMissingShareInfo(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddMissingShareInfo(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
drop table if exists share;
|
||||
create table share
|
||||
(
|
||||
@ -37,6 +37,6 @@ create table share
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddMissingShareInfo(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddMissingShareInfo(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -16,10 +16,10 @@ func init() {
|
||||
goose.AddMigrationContext(upChangePathListSeparator, downChangePathListSeparator)
|
||||
}
|
||||
|
||||
func upChangePathListSeparator(_ context.Context, tx *sql.Tx) error {
|
||||
func upChangePathListSeparator(ctx context.Context, tx *sql.Tx) error {
|
||||
//nolint:gosec
|
||||
rows, err := tx.Query(`
|
||||
select album_id, group_concat(path, '` + consts.Zwsp + `') from media_file group by album_id
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
select album_id, group_concat(path, '`+consts.Zwsp+`') from media_file group by album_id
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -58,6 +58,6 @@ func upChangePathListSeparatorDirs(filePaths string) string {
|
||||
return strings.Join(dirs, consts.Zwsp)
|
||||
}
|
||||
|
||||
func downChangePathListSeparator(_ context.Context, tx *sql.Tx) error {
|
||||
func downChangePathListSeparator(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -16,8 +16,8 @@ func init() {
|
||||
goose.AddMigrationContext(upChangeImageFilesListSeparator, downChangeImageFilesListSeparator)
|
||||
}
|
||||
|
||||
func upChangeImageFilesListSeparator(_ context.Context, tx *sql.Tx) error {
|
||||
rows, err := tx.Query(`select id, image_files from album`)
|
||||
func upChangeImageFilesListSeparator(ctx context.Context, tx *sql.Tx) error {
|
||||
rows, err := tx.QueryContext(ctx, `select id, image_files from album`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -54,7 +54,7 @@ func upChangeImageFilesListSeparatorDirs(filePaths string) string {
|
||||
return strings.Join(allPaths, consts.Zwsp)
|
||||
}
|
||||
|
||||
func downChangeImageFilesListSeparator(_ context.Context, tx *sql.Tx) error {
|
||||
func downChangeImageFilesListSeparator(ctx context.Context, tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
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