diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 6f858a5a7..8d3cd3ad0 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -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 diff --git a/.github/workflows/push-translations.yml b/.github/workflows/push-translations.yml index f7cf00621..546c64114 100644 --- a/.github/workflows/push-translations.yml +++ b/.github/workflows/push-translations.yml @@ -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 diff --git a/.github/workflows/update-translations.yml b/.github/workflows/update-translations.yml index 8fe0b5379..5db32f3fa 100644 --- a/.github/workflows/update-translations.yml +++ b/.github/workflows/update-translations.yml @@ -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: diff --git a/.golangci.yml b/.golangci.yml index 28eb375a5..200fe122f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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 diff --git a/README.md b/README.md index 0ae5bdfaf..4bc85e6a6 100644 --- a/README.md +++ b/README.md @@ -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** diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 0939eef4d..d6ffc44d4 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -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) diff --git a/conf/configuration.go b/conf/configuration.go index 08f12fc94..665a7992f 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -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) diff --git a/consts/consts.go b/consts/consts.go index edd8f2b54..3795b590a 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -14,6 +14,9 @@ const ( DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on&synchronous=normal" InitialSetupFlagKey = "InitialSetup" FullScanAfterMigrationFlagKey = "FullScanAfterMigration" + // PlaylistsImportPendingFlagKey marks that playlist import was deferred because + // no admin user existed yet; the next scan with an admin imports them. + PlaylistsImportPendingFlagKey = "PlaylistsImportPending" LastScanErrorKey = "LastScanError" LastScanTypeKey = "LastScanType" LastScanStartTimeKey = "LastScanStartTime" @@ -153,25 +156,25 @@ var ( Name: "mp3 audio", TargetFormat: "mp3", DefaultBitRate: 192, - Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -f mp3 -", }, { Name: "opus audio", TargetFormat: "opus", DefaultBitRate: 128, - Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", }, { Name: "aac audio", TargetFormat: "aac", DefaultBitRate: 256, - Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a aac -f adts -", }, { Name: "flac audio", TargetFormat: "flac", DefaultBitRate: 0, - Command: "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -v 0 -c:a flac -f flac -", }, } ) diff --git a/core/artwork/reader_resized.go b/core/artwork/reader_resized.go index 08f42f130..cd16cbada 100644 --- a/core/artwork/reader_resized.go +++ b/core/artwork/reader_resized.go @@ -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) diff --git a/core/external/provider_similarsongs_test.go b/core/external/provider_similarsongs_test.go index c9a1a64ef..f109bf8b1 100644 --- a/core/external/provider_similarsongs_test.go +++ b/core/external/provider_similarsongs_test.go @@ -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() @@ -68,13 +76,16 @@ var _ = Describe("Provider - SimilarSongs", func() { {Name: "Dreaming of Me", MBID: "", Artist: "Depeche Mode", ArtistMBID: "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) diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go index 0d9b5800d..600524819 100644 --- a/core/external/provider_topsongs_test.go +++ b/core/external/provider_topsongs_test.go @@ -148,6 +148,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 +161,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 +197,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 +207,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 +234,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 +248,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) diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index 58e9fd152..3d4cd0e72 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -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) } diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 2e2895738..9c20e6c05 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -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", diff --git a/core/library.go b/core/library.go index 0bf3be9fa..365dcbd4c 100644 --- a/core/library.go +++ b/core/library.go @@ -253,7 +253,11 @@ func (r *libraryRepositoryWrapper) Delete(id string) error { return r.mapError(err) } - err = r.LibraryRepository.Delete(libID) + // Run the deletion in a transaction so the cascade delete and the orphaned-artist + // reconciliation it triggers (see libraryRepository.Delete) commit atomically. + err = r.ds.WithTx(func(tx model.DataStore) error { + return tx.Library(r.ctx).Delete(libID) + }, "delete library") if err != nil { return r.mapError(err) } diff --git a/core/lyrics/lyrics.go b/core/lyrics/lyrics.go index 758053042..b9fb8cb74 100644 --- a/core/lyrics/lyrics.go +++ b/core/lyrics/lyrics.go @@ -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) + } +} diff --git a/core/lyrics/lyrics_suite_test.go b/core/lyrics/lyrics_suite_test.go index f87381905..c9fdcbae8 100644 --- a/core/lyrics/lyrics_suite_test.go +++ b/core/lyrics/lyrics_suite_test.go @@ -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" } diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go index 9ab732ad1..6baacbe71 100644 --- a/core/lyrics/lyrics_test.go +++ b/core/lyrics/lyrics_test.go @@ -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 } diff --git a/core/lyrics/sources.go b/core/lyrics/sources.go index 82a10ca41..9de2f6a18 100644 --- a/core/lyrics/sources.go +++ b/core/lyrics/sources.go @@ -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. diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go index d1aefcb5d..68f45424e 100644 --- a/core/lyrics/sources_test.go +++ b/core/lyrics/sources_test.go @@ -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 , 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")) + }) }) }) diff --git a/core/matcher/doc.go b/core/matcher/doc.go new file mode 100644 index 000000000..cff866799 --- /dev/null +++ b/core/matcher/doc.go @@ -0,0 +1,108 @@ +// 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. Preferred-track flag (enabled by conf.Server.Matcher.PreferStarred; +// prioritizes tracks that are starred or rated >= 4) +// 4. Specificity level (0–5, based on metadata precision; higher is better) +// 5. Album similarity (Jaro-Winkler, as the final tiebreaker) +// +// The specificity levels, from most to least specific, are: +// +// Level 5: Title + Artist MBID + Album MBID +// 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 +// +// 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. +// +// 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 even over a more specific match, +// because the preferred flag outranks specificity: +// +// 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: starred (the preferred flag outranks the better album match) +package matcher diff --git a/core/matcher/matcher.go b/core/matcher/matcher.go index 54c2a368e..5322b649f 100644 --- a/core/matcher/matcher.go +++ b/core/matcher/matcher.go @@ -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,45 +141,72 @@ 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 } // songQuery represents a normalized query for matching a song to library tracks. @@ -308,96 +246,234 @@ func (s matchScore) betterThan(other matchScore) bool { } // 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 + artistMBID string // resolved from the artist table; mf.MbzArtistID is not populated on the bulk path } -func newSanitizedTrack(mf *model.MediaFile) sanitizedTrack { +func newSanitizedTrack(mf *model.MediaFile, artistMBID string) 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), + artistMBID: artistMBID, } } // computeSpecificityLevel determines how well query metadata matches a track (0-5). -// The track's title, artist, and album fields must be pre-sanitized. +// The track's title, artist, and album fields must be pre-sanitized, and artistMBID +// must hold the resolved artist MBID. func computeSpecificityLevel(q songQuery, t sanitizedTrack, albumThreshold float64) int { if q.artistMBID != "" && q.albumMBID != "" && - t.mf.MbzArtistID == q.artistMBID && t.mf.MbzAlbumID == q.albumMBID { + t.artistMBID == 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 { + t.artistMBID == 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 { + if q.artistMBID != "" && t.artistMBID == q.artistMBID { return 2 } if q.artist != "" && t.artist == q.artist { return 1 } - if t.title == q.title { - return 0 - } - return -1 + return 0 } -// 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 +// 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 { + byArtist := groupQueriesByArtist(songs, result) + if len(byArtist) == 0 { + return nil } + resolved, err := m.resolveArtists(ctx, byArtist) + 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 - - byArtist := map[string][]songQuery{} - for _, q := range queries { - if q.artist != "" { - byArtist[q.artist] = append(byArtist[q.artist], q) - } - } - - 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 { - continue - } - - sanitized := make([]sanitizedTrack, len(tracks)) - for i := range tracks { - sanitized[i] = newSanitizedTrack(&tracks[i]) - } - - 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 - } + for artist, queries := range byArtist { + sanitized := tracksByQuery[artist] + // Each song is matched independently by index, so two songs with the same + // (title, artist) but different durations can resolve to different tracks. + for _, iq := range queries { + if mf, found := m.findBestMatch(iq.query, sanitized, threshold); found { + result[iq.index] = mf } } } - return matches, nil + return nil +} + +// groupQueriesByArtist buckets the still-unmatched title queries by sanitized artist name. +// Songs without an artist are skipped: title matching needs one to scope the library query. +func groupQueriesByArtist(songs []agents.Song, result map[int]model.MediaFile) map[string][]indexedQuery { + byArtist := map[string][]indexedQuery{} + for i, s := range songs { + if _, done := result[i]; done { + continue + } + artist := str.SanitizeFieldForSortingNoArticle(s.Artist) + if artist == "" { + continue + } + byArtist[artist] = append(byArtist[artist], indexedQuery{index: i, query: songQuery{ + title: str.SanitizeFieldForSorting(s.Name), + artist: artist, + artistMBID: s.ArtistMBID, + album: str.SanitizeFieldForSorting(s.Album), + albumMBID: s.AlbumMBID, + durationMs: s.Duration, + }}) + } + return byArtist +} + +// resolvedArtists holds the agent artists resolved to artist-table rows. Everything routes by +// stable artist ID, never by name, so MBID-resolved artists whose order name differs from the +// query name are not misrouted. +type resolvedArtists struct { + byQuery map[string]map[string]struct{} // sanitized query name -> set of resolved artist IDs + mbid map[string]string // artist ID -> its MBID (the real one, from the artist table) + allIDs []string // every resolved artist ID, for the track lookup +} + +// resolveArtists resolves the queries' artists against the artist table (by sort name or +// agent-provided MBID) and records, for each query, which artist IDs it owns. +func (m *Matcher) resolveArtists(ctx context.Context, byArtist map[string][]indexedQuery) (resolvedArtists, error) { + names := make([]string, 0, len(byArtist)) + mbidToQueries := make(map[string][]string, len(byArtist)) // agent ArtistMBID -> query names that supplied it + for name, queries := range byArtist { + names = append(names, name) + for _, iq := range queries { + if iq.query.artistMBID != "" { + mbidToQueries[iq.query.artistMBID] = append(mbidToQueries[iq.query.artistMBID], name) + } + } + } + + filter := squirrel.Or{squirrel.Eq{"order_artist_name": names}} + if len(mbidToQueries) > 0 { + filter = append(filter, squirrel.Eq{"mbz_artist_id": slices.Collect(maps.Keys(mbidToQueries))}) + } + artists, err := m.ds.Artist(ctx).GetAll(model.QueryOptions{Filters: filter}) + if err != nil { + return resolvedArtists{}, err + } + + res := resolvedArtists{ + byQuery: make(map[string]map[string]struct{}, len(byArtist)), + mbid: make(map[string]string, len(artists)), + allIDs: make([]string, 0, len(artists)), + } + for _, a := range artists { + res.mbid[a.ID] = a.MbzArtistID + res.allIDs = append(res.allIDs, a.ID) + // An artist belongs to a query if its order name matches the query name, or if its MBID + // matches one a query supplied. The same MBID can come from several queries (agent aliases), + // so every one of them owns the artist. + res.own(a.OrderArtistName, a.ID) + if a.MbzArtistID != "" { + for _, name := range mbidToQueries[a.MbzArtistID] { + res.own(name, a.ID) + } + } + } + return res, nil +} + +// own records that the named query owns the given artist ID. A name that is not a query simply +// gets its own (unused) entry. +func (r resolvedArtists) own(name, artistID string) { + if r.byQuery[name] == nil { + r.byQuery[name] = map[string]struct{}{} + } + r.byQuery[name][artistID] = struct{}{} +} + +// bucketTracks groups tracks by query name, at most once per query even when a track credits +// several of that query's artists, so the same track is not scored twice. The participants JSON +// on each track carries artist IDs but not their MBID, so the MBID comes from r.mbid instead. +func (r resolvedArtists) bucketTracks(tracks []model.MediaFile) map[string][]sanitizedTrack { + // Invert byQuery once so each participant maps straight to the queries that own it, instead of + // scanning every query per participant. + queriesByArtist := make(map[string][]string) + for name, ids := range r.byQuery { + for id := range ids { + queriesByArtist[id] = append(queriesByArtist[id], name) + } + } + + byQuery := make(map[string][]sanitizedTrack, len(r.byQuery)) + added := make(map[string]map[string]struct{}, len(r.byQuery)) // query name -> set of track IDs already bucketed + for i := range tracks { + for _, p := range tracks[i].Participants[model.RoleArtist] { + mbid, isResolved := r.mbid[p.ID] + if !isResolved { + continue + } + for _, name := range queriesByArtist[p.ID] { + if added[name] == nil { + added[name] = map[string]struct{}{} + } + if _, dup := added[name][tracks[i].ID]; dup { + continue + } + added[name][tracks[i].ID] = struct{}{} + byQuery[name] = append(byQuery[name], newSanitizedTrack(&tracks[i], mbid)) + } + } + } + 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 @@ -417,6 +493,7 @@ func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, t bestScore := matchScore{titleSimilarity: -1} found := false + preferStarred := conf.Server.Matcher.PreferStarred for _, t := range sanitizedTracks { titleSim := similarityRatio(q.title, t.title) @@ -432,7 +509,7 @@ func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, t score := matchScore{ titleSimilarity: titleSim, durationProximity: durationProximity(q.durationMs, t.mf.Duration), - preferredMatch: conf.Server.Matcher.PreferStarred && isPreferredTrack(t.mf), + preferredMatch: preferStarred && isPreferredTrack(t.mf), albumSimilarity: albumSim, specificityLevel: computeSpecificityLevel(q, t, threshold), } @@ -450,66 +527,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 != 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 { diff --git a/core/matcher/matcher_internal_test.go b/core/matcher/matcher_internal_test.go index f111364c1..62bccd844 100644 --- a/core/matcher/matcher_internal_test.go +++ b/core/matcher/matcher_internal_test.go @@ -1,6 +1,7 @@ package matcher import ( + "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -51,3 +52,16 @@ var _ = Describe("similarityRatio", func() { Expect(ratio1).To(Equal(ratio2)) }) }) + +var _ = Describe("matcher internals", func() { + It("computeSpecificityLevel uses sanitizedTrack.artistMBID for artist-MBID levels", func() { + q := songQuery{ + title: "song", + artistMBID: "artist-mbid-1", + albumMBID: "album-mbid-1", + } + mf := model.MediaFile{Title: "Song", MbzAlbumID: "album-mbid-1"} // note: mf.MbzArtistID intentionally empty + t := newSanitizedTrack(&mf, "artist-mbid-1") // resolved MBID supplied here + Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(5)) + }) +}) diff --git a/core/matcher/matcher_test.go b/core/matcher/matcher_test.go index 42e3ec88d..1fad0ebcc 100644 --- a/core/matcher/matcher_test.go +++ b/core/matcher/matcher_test.go @@ -3,6 +3,7 @@ package matcher_test import ( "context" "errors" + "strings" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" @@ -19,6 +20,7 @@ import ( var _ = Describe("Matcher", func() { var ds model.DataStore var mediaFileRepo *mockMediaFileRepo + var artistRepo *mockArtistRepo var ctx context.Context var m *matcher.Matcher @@ -26,11 +28,13 @@ var _ = Describe("Matcher", func() { ctx = GinkgoT().Context() DeferCleanup(configtest.SetupConfig()) mediaFileRepo = newMockMediaFileRepo() + artistRepo = newMockArtistRepo() DeferCleanup(func() { mediaFileRepo.AssertExpectations(GinkgoT()) }) ds = &tests.MockDataStore{ MockedMediaFile: mediaFileRepo, + MockedArtist: artistRepo, } m = matcher.New(ds) }) @@ -53,26 +57,49 @@ var _ = Describe("Matcher", func() { Return(matches, nil).Once() } - // allowOtherPhases installs .Maybe() catch-alls so phases that short-circuit (return - // early without hitting the DB) don't cause test failures for unexpected calls. Call - // this after expect*Phase for the phases the test actually wants to verify. - allowOtherPhases := func() { + // allowIdentifierPhases installs .Maybe() catch-alls for the ID/MBID/ISRC phases so + // tests that only care about the title phase don't fail on those unexpected calls. + allowIdentifierPhases := func() { mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("media_file.id"))). Return(model.MediaFiles{}, nil).Maybe() mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("mbz_recording_id"))). Return(model.MediaFiles{}, nil).Maybe() mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInEq("missing"))). Return(model.MediaFiles{}, nil).Maybe() - mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))). + } + + // allowOtherPhases installs .Maybe() catch-alls so phases that short-circuit (return + // early without hitting the DB) don't cause test failures for unexpected calls. Call + // this after expect*Phase for the phases the test actually wants to verify. + allowOtherPhases := func() { + allowIdentifierPhases() + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). Return(model.MediaFiles{}, nil).Maybe() } - // setupTitleOnlyExpectations is a convenience for fuzzy-match tests that only exercise - // the title+artist phase. The title phase uses .Maybe() because it may short-circuit - // when no songs have an artist. - setupTitleOnlyExpectations := func(artistTracks model.MediaFiles) { - mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))). - Return(artistTracks, nil).Maybe() + // allowTitlePhase wires title matching from a list of library tracks. Each track must carry + // Participants[RoleArtist] with the artist IDs that credit it; the helper derives the artist + // rows the artist resolution returns from those participants, then returns the tracks from + // the track-fetch query. + allowTitlePhase := func(tracks model.MediaFiles) { + // Artist resolution: build artist rows from the tracks' participants. + seen := map[string]model.Artist{} + for _, t := range tracks { + for _, p := range t.Participants[model.RoleArtist] { + if _, ok := seen[p.ID]; !ok { + seen[p.ID] = p.Artist + } + } + } + artists := make(model.Artists, 0, len(seen)) + for _, a := range seen { + artists = append(artists, a) + } + artistRepo.On("GetAll", mock.Anything).Return(artists, nil).Maybe() + // Track fetch (media_file_artists subquery). + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). + Return(tracks, nil).Maybe() } Describe("MatchSongs", func() { @@ -140,8 +167,9 @@ var _ = Describe("Matcher", func() { } titleMatch := model.MediaFile{ ID: "track-title", Title: "Enjoy the Silence", Artist: "Depeche Mode", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } - setupTitleOnlyExpectations(model.MediaFiles{titleMatch}) + allowTitlePhase(model.MediaFiles{titleMatch}) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -155,8 +183,9 @@ var _ = Describe("Matcher", func() { } fuzzyMatch := model.MediaFile{ ID: "track-fuzzy", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", + Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), } - setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch}) + allowTitlePhase(model.MediaFiles{fuzzyMatch}) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -169,9 +198,11 @@ var _ = Describe("Matcher", func() { {Name: "Yesterday", Artist: "The Beatles"}, } differentTracks := model.MediaFiles{ - {ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"}, + {ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles", + Participants: artistParticipants(model.Artist{ID: "beatles", Name: "The Beatles", OrderArtistName: "beatles"}), + }, } - setupTitleOnlyExpectations(differentTracks) + allowTitlePhase(differentTracks) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(BeEmpty()) @@ -187,8 +218,9 @@ var _ = Describe("Matcher", func() { } libraryTrack := model.MediaFile{ ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", + Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), } - setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) + allowTitlePhase(model.MediaFiles{libraryTrack}) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -203,8 +235,9 @@ var _ = Describe("Matcher", func() { } libraryTrack := model.MediaFile{ ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera", + Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), } - setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) + allowTitlePhase(model.MediaFiles{libraryTrack}) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) @@ -243,11 +276,17 @@ var _ = Describe("Matcher", func() { {Name: "Song C", Artist: "Artist"}, } tracks := model.MediaFiles{ - {ID: "a", Title: "Song A", Artist: "Artist"}, - {ID: "b", Title: "Song B", Artist: "Artist"}, - {ID: "c", Title: "Song C", Artist: "Artist"}, + {ID: "a", Title: "Song A", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + }, + {ID: "b", Title: "Song B", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + }, + {ID: "c", Title: "Song C", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + }, } - setupTitleOnlyExpectations(tracks) + allowTitlePhase(tracks) result, err := m.MatchSongs(ctx, songs, 2) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) @@ -261,6 +300,172 @@ var _ = Describe("Matcher", func() { Expect(result).To(BeEmpty()) }) }) + + Context("artist grouping", func() { + It("groups title-phase tracks by participant artist ID, not display Artist", func() { + songs := []agents.Song{ + {Name: "Song A", Artist: "Daft Punk"}, + } + // Display Artist differs from the query artist; only the participant + // with order_artist_name "daft punk" routes to this query bucket. + track := model.MediaFile{ + ID: "oan-track", Title: "Song A", + Artist: "Daft Punk feat. Pharrell", + Participants: artistParticipants( + model.Artist{ID: "dp", Name: "Daft Punk", OrderArtistName: "daft punk"}, + model.Artist{ID: "ph", Name: "Pharrell", OrderArtistName: "pharrell"}, + ), + } + allowTitlePhase(model.MediaFiles{track}) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("oan-track")) + }) + + It("matches a track that credits the searched artist as a collaborator", func() { + songs := []agents.Song{ + {Name: "Crazy", Artist: "INXS"}, + } + // "Par-T-One vs. INXS" — display Artist is the collaboration, but INXS is a + // credited artist participant. Searching INXS must match it. + track := model.MediaFile{ + ID: "collab", Title: "Crazy", Artist: "Par-T-One vs. INXS", + Participants: artistParticipants( + model.Artist{ID: "a-partone", Name: "Par-T-One", OrderArtistName: "par-t-one"}, + model.Artist{ID: "a-inxs", Name: "INXS", OrderArtistName: "inxs"}, + ), + } + allowTitlePhase(model.MediaFiles{track}) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("collab")) + }) + + It("does not match a track where the searched artist is only the album artist", func() { + songs := []agents.Song{ + {Name: "Qmart", Artist: "808 State"}, + } + // Track performed by Björk on an "808 State" compilation: 808 State is the + // albumartist, Björk is the performer. Searching 808 State must NOT match it. + track := model.MediaFile{ + ID: "comp", Title: "Qmart", Artist: "Björk", + Participants: model.Participants{ + model.RoleArtist: model.ParticipantList{ + {Artist: model.Artist{ID: "a-bjork", Name: "Björk", OrderArtistName: "bjork"}}, + }, + model.RoleAlbumArtist: model.ParticipantList{ + {Artist: model.Artist{ID: "a-808", Name: "808 State", OrderArtistName: "808 state"}}, + }, + }, + } + // Artist resolution returns "808 state" only if some artist row matches; here the + // album-artist participant exists but is NOT role='artist', so the track-fetch query's + // EXISTS (role='artist') would not return the track in production. The mock + // returns it anyway; back-mapping must drop it because no role='artist' + // participant is a resolved artist for the query "808 state". + artistRepo.On("GetAll", mock.Anything). + Return(model.Artists{{ID: "a-808", Name: "808 State", OrderArtistName: "808 state"}}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). + Return(model.MediaFiles{track}, nil).Maybe() + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + + It("resolves the artist by ArtistMBID when the name differs", func() { + songs := []agents.Song{ + {Name: "Song A", Artist: "Typo Artist", ArtistMBID: "mbid-9"}, + } + track := model.MediaFile{ + ID: "by-mbid", Title: "Song A", Artist: "Correct Artist", + Participants: artistParticipants(model.Artist{ID: "a9", Name: "Correct Artist", OrderArtistName: "correct artist", MbzArtistID: "mbid-9"}), + } + // Artist resolution returns the artist matched by mbz_artist_id; its order name + // ("correct artist") differs from the query name ("typo artist"), so + // resolution must come from the MBID branch. + artistRepo.On("GetAll", mock.Anything). + Return(model.Artists{{ID: "a9", Name: "Correct Artist", OrderArtistName: "correct artist", MbzArtistID: "mbid-9"}}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). + Return(model.MediaFiles{track}, nil).Maybe() + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("by-mbid")) + }) + + It("resolves both queries when two share one ArtistMBID under different names", func() { + // Two agent results for the same MusicBrainz artist but spelled differently + // (an alias). Both must match the artist's track via the shared MBID. + songs := []agents.Song{ + {Name: "Song A", Artist: "Alias One", ArtistMBID: "mbid-shared"}, + {Name: "Song B", Artist: "Alias Two", ArtistMBID: "mbid-shared"}, + } + artist := model.Artist{ID: "a-shared", Name: "Canonical", OrderArtistName: "canonical", MbzArtistID: "mbid-shared"} + trackA := model.MediaFile{ID: "ta", Title: "Song A", Artist: "Canonical", Participants: artistParticipants(artist)} + trackB := model.MediaFile{ID: "tb", Title: "Song B", Artist: "Canonical", Participants: artistParticipants(artist)} + artistRepo.On("GetAll", mock.Anything). + Return(model.Artists{{ID: "a-shared", Name: "Canonical", OrderArtistName: "canonical", MbzArtistID: "mbid-shared"}}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). + Return(model.MediaFiles{trackA, trackB}, nil).Maybe() + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect([]string{result[0].ID, result[1].ID}).To(ConsistOf("ta", "tb")) + }) + }) + + // These tests register their own track-fetch expectations per-test (to inject + // an error), so they use allowIdentifierPhases — NOT allowOtherPhases, which would + // add a .Maybe() title-phase catch-all that masks the injected error. + Context("title phase DB errors", func() { + It("returns an error when the title query fails and nothing else matched", func() { + songs := []agents.Song{ + {Name: "Song A", Artist: "Artist One"}, + {Name: "Song B", Artist: "Artist Two"}, + } + allowIdentifierPhases() + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{ + {ID: "a1", Name: "Artist One", OrderArtistName: "artist one"}, + {ID: "a2", Name: "Artist Two", OrderArtistName: "artist two"}, + }, nil) + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). + Return(nil, errors.New("db down")) + + _, err := m.MatchSongs(ctx, songs, 5) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("db down")) + }) + + It("keeps exact-phase matches when the title query fails", func() { + songs := []agents.Song{ + {ID: "track-1", Name: "Exact Song", Artist: "Exact Artist"}, + {Name: "Fuzzy Song", Artist: "Fuzzy Artist"}, + } + idMatch := model.MediaFile{ID: "track-1", Title: "Exact Song", Artist: "Exact Artist"} + expectIDPhase(model.MediaFiles{idMatch}) + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("mbz_recording_id"))). + Return(model.MediaFiles{}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInEq("missing"))). + Return(model.MediaFiles{}, nil).Maybe() + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{ + {ID: "fa", Name: "Fuzzy Artist", OrderArtistName: "fuzzy artist"}, + }, nil) + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). + Return(nil, errors.New("db down")) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("track-1")) + }) + }) }) Describe("MatchSongsIndexed", func() { @@ -319,16 +524,18 @@ var _ = Describe("Matcher", func() { correctMatch := model.MediaFile{ ID: "correct-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Violator", MbzArtistID: "artist-mbid-123", MbzAlbumID: "album-mbid-456", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode", MbzArtistID: "artist-mbid-123"}), } wrongMatch := model.MediaFile{ ID: "wrong-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Some Other Album", MbzArtistID: "artist-mbid-123", MbzAlbumID: "different-album-mbid", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode", MbzArtistID: "artist-mbid-123"}), } songs := []agents.Song{ {Name: "Similar Song", Artist: "Depeche Mode", ArtistMBID: "artist-mbid-123", Album: "Violator", AlbumMBID: "album-mbid-456"}, } - setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -340,15 +547,17 @@ var _ = Describe("Matcher", func() { It("matches by title + artist name + album name when MBIDs unavailable", func() { correctMatch := model.MediaFile{ ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "violator", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } wrongMatch := model.MediaFile{ ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album", + Participants: artistParticipants(model.Artist{ID: "oa", Name: "Other Artist", OrderArtistName: "other artist"}), } songs := []agents.Song{ {Name: "Similar Song", Artist: "Depeche Mode", Album: "Violator"}, } - setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -360,15 +569,17 @@ var _ = Describe("Matcher", func() { It("matches by title + artist only when album info unavailable", func() { correctMatch := model.MediaFile{ ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "Some Album", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } wrongMatch := model.MediaFile{ ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album", + Participants: artistParticipants(model.Artist{ID: "oa", Name: "Other Artist", OrderArtistName: "other artist"}), } songs := []agents.Song{ {Name: "Similar Song", Artist: "Depeche Mode"}, } - setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -382,7 +593,7 @@ var _ = Describe("Matcher", func() { {Name: "Similar Song"}, } - setupTitleOnlyExpectations(model.MediaFiles{}) + allowTitlePhase(model.MediaFiles{}) result, err := m.MatchSongs(ctx, songs, 5) @@ -391,9 +602,15 @@ var _ = Describe("Matcher", func() { }) It("returns distinct matches for each artist's version (covers scenario)", func() { - cover1 := model.MediaFile{ID: "cover-1", Title: "Yesterday", Artist: "The Beatles", Album: "Help!"} - cover2 := model.MediaFile{ID: "cover-2", Title: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"} - cover3 := model.MediaFile{ID: "cover-3", Title: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"} + cover1 := model.MediaFile{ID: "cover-1", Title: "Yesterday", Artist: "The Beatles", Album: "Help!", + Participants: artistParticipants(model.Artist{ID: "beatles", Name: "The Beatles", OrderArtistName: "beatles"}), + } + cover2 := model.MediaFile{ID: "cover-2", Title: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits", + Participants: artistParticipants(model.Artist{ID: "ray-charles", Name: "Ray Charles", OrderArtistName: "ray charles"}), + } + cover3 := model.MediaFile{ID: "cover-3", Title: "Yesterday", Artist: "Frank Sinatra", Album: "My Way", + Participants: artistParticipants(model.Artist{ID: "sinatra", Name: "Frank Sinatra", OrderArtistName: "frank sinatra"}), + } songs := []agents.Song{ {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, @@ -401,7 +618,7 @@ var _ = Describe("Matcher", func() { {Name: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"}, } - setupTitleOnlyExpectations(model.MediaFiles{cover1, cover2, cover3}) + allowTitlePhase(model.MediaFiles{cover1, cover2, cover3}) result, err := m.MatchSongs(ctx, songs, 5) @@ -415,13 +632,16 @@ var _ = Describe("Matcher", func() { preciseMatch := model.MediaFile{ ID: "precise", Title: "Song A", Artist: "Artist One", Album: "Album One", MbzArtistID: "mbid-1", MbzAlbumID: "album-mbid-1", + Participants: artistParticipants(model.Artist{ID: "a1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-1"}), } lessAccurateMatch := model.MediaFile{ ID: "less-accurate", Title: "Song A", Artist: "Artist One", Album: "Compilation", - MbzArtistID: "mbid-1", + MbzArtistID: "mbid-1", + Participants: artistParticipants(model.Artist{ID: "a1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-1"}), } artistTwoMatch := model.MediaFile{ ID: "artist-two", Title: "Song B", Artist: "Artist Two", + Participants: artistParticipants(model.Artist{ID: "a2", Name: "Artist Two", OrderArtistName: "artist two"}), } songs := []agents.Song{ @@ -429,7 +649,7 @@ var _ = Describe("Matcher", func() { {Name: "Song B", Artist: "Artist Two"}, } - setupTitleOnlyExpectations(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch}) + allowTitlePhase(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -438,6 +658,31 @@ var _ = Describe("Matcher", func() { Expect(result[0].ID).To(Equal("precise")) Expect(result[1].ID).To(Equal("artist-two")) }) + + It("uses the resolved artist MBID for specificity (level 5)", func() { + songs := []agents.Song{ + {Name: "Song A", Artist: "Artist One", ArtistMBID: "mbid-1", Album: "Album One", AlbumMBID: "album-mbid-1"}, + } + // Two tracks with the same title and album; only the one whose resolved artist + // carries mbid-1 (and whose album MBID matches) wins via Level 5. Without the + // resolved MBID, both tracks tie at Level 3 (name+album) and the first wins by + // chance — verifiable by RED-proof: see task-2-report.md. + precise := model.MediaFile{ + ID: "precise", Title: "Song A", Artist: "Artist One", Album: "Album One", MbzAlbumID: "album-mbid-1", + Participants: artistParticipants(model.Artist{ID: "a1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-1"}), + } + other := model.MediaFile{ + ID: "other", Title: "Song A", Artist: "Artist One", Album: "Album One", MbzAlbumID: "wrong-album-mbid", + Participants: artistParticipants(model.Artist{ID: "a1b", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: ""}), + } + // Artist resolution returns both a1 (by name+mbid) and a1b (by name). + allowTitlePhase(model.MediaFiles{other, precise}) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("precise")) + }) }) Describe("fuzzy matching thresholds", func() { @@ -449,10 +694,12 @@ var _ = Describe("Matcher", func() { {Name: "Paranoid Android", Artist: "Radiohead"}, } artistTracks := model.MediaFiles{ - {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, + {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead", + Participants: artistParticipants(model.Artist{ID: "rh", Name: "Radiohead", OrderArtistName: "radiohead"}), + }, } - setupTitleOnlyExpectations(artistTracks) + allowTitlePhase(artistTracks) result, err := m.MatchSongs(ctx, songs, 5) @@ -468,10 +715,12 @@ var _ = Describe("Matcher", func() { {Name: "Bohemian Rhapsody", Artist: "Queen"}, } artistTracks := model.MediaFiles{ - {ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"}, + {ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", + Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), + }, } - setupTitleOnlyExpectations(artistTracks) + allowTitlePhase(artistTracks) result, err := m.MatchSongs(ctx, songs, 5) @@ -489,10 +738,12 @@ var _ = Describe("Matcher", func() { {Name: "Paranoid Android", Artist: "Radiohead"}, } artistTracks := model.MediaFiles{ - {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, + {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead", + Participants: artistParticipants(model.Artist{ID: "rh", Name: "Radiohead", OrderArtistName: "radiohead"}), + }, } - setupTitleOnlyExpectations(artistTracks) + allowTitlePhase(artistTracks) result, err := m.MatchSongs(ctx, songs, 5) @@ -509,10 +760,12 @@ var _ = Describe("Matcher", func() { {Name: "Song", Artist: "Artist"}, } artistTracks := model.MediaFiles{ - {ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"}, + {ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + }, } - setupTitleOnlyExpectations(artistTracks) + allowTitlePhase(artistTracks) result, err := m.MatchSongs(ctx, songs, 5) @@ -535,12 +788,14 @@ var _ = Describe("Matcher", func() { } correctMatch := model.MediaFile{ ID: "correct", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera (2011 Remaster)", + Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), } wrongMatch := model.MediaFile{ ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits", + Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), } - setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -555,12 +810,14 @@ var _ = Describe("Matcher", func() { } correctMatch := model.MediaFile{ ID: "correct", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } wrongMatch := model.MediaFile{ ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } - setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -575,12 +832,14 @@ var _ = Describe("Matcher", func() { } exactMatch := model.MediaFile{ ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } fuzzyMatch := model.MediaFile{ ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } - setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch, exactMatch}) + allowTitlePhase(model.MediaFiles{fuzzyMatch, exactMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -596,12 +855,15 @@ var _ = Describe("Matcher", func() { } albumMatch := model.MediaFile{ ID: "album-match", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } starredTrack := model.MediaFile{ - ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Annotations: model.Annotations{Starred: true}, + ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", + Annotations: model.Annotations{Starred: true}, + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } - setupTitleOnlyExpectations(model.MediaFiles{albumMatch, starredTrack}) + allowTitlePhase(model.MediaFiles{albumMatch, starredTrack}) result, err := m.MatchSongs(ctx, songs, 5) @@ -617,12 +879,15 @@ var _ = Describe("Matcher", func() { } albumMatch := model.MediaFile{ ID: "album-match", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } ratedTrack := model.MediaFile{ - ID: "rated", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Annotations: model.Annotations{Rating: 4}, + ID: "rated", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", + Annotations: model.Annotations{Rating: 4}, + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } - setupTitleOnlyExpectations(model.MediaFiles{albumMatch, ratedTrack}) + allowTitlePhase(model.MediaFiles{albumMatch, ratedTrack}) result, err := m.MatchSongs(ctx, songs, 5) @@ -643,12 +908,14 @@ var _ = Describe("Matcher", func() { } correctMatch := model.MediaFile{ ID: "correct", Title: "Similar Song", Artist: "Test Artist", Duration: 180.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } wrongDuration := model.MediaFile{ ID: "wrong", Title: "Similar Song", Artist: "Test Artist", Duration: 240.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } - setupTitleOnlyExpectations(model.MediaFiles{wrongDuration, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongDuration, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -663,9 +930,10 @@ var _ = Describe("Matcher", func() { } closeDuration := model.MediaFile{ ID: "close-duration", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } - setupTitleOnlyExpectations(model.MediaFiles{closeDuration}) + allowTitlePhase(model.MediaFiles{closeDuration}) result, err := m.MatchSongs(ctx, songs, 5) @@ -680,12 +948,14 @@ var _ = Describe("Matcher", func() { } closeDuration := model.MediaFile{ ID: "close", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } farDuration := model.MediaFile{ ID: "far", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } - setupTitleOnlyExpectations(model.MediaFiles{farDuration, closeDuration}) + allowTitlePhase(model.MediaFiles{farDuration, closeDuration}) result, err := m.MatchSongs(ctx, songs, 5) @@ -700,9 +970,10 @@ var _ = Describe("Matcher", func() { } differentDuration := model.MediaFile{ ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } - setupTitleOnlyExpectations(model.MediaFiles{differentDuration}) + allowTitlePhase(model.MediaFiles{differentDuration}) result, err := m.MatchSongs(ctx, songs, 5) @@ -717,12 +988,14 @@ var _ = Describe("Matcher", func() { } differentTitle := model.MediaFile{ ID: "wrong-title", Title: "Different Song", Artist: "Test Artist", Duration: 180.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } correctTitle := model.MediaFile{ ID: "correct-title", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } - setupTitleOnlyExpectations(model.MediaFiles{differentTitle, correctTitle}) + allowTitlePhase(model.MediaFiles{differentTitle, correctTitle}) result, err := m.MatchSongs(ctx, songs, 5) @@ -737,9 +1010,10 @@ var _ = Describe("Matcher", func() { } anyTrack := model.MediaFile{ ID: "any", Title: "Similar Song", Artist: "Test Artist", Duration: 999.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } - setupTitleOnlyExpectations(model.MediaFiles{anyTrack}) + allowTitlePhase(model.MediaFiles{anyTrack}) result, err := m.MatchSongs(ctx, songs, 5) @@ -754,9 +1028,10 @@ var _ = Describe("Matcher", func() { } shortTrack := model.MediaFile{ ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } - setupTitleOnlyExpectations(model.MediaFiles{shortTrack}) + allowTitlePhase(model.MediaFiles{shortTrack}) result, err := m.MatchSongs(ctx, songs, 5) @@ -764,6 +1039,30 @@ var _ = Describe("Matcher", func() { Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("short")) }) + + It("matches same title+artist songs to their own closest-duration track", func() { + songs := []agents.Song{ + {Name: "Same Song", Artist: "Same Artist", Duration: 180000}, + {Name: "Same Song", Artist: "Same Artist", Duration: 240000}, + } + shortTrack := model.MediaFile{ + ID: "short", Title: "Same Song", Artist: "Same Artist", Duration: 180.0, + Participants: artistParticipants(model.Artist{ID: "sa", Name: "Same Artist", OrderArtistName: "same artist"}), + } + longTrack := model.MediaFile{ + ID: "long", Title: "Same Song", Artist: "Same Artist", Duration: 240.0, + Participants: artistParticipants(model.Artist{ID: "sa", Name: "Same Artist", OrderArtistName: "same artist"}), + } + + allowTitlePhase(model.MediaFiles{shortTrack, longTrack}) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(result[0].ID).To(Equal("short")) + Expect(result[1].ID).To(Equal("long")) + }) }) Describe("deduplication edge cases", func() { @@ -780,9 +1079,10 @@ var _ = Describe("Matcher", func() { } libraryTrack := model.MediaFile{ ID: "yesterday", Title: "Yesterday", Artist: "The Beatles", Album: "Help!", + Participants: artistParticipants(model.Artist{ID: "beatles", Name: "The Beatles", OrderArtistName: "beatles"}), } - setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) + allowTitlePhase(model.MediaFiles{libraryTrack}) result, err := m.MatchSongs(ctx, songs, 5) @@ -798,11 +1098,17 @@ var _ = Describe("Matcher", func() { {Name: "Song B", Artist: "Artist"}, {Name: "Song C", Artist: "Artist"}, } - trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"} - trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} - trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist"} + trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + } + trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + } + trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + } - setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB, trackC}) + allowTitlePhase(model.MediaFiles{trackA, trackB, trackC}) result, err := m.MatchSongs(ctx, songs, 5) @@ -820,10 +1126,14 @@ var _ = Describe("Matcher", func() { {Name: "Song B", Artist: "Artist"}, {Name: "Song B (Remix)", Artist: "Artist"}, } - trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"} - trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} + trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + } + trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + } - setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB}) + allowTitlePhase(model.MediaFiles{trackA, trackB}) result, err := m.MatchSongs(ctx, songs, 2) @@ -866,6 +1176,27 @@ func (m *mockMediaFileRepo) SetError(hasError bool) { } } +type mockArtistRepo struct { + mock.Mock + model.ArtistRepository +} + +func newMockArtistRepo() *mockArtistRepo { + return &mockArtistRepo{} +} + +func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) { + argsSlice := make([]any, len(options)) + for i, v := range options { + argsSlice[i] = v + } + args := m.Called(argsSlice...) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(model.Artists), args.Error(1) +} + // matchFieldInAnd returns a matcher that checks whether QueryOptions.Filters is a // squirrel.And whose first element is a squirrel.Eq containing the given field name. func matchFieldInAnd(fieldName string) func(opt model.QueryOptions) bool { @@ -895,3 +1226,30 @@ func matchFieldInEq(fieldName string) func(opt model.QueryOptions) bool { return hasField } } + +// 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} +} + +// matchTracksByArtistQuery matches the title phase's track-fetch query, identified by its +// squirrel.And containing a squirrel.Expr whose SQL references media_file_artists. +func matchTracksByArtistQuery() func(opt model.QueryOptions) bool { + return func(opt model.QueryOptions) bool { + and, ok := opt.Filters.(squirrel.And) + if !ok { + return false + } + for _, f := range and { + sql, _, err := f.ToSql() + if err == nil && strings.Contains(sql, "media_file_artists") { + return true + } + } + return false + } +} diff --git a/core/stream/legacy_client.go b/core/stream/legacy_client.go index 9dd6179a0..652e42eba 100644 --- a/core/stream/legacy_client.go +++ b/core/stream/legacy_client.go @@ -12,7 +12,7 @@ import ( // buildLegacyClientInfo translates legacy Subsonic stream/download parameters // into a ClientInfo for use with MakeDecision. -func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int) *ClientInfo { +func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int, playerMaxBitRate int) *ClientInfo { ci := &ClientInfo{Name: "legacy"} // Determine target format for transcoding @@ -22,6 +22,10 @@ func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int targetFormat = reqFormat case reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "": targetFormat = conf.Server.DefaultDownsamplingFormat + case playerMaxBitRate > 0 && playerMaxBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "": + // Server-side player MaxBitRate alone forces downsampling, even when the + // client sent no format/bitrate params (issue #5583, legacy /stream path). + targetFormat = conf.Server.DefaultDownsamplingFormat } if targetFormat != "" { @@ -63,15 +67,19 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile return req } - clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate) + playerMaxBitRate := 0 + if player, ok := request.PlayerFrom(ctx); ok { + playerMaxBitRate = player.MaxBitRate + } + + clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate, playerMaxBitRate) // Apply server-side player transcoding override before making the decision if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { clientInfo = applyServerOverride(ctx, clientInfo, &trc) - } else if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 { - if clientInfo.MaxAudioBitrate == 0 || player.MaxBitRate < clientInfo.MaxAudioBitrate { - modified := *clientInfo - modified.MaxAudioBitrate = player.MaxBitRate + } else if player, ok := request.PlayerFrom(ctx); ok { + modified := *clientInfo + if modified.CapBitrate(player.MaxBitRate) { clientInfo = &modified log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name) } diff --git a/core/stream/legacy_client_test.go b/core/stream/legacy_client_test.go index 2ddd74ce0..bc8405976 100644 --- a/core/stream/legacy_client_test.go +++ b/core/stream/legacy_client_test.go @@ -21,7 +21,7 @@ var _ = Describe("buildLegacyClientInfo", func() { }) It("sets transcoding profile for explicit format without bitrate", func() { - ci := buildLegacyClientInfo(mf, "mp3", 0) + ci := buildLegacyClientInfo(mf, "mp3", 0, 0) Expect(ci.Name).To(Equal("legacy")) Expect(ci.TranscodingProfiles).To(HaveLen(1)) @@ -34,7 +34,7 @@ var _ = Describe("buildLegacyClientInfo", func() { }) It("does not add direct play profile when explicit format differs from source (no bitrate)", func() { - ci := buildLegacyClientInfo(mf, "opus", 0) + ci := buildLegacyClientInfo(mf, "opus", 0, 0) Expect(ci.TranscodingProfiles).To(HaveLen(1)) Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus")) @@ -42,7 +42,7 @@ var _ = Describe("buildLegacyClientInfo", func() { }) It("adds direct play profile when explicit format matches source format", func() { - ci := buildLegacyClientInfo(mf, "flac", 0) + ci := buildLegacyClientInfo(mf, "flac", 0, 0) Expect(ci.TranscodingProfiles).To(HaveLen(1)) Expect(ci.TranscodingProfiles[0].Container).To(Equal("flac")) @@ -52,7 +52,7 @@ var _ = Describe("buildLegacyClientInfo", func() { }) It("sets transcoding profile and bitrate for explicit format with bitrate", func() { - ci := buildLegacyClientInfo(mf, "mp3", 192) + ci := buildLegacyClientInfo(mf, "mp3", 192, 0) Expect(ci.TranscodingProfiles).To(HaveLen(1)) Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3")) @@ -63,7 +63,7 @@ var _ = Describe("buildLegacyClientInfo", func() { }) It("returns direct play profile when no format and no bitrate", func() { - ci := buildLegacyClientInfo(mf, "", 0) + ci := buildLegacyClientInfo(mf, "", 0, 0) Expect(ci.DirectPlayProfiles).To(HaveLen(1)) Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty()) @@ -77,7 +77,7 @@ var _ = Describe("buildLegacyClientInfo", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.DefaultDownsamplingFormat = "opus" - ci := buildLegacyClientInfo(mf, "", 128) + ci := buildLegacyClientInfo(mf, "", 128, 0) Expect(ci.TranscodingProfiles).To(HaveLen(1)) Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus")) @@ -91,7 +91,7 @@ var _ = Describe("buildLegacyClientInfo", func() { }) It("returns direct play when bitrate >= source bitrate", func() { - ci := buildLegacyClientInfo(mf, "", 960) + ci := buildLegacyClientInfo(mf, "", 960, 0) Expect(ci.DirectPlayProfiles).To(HaveLen(1)) Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty()) @@ -100,6 +100,51 @@ var _ = Describe("buildLegacyClientInfo", func() { Expect(ci.TranscodingProfiles).To(BeEmpty()) Expect(ci.MaxAudioBitrate).To(BeZero()) }) + + It("uses default downsampling format when player MaxBitRate is below source and no format/bitrate", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "opus" + + ci := buildLegacyClientInfo(mf, "", 0, 256) + + Expect(ci.TranscodingProfiles).To(HaveLen(1)) + Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus")) + Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) + Expect(ci.DirectPlayProfiles).To(HaveLen(1)) + Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"})) + }) + + It("does not downsample when player MaxBitRate is >= source bitrate", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "opus" + + ci := buildLegacyClientInfo(mf, "", 0, 960) + + Expect(ci.TranscodingProfiles).To(BeEmpty()) + Expect(ci.DirectPlayProfiles).To(HaveLen(1)) + Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty()) + }) + + It("does not downsample when DefaultDownsamplingFormat is empty even with player cap", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "" + + ci := buildLegacyClientInfo(mf, "", 0, 256) + + Expect(ci.TranscodingProfiles).To(BeEmpty()) + Expect(ci.DirectPlayProfiles).To(HaveLen(1)) + Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty()) + }) + + It("prefers explicit request format over player cap", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "opus" + + ci := buildLegacyClientInfo(mf, "mp3", 0, 256) + + Expect(ci.TranscodingProfiles).To(HaveLen(1)) + Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3")) + }) }) var _ = Describe("ResolveRequest", func() { @@ -289,6 +334,33 @@ var _ = Describe("ResolveRequest", func() { Expect(req.Format).To(Equal("raw")) }) + + It("downsamples using DefaultDownsamplingFormat when only player MaxBitRate is set (no format/bitrate)", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "opus" + + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) + playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 256}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(playerCtx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("opus")) + Expect(req.BitRate).To(Equal(256)) + }) + + It("serves raw when only player MaxBitRate is set but no DefaultDownsamplingFormat", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "" + + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) + playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 256}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(playerCtx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("raw")) + }) }) Context("fallback for unknown format", func() { diff --git a/core/stream/types.go b/core/stream/types.go index bd8ce292c..19474dd91 100644 --- a/core/stream/types.go +++ b/core/stream/types.go @@ -40,6 +40,50 @@ type ClientInfo struct { CodecProfiles []CodecProfile } +// CapBitrate lowers the client's declared audio bitrate limits to maxKbps, +// never raising them. A zero limit means "unlimited" and is set to maxKbps. +// Returns true if anything changed. No-op when maxKbps <= 0. +func (ci *ClientInfo) CapBitrate(maxKbps int) bool { + if maxKbps <= 0 { + return false + } + changed := false + if ci.MaxAudioBitrate == 0 || maxKbps < ci.MaxAudioBitrate { + ci.MaxAudioBitrate = maxKbps + changed = true + } + if ci.MaxTranscodingAudioBitrate == 0 || maxKbps < ci.MaxTranscodingAudioBitrate { + ci.MaxTranscodingAudioBitrate = maxKbps + changed = true + } + return changed +} + +// ForceFormat narrows the client to transcoding to targetFormat and suppresses +// direct play, but only if the client already declares a profile for that +// format. All matching profiles are kept so negotiation can still pick among +// them (e.g. by protocol). Returns false (no-op) when targetFormat is empty or +// unsupported. +func (ci *ClientInfo) ForceFormat(targetFormat string) bool { + if targetFormat == "" { + return false + } + var matched []Profile + for i := range ci.TranscodingProfiles { + // matchesContainer is alias-aware, so a forced "oga" (legacy Opus + // target_format) still matches a resolved "opus" profile. + if _, format := resolveTargetFormat(&ci.TranscodingProfiles[i]); matchesContainer(format, []string{targetFormat}) { + matched = append(matched, ci.TranscodingProfiles[i]) + } + } + if len(matched) == 0 { + return false + } + ci.TranscodingProfiles = matched + ci.DirectPlayProfiles = nil + return true +} + // DirectPlayProfile describes a format the client can play directly type DirectPlayProfile struct { Containers []string diff --git a/core/stream/types_test.go b/core/stream/types_test.go new file mode 100644 index 000000000..eff408362 --- /dev/null +++ b/core/stream/types_test.go @@ -0,0 +1,133 @@ +package stream + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ClientInfo", func() { + Describe("CapBitrate", func() { + It("is a no-op when maxKbps is zero", func() { + ci := &ClientInfo{MaxAudioBitrate: 320, MaxTranscodingAudioBitrate: 320} + Expect(ci.CapBitrate(0)).To(BeFalse()) + Expect(ci.MaxAudioBitrate).To(Equal(320)) + Expect(ci.MaxTranscodingAudioBitrate).To(Equal(320)) + }) + + It("is a no-op when maxKbps is negative", func() { + ci := &ClientInfo{MaxAudioBitrate: 320, MaxTranscodingAudioBitrate: 320} + Expect(ci.CapBitrate(-1)).To(BeFalse()) + Expect(ci.MaxAudioBitrate).To(Equal(320)) + Expect(ci.MaxTranscodingAudioBitrate).To(Equal(320)) + }) + + It("sets both limits when both are zero (unlimited)", func() { + ci := &ClientInfo{} + Expect(ci.CapBitrate(256)).To(BeTrue()) + Expect(ci.MaxAudioBitrate).To(Equal(256)) + Expect(ci.MaxTranscodingAudioBitrate).To(Equal(256)) + }) + + It("lowers limits higher than maxKbps", func() { + ci := &ClientInfo{MaxAudioBitrate: 320, MaxTranscodingAudioBitrate: 500} + Expect(ci.CapBitrate(192)).To(BeTrue()) + Expect(ci.MaxAudioBitrate).To(Equal(192)) + Expect(ci.MaxTranscodingAudioBitrate).To(Equal(192)) + }) + + It("does not raise limits lower than maxKbps", func() { + ci := &ClientInfo{MaxAudioBitrate: 128, MaxTranscodingAudioBitrate: 96} + Expect(ci.CapBitrate(320)).To(BeFalse()) + Expect(ci.MaxAudioBitrate).To(Equal(128)) + Expect(ci.MaxTranscodingAudioBitrate).To(Equal(96)) + }) + + It("reports changed when only one limit is lowered", func() { + ci := &ClientInfo{MaxAudioBitrate: 320, MaxTranscodingAudioBitrate: 128} + Expect(ci.CapBitrate(192)).To(BeTrue()) + Expect(ci.MaxAudioBitrate).To(Equal(192)) + Expect(ci.MaxTranscodingAudioBitrate).To(Equal(128)) + }) + + It("caps only the zero (unlimited) limit", func() { + ci := &ClientInfo{MaxAudioBitrate: 128, MaxTranscodingAudioBitrate: 0} + Expect(ci.CapBitrate(192)).To(BeTrue()) + Expect(ci.MaxAudioBitrate).To(Equal(128)) + Expect(ci.MaxTranscodingAudioBitrate).To(Equal(192)) + }) + }) + + Describe("ForceFormat", func() { + It("restricts to the forced format and clears direct play when supported", func() { + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}}, + TranscodingProfiles: []Profile{ + {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, + {Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP}, + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + ok := ci.ForceFormat("opus") + Expect(ok).To(BeTrue()) + Expect(ci.TranscodingProfiles).To(HaveLen(1)) + Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) + Expect(ci.DirectPlayProfiles).To(BeEmpty()) + }) + + It("matches a container-only forced format (mp3)", func() { + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP}, + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + ok := ci.ForceFormat("mp3") + Expect(ok).To(BeTrue()) + Expect(ci.TranscodingProfiles).To(HaveLen(1)) + Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3")) + }) + + It("matches the forced format against codec aliases (oga/opus)", func() { + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP}, + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + // Legacy DBs may store the Opus transcoding as target_format "oga". + ok := ci.ForceFormat("oga") + Expect(ok).To(BeTrue()) + Expect(ci.TranscodingProfiles).To(HaveLen(1)) + Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) + }) + + It("is a no-op when the forced format is not supported by the client", func() { + original := []Profile{{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}} + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}}}, + TranscodingProfiles: original, + } + ok := ci.ForceFormat("opus") + Expect(ok).To(BeFalse()) + Expect(ci.TranscodingProfiles).To(Equal(original)) + Expect(ci.DirectPlayProfiles).To(HaveLen(1)) + }) + + It("is a no-op for an empty target format", func() { + ci := &ClientInfo{TranscodingProfiles: []Profile{{Container: "mp3", AudioCodec: "mp3"}}} + Expect(ci.ForceFormat("")).To(BeFalse()) + }) + + It("keeps all matching profiles when multiple resolve to the forced format", func() { + first := Profile{Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP} + second := Profile{Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP, MaxAudioChannels: 2} + other := Profile{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP} + ci := &ClientInfo{TranscodingProfiles: []Profile{first, other, second}} + + ok := ci.ForceFormat("opus") + + Expect(ok).To(BeTrue()) + Expect(ci.TranscodingProfiles).To(ConsistOf(first, second)) + }) + }) +}) diff --git a/db/migrations/20200130083147_create_schema.go b/db/migrations/20200130083147_create_schema.go index 2fae4f57d..250fb00a5 100644 --- a/db/migrations/20200130083147_create_schema.go +++ b/db/migrations/20200130083147_create_schema.go @@ -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 } diff --git a/db/migrations/20200131183653_standardize_item_type.go b/db/migrations/20200131183653_standardize_item_type.go index 471dc8002..bf7d9d5f7 100644 --- a/db/migrations/20200131183653_standardize_item_type.go +++ b/db/migrations/20200131183653_standardize_item_type.go @@ -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 diff --git a/db/migrations/20200208222418_add_defaults_to_annotations.go b/db/migrations/20200208222418_add_defaults_to_annotations.go index d058b02c3..6807c8ad2 100644 --- a/db/migrations/20200208222418_add_defaults_to_annotations.go +++ b/db/migrations/20200208222418_add_defaults_to_annotations.go @@ -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 } diff --git a/db/migrations/20200220143731_change_duration_to_float.go b/db/migrations/20200220143731_change_duration_to_float.go index 72b785ef8..ea5465ade 100644 --- a/db/migrations/20200220143731_change_duration_to_float.go +++ b/db/migrations/20200220143731_change_duration_to_float.go @@ -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 } diff --git a/db/migrations/20200310171621_enable_search_by_albumartist.go b/db/migrations/20200310171621_enable_search_by_albumartist.go index 373e0a475..73436c890 100644 --- a/db/migrations/20200310171621_enable_search_by_albumartist.go +++ b/db/migrations/20200310171621_enable_search_by_albumartist.go @@ -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 } diff --git a/db/migrations/20200310181627_add_transcoding_and_player_tables.go b/db/migrations/20200310181627_add_transcoding_and_player_tables.go index 3be91ac35..ef872c4ae 100644 --- a/db/migrations/20200310181627_add_transcoding_and_player_tables.go +++ b/db/migrations/20200310181627_add_transcoding_and_player_tables.go @@ -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; `) diff --git a/db/migrations/20200319211049_merge_search_into_main_tables.go b/db/migrations/20200319211049_merge_search_into_main_tables.go index f888cdd4c..a7a6ff0f9 100644 --- a/db/migrations/20200319211049_merge_search_into_main_tables.go +++ b/db/migrations/20200319211049_merge_search_into_main_tables.go @@ -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 } diff --git a/db/migrations/20200325185135_add_album_artist_id.go b/db/migrations/20200325185135_add_album_artist_id.go index f01f2c558..01537f886 100644 --- a/db/migrations/20200325185135_add_album_artist_id.go +++ b/db/migrations/20200325185135_add_album_artist_id.go @@ -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 } diff --git a/db/migrations/20200326090707_fix_album_artists_importing.go b/db/migrations/20200326090707_fix_album_artists_importing.go index c42e8c327..17afe37fe 100644 --- a/db/migrations/20200326090707_fix_album_artists_importing.go +++ b/db/migrations/20200326090707_fix_album_artists_importing.go @@ -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 } diff --git a/db/migrations/20200327193744_add_year_range_to_album.go b/db/migrations/20200327193744_add_year_range_to_album.go index 66f2b23e8..d9b048e22 100644 --- a/db/migrations/20200327193744_add_year_range_to_album.go +++ b/db/migrations/20200327193744_add_year_range_to_album.go @@ -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 } diff --git a/db/migrations/20200404214704_add_indexes.go b/db/migrations/20200404214704_add_indexes.go index 6207b0a3d..8b8d8607e 100644 --- a/db/migrations/20200404214704_add_indexes.go +++ b/db/migrations/20200404214704_add_indexes.go @@ -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 } diff --git a/db/migrations/20200409002249_enable_search_by_tracks_artists.go b/db/migrations/20200409002249_enable_search_by_tracks_artists.go index 22006c8af..482341a89 100644 --- a/db/migrations/20200409002249_enable_search_by_tracks_artists.go +++ b/db/migrations/20200409002249_enable_search_by_tracks_artists.go @@ -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 } diff --git a/db/migrations/20200411164603_add_created_and_updated_fields_to_playlists.go b/db/migrations/20200411164603_add_created_and_updated_fields_to_playlists.go index 266dc087d..4aa502b4b 100644 --- a/db/migrations/20200411164603_add_created_and_updated_fields_to_playlists.go +++ b/db/migrations/20200411164603_add_created_and_updated_fields_to_playlists.go @@ -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 } diff --git a/db/migrations/20200418110522_reindex_to_fix_album_years.go b/db/migrations/20200418110522_reindex_to_fix_album_years.go index 22b024cea..54e03f4c6 100644 --- a/db/migrations/20200418110522_reindex_to_fix_album_years.go +++ b/db/migrations/20200418110522_reindex_to_fix_album_years.go @@ -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 } diff --git a/db/migrations/20200419222708_reindex_to_change_full_text_search.go b/db/migrations/20200419222708_reindex_to_change_full_text_search.go index efeb1bb84..89e3ccee5 100644 --- a/db/migrations/20200419222708_reindex_to_change_full_text_search.go +++ b/db/migrations/20200419222708_reindex_to_change_full_text_search.go @@ -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 } diff --git a/db/migrations/20200423204116_add_sort_fields.go b/db/migrations/20200423204116_add_sort_fields.go index 4097a9d60..a51bb2270 100644 --- a/db/migrations/20200423204116_add_sort_fields.go +++ b/db/migrations/20200423204116_add_sort_fields.go @@ -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 } diff --git a/db/migrations/20200508093059_add_artist_song_count.go b/db/migrations/20200508093059_add_artist_song_count.go index aac78e698..72a47bc94 100644 --- a/db/migrations/20200508093059_add_artist_song_count.go +++ b/db/migrations/20200508093059_add_artist_song_count.go @@ -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 } diff --git a/db/migrations/20200512104202_add_disc_subtitle.go b/db/migrations/20200512104202_add_disc_subtitle.go index b3e907d8d..29734e0c0 100644 --- a/db/migrations/20200512104202_add_disc_subtitle.go +++ b/db/migrations/20200512104202_add_disc_subtitle.go @@ -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 } diff --git a/db/migrations/20200516140647_add_playlist_tracks_table.go b/db/migrations/20200516140647_add_playlist_tracks_table.go index fcaae9d8e..59265e410 100644 --- a/db/migrations/20200516140647_add_playlist_tracks_table.go +++ b/db/migrations/20200516140647_add_playlist_tracks_table.go @@ -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 } diff --git a/db/migrations/20200608153717_referential_integrity.go b/db/migrations/20200608153717_referential_integrity.go index 2959237fa..c9c766f7e 100644 --- a/db/migrations/20200608153717_referential_integrity.go +++ b/db/migrations/20200608153717_referential_integrity.go @@ -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 } diff --git a/db/migrations/20200706231659_add_default_transcodings.go b/db/migrations/20200706231659_add_default_transcodings.go index a498d32b0..e87481ae1 100644 --- a/db/migrations/20200706231659_add_default_transcodings.go +++ b/db/migrations/20200706231659_add_default_transcodings.go @@ -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 } diff --git a/db/migrations/20200710211442_add_playlist_path.go b/db/migrations/20200710211442_add_playlist_path.go index 8abfed6cf..32cc8d034 100644 --- a/db/migrations/20200710211442_add_playlist_path.go +++ b/db/migrations/20200710211442_add_playlist_path.go @@ -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 } diff --git a/db/migrations/20200731095603_create_play_queues_table.go b/db/migrations/20200731095603_create_play_queues_table.go index d63a1ecb9..7a27137bc 100644 --- a/db/migrations/20200731095603_create_play_queues_table.go +++ b/db/migrations/20200731095603_create_play_queues_table.go @@ -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 } diff --git a/db/migrations/20200801101355_create_bookmark_table.go b/db/migrations/20200801101355_create_bookmark_table.go index fe68fafd7..df814d7b8 100644 --- a/db/migrations/20200801101355_create_bookmark_table.go +++ b/db/migrations/20200801101355_create_bookmark_table.go @@ -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 } diff --git a/db/migrations/20200819111809_drop_email_unique_constraint.go b/db/migrations/20200819111809_drop_email_unique_constraint.go index b2dd4285c..8259ad3fe 100644 --- a/db/migrations/20200819111809_drop_email_unique_constraint.go +++ b/db/migrations/20200819111809_drop_email_unique_constraint.go @@ -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 } diff --git a/db/migrations/20201003111749_add_starred_at_index.go b/db/migrations/20201003111749_add_starred_at_index.go index 7ee7a283f..b46430743 100644 --- a/db/migrations/20201003111749_add_starred_at_index.go +++ b/db/migrations/20201003111749_add_starred_at_index.go @@ -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 } diff --git a/db/migrations/20201010162350_add_album_size.go b/db/migrations/20201010162350_add_album_size.go index f1182ab6c..df1fa8ca2 100644 --- a/db/migrations/20201010162350_add_album_size.go +++ b/db/migrations/20201010162350_add_album_size.go @@ -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 } diff --git a/db/migrations/20201012210022_add_artist_playlist_size.go b/db/migrations/20201012210022_add_artist_playlist_size.go index 4eb67f14e..1c738dd1e 100644 --- a/db/migrations/20201012210022_add_artist_playlist_size.go +++ b/db/migrations/20201012210022_add_artist_playlist_size.go @@ -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 } diff --git a/db/migrations/20201021085410_add_mbids.go b/db/migrations/20201021085410_add_mbids.go index 624bb1a67..53001fc73 100644 --- a/db/migrations/20201021085410_add_mbids.go +++ b/db/migrations/20201021085410_add_mbids.go @@ -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 } diff --git a/db/migrations/20201021093209_add_media_file_indexes.go b/db/migrations/20201021093209_add_media_file_indexes.go index f3a800949..7d6ad4965 100644 --- a/db/migrations/20201021093209_add_media_file_indexes.go +++ b/db/migrations/20201021093209_add_media_file_indexes.go @@ -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 } diff --git a/db/migrations/20201021135455_add_media_file_artist_index.go b/db/migrations/20201021135455_add_media_file_artist_index.go index ca04d8a20..e8f22c3a7 100644 --- a/db/migrations/20201021135455_add_media_file_artist_index.go +++ b/db/migrations/20201021135455_add_media_file_artist_index.go @@ -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 } diff --git a/db/migrations/20201030162009_add_artist_info_table.go b/db/migrations/20201030162009_add_artist_info_table.go index f2917ae49..e33e15c23 100644 --- a/db/migrations/20201030162009_add_artist_info_table.go +++ b/db/migrations/20201030162009_add_artist_info_table.go @@ -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 } diff --git a/db/migrations/20201110205344_add_comments_and_lyrics.go b/db/migrations/20201110205344_add_comments_and_lyrics.go index 5bb17b8d0..c60917bdd 100644 --- a/db/migrations/20201110205344_add_comments_and_lyrics.go +++ b/db/migrations/20201110205344_add_comments_and_lyrics.go @@ -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 } diff --git a/db/migrations/20201128100726_add_real-path_option.go b/db/migrations/20201128100726_add_real-path_option.go index db102dfa9..4b3f62128 100644 --- a/db/migrations/20201128100726_add_real-path_option.go +++ b/db/migrations/20201128100726_add_real-path_option.go @@ -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 } diff --git a/db/migrations/20201213124814_add_all_artist_ids_to_album.go b/db/migrations/20201213124814_add_all_artist_ids_to_album.go index 170497f5c..81c30d611 100644 --- a/db/migrations/20201213124814_add_all_artist_ids_to_album.go +++ b/db/migrations/20201213124814_add_all_artist_ids_to_album.go @@ -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 } diff --git a/db/migrations/20210322132848_add_timestamp_indexes.go b/db/migrations/20210322132848_add_timestamp_indexes.go index 3341dd3d2..5ed250fea 100644 --- a/db/migrations/20210322132848_add_timestamp_indexes.go +++ b/db/migrations/20210322132848_add_timestamp_indexes.go @@ -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 } diff --git a/db/migrations/20210418232815_fix_album_comments.go b/db/migrations/20210418232815_fix_album_comments.go index 59067640a..3c7ed86c1 100644 --- a/db/migrations/20210418232815_fix_album_comments.go +++ b/db/migrations/20210418232815_fix_album_comments.go @@ -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 } diff --git a/db/migrations/20210430212322_add_bpm_metadata.go b/db/migrations/20210430212322_add_bpm_metadata.go index 721c9e179..00a0f1447 100644 --- a/db/migrations/20210430212322_add_bpm_metadata.go +++ b/db/migrations/20210430212322_add_bpm_metadata.go @@ -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 } diff --git a/db/migrations/20210530121921_create_shares_table.go b/db/migrations/20210530121921_create_shares_table.go index e9208bd69..d9e902a43 100644 --- a/db/migrations/20210530121921_create_shares_table.go +++ b/db/migrations/20210530121921_create_shares_table.go @@ -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 } diff --git a/db/migrations/20210601231734_update_share_fieldnames.go b/db/migrations/20210601231734_update_share_fieldnames.go index 965c0186e..5a459a34c 100644 --- a/db/migrations/20210601231734_update_share_fieldnames.go +++ b/db/migrations/20210601231734_update_share_fieldnames.go @@ -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 } diff --git a/db/migrations/20210616150710_encrypt_all_passwords.go b/db/migrations/20210616150710_encrypt_all_passwords.go index f67e3fb0a..dc8a9abd4 100644 --- a/db/migrations/20210616150710_encrypt_all_passwords.go +++ b/db/migrations/20210616150710_encrypt_all_passwords.go @@ -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 } diff --git a/db/migrations/20210619231716_drop_player_name_unique_constraint.go b/db/migrations/20210619231716_drop_player_name_unique_constraint.go index 200332156..734ffc340 100644 --- a/db/migrations/20210619231716_drop_player_name_unique_constraint.go +++ b/db/migrations/20210619231716_drop_player_name_unique_constraint.go @@ -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 } diff --git a/db/migrations/20210623155401_add_user_prefs_player_scrobbler_enabled.go b/db/migrations/20210623155401_add_user_prefs_player_scrobbler_enabled.go index 5257dfab3..aa5e7a8f0 100644 --- a/db/migrations/20210623155401_add_user_prefs_player_scrobbler_enabled.go +++ b/db/migrations/20210623155401_add_user_prefs_player_scrobbler_enabled.go @@ -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 } diff --git a/db/migrations/20210625223901_add_referential_integrity_to_user_props.go b/db/migrations/20210625223901_add_referential_integrity_to_user_props.go index 033392d93..b2f93b4e3 100644 --- a/db/migrations/20210625223901_add_referential_integrity_to_user_props.go +++ b/db/migrations/20210625223901_add_referential_integrity_to_user_props.go @@ -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 } diff --git a/db/migrations/20210626213026_add_scrobble_buffer.go b/db/migrations/20210626213026_add_scrobble_buffer.go index 1c4d0de2a..75d9d681c 100644 --- a/db/migrations/20210626213026_add_scrobble_buffer.go +++ b/db/migrations/20210626213026_add_scrobble_buffer.go @@ -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 } diff --git a/db/migrations/20210715151153_add_genre_tables.go b/db/migrations/20210715151153_add_genre_tables.go index ab2c54239..143f9c72b 100644 --- a/db/migrations/20210715151153_add_genre_tables.go +++ b/db/migrations/20210715151153_add_genre_tables.go @@ -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 } diff --git a/db/migrations/20210821212604_add_mediafile_channels.go b/db/migrations/20210821212604_add_mediafile_channels.go index 9a0988b17..ee18be01b 100644 --- a/db/migrations/20210821212604_add_mediafile_channels.go +++ b/db/migrations/20210821212604_add_mediafile_channels.go @@ -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 } diff --git a/db/migrations/20211008205505_add_smart_playlist.go b/db/migrations/20211008205505_add_smart_playlist.go index c8ed67c47..0d2d1ad4e 100644 --- a/db/migrations/20211008205505_add_smart_playlist.go +++ b/db/migrations/20211008205505_add_smart_playlist.go @@ -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 } diff --git a/db/migrations/20211023184825_add_order_title_to_media_file.go b/db/migrations/20211023184825_add_order_title_to_media_file.go index ee6fc67d1..4a2ae4047 100644 --- a/db/migrations/20211023184825_add_order_title_to_media_file.go +++ b/db/migrations/20211023184825_add_order_title_to_media_file.go @@ -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 } diff --git a/db/migrations/20211026191915_unescape_lyrics_and_comments.go b/db/migrations/20211026191915_unescape_lyrics_and_comments.go index d4ba5e194..a7969ffed 100644 --- a/db/migrations/20211026191915_unescape_lyrics_and_comments.go +++ b/db/migrations/20211026191915_unescape_lyrics_and_comments.go @@ -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 } diff --git a/db/migrations/20211029213200_add_userid_to_playlist.go b/db/migrations/20211029213200_add_userid_to_playlist.go index e262fc205..909ea1c54 100644 --- a/db/migrations/20211029213200_add_userid_to_playlist.go +++ b/db/migrations/20211029213200_add_userid_to_playlist.go @@ -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 } diff --git a/db/migrations/20211102215414_add_alphabetical_by_artist_index.go b/db/migrations/20211102215414_add_alphabetical_by_artist_index.go index 4ab4305d0..f786b69e7 100644 --- a/db/migrations/20211102215414_add_alphabetical_by_artist_index.go +++ b/db/migrations/20211102215414_add_alphabetical_by_artist_index.go @@ -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 } diff --git a/db/migrations/20211105162746_remove_invalid_artist_ids.go b/db/migrations/20211105162746_remove_invalid_artist_ids.go index 5e078c820..8c9887dd1 100644 --- a/db/migrations/20211105162746_remove_invalid_artist_ids.go +++ b/db/migrations/20211105162746_remove_invalid_artist_ids.go @@ -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 } diff --git a/db/migrations/20220724231849_add_musicbrainz_release_track_id.go b/db/migrations/20220724231849_add_musicbrainz_release_track_id.go index 481762117..42e13a1e5 100644 --- a/db/migrations/20220724231849_add_musicbrainz_release_track_id.go +++ b/db/migrations/20220724231849_add_musicbrainz_release_track_id.go @@ -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 } diff --git a/db/migrations/20221219112733_add_album_image_paths.go b/db/migrations/20221219112733_add_album_image_paths.go index ee9c77c8a..f8ebd40e9 100644 --- a/db/migrations/20221219112733_add_album_image_paths.go +++ b/db/migrations/20221219112733_add_album_image_paths.go @@ -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 } diff --git a/db/migrations/20221219140528_remove_cover_art_id.go b/db/migrations/20221219140528_remove_cover_art_id.go index a1eaa89f9..30f86297a 100644 --- a/db/migrations/20221219140528_remove_cover_art_id.go +++ b/db/migrations/20221219140528_remove_cover_art_id.go @@ -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 } diff --git a/db/migrations/20230112111457_add_album_paths.go b/db/migrations/20230112111457_add_album_paths.go index 2dfb9a747..2819522a1 100644 --- a/db/migrations/20230112111457_add_album_paths.go +++ b/db/migrations/20230112111457_add_album_paths.go @@ -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 } diff --git a/db/migrations/20230114121537_touch_playlists.go b/db/migrations/20230114121537_touch_playlists.go index 0f10e275c..71959b0a8 100644 --- a/db/migrations/20230114121537_touch_playlists.go +++ b/db/migrations/20230114121537_touch_playlists.go @@ -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 } diff --git a/db/migrations/20230115103212_create_internet_radio.go b/db/migrations/20230115103212_create_internet_radio.go index 5c014dac2..3e0da348f 100644 --- a/db/migrations/20230115103212_create_internet_radio.go +++ b/db/migrations/20230115103212_create_internet_radio.go @@ -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 } diff --git a/db/migrations/20230117155559_add_replaygain_metadata.go b/db/migrations/20230117155559_add_replaygain_metadata.go index d6be3b313..3aad70925 100644 --- a/db/migrations/20230117155559_add_replaygain_metadata.go +++ b/db/migrations/20230117155559_add_replaygain_metadata.go @@ -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 } diff --git a/db/migrations/20230117180400_add_album_info.go b/db/migrations/20230117180400_add_album_info.go index 5d6dd8230..750d3838f 100644 --- a/db/migrations/20230117180400_add_album_info.go +++ b/db/migrations/20230117180400_add_album_info.go @@ -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 } diff --git a/db/migrations/20230119152657_recreate_share_table.go b/db/migrations/20230119152657_recreate_share_table.go index e1ae816c0..10eff31ca 100644 --- a/db/migrations/20230119152657_recreate_share_table.go +++ b/db/migrations/20230119152657_recreate_share_table.go @@ -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 } diff --git a/db/migrations/20230202143713_change_path_list_separator.go b/db/migrations/20230202143713_change_path_list_separator.go index 78b030ae4..fb5f2be1a 100644 --- a/db/migrations/20230202143713_change_path_list_separator.go +++ b/db/migrations/20230202143713_change_path_list_separator.go @@ -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 } diff --git a/db/migrations/20230209181414_change_image_files_list_separator.go b/db/migrations/20230209181414_change_image_files_list_separator.go index 7f4d4cb0e..e5dc4ab43 100644 --- a/db/migrations/20230209181414_change_image_files_list_separator.go +++ b/db/migrations/20230209181414_change_image_files_list_separator.go @@ -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 } diff --git a/db/migrations/20230310222612_add_download_to_share.go b/db/migrations/20230310222612_add_download_to_share.go index ed2879ec3..3ee24cc77 100644 --- a/db/migrations/20230310222612_add_download_to_share.go +++ b/db/migrations/20230310222612_add_download_to_share.go @@ -11,14 +11,14 @@ func init() { goose.AddMigrationContext(upAddDownloadToShare, downAddDownloadToShare) } -func upAddDownloadToShare(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddDownloadToShare(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table share add downloadable bool not null default false; `) return err } -func downAddDownloadToShare(_ context.Context, tx *sql.Tx) error { +func downAddDownloadToShare(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230515184510_add_release_date.go b/db/migrations/20230515184510_add_release_date.go index 1141a1e74..f22bdfae8 100644 --- a/db/migrations/20230515184510_add_release_date.go +++ b/db/migrations/20230515184510_add_release_date.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddRelRecYear, downAddRelRecYear) } -func upAddRelRecYear(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddRelRecYear(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add date varchar(255) default '' not null; alter table media_file @@ -41,10 +41,10 @@ alter table album 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 downAddRelRecYear(_ context.Context, tx *sql.Tx) error { +func downAddRelRecYear(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230616214944_rename_musicbrainz_recording_id.go b/db/migrations/20230616214944_rename_musicbrainz_recording_id.go index 170fc264c..562a59bb5 100644 --- a/db/migrations/20230616214944_rename_musicbrainz_recording_id.go +++ b/db/migrations/20230616214944_rename_musicbrainz_recording_id.go @@ -11,16 +11,16 @@ func init() { goose.AddMigrationContext(upRenameMusicbrainzRecordingId, downRenameMusicbrainzRecordingId) } -func upRenameMusicbrainzRecordingId(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upRenameMusicbrainzRecordingId(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file rename column mbz_track_id to mbz_recording_id; `) return err } -func downRenameMusicbrainzRecordingId(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func downRenameMusicbrainzRecordingId(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file rename column mbz_recording_id to mbz_track_id; `) diff --git a/db/migrations/20231209211223_alter_lyric_column.go b/db/migrations/20231209211223_alter_lyric_column.go index ac73fc98f..259a37745 100644 --- a/db/migrations/20231209211223_alter_lyric_column.go +++ b/db/migrations/20231209211223_alter_lyric_column.go @@ -29,7 +29,7 @@ func upAlterLyricColumn(ctx context.Context, tx *sql.Tx) error { return err } - rows, err := tx.Query(`select id, lyrics_old FROM media_file WHERE lyrics_old <> '';`) + rows, err := tx.QueryContext(ctx, `select id, lyrics_old FROM media_file WHERE lyrics_old <> '';`) if err != nil { return err } @@ -46,12 +46,12 @@ func upAlterLyricColumn(ctx context.Context, tx *sql.Tx) error { continue } - lyrics, err := model.ToLyrics("xxx", lyrics.String) + parsed, err := model.ParseLyrics(".lrc", "xxx", []byte(lyrics.String)) if err != nil { return err } - text, err := json.Marshal(model.LyricList{*lyrics}) + text, err := json.Marshal(parsed) if err != nil { return err } @@ -72,7 +72,7 @@ func upAlterLyricColumn(ctx context.Context, tx *sql.Tx) error { return err } - notice(tx, "A full rescan should be performed to pick up additional lyrics (existing lyrics have been preserved)") + notice(ctx, tx, "A full rescan should be performed to pick up additional lyrics (existing lyrics have been preserved)") return nil } diff --git a/db/migrations/20240122223340_add_default_values_to_null_columns.go.go b/db/migrations/20240122223340_add_default_values_to_null_columns.go.go index a65b0aefd..518d125e4 100644 --- a/db/migrations/20240122223340_add_default_values_to_null_columns.go.go +++ b/db/migrations/20240122223340_add_default_values_to_null_columns.go.go @@ -558,6 +558,6 @@ create index media_file_mbz_track_id return err } -func Down20240122223340(context.Context, *sql.Tx) error { +func Down20240122223340(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20240511210036_add_sample_rate.go b/db/migrations/20240511210036_add_sample_rate.go index 619cdcffd..76b809c36 100644 --- a/db/migrations/20240511210036_add_sample_rate.go +++ b/db/migrations/20240511210036_add_sample_rate.go @@ -19,7 +19,7 @@ alter table media_file create index if not exists media_file_sample_rate on media_file (sample_rate); `) - notice(tx, "A full rescan should be performed to pick up additional tags") + notice(ctx, tx, "A full rescan should be performed to pick up additional tags") return err } diff --git a/db/migrations/20240629152843_remove_annotation_id.go b/db/migrations/20240629152843_remove_annotation_id.go index b450b26d4..972932e10 100644 --- a/db/migrations/20240629152843_remove_annotation_id.go +++ b/db/migrations/20240629152843_remove_annotation_id.go @@ -61,6 +61,6 @@ create index annotation_starred_at return err } -func downRemoveAnnotationId(ctx context.Context, tx *sql.Tx) error { +func downRemoveAnnotationId(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20241026183640_support_new_scanner.go b/db/migrations/20241026183640_support_new_scanner.go index fcbef7e4e..f5899f08f 100644 --- a/db/migrations/20241026183640_support_new_scanner.go +++ b/db/migrations/20241026183640_support_new_scanner.go @@ -97,8 +97,8 @@ insert into property (id, value) values ('PIDTrack', 'track_legacy') on conflict insert into property (id, value) values ('PIDAlbum', 'album_legacy') on conflict do nothing; `), func() error { - notice(tx, "A full scan will be triggered to populate the new tables. This may take a while.") - return forceFullRescan(tx) + notice(ctx, tx, "A full scan will be triggered to populate the new tables. This may take a while.") + return forceFullRescan(ctx, tx) }, ) } @@ -314,6 +314,6 @@ alter table artist } } -func downSupportNewScanner(context.Context, *sql.Tx) error { +func downSupportNewScanner(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20250611010101_playqueue_current_to_index.go b/db/migrations/20250611010101_playqueue_current_to_index.go index d9250eba2..1b83c0b35 100644 --- a/db/migrations/20250611010101_playqueue_current_to_index.go +++ b/db/migrations/20250611010101_playqueue_current_to_index.go @@ -75,6 +75,6 @@ create table playqueue_dg_tmp( return err } -func downPlayQueueCurrentToIndex(ctx context.Context, tx *sql.Tx) error { +func downPlayQueueCurrentToIndex(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20250701010101_add_folder_hash.go b/db/migrations/20250701010101_add_folder_hash.go index e82a0749f..c350d31f5 100644 --- a/db/migrations/20250701010101_add_folder_hash.go +++ b/db/migrations/20250701010101_add_folder_hash.go @@ -16,6 +16,6 @@ func upAddFolderHash(ctx context.Context, tx *sql.Tx) error { return err } -func downAddFolderHash(ctx context.Context, tx *sql.Tx) error { +func downAddFolderHash(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20250701010103_add_library_stats.go b/db/migrations/20250701010103_add_library_stats.go index 8025229cc..a84758a04 100644 --- a/db/migrations/20250701010103_add_library_stats.go +++ b/db/migrations/20250701010103_add_library_stats.go @@ -43,6 +43,6 @@ update library set return err } -func downAddLibraryStats(ctx context.Context, tx *sql.Tx) error { +func downAddLibraryStats(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20250701010104_make_replaygain_fields_nullable.go b/db/migrations/20250701010104_make_replaygain_fields_nullable.go index 163608d32..c6beb2a51 100644 --- a/db/migrations/20250701010104_make_replaygain_fields_nullable.go +++ b/db/migrations/20250701010104_make_replaygain_fields_nullable.go @@ -39,7 +39,7 @@ ALTER TABLE media_file RENAME COLUMN rg_track_peak_new TO rg_track_peak; return err } - notice(tx, "Fetching replaygain fields properly will require a full scan") + notice(ctx, tx, "Fetching replaygain fields properly will require a full scan") return nil } diff --git a/db/migrations/20260220173400_add_fts5_search.go b/db/migrations/20260220173400_add_fts5_search.go index dc4cd647b..6f2bde429 100644 --- a/db/migrations/20260220173400_add_fts5_search.go +++ b/db/migrations/20260220173400_add_fts5_search.go @@ -22,7 +22,7 @@ func stripPunct(col string) string { } func upAddFts5Search(ctx context.Context, tx *sql.Tx) error { - notice(tx, "Adding FTS5 full-text search indexes. This may take a moment on large libraries.") + notice(ctx, tx, "Adding FTS5 full-text search indexes. This may take a moment on large libraries.") // Step 1: Add search_participants and search_normalized columns to media_file, album, and artist _, err := tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN search_participants TEXT NOT NULL DEFAULT ''`) diff --git a/db/migrations/20260307175815_add_codec_and_update_transcodings.go b/db/migrations/20260307175815_add_codec_and_update_transcodings.go index 4e8b1b7f5..f52f48440 100644 --- a/db/migrations/20260307175815_add_codec_and_update_transcodings.go +++ b/db/migrations/20260307175815_add_codec_and_update_transcodings.go @@ -12,20 +12,20 @@ func init() { goose.AddMigrationContext(upAddCodecAndUpdateTranscodings, downAddCodecAndUpdateTranscodings) } -func upAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error { +func upAddCodecAndUpdateTranscodings(ctx context.Context, tx *sql.Tx) error { // Add codec column to media_file. - _, err := tx.Exec(`ALTER TABLE media_file ADD COLUMN codec VARCHAR(255) DEFAULT '' NOT NULL`) + _, err := tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN codec VARCHAR(255) DEFAULT '' NOT NULL`) if err != nil { return err } - _, err = tx.Exec(`CREATE INDEX IF NOT EXISTS media_file_codec ON media_file(codec)`) + _, err = tx.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS media_file_codec ON media_file(codec)`) if err != nil { return err } // Update old AAC default (adts) to new default (ipod with fragmented MP4). // Only affects users who still have the unmodified old default command. - _, err = tx.Exec( + _, err = tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND command = ?`, "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", @@ -36,12 +36,12 @@ func upAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error { // Add FLAC transcoding for existing installations that were seeded before FLAC was added. var count int - err = tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = 'flac'").Scan(&count) + err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM transcoding WHERE target_format = 'flac'").Scan(&count) if err != nil { return err } if count == 0 { - _, err = tx.Exec( + _, err = tx.ExecContext(ctx, "INSERT INTO transcoding (id, name, target_format, default_bit_rate, command) VALUES (?, ?, ?, ?, ?)", id.NewRandom(), "flac audio", "flac", 0, "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -", @@ -52,22 +52,22 @@ func upAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error { } // Add probe_data column for caching ffprobe results. - _, err = tx.Exec(`ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT NULL`) + _, err = tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT NULL`) if err != nil { return err } return nil } -func downAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(`ALTER TABLE media_file DROP COLUMN probe_data`) +func downAddCodecAndUpdateTranscodings(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `ALTER TABLE media_file DROP COLUMN probe_data`) if err != nil { return err } - _, err = tx.Exec(`DROP INDEX IF EXISTS media_file_codec`) + _, err = tx.ExecContext(ctx, `DROP INDEX IF EXISTS media_file_codec`) if err != nil { return err } - _, err = tx.Exec(`ALTER TABLE media_file DROP COLUMN codec`) + _, err = tx.ExecContext(ctx, `ALTER TABLE media_file DROP COLUMN codec`) return err } diff --git a/db/migrations/20260309120007_fix_probe_data_null.go b/db/migrations/20260309120007_fix_probe_data_null.go index a7e7366ed..c76d6ed1a 100644 --- a/db/migrations/20260309120007_fix_probe_data_null.go +++ b/db/migrations/20260309120007_fix_probe_data_null.go @@ -11,18 +11,18 @@ func init() { goose.AddMigrationContext(upFixProbeDataNull, downFixProbeDataNull) } -func upFixProbeDataNull(_ context.Context, tx *sql.Tx) error { +func upFixProbeDataNull(ctx context.Context, tx *sql.Tx) error { // Recreate probe_data column as NOT NULL with empty string default. // The previous migration created it with DEFAULT NULL, which causes // scan errors when reading into Go string fields. - _, err := tx.Exec(`ALTER TABLE media_file DROP COLUMN probe_data`) + _, err := tx.ExecContext(ctx, `ALTER TABLE media_file DROP COLUMN probe_data`) if err != nil { return err } - _, err = tx.Exec(`ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT '' NOT NULL`) + _, err = tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT '' NOT NULL`) return err } -func downFixProbeDataNull(_ context.Context, tx *sql.Tx) error { +func downFixProbeDataNull(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20260309203355_ensure_default_transcodings.go b/db/migrations/20260309203355_ensure_default_transcodings.go index ab6d24952..4df66d26d 100644 --- a/db/migrations/20260309203355_ensure_default_transcodings.go +++ b/db/migrations/20260309203355_ensure_default_transcodings.go @@ -13,7 +13,7 @@ func init() { goose.AddMigrationContext(upEnsureDefaultTranscodings, downEnsureDefaultTranscodings) } -func upEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error { +func upEnsureDefaultTranscodings(ctx context.Context, tx *sql.Tx) error { // Older installations may be missing default transcodings that were added // after the initial seeding (e.g., aac was added later than mp3/opus). // Insert any missing defaults without touching user-customized entries. @@ -22,12 +22,12 @@ func upEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error { // but the same name. for _, t := range consts.DefaultTranscodings { var count int - err := tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = ? OR name = ?", t.TargetFormat, t.Name).Scan(&count) + err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM transcoding WHERE target_format = ? OR name = ?", t.TargetFormat, t.Name).Scan(&count) if err != nil { return err } if count == 0 { - _, err = tx.Exec( + _, err = tx.ExecContext(ctx, "INSERT INTO transcoding (id, name, target_format, default_bit_rate, command) VALUES (?, ?, ?, ?, ?)", id.NewRandom(), t.Name, t.TargetFormat, t.DefaultBitRate, t.Command, ) @@ -39,6 +39,6 @@ func upEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error { return nil } -func downEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error { +func downEnsureDefaultTranscodings(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20260310113858_fix_aac_transcode_command.go b/db/migrations/20260310113858_fix_aac_transcode_command.go index 588137383..a4fa8fcc1 100644 --- a/db/migrations/20260310113858_fix_aac_transcode_command.go +++ b/db/migrations/20260310113858_fix_aac_transcode_command.go @@ -11,20 +11,20 @@ func init() { goose.AddMigrationContext(upFixAacTranscodeCommand, downFixAacTranscodeCommand) } -func upFixAacTranscodeCommand(_ context.Context, tx *sql.Tx) error { +func upFixAacTranscodeCommand(ctx context.Context, tx *sql.Tx) error { // The old AAC command used `-f ipod -movflags frag_keyframe+empty_moov` which produces // corrupt/silent audio when ffmpeg pipes to stdout (confirmed in ffmpeg 8.0+). // Switch to `-f adts` (raw AAC framing) which works reliably via pipe. // Only update rows that still have the old default command. const oldCommand = "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -" const newCommand = "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -" - _, err := tx.Exec( + _, err := tx.ExecContext(ctx, "UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND command = ?", newCommand, oldCommand, ) return err } -func downFixAacTranscodeCommand(_ context.Context, tx *sql.Tx) error { +func downFixAacTranscodeCommand(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20260513173954_move_ss_before_input.go b/db/migrations/20260513173954_move_ss_before_input.go index c16583aa0..472ae7b43 100644 --- a/db/migrations/20260513173954_move_ss_before_input.go +++ b/db/migrations/20260513173954_move_ss_before_input.go @@ -36,18 +36,18 @@ var ssSeekPairs = [][2]string{ }, } -func upMoveSsBeforeInput(_ context.Context, tx *sql.Tx) error { +func upMoveSsBeforeInput(ctx context.Context, tx *sql.Tx) error { for _, p := range ssSeekPairs { - if _, err := tx.Exec(`UPDATE transcoding SET command = ? WHERE command = ?`, p[1], p[0]); err != nil { + if _, err := tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE command = ?`, p[1], p[0]); err != nil { return err } } return nil } -func downMoveSsBeforeInput(_ context.Context, tx *sql.Tx) error { +func downMoveSsBeforeInput(ctx context.Context, tx *sql.Tx) error { for _, p := range ssSeekPairs { - if _, err := tx.Exec(`UPDATE transcoding SET command = ? WHERE command = ?`, p[0], p[1]); err != nil { + if _, err := tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE command = ?`, p[0], p[1]); err != nil { return err } } diff --git a/db/migrations/20260618120509_add_metadata_to_default_transcodings.go b/db/migrations/20260618120509_add_metadata_to_default_transcodings.go new file mode 100644 index 000000000..2186cda91 --- /dev/null +++ b/db/migrations/20260618120509_add_metadata_to_default_transcodings.go @@ -0,0 +1,64 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddMetadataToDefaultTranscodings, downAddMetadataToDefaultTranscodings) +} + +// metadataPairs maps the current default commands (no metadata mapping) to the +// new defaults that preserve source tags. Index 0 = old, index 1 = new. +// +// The new commands add `-map_metadata 0 -map_metadata 0:s:a:0` after `-map 0:a:0`: +// `-map_metadata 0` copies format-level tags (MP3/FLAC sources) and +// `-map_metadata 0:s:a:0` copies tags from the first audio stream (OPUS/OGG +// sources); both are needed because the two source families store tags at +// different levels. Targeting the audio stream explicitly avoids pulling +// metadata from an embedded cover-art/video stream at index 0. +// +// AAC is included for consistency, but its `-f adts` container cannot hold tags, +// so the flags are a no-op there. +// +// Only rows still holding the exact unmodified default are updated, so any +// user-customized command is left untouched. +var metadataPairs = [][2]string{ + { + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f 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 -", + }, + { + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f 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 -", + }, + { + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + "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 -", + }, + { + "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f 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 -", + }, +} + +func upAddMetadataToDefaultTranscodings(ctx context.Context, tx *sql.Tx) error { + for _, p := range metadataPairs { + if _, err := tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE command = ?`, p[1], p[0]); err != nil { + return err + } + } + return nil +} + +func downAddMetadataToDefaultTranscodings(ctx context.Context, tx *sql.Tx) error { + for _, p := range metadataPairs { + if _, err := tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE command = ?`, p[0], p[1]); err != nil { + return err + } + } + return nil +} diff --git a/db/migrations/migration.go b/db/migrations/migration.go index fde6f5817..9b1098af1 100644 --- a/db/migrations/migration.go +++ b/db/migrations/migration.go @@ -12,23 +12,23 @@ import ( ) // Use this in migrations that need to communicate something important (breaking changes, forced reindexes, etc...) -func notice(tx *sql.Tx, msg string) { - if isDBInitialized(tx) { +func notice(ctx context.Context, tx *sql.Tx, msg string) { + if isDBInitialized(ctx, tx) { line := strings.Repeat("*", len(msg)+8) fmt.Printf("\n%s\nNOTICE: %s\n%s\n\n", line, msg, line) } } // Call this in migrations that requires a full rescan -func forceFullRescan(tx *sql.Tx) error { +func forceFullRescan(ctx context.Context, tx *sql.Tx) error { // If a full scan is required, most probably the query optimizer is outdated, so we run `analyze`. if conf.Server.DevOptimizeDB { - _, err := tx.Exec(`ANALYZE;`) + _, err := tx.ExecContext(ctx, `ANALYZE;`) if err != nil { return err } } - _, err := tx.Exec(fmt.Sprintf(` + _, err := tx.ExecContext(ctx, fmt.Sprintf(` INSERT OR REPLACE into property (id, value) values ('%s', '1'); `, consts.FullScanAfterMigrationFlagKey)) return err @@ -44,9 +44,9 @@ var ( initialized bool ) -func isDBInitialized(tx *sql.Tx) bool { +func isDBInitialized(ctx context.Context, tx *sql.Tx) bool { once.Do(func() { - rows, err := tx.Query("select count(*) from property where id=?", consts.InitialSetupFlagKey) + rows, err := tx.QueryContext(ctx, "select count(*) from property where id=?", consts.InitialSetupFlagKey) checkErr(err) initialized = checkCount(rows) > 0 }) diff --git a/go.mod b/go.mod index 29a415126..91da9e52f 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,11 @@ module github.com/navidrome/navidrome go 1.26 // Fork to implement raw tags support -replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a +replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d require ( github.com/Masterminds/squirrel v1.5.4 - github.com/andybalholm/cascadia v1.3.3 + github.com/andybalholm/cascadia v1.3.4 github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 @@ -19,7 +19,7 @@ require ( github.com/dustin/go-humanize v1.0.1 github.com/extism/go-sdk v1.7.1 github.com/fatih/structs v1.1.0 - github.com/gen2brain/webp v0.5.5 + github.com/gen2brain/webp v0.6.0 github.com/go-chi/chi/v5 v5.3.0 github.com/go-chi/cors v1.2.2 github.com/go-chi/httprate v0.15.0 @@ -32,16 +32,16 @@ require ( github.com/google/wire v0.7.0 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-multierror v1.1.1 - github.com/jellydator/ttlcache/v3 v3.4.0 + github.com/jellydator/ttlcache/v3 v3.4.1 github.com/kardianos/service v1.2.4 github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v3 v3.1.1 - github.com/mattn/go-sqlite3 v1.14.44 + github.com/mattn/go-sqlite3 v1.14.47 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 - github.com/onsi/ginkgo/v2 v2.29.0 - github.com/onsi/gomega v1.41.0 - github.com/pelletier/go-toml/v2 v2.3.1 + github.com/onsi/ginkgo/v2 v2.32.0 + github.com/onsi/gomega v1.42.0 + github.com/pelletier/go-toml/v2 v2.4.1 github.com/pmezard/go-difflib v1.0.0 github.com/pocketbase/dbx v1.12.0 github.com/pressly/goose/v3 v3.27.1 @@ -54,17 +54,17 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 - github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633 + github.com/tetratelabs/wazero v1.12.0 github.com/unrolled/secure v1.17.0 github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 - golang.org/x/image v0.41.0 - golang.org/x/net v0.55.0 - golang.org/x/sync v0.20.0 - golang.org/x/sys v0.45.0 - golang.org/x/term v0.43.0 - golang.org/x/text v0.37.0 + golang.org/x/image v0.43.0 + golang.org/x/net v0.56.0 + golang.org/x/sync v0.21.0 + golang.org/x/sys v0.46.0 + golang.org/x/term v0.44.0 + golang.org/x/text v0.38.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -89,7 +89,7 @@ require ( github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20260507013755-92041b743c96 // indirect + github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -104,7 +104,7 @@ require ( github.com/lestrrat-go/dsig v1.3.0 // indirect github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect - github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.6 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/maruel/natural v1.3.0 // indirect github.com/mfridman/interpolate v0.0.2 // indirect @@ -133,12 +133,12 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/mod v0.36.0 // indirect - golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 // indirect - golang.org/x/tools v0.45.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/telemetry v0.0.0-20260610154732-fb80ec83bdd9 // indirect + golang.org/x/tools v0.46.0 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/ini.v1 v1.67.2 // indirect + gopkg.in/ini.v1 v1.67.3 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect ) diff --git a/go.sum b/go.sum index 57289abfd..1c0dcc9fe 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAw github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= -github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= -github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg= +github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM= github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY= github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= @@ -32,8 +32,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a h1:L5E3uF4hKLEqoEYT0tXXuFH6c3PEEzQSWLfTqF5Lpqw= -github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a/go.mod h1:+k5CamBu88xgydgNGJjugYVeafoCCswoGjpw5w5CvD4= +github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d h1:/MmnVPIlGzX5kYF6sNtMaOHMkjmu0Us7WtDyJZTglMs= +github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8= github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4= @@ -65,8 +65,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/gen2brain/webp v0.5.5 h1:MvQR75yIPU/9nSqYT5h13k4URaJK3gf9tgz/ksRbyEg= -github.com/gen2brain/webp v0.5.5/go.mod h1:xOSMzp4aROt2KFW++9qcK/RBTOVC2S9tJG66ip/9Oc0= +github.com/gen2brain/webp v0.6.0 h1:VN/cmeDv78sAKbJayyB1YVm9SHlz5qXO06bo4v6V3hY= +github.com/gen2brain/webp v0.6.0/go.mod h1:iGWMaCSw7t3I/Cv9llzEKmpnR36S8lS8VL/ZVjxU0JE= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= @@ -101,13 +101,12 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg= github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= -github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M= -github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE= +github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -129,8 +128,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f h1:NW3E2QS github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= -github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= +github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ= +github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= @@ -165,8 +164,8 @@ github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7 github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= -github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM= -github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI= +github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw= github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= @@ -175,8 +174,8 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= -github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo= +github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= @@ -193,12 +192,12 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= -github.com/onsi/ginkgo/v2 v2.29.0 h1:rfh+ZFjgJhYWRoIqVf3Uwx/W20yLrcrE2h2GmYVRaag= -github.com/onsi/ginkgo/v2 v2.29.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= -github.com/onsi/gomega v1.41.0 h1:OwKp4pXNgVxf6sCplzYo794OFNuoL2q2SBMU5NSWOjA= -github.com/onsi/gomega v1.41.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= -github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= -github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E= +github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.42.0 h1:CJby8u36xb7v34W78F8WKvqTQP7PCMIPB78IVDB73l4= +github.com/onsi/gomega v1.42.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= +github.com/pelletier/go-toml/v2 v2.4.1 h1:j5OMOImsH+j2k7GJ5YO+RxfWwohNiH6t5zB/+h3bagc= +github.com/pelletier/go-toml/v2 v2.4.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -279,8 +278,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q= github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk= -github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633 h1:6GN/lazdqr69FIzz1U6c4TF/ppE2dInMR4GzU9QKxjg= -github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633/go.mod h1:3ghOSSWYnzX0zd/3Ns4ni2tKxcXDE9/QgkwuH1PW3Rs= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -295,7 +294,6 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= @@ -311,106 +309,47 @@ go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= -golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= +golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= -golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1rwCn+UELEmwV+VsTLzj/W4sE= -golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260610154732-fb80ec83bdd9 h1:FjUup8XrRy7lv+XHONi6KKUSizeF2NnVrTnz/HhbohQ= +golang.org/x/telemetry v0.0.0-20260610154732-fb80ec83bdd9/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= +golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss= -gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= +gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU= gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/model/folder.go b/model/folder.go index 39cb6db84..81800c072 100644 --- a/model/folder.go +++ b/model/folder.go @@ -93,4 +93,7 @@ type FolderRepository interface { Put(*Folder) error MarkMissing(missing bool, ids ...string) error GetTouchedWithPlaylists() (FolderCursor, error) + // GetAllWithPlaylists returns all non-missing folders with playlists, ignoring + // the scan-timestamp gate used by GetTouchedWithPlaylists. + GetAllWithPlaylists() (FolderCursor, error) } diff --git a/model/lyrics.go b/model/lyrics.go index f75f3b11b..111b1c2a9 100644 --- a/model/lyrics.go +++ b/model/lyrics.go @@ -1,229 +1,93 @@ package model import ( - "cmp" - "regexp" - "slices" - "strconv" + "encoding/json" "strings" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/utils/str" ) +type Cue struct { + Start *int64 `structs:"start,omitempty" json:"start,omitempty"` + End *int64 `structs:"end,omitempty" json:"end,omitempty"` + Value string `structs:"value" json:"value"` + ByteStart int `structs:"byteStart" json:"byteStart"` + ByteEnd int `structs:"byteEnd" json:"byteEnd"` + AgentID string `structs:"agentId,omitempty" json:"agentId,omitempty"` +} + +type Agent struct { + ID string `structs:"id" json:"id"` + Role string `structs:"role" json:"role"` + Name string `structs:"name,omitempty" json:"name,omitempty"` +} + type Line struct { Start *int64 `structs:"start,omitempty" json:"start,omitempty"` + End *int64 `structs:"end,omitempty" json:"end,omitempty"` Value string `structs:"value" json:"value"` + Cue []Cue `structs:"cue,omitempty" json:"cue,omitempty"` } type Lyrics struct { - DisplayArtist string `structs:"displayArtist,omitempty" json:"displayArtist,omitempty"` - DisplayTitle string `structs:"displayTitle,omitempty" json:"displayTitle,omitempty"` - Lang string `structs:"lang" json:"lang"` - Line []Line `structs:"line" json:"line"` - Offset *int64 `structs:"offset,omitempty" json:"offset,omitempty"` - Synced bool `structs:"synced" json:"synced"` + DisplayArtist string `structs:"displayArtist,omitempty" json:"displayArtist,omitempty"` + DisplayTitle string `structs:"displayTitle,omitempty" json:"displayTitle,omitempty"` + Kind string `structs:"kind,omitempty" json:"kind,omitempty"` + Lang string `structs:"lang" json:"lang"` + Agents []Agent `structs:"agents,omitempty" json:"agents,omitempty"` + Line []Line `structs:"line" json:"line"` + Offset *int64 `structs:"offset,omitempty" json:"offset,omitempty"` + Synced bool `structs:"synced" json:"synced"` } -// support the standard [mm:ss.mm], as well as [hh:*] and [*.mmm] -const timeRegexString = `\[([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(.[0-9]{1,3})?\]` - -var ( - // Should either be at the beginning of file, or beginning of line - syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString) - timeRegex = regexp.MustCompile(timeRegexString) - lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`) +// Lyric kinds, as defined by the OpenSubsonic songLyrics v2 contract. These are +// the canonical wire values; keep them in sync with the spec. +const ( + LyricKindMain = "main" + LyricKindTranslation = "translation" + LyricKindPronunciation = "pronunciation" ) func (l Lyrics) IsEmpty() bool { return len(l.Line) == 0 } -func ToLyrics(language, text string) (*Lyrics, error) { - text = str.SanitizeText(text) - - lines := strings.Split(text, "\n") - structuredLines := make([]Line, 0, len(lines)*2) - - artist := "" - title := "" - var offset *int64 = nil - - synced := syncRegex.MatchString(text) - priorLine := "" - validLine := false - repeated := false - var timestamps []int64 - - for _, line := range lines { - line := strings.TrimSpace(line) - if line == "" { - if validLine { - priorLine += "\n" - } - continue - } - var text string - var time *int64 = nil - - if synced { - idTag := lrcIdRegex.FindStringSubmatch(line) - if idTag != nil { - switch idTag[1] { - case "ar": - artist = str.SanitizeText(strings.TrimSpace(idTag[2])) - case "lang": - language = str.SanitizeText(strings.TrimSpace(idTag[2])) - case "offset": - { - off, err := strconv.ParseInt(strings.TrimSpace(idTag[2]), 10, 64) - if err != nil { - log.Warn("Error parsing offset", "offset", idTag[2], "error", err) - } else { - offset = &off - } - } - case "ti": - title = str.SanitizeText(strings.TrimSpace(idTag[2])) - } - - continue - } - - times := timeRegex.FindAllStringSubmatchIndex(line, -1) - if len(times) > 1 { - repeated = true - } - - // The second condition is for when there is a timestamp in the middle of - // a line (after any text) - if times == nil || times[0][0] != 0 { - if validLine { - priorLine += "\n" + line - } - continue - } - - if validLine { - for idx := range timestamps { - structuredLines = append(structuredLines, Line{ - Start: ×tamps[idx], - Value: strings.TrimSpace(priorLine), - }) - } - timestamps = nil - } - - end := 0 - - // [fullStart, fullEnd, hourStart, hourEnd, minStart, minEnd, secStart, secEnd, msStart, msEnd] - for _, match := range times { - // for multiple matches, we need to check that later matches are not - // in the middle of the string - if end != 0 { - middle := strings.TrimSpace(line[end:match[0]]) - if middle != "" { - break - } - } - - end = match[1] - timeInMillis, err := parseTime(line, match) - if err != nil { - return nil, err - } - - timestamps = append(timestamps, timeInMillis) - } - - if end >= len(line) { - priorLine = "" - } else { - priorLine = strings.TrimSpace(line[end:]) - } - - validLine = true - } else { - text = line - structuredLines = append(structuredLines, Line{ - Start: time, - Value: text, - }) - } - } - - if validLine { - for idx := range timestamps { - structuredLines = append(structuredLines, Line{ - Start: ×tamps[idx], - Value: strings.TrimSpace(priorLine), - }) - } - } - - // If there are repeated values, there is no guarantee that they are in order - // In this, case, sort the lyrics by start time - if repeated { - slices.SortFunc(structuredLines, func(a, b Line) int { - return cmp.Compare(*a.Start, *b.Start) - }) - } - - lyrics := Lyrics{ - DisplayArtist: artist, - DisplayTitle: title, - Lang: language, - Line: structuredLines, - Offset: offset, - Synced: synced, - } - return &lyrics, nil +// IsMainKind reports whether the lyric is the main track. A blank kind is an +// untyped (single-track) lyric, which the contract treats as main. +func (l Lyrics) IsMainKind() bool { + return l.EffectiveKind() == LyricKindMain } -func parseTime(line string, match []int) (int64, error) { - var hours, millis int64 - var err error - - hourStart := match[2] - if hourStart != -1 { - // subtract 1 because group has : at the end - hourEnd := match[3] - 1 - hours, err = strconv.ParseInt(line[hourStart:hourEnd], 10, 64) - if err != nil { - return 0, err - } +// EffectiveKind returns the lyric kind, defaulting to LyricKindMain when blank. +// A blank kind means an untyped (single-track) lyric, which the contract treats +// as main. +func (l Lyrics) EffectiveKind() string { + if strings.TrimSpace(l.Kind) == "" { + return LyricKindMain } - - minutes, err := strconv.ParseInt(line[match[4]:match[5]], 10, 64) - if err != nil { - return 0, err - } - - sec, err := strconv.ParseInt(line[match[6]:match[7]], 10, 64) - if err != nil { - return 0, err - } - - msStart := match[8] - if msStart != -1 { - msEnd := match[9] - // +1 offset since this capture group contains . - millis, err = strconv.ParseInt(line[msStart+1:msEnd], 10, 64) - if err != nil { - return 0, err - } - - length := msEnd - msStart - - if length == 3 { - millis *= 10 - } else if length == 2 { - millis *= 100 - } - } - - timeInMillis := (((((hours * 60) + minutes) * 60) + sec) * 1000) + millis - return timeInMillis, nil + return l.Kind } type LyricList []Lyrics + +// MarshalJSON keeps the lyrics column invariant: empty/nil serializes to [], never null. +func (ll LyricList) MarshalJSON() ([]byte, error) { + if len(ll) == 0 { + return []byte("[]"), nil + } + return json.Marshal([]Lyrics(ll)) +} + +// Main returns the main-kind lyric, falling back to the first entry so untyped +// lyrics still resolve. The bool is false only when the list is empty. It is +// used to surface a single lyric through the plain-text legacy getLyrics +// endpoint, which has no notion of translation/pronunciation tracks. +func (ll LyricList) Main() (Lyrics, bool) { + if len(ll) == 0 { + return Lyrics{}, false + } + for _, l := range ll { + if l.IsMainKind() { + return l, true + } + } + return ll[0], true +} diff --git a/model/lyrics_benchmark_test.go b/model/lyrics_benchmark_test.go new file mode 100644 index 000000000..0de2549e7 --- /dev/null +++ b/model/lyrics_benchmark_test.go @@ -0,0 +1,45 @@ +package model + +import ( + "os" + "path/filepath" + "testing" +) + +// Benchmark payloads are real public-domain lyrics ("Auld Lang Syne", Robert +// Burns, 1788) rendered into every supported format, so the numbers reflect +// realistic content and sizing. The same song across formats makes per-format +// cost directly comparable. Fixtures live in tests/fixtures/lyrics/. +func loadLyricFixture(b *testing.B, name string) []byte { + b.Helper() + contents, err := os.ReadFile(filepath.Join("..", "tests", "fixtures", "lyrics", name)) + if err != nil { + b.Fatal(err) + } + return contents +} + +func benchmarkParse(b *testing.B, suffix, fixture string) { + contents := loadLyricFixture(b, fixture) + b.ReportAllocs() + b.SetBytes(int64(len(contents))) + for b.Loop() { + if _, err := ParseLyrics(suffix, "eng", contents); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkParseLyrics_LRC(b *testing.B) { benchmarkParse(b, ".lrc", "auld-lang-syne.lrc") } +func BenchmarkParseLyrics_Plain(b *testing.B) { benchmarkParse(b, ".txt", "auld-lang-syne.txt") } +func BenchmarkParseLyrics_EnhancedLRC(b *testing.B) { benchmarkParse(b, ".lrc", "auld-lang-syne.elrc") } +func BenchmarkParseLyrics_SRT(b *testing.B) { benchmarkParse(b, ".srt", "auld-lang-syne.srt") } +func BenchmarkParseLyrics_TTML(b *testing.B) { benchmarkParse(b, ".ttml", "auld-lang-syne.ttml") } +func BenchmarkParseLyrics_YAML(b *testing.B) { benchmarkParse(b, ".yaml", "auld-lang-syne.yaml") } + +// Content-sniff path (empty suffix) — what embedded tags and plugins hit. +func BenchmarkParseLyrics_SniffTTML(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.ttml") } +func BenchmarkParseLyrics_SniffSRT(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.srt") } +func BenchmarkParseLyrics_SniffYAML(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.yaml") } +func BenchmarkParseLyrics_SniffLRC(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.lrc") } +func BenchmarkParseLyrics_SniffPlain(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.txt") } diff --git a/model/lyrics_lrc.go b/model/lyrics_lrc.go new file mode 100644 index 000000000..6fde0c8f5 --- /dev/null +++ b/model/lyrics_lrc.go @@ -0,0 +1,350 @@ +package model + +import ( + "cmp" + "regexp" + "slices" + "strconv" + "strings" + "unicode" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/utils/str" +) + +// support the standard [mm:ss.mm], as well as [hh:*] and [*.mmm] +const timeRegexString = `\[([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?\]` + +var ( + // Should either be at the beginning of file, or beginning of line + syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString) + timeRegex = regexp.MustCompile(timeRegexString) + lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`) + + // Enhanced LRC: inline word-level timing markers like <00:12.34> + enhancedLRCTimeString = `<([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?>` + enhancedLRCRegex = regexp.MustCompile(enhancedLRCTimeString) +) + +func parseLRC(language, text string) (*Lyrics, error) { + text = str.SanitizeText(text) + + lines := strings.Split(text, "\n") + structuredLines := make([]Line, 0, len(lines)*2) + + artist := "" + title := "" + var offset *int64 = nil + + synced := syncRegex.MatchString(text) + priorLine := "" + validLine := false + repeated := false + var timestamps []int64 + + for _, line := range lines { + line := strings.TrimSpace(line) + if line == "" { + if validLine { + priorLine += "\n" + } + continue + } + var text string + var time *int64 = nil + + if synced { + idTag := lrcIdRegex.FindStringSubmatch(line) + if idTag != nil { + switch idTag[1] { + case "ar": + artist = str.SanitizeText(strings.TrimSpace(idTag[2])) + case "lang": + language = str.SanitizeText(strings.TrimSpace(idTag[2])) + case "offset": + { + off, err := strconv.ParseInt(strings.TrimSpace(idTag[2]), 10, 64) + if err != nil { + log.Warn("Error parsing offset", "offset", idTag[2], "error", err) + } else { + offset = &off + } + } + case "ti": + title = str.SanitizeText(strings.TrimSpace(idTag[2])) + } + + continue + } + + times := timeRegex.FindAllStringSubmatchIndex(line, -1) + if len(times) > 1 { + repeated = true + } + + // The second condition is for when there is a timestamp in the middle of + // a line (after any text) + if len(times) == 0 || times[0][0] != 0 { + if validLine { + priorLine += "\n" + line + } + continue + } + + if validLine { + value, baseCues := parseEnhancedLine(priorLine) + for idx := range timestamps { + startCopy := timestamps[idx] + structuredLines = append(structuredLines, Line{ + Start: &startCopy, + Value: value, + Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]), + }) + } + timestamps = nil + } + + end := 0 + + // [fullStart, fullEnd, hourStart, hourEnd, minStart, minEnd, secStart, secEnd, msStart, msEnd] + for _, match := range times { + // for multiple matches, we need to check that later matches are not + // in the middle of the string + if end != 0 { + middle := strings.TrimSpace(line[end:match[0]]) + if middle != "" { + break + } + } + + end = match[1] + timeInMillis, err := parseTime(line, match) + if err != nil { + return nil, err + } + + timestamps = append(timestamps, timeInMillis) + } + + if end >= len(line) { + priorLine = "" + } else { + priorLine = strings.TrimSpace(line[end:]) + } + + validLine = true + } else { + text = line + structuredLines = append(structuredLines, Line{ + Start: time, + Value: text, + }) + } + } + + if validLine { + value, baseCues := parseEnhancedLine(priorLine) + for idx := range timestamps { + startCopy := timestamps[idx] + structuredLines = append(structuredLines, Line{ + Start: &startCopy, + Value: value, + Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]), + }) + } + } + + // If there are repeated values, there is no guarantee that they are in order + // In this, case, sort the lyrics by start time + if repeated { + slices.SortFunc(structuredLines, func(a, b Line) int { + return cmp.Compare(*a.Start, *b.Start) + }) + } + + lyrics := Lyrics{ + DisplayArtist: artist, + DisplayTitle: title, + Lang: language, + Line: normalizeCueLines(structuredLines), + Offset: offset, + Synced: synced, + } + return &lyrics, nil +} + +// parseEnhancedLine extracts word-level timing cues from Enhanced LRC inline markers +// and computes UTF-8 byte offsets against the final stripped line value. +func parseEnhancedLine(text string) (string, []Cue) { + matches := enhancedLRCRegex.FindAllStringSubmatchIndex(text, -1) + if len(matches) == 0 { + return strings.TrimSpace(text), nil + } + + type segment struct { + start int64 + rawStart int + rawEnd int + } + + segments := make([]segment, 0, len(matches)) + var rawValue strings.Builder + for i, match := range matches { + timeMs, err := parseTime( + // Rewrite <...> as [...] so parseTime can handle it with the same logic + "["+text[match[0]+1:match[1]-1]+"]", + // Adjust match indices to point into our rewritten string (need start/end pairs for each group) + []int{ + 0, match[1] - match[0], + adjustGroup(match, 2), adjustGroup(match, 3), + adjustGroup(match, 4), adjustGroup(match, 5), + adjustGroup(match, 6), adjustGroup(match, 7), + adjustGroup(match, 8), adjustGroup(match, 9), + }, + ) + if err != nil { + continue + } + + // Text runs from after this marker to the start of the next marker (or end of string) + textStart := match[1] + var textEnd int + if i+1 < len(matches) { + textEnd = matches[i+1][0] + } else { + textEnd = len(text) + } + + word := text[textStart:textEnd] + if word == "" { + continue + } + + rawStart := rawValue.Len() + rawValue.WriteString(word) + segments = append(segments, segment{ + start: timeMs, + rawStart: rawStart, + rawEnd: rawValue.Len(), + }) + } + + if len(segments) == 0 { + return strings.TrimSpace(stripEnhancedMarkers(text)), nil + } + + finalRaw := rawValue.String() + leftTrimBytes := len(finalRaw) - len(strings.TrimLeftFunc(finalRaw, unicode.IsSpace)) + rightTrimBytes := len(finalRaw) - len(strings.TrimRightFunc(finalRaw, unicode.IsSpace)) + trimmedEnd := len(finalRaw) - rightTrimBytes + if trimmedEnd < leftTrimBytes { + trimmedEnd = leftTrimBytes + } + + cues := make([]Cue, 0, len(segments)) + for _, seg := range segments { + start := seg.start + byteStart := max(seg.rawStart, leftTrimBytes) + byteEnd := min(seg.rawEnd, trimmedEnd) + if byteStart >= byteEnd { + continue + } + + cues = append(cues, Cue{ + Start: &start, + Value: finalRaw[byteStart:byteEnd], + ByteStart: byteStart - leftTrimBytes, + ByteEnd: byteEnd - leftTrimBytes - 1, + }) + } + + return strings.TrimSpace(finalRaw), cues +} + +// adjustGroup remaps a capture group index from the original match to our rewritten "[...]" string. +// The rewrite shifts by -1 (removed '<', added '[') so positions within the brackets stay the same. +func adjustGroup(match []int, groupIdx int) int { + orig := match[groupIdx] + if orig == -1 { + return -1 + } + // Offset is: original position minus the position of '<' in the original, plus 1 for '[' + return orig - match[0] +} + +// stripEnhancedMarkers removes all inline markers from text, +// returning the plain lyric text. +func stripEnhancedMarkers(text string) string { + return enhancedLRCRegex.ReplaceAllString(text, "") +} + +// shiftELRCCues returns a deep copy of baseCues with each cue's Start/End +// timestamps shifted by offsetMs. Inline ELRC word markers parse to absolute +// timestamps anchored at the line's first occurrence, so repeated-line LRC +// inputs of the form `[t0][t1]...` must shift the cues by (t1-t0) for the +// second occurrence to point at the correct moment. Returned *int64 pointers +// are freshly allocated so the input slice is never aliased into the result. +func shiftELRCCues(baseCues []Cue, offsetMs int64) []Cue { + if len(baseCues) == 0 { + return nil + } + out := make([]Cue, len(baseCues)) + for i, c := range baseCues { + out[i] = c + if c.Start != nil { + s := *c.Start + offsetMs + out[i].Start = &s + } + if c.End != nil { + e := *c.End + offsetMs + out[i].End = &e + } + } + return out +} + +func parseTime(line string, match []int) (int64, error) { + var hours, millis int64 + var err error + + hourStart := match[2] + if hourStart != -1 { + // subtract 1 because group has : at the end + hourEnd := match[3] - 1 + hours, err = strconv.ParseInt(line[hourStart:hourEnd], 10, 64) + if err != nil { + return 0, err + } + } + + minutes, err := strconv.ParseInt(line[match[4]:match[5]], 10, 64) + if err != nil { + return 0, err + } + + sec, err := strconv.ParseInt(line[match[6]:match[7]], 10, 64) + if err != nil { + return 0, err + } + + msStart := match[8] + if msStart != -1 { + msEnd := match[9] + // +1 offset since this capture group contains . + millis, err = strconv.ParseInt(line[msStart+1:msEnd], 10, 64) + if err != nil { + return 0, err + } + + length := msEnd - msStart + + if length == 3 { + millis *= 10 + } else if length == 2 { + millis *= 100 + } + } + + timeInMillis := (((((hours * 60) + minutes) * 60) + sec) * 1000) + millis + return timeInMillis, nil +} diff --git a/model/lyrics_lrc_test.go b/model/lyrics_lrc_test.go new file mode 100644 index 000000000..38f03587a --- /dev/null +++ b/model/lyrics_lrc_test.go @@ -0,0 +1,219 @@ +package model + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("parseLRC", func() { + It("should parse tags with spaces", func() { + lyrics, err := parseLRC("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Lang).To(Equal("eng")) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.DisplayArtist).To(Equal("An artist")) + Expect(lyrics.DisplayTitle).To(Equal("A title")) + Expect(lyrics.Offset).To(Equal(new(int64(1551)))) + }) + + It("Should ignore bad offset", func() { + lyrics, err := parseLRC("xxx", "[offset: NotANumber ]\n[00:00.00]Hi there") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Offset).To(BeNil()) + }) + + It("should accept lines with no text and weird times", func() { + lyrics, err := parseLRC("xxx", "[00:00.00]Hi there\n\n\n[00:10.040]\n[00:40]Test\n[01:00:00]late") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(0)), Value: "Hi there"}, + {Start: new(int64(10040)), Value: ""}, + {Start: new(int64(40000)), Value: "Test"}, + {Start: new(int64(1000 * 60 * 60)), Value: "late"}, + })) + }) + + It("Should support multiple timestamps per line", func() { + lyrics, err := parseLRC("xxx", "[00:00.00] [00:10.00]Repeated\n[13:00][51:00:00.00]") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(0)), Value: "Repeated"}, + {Start: new(int64(10000)), Value: "Repeated"}, + {Start: new(int64(13 * 60 * 1000)), Value: ""}, + {Start: new(int64(1000 * 60 * 60 * 51)), Value: ""}, + })) + }) + + It("Should support parsing multiline string", func() { + lyrics, err := parseLRC("xxx", "[00:00.00]This is\na multiline \n\n [:0] string\n[10:00.001]This is\nalso one") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(0)), Value: "This is\na multiline\n\n[:0] string"}, + {Start: new(int64(10*60*1000 + 1)), Value: "This is\nalso one"}, + })) + }) + + It("Does not match timestamp in middle of line", func() { + lyrics, err := parseLRC("xxx", "This could [00:00:00] be a synced file") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeFalse()) + Expect(lyrics.Line).To(Equal([]Line{ + {Value: "This could [00:00:00] be a synced file"}, + })) + }) + + It("Allows timestamp in middle of line if also at beginning", func() { + lyrics, err := parseLRC("xxx", " [00:00] This is [00:00:00] be a synced file\n [00:01]Line 2") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(0)), Value: "This is [00:00:00] be a synced file"}, + {Start: new(int64(1000)), Value: "Line 2"}, + })) + }) + + It("Ignores lines in synchronized lyric prior to first timestamp", func() { + lyrics, err := parseLRC("xxx", "This is some prelude\nThat doesn't\nmatter\n[00:00]Text") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(0)), Value: "Text"}, + })) + }) + + It("Handles all possible ms cases", func() { + lyrics, err := parseLRC("xxx", "[00:00.001]a\n[00:00.01]b\n[00:00.1]c") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(1)), Value: "a"}, + {Start: new(int64(10)), Value: "b"}, + {Start: new(int64(100)), Value: "c"}, + })) + }) + + It("Properly sorts repeated lyrics out of order", func() { + lyrics, err := parseLRC("xxx", "[00:00.00] [13:00]Repeated\n[00:10.00][51:00:00.00]Test\n[00:40.00]Not repeated") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(0)), Value: "Repeated"}, + {Start: new(int64(10000)), Value: "Test"}, + {Start: new(int64(40000)), Value: "Not repeated"}, + {Start: new(int64(13 * 60 * 1000)), Value: "Repeated"}, + {Start: new(int64(1000 * 60 * 60 * 51)), Value: "Test"}, + })) + }) + + It("should parse Enhanced LRC with word-level timing", func() { + lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics <00:02.00>here\n[00:03.00]<00:03.00>More <00:03.50>words") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(HaveLen(2)) + + t1000, t1500, t2000, t3000, t3500 := int64(1000), int64(1500), int64(2000), int64(3000), int64(3500) + + line0 := lyrics.Line[0] + Expect(line0.Start).To(Equal(&t1000)) + Expect(line0.End).To(Equal(&t3000)) + Expect(line0.Value).To(Equal("Some lyrics here")) + Expect(line0.Cue).To(Equal([]Cue{ + {Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, + {Start: &t1500, End: &t2000, Value: "lyrics ", ByteStart: 5, ByteEnd: 11}, + {Start: &t2000, End: &t3000, Value: "here", ByteStart: 12, ByteEnd: 15}, + })) + + line1 := lyrics.Line[1] + Expect(line1.Start).To(Equal(&t3000)) + Expect(line1.End).To(Equal(&t3500)) + Expect(line1.Value).To(Equal("More words")) + Expect(line1.Cue).To(Equal([]Cue{ + {Start: &t3000, Value: "More ", ByteStart: 0, ByteEnd: 4}, + {Start: &t3500, Value: "words", ByteStart: 5, ByteEnd: 9}, + })) + + Expect(line1.Cue[1].End).To(BeNil()) + }) + + It("should not parse malformed Enhanced LRC timing markers", func() { + lyrics, err := parseLRC("xxx", "[00:01.00]<00:01a50>Not a marker") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(1000)), Value: "<00:01a50>Not a marker"}, + })) + }) + + It("should handle mixed Enhanced and plain LRC lines", func() { + lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics\n[00:03.00]Plain line\n[00:05.00]<00:05.00>More <00:05.50>words") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Line).To(HaveLen(3)) + + t1000, t1500, t5000, t5500 := int64(1000), int64(1500), int64(5000), int64(5500) + t3000 := int64(3000) + + Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ + {Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, + {Start: &t1500, End: &t3000, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, + })) + Expect(lyrics.Line[0].Value).To(Equal("Some lyrics")) + Expect(lyrics.Line[0].End).To(Equal(&t3000)) + + Expect(lyrics.Line[1].Cue).To(BeNil()) + Expect(lyrics.Line[1].Value).To(Equal("Plain line")) + + Expect(lyrics.Line[2].Cue).To(Equal([]Cue{ + {Start: &t5000, Value: "More ", ByteStart: 0, ByteEnd: 4}, + {Start: &t5500, Value: "words", ByteStart: 5, ByteEnd: 9}, + })) + Expect(lyrics.Line[2].Value).To(Equal("More words")) + }) + + It("should preserve byte offsets for Enhanced LRC cues", func() { + lyrics, err := parseLRC("xxx", "[00:00.00]<00:00.00>Oh <00:00.90>love<00:01.30> me <00:01.60>tonight") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Line).To(HaveLen(1)) + + t0, t900, t1300, t1600 := int64(0), int64(900), int64(1300), int64(1600) + line := lyrics.Line[0] + Expect(line.Value).To(Equal("Oh love me tonight")) + Expect(line.Cue).To(Equal([]Cue{ + {Start: &t0, Value: "Oh ", ByteStart: 0, ByteEnd: 2}, + {Start: &t900, Value: "love", ByteStart: 3, ByteEnd: 6}, + {Start: &t1300, Value: " me ", ByteStart: 7, ByteEnd: 10}, + {Start: &t1600, Value: "tonight", ByteStart: 11, ByteEnd: 17}, + })) + }) + + It("should shift inline ELRC word timestamps for each repeated line occurrence", func() { + lyrics, err := parseLRC("xxx", "[00:10.00][00:30.00]<00:10.10>Hello <00:10.50>world") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Line).To(HaveLen(2)) + + t10000 := int64(10000) + t10100 := int64(10100) + t10500 := int64(10500) + t30000 := int64(30000) + t30100 := int64(30100) + t30500 := int64(30500) + + Expect(lyrics.Line[0].Start).To(Equal(&t10000)) + Expect(lyrics.Line[0].End).To(Equal(&t30000)) + Expect(lyrics.Line[0].Value).To(Equal("Hello world")) + Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ + {Start: &t10100, End: &t10500, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, + {Start: &t10500, End: &t30000, Value: "world", ByteStart: 6, ByteEnd: 10}, + })) + + Expect(lyrics.Line[1].Start).To(Equal(&t30000)) + Expect(lyrics.Line[1].End).To(Equal(&t30500)) + Expect(lyrics.Line[1].Value).To(Equal("Hello world")) + Expect(lyrics.Line[1].Cue).To(Equal([]Cue{ + {Start: &t30100, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, + {Start: &t30500, Value: "world", ByteStart: 6, ByteEnd: 10}, + })) + }) +}) diff --git a/model/lyrics_lyricsfile.go b/model/lyrics_lyricsfile.go new file mode 100644 index 000000000..49d416f3f --- /dev/null +++ b/model/lyrics_lyricsfile.go @@ -0,0 +1,283 @@ +package model + +import ( + "bytes" + "fmt" + "strings" + + "github.com/navidrome/navidrome/utils/str" + "gopkg.in/yaml.v3" +) + +// parseLyricsfile parses a LRCLIB Lyricsfile YAML document +// (see https://github.com/tranxuanthang/lrcget/blob/main/LYRICSFILE_CONCEPT.md) +// into a model.LyricList containing a single main Lyrics entry. Returns +// (nil, nil) when the input parses as YAML but does not declare Lyricsfile +// version 1.0. +// +// When the source contains per-word timing via lines[].words[], each word +// becomes a model.Cue with inclusive UTF-8 byte offsets into Line.Value, and +// overlapping lines are attributed to synthetic voice agents via lowest-free +// voice ID assignment so the OpenSubsonic v2 enhanced response can split +// parallel vocals. +func parseLyricsfile(lang string, contents []byte) (LyricList, error) { + var doc lyricsfileDocument + dec := yaml.NewDecoder(bytes.NewReader(contents)) + dec.KnownFields(false) + if err := dec.Decode(&doc); err != nil { + return nil, fmt.Errorf("not a valid Lyricsfile YAML: %w", err) + } + + if strings.TrimSpace(doc.Version) != lyricsfileVersion { + return nil, nil + } + + // Fall back to the caller's language when the document omits its own, matching + // the SRT/TTML parsers; normalizeLyricLang yields "xxx" only if both are empty. + docLang := doc.Metadata.Language + if strings.TrimSpace(docLang) == "" { + docLang = lang + } + lyrics := Lyrics{ + DisplayArtist: str.SanitizeText(doc.Metadata.Artist), + DisplayTitle: str.SanitizeText(doc.Metadata.Title), + Lang: normalizeLyricLang(docLang), + Kind: LyricKindMain, + } + if doc.Metadata.OffsetMs != 0 { + off := doc.Metadata.OffsetMs + lyrics.Offset = &off + } + + if doc.Metadata.Instrumental { + return LyricList{normalizeLyrics(lyrics)}, nil + } + + if len(doc.Lines) == 0 { + lines := buildPlainLyricsfileLines(doc.Plain) + if len(lines) == 0 { + return nil, nil + } + lyrics.Line = lines + return LyricList{normalizeLyrics(lyrics)}, nil + } + + lines, agents := buildLyricsfileLines(doc.Lines) + lyrics.Line = lines + lyrics.Agents = agents + lyrics.Synced = true + return LyricList{normalizeLyrics(lyrics)}, nil +} + +const lyricsfileVersion = "1.0" + +type lyricsfileDocument struct { + Version string `yaml:"version"` + Metadata lyricsfileMetadata `yaml:"metadata"` + Lines []lyricsfileLineEntry `yaml:"lines"` + Plain string `yaml:"plain"` +} + +type lyricsfileMetadata struct { + Title string `yaml:"title"` + Artist string `yaml:"artist"` + Album string `yaml:"album"` + DurationMs int64 `yaml:"duration_ms"` + OffsetMs int64 `yaml:"offset_ms"` + Language string `yaml:"language"` + Instrumental bool `yaml:"instrumental"` +} + +type lyricsfileLineEntry struct { + Text string `yaml:"text"` + StartMs int64 `yaml:"start_ms"` + EndMs *int64 `yaml:"end_ms"` + Words []lyricsfileWordEntry `yaml:"words"` +} + +type lyricsfileWordEntry struct { + Text string `yaml:"text"` + StartMs int64 `yaml:"start_ms"` + EndMs *int64 `yaml:"end_ms"` +} + +// buildLyricsfileLines converts YAML line entries to model.Line entries with +// per-cue AgentIDs assigned by streaming overlap clustering (lowest-free +// voice ID). The Agents slice is emitted only when at least one cue carries +// attribution AND more than one voice is used; otherwise AgentIDs are +// stripped so the wire shape stays simple per the OpenSubsonic spec rule +// "agents should not be emitted without cueLine data". +func buildLyricsfileLines(entries []lyricsfileLineEntry) ([]Line, []Agent) { + if len(entries) == 0 { + return nil, nil + } + + // Resolved end timestamps per entry: explicit end_ms, final word end_ms, + // then the next entry's start. The last entry's end stays nil when no + // explicit or word-level end is available. + ends := make([]*int64, len(entries)) + for i := range entries { + var nextStart *int64 + if i+1 < len(entries) { + v := entries[i+1].StartMs + nextStart = &v + } + ends[i] = lyricsfileLineEnd(entries[i], nextStart) + } + + active := map[int]int64{} + maxVoice := -1 + anyCues := false + lines := make([]Line, 0, len(entries)) + + for i, entry := range entries { + for vID, vEnd := range active { + if vEnd <= entry.StartMs { + delete(active, vID) + } + } + + voiceID := 0 + for { + if _, busy := active[voiceID]; !busy { + break + } + voiceID++ + } + if voiceID > maxVoice { + maxVoice = voiceID + } + + agentID := fmt.Sprintf("voice-%d", voiceID) + cues, value := wordsToLineCues(entry, agentID) + if len(cues) > 0 { + anyCues = true + } + + startMs := entry.StartMs + line := Line{ + Start: &startMs, + End: ends[i], + Value: value, + Cue: cues, + } + lines = append(lines, line) + + var endMs int64 + if ends[i] != nil { + endMs = *ends[i] + } else { + endMs = entry.StartMs + } + active[voiceID] = endMs + } + + // Monophonic source, or attribution that has nowhere to land: emit no + // agents and strip per-cue AgentIDs to keep the wire shape simple. + if maxVoice <= 0 || !anyCues { + for i := range lines { + for j := range lines[i].Cue { + lines[i].Cue[j].AgentID = "" + } + } + return lines, nil + } + + agents := make([]Agent, 0, maxVoice+1) + for v := 0; v <= maxVoice; v++ { + role := "voice" + if v == 0 { + role = "main" + } + agents = append(agents, Agent{ + ID: fmt.Sprintf("voice-%d", v), + Role: role, + }) + } + return lines, agents +} + +func lyricsfileLineEnd(entry lyricsfileLineEntry, nextStart *int64) *int64 { + if entry.EndMs != nil { + v := *entry.EndMs + return &v + } + if len(entry.Words) > 0 { + lastWord := entry.Words[len(entry.Words)-1] + if lastWord.EndMs != nil { + v := *lastWord.EndMs + return &v + } + } + if nextStart != nil { + v := *nextStart + return &v + } + return nil +} + +func buildPlainLyricsfileLines(plain string) []Line { + plain = str.SanitizeText(plain) + rawLines := strings.Split(plain, "\n") + lines := make([]Line, 0, len(rawLines)) + for _, raw := range rawLines { + value := strings.TrimSpace(raw) + if value == "" { + continue + } + lines = append(lines, Line{Value: value}) + } + return lines +} + +// wordsToLineCues converts a Lyricsfile line entry's words[] into model.Cue +// entries with inclusive UTF-8 byte offsets into the reconstructed line +// value. The line value is built from cue text concatenation rather than +// trusting entry.Text, because the Lyricsfile spec only requires word.text +// to "approximate" line.text - byte offsets must always land inside +// Line.Value. +func wordsToLineCues(entry lyricsfileLineEntry, agentID string) ([]Cue, string) { + if len(entry.Words) == 0 { + return nil, str.SanitizeText(entry.Text) + } + + var sb strings.Builder + for _, w := range entry.Words { + sb.WriteString(w.Text) + } + lineValue := sb.String() + + cues := make([]Cue, len(entry.Words)) + cursor := 0 + for i, w := range entry.Words { + valueBytes := len(w.Text) + bs := cursor + be := bs + if valueBytes > 0 { + be = bs + valueBytes - 1 + cursor = be + 1 + } + + s := w.StartMs + cue := Cue{ + Start: &s, + Value: w.Text, + ByteStart: bs, + ByteEnd: be, + AgentID: agentID, + } + if w.EndMs != nil { + e := *w.EndMs + cue.End = &e + } + cues[i] = cue + } + + for i := 0; i < len(cues)-1; i++ { + if cues[i].End == nil && cues[i+1].Start != nil { + v := *cues[i+1].Start + cues[i].End = &v + } + } + return cues, lineValue +} diff --git a/model/lyrics_lyricsfile_test.go b/model/lyrics_lyricsfile_test.go new file mode 100644 index 000000000..45899cda7 --- /dev/null +++ b/model/lyrics_lyricsfile_test.go @@ -0,0 +1,300 @@ +package model + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("parseLyricsfile", func() { + DescribeTable("returns nil,nil for YAML without the Lyricsfile version marker", + func(input string) { + lyrics, err := parseLyricsfile("", []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(BeNil()) + }, + Entry("arbitrary YAML", "hello: world\n"), + Entry("Lyricsfile-shaped but unversioned", `metadata: + title: 'Looks close' +lines: + - text: "But should not be claimed" + start_ms: 1000 +`), + ) + + It("returns an error for invalid YAML", func() { + _, err := parseLyricsfile("", []byte("not: valid: yaml: [")) + Expect(err).To(HaveOccurred()) + }) + + It("parses line-level metadata without cues", func() { + input := `version: '1.0' +metadata: + title: 'Sample Track' + artist: 'Test Artist' + language: 'eng' + offset_ms: -100 +lines: + - text: "We're no strangers to love" + start_ms: 18800 + - text: "You know the rules and so do I" + start_ms: 22801 +` + lyrics, err := parseLyricsfile("", []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Kind).To(Equal("main")) + Expect(l.Lang).To(Equal("eng")) + Expect(l.DisplayArtist).To(Equal("Test Artist")) + Expect(l.DisplayTitle).To(Equal("Sample Track")) + Expect(l.Synced).To(BeTrue()) + Expect(l.Offset).ToNot(BeNil()) + Expect(*l.Offset).To(Equal(int64(-100))) + Expect(l.Agents).To(BeNil()) + + Expect(l.Line).To(HaveLen(2)) + Expect(*l.Line[0].Start).To(Equal(int64(18800))) + Expect(l.Line[0].End).ToNot(BeNil()) + Expect(*l.Line[0].End).To(Equal(int64(22801))) + Expect(l.Line[0].Value).To(Equal("We're no strangers to love")) + Expect(l.Line[0].Cue).To(BeNil()) + + Expect(*l.Line[1].Start).To(Equal(int64(22801))) + Expect(l.Line[1].End).To(BeNil()) + Expect(l.Line[1].Value).To(Equal("You know the rules and so do I")) + Expect(l.Line[1].Cue).To(BeNil()) + }) + + DescribeTable("resolves the lyric language", + func(metaLanguage, callerLang, want string) { + input := "version: '1.0'\nmetadata:\n title: 'T'\n" + if metaLanguage != "" { + input += " language: '" + metaLanguage + "'\n" + } + input += "lines:\n - text: \"line\"\n start_ms: 0\n" + + lyrics, err := parseLyricsfile(callerLang, []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + Expect(lyrics[0].Lang).To(Equal(want)) + }, + Entry("prefers the document's own language", "eng", "deu", "eng"), + Entry("falls back to the caller language when metadata omits it", "", "deu", "deu"), + Entry("uses xxx when neither is provided", "", "", "xxx"), + ) + + It("parses plain-only Lyricsfile lyrics as unsynced lines", func() { + input := `version: '1.0' +metadata: + title: 'Plain Track' + artist: 'Plain Artist' + language: 'en' +lines: [] +plain: | + [Verse 1] + First line + + Second line +` + lyrics, err := parseLyricsfile("", []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Kind).To(Equal("main")) + Expect(l.Lang).To(Equal("en")) + Expect(l.DisplayArtist).To(Equal("Plain Artist")) + Expect(l.DisplayTitle).To(Equal("Plain Track")) + Expect(l.Synced).To(BeFalse()) + Expect(l.Agents).To(BeNil()) + Expect(l.Line).To(Equal([]Line{ + {Value: "[Verse 1]"}, + {Value: "First line"}, + {Value: "Second line"}, + })) + }) + + It("produces word cues with inclusive UTF-8 byte offsets for monophonic word data", func() { + input := `version: '1.0' +metadata: + title: 'Karaoke' + artist: 'Singer' + language: 'eng' +lines: + - text: "Hello world" + start_ms: 1000 + end_ms: 3000 + words: + - text: "Hello " + start_ms: 1000 + end_ms: 1500 + - text: "world" + start_ms: 1500 + end_ms: 3000 +` + lyrics, err := parseLyricsfile("", []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Synced).To(BeTrue()) + Expect(l.Agents).To(BeNil()) + Expect(l.Line).To(HaveLen(1)) + + line := l.Line[0] + Expect(*line.Start).To(Equal(int64(1000))) + Expect(*line.End).To(Equal(int64(3000))) + Expect(line.Value).To(Equal("Hello world")) + Expect(line.Cue).To(HaveLen(2)) + + Expect(*line.Cue[0].Start).To(Equal(int64(1000))) + Expect(*line.Cue[0].End).To(Equal(int64(1500))) + Expect(line.Cue[0].Value).To(Equal("Hello ")) + Expect(line.Cue[0].ByteStart).To(Equal(0)) + Expect(line.Cue[0].ByteEnd).To(Equal(5)) + Expect(line.Cue[0].AgentID).To(Equal("")) + + Expect(*line.Cue[1].Start).To(Equal(int64(1500))) + Expect(*line.Cue[1].End).To(Equal(int64(3000))) + Expect(line.Cue[1].Value).To(Equal("world")) + Expect(line.Cue[1].ByteStart).To(Equal(6)) + Expect(line.Cue[1].ByteEnd).To(Equal(10)) + Expect(line.Cue[1].AgentID).To(Equal("")) + }) + + It("prefers final word end_ms over next line start when inferring line end", func() { + input := `version: '1.0' +metadata: + title: 'Overlap From Words' +lines: + - text: "Long vocal" + start_ms: 1000 + words: + - text: "Long " + start_ms: 1000 + end_ms: 2000 + - text: "vocal" + start_ms: 2000 + end_ms: 4000 + - text: "echo" + start_ms: 3000 + end_ms: 3500 + words: + - text: "echo" + start_ms: 3000 + end_ms: 3500 +` + lyrics, err := parseLyricsfile("", []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Agents).To(Equal([]Agent{ + {ID: "voice-0", Role: "main"}, + {ID: "voice-1", Role: "voice"}, + })) + Expect(l.Line).To(HaveLen(2)) + Expect(l.Line[0].End).ToNot(BeNil()) + Expect(*l.Line[0].End).To(Equal(int64(4000))) + Expect(l.Line[0].Cue[1].End).To(Equal(l.Line[0].End)) + Expect(l.Line[1].Cue[0].AgentID).To(Equal("voice-1")) + }) + + It("synthesises voice agents for overlapping lines and attributes per-cue", func() { + input := `version: '1.0' +metadata: + title: 'Duet' +lines: + - text: "Lead vocal" + start_ms: 1000 + end_ms: 4000 + words: + - text: "Lead " + start_ms: 1000 + end_ms: 2000 + - text: "vocal" + start_ms: 2000 + end_ms: 4000 + - text: "echo" + start_ms: 2000 + end_ms: 3000 + words: + - text: "echo" + start_ms: 2000 + end_ms: 3000 +` + lyrics, err := parseLyricsfile("", []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Agents).To(Equal([]Agent{ + {ID: "voice-0", Role: "main"}, + {ID: "voice-1", Role: "voice"}, + })) + Expect(l.Line).To(HaveLen(2)) + + Expect(l.Line[0].Value).To(Equal("Lead vocal")) + Expect(*l.Line[0].Start).To(Equal(int64(1000))) + Expect(*l.Line[0].End).To(Equal(int64(4000))) + Expect(l.Line[0].Cue).To(HaveLen(2)) + Expect(l.Line[0].Cue[0].AgentID).To(Equal("voice-0")) + Expect(l.Line[0].Cue[1].AgentID).To(Equal("voice-0")) + Expect(l.Line[0].Cue[0].ByteStart).To(Equal(0)) + Expect(l.Line[0].Cue[0].ByteEnd).To(Equal(4)) + Expect(l.Line[0].Cue[1].ByteStart).To(Equal(5)) + Expect(l.Line[0].Cue[1].ByteEnd).To(Equal(9)) + + Expect(l.Line[1].Value).To(Equal("echo")) + Expect(*l.Line[1].Start).To(Equal(int64(2000))) + Expect(*l.Line[1].End).To(Equal(int64(3000))) + Expect(l.Line[1].Cue).To(HaveLen(1)) + Expect(l.Line[1].Cue[0].AgentID).To(Equal("voice-1")) + Expect(l.Line[1].Cue[0].ByteStart).To(Equal(0)) + Expect(l.Line[1].Cue[0].ByteEnd).To(Equal(3)) + }) + + It("emits empty lines with Synced=false for instrumental tracks", func() { + input := `version: '1.0' +metadata: + title: 'Solo Piano' + artist: 'Composer' + language: 'eng' + instrumental: true +` + lyrics, err := parseLyricsfile("", []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Kind).To(Equal("main")) + Expect(l.Lang).To(Equal("eng")) + Expect(l.DisplayArtist).To(Equal("Composer")) + Expect(l.DisplayTitle).To(Equal("Solo Piano")) + Expect(l.Synced).To(BeFalse()) + Expect(l.Line).To(BeEmpty()) + Expect(l.Agents).To(BeNil()) + }) + + It("strips agent attribution when overlapping lines carry no cues", func() { + input := `version: '1.0' +lines: + - text: "Lead" + start_ms: 1000 + end_ms: 4000 + - text: "echo" + start_ms: 2000 + end_ms: 3000 +` + lyrics, err := parseLyricsfile("", []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Line).To(HaveLen(2)) + Expect(l.Agents).To(BeNil()) + Expect(l.Line[0].Cue).To(BeNil()) + Expect(l.Line[1].Cue).To(BeNil()) + }) +}) diff --git a/model/lyrics_normalize.go b/model/lyrics_normalize.go new file mode 100644 index 000000000..276aa4da3 --- /dev/null +++ b/model/lyrics_normalize.go @@ -0,0 +1,134 @@ +package model + +import ( + "slices" + + "github.com/navidrome/navidrome/utils/gg" +) + +func normalizeLyrics(lyrics Lyrics) Lyrics { + lyrics.Line = normalizeCueLines(lyrics.Line) + if len(lyrics.Agents) == 0 { + lyrics.Agents = nil + } + return lyrics +} + +func normalizeCueLines(lines []Line) []Line { + if len(lines) == 0 { + return lines + } + + normalized := make([]Line, len(lines)) + copy(normalized, lines) + + for i := range normalized { + if len(normalized[i].Cue) > 0 { + normalized[i].Cue = slices.Clone(normalized[i].Cue) + } + + var fallbackEnd *int64 + if normalized[i].End != nil { + v := *normalized[i].End + fallbackEnd = &v + } else if i+1 < len(normalized) && normalized[i+1].Start != nil { + v := *normalized[i+1].Start + fallbackEnd = &v + } + + normalized[i] = normalizeCueLine(normalized[i], fallbackEnd) + } + + return normalized +} + +func normalizeLineTiming(line Line) Line { + if len(line.Cue) == 0 { + return line + } + + var earliestStart *int64 + var latestEnd *int64 + for i := range line.Cue { + token := line.Cue[i] + if token.Start != nil { + if earliestStart == nil || *token.Start < *earliestStart { + v := *token.Start + earliestStart = &v + } + } + + candidateEnd := token.End + if candidateEnd == nil { + candidateEnd = token.Start + } + if candidateEnd != nil { + if latestEnd == nil || *candidateEnd > *latestEnd { + v := *candidateEnd + latestEnd = &v + } + } + } + + if line.Start == nil && earliestStart != nil { + v := *earliestStart + line.Start = &v + } + if line.End == nil && latestEnd != nil { + v := *latestEnd + line.End = &v + } + return line +} + +func normalizeCueLine(line Line, fallbackEnd *int64) Line { + if len(line.Cue) == 0 { + return line + } + line.Cue = NormalizeCueEnds(line.Cue, fallbackEnd) + return normalizeLineTiming(line) +} + +// NormalizeCueEnds resolves missing cue end times within a single ordered cue +// group: each end is filled from the next cue's start, then from fallbackEnd, +// and is clamped so it never precedes the cue's own start nor overruns the next +// cue. End times are all-or-none — if any cue still lacks an end afterwards, all +// ends in the group are cleared. The input slice is never mutated. +// +// Exported because the Subsonic enhanced-lyrics serializer resolves cue ends +// per agent group while building the response; all other normalization is +// package-internal. +func NormalizeCueEnds(cues []Cue, fallbackEnd *int64) []Cue { + if len(cues) == 0 { + return cues + } + + out := slices.Clone(cues) + for i := range out { + end := out[i].End + if end == nil { + if i+1 < len(out) && out[i+1].Start != nil { + end = out[i+1].Start + } else { + end = fallbackEnd + } + } + if end != nil && i+1 < len(out) && out[i+1].Start != nil && *end > *out[i+1].Start { + end = out[i+1].Start + } + if end != nil && out[i].Start != nil && *end < *out[i].Start { + end = out[i].Start + } + out[i].End = gg.Clone(end) + } + + for i := range out { + if out[i].End == nil { + for j := range out { + out[j].End = nil + } + break + } + } + return out +} diff --git a/model/lyrics_normalize_test.go b/model/lyrics_normalize_test.go new file mode 100644 index 000000000..24faffd54 --- /dev/null +++ b/model/lyrics_normalize_test.go @@ -0,0 +1,120 @@ +package model + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("normalizeCueLines", func() { + It("should not mutate caller cue slices when filling missing cue end times", func() { + start0, start1, nextLineStart := int64(1000), int64(1500), int64(3000) + lines := []Line{ + { + Start: &start0, + Value: "Some lyrics", + Cue: []Cue{ + {Start: &start0, Value: "Some ", ByteStart: 0, ByteEnd: 4}, + {Start: &start1, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, + }, + }, + { + Start: &nextLineStart, + Value: "Next line", + }, + } + + normalized := normalizeCueLines(lines) + + Expect(normalized[0].Cue[0].End).To(Equal(&start1)) + Expect(normalized[0].Cue[1].End).To(Equal(&nextLineStart)) + Expect(lines[0].Cue[0].End).To(BeNil()) + Expect(lines[0].Cue[1].End).To(BeNil()) + }) +}) + +var _ = Describe("NormalizeCueEnds", func() { + // p returns a fresh pointer so cases don't share *int64 state. + p := func(v int64) *int64 { return &v } + + // endsOf extracts the resolved end times (nil-safe) for compact assertions. + endsOf := func(cues []Cue) []*int64 { + out := make([]*int64, len(cues)) + for i := range cues { + out[i] = cues[i].End + } + return out + } + + It("returns the input as-is when empty", func() { + Expect(NormalizeCueEnds(nil, p(1000))).To(BeNil()) + Expect(NormalizeCueEnds([]Cue{}, p(1000))).To(BeEmpty()) + }) + + It("fills a missing end from the next cue's start", func() { + cues := []Cue{ + {Start: p(1000)}, + {Start: p(1500)}, + } + + out := NormalizeCueEnds(cues, p(3000)) + + Expect(endsOf(out)).To(Equal([]*int64{p(1500), p(3000)})) + }) + + It("fills the last cue's missing end from fallbackEnd", func() { + cues := []Cue{ + {Start: p(1000), End: p(1200)}, + {Start: p(1500)}, + } + + out := NormalizeCueEnds(cues, p(3000)) + + Expect(endsOf(out)).To(Equal([]*int64{p(1200), p(3000)})) + }) + + It("clamps an end that overruns the next cue's start", func() { + cues := []Cue{ + {Start: p(1000), End: p(9999)}, + {Start: p(1500), End: p(2000)}, + } + + out := NormalizeCueEnds(cues, p(3000)) + + Expect(endsOf(out)).To(Equal([]*int64{p(1500), p(2000)})) + }) + + It("clamps an end that precedes the cue's own start", func() { + cues := []Cue{ + {Start: p(1000), End: p(500)}, + } + + out := NormalizeCueEnds(cues, p(3000)) + + Expect(endsOf(out)).To(Equal([]*int64{p(1000)})) + }) + + It("clears all ends when any cue still lacks one (all-or-none)", func() { + // The last cue has no end and there is no fallback, so it stays nil and + // every end in the group is cleared. + cues := []Cue{ + {Start: p(1000), End: p(1200)}, + {Start: p(1500)}, + } + + out := NormalizeCueEnds(cues, nil) + + Expect(endsOf(out)).To(Equal([]*int64{nil, nil})) + }) + + It("does not mutate the input slice", func() { + cues := []Cue{ + {Start: p(1000)}, + {Start: p(1500)}, + } + + _ = NormalizeCueEnds(cues, p(3000)) + + Expect(cues[0].End).To(BeNil()) + Expect(cues[1].End).To(BeNil()) + }) +}) diff --git a/model/lyrics_parse.go b/model/lyrics_parse.go new file mode 100644 index 000000000..4bfaa29e8 --- /dev/null +++ b/model/lyrics_parse.go @@ -0,0 +1,73 @@ +package model + +import ( + "bytes" + "fmt" + "slices" + "strings" + + "github.com/navidrome/navidrome/log" +) + +// lyricParser returns an empty list (not an error) when the input is not its +// format, so parsers can be tried in order. lang is the default for formats that +// do not carry their own. +type lyricParser func(lang string, contents []byte) (LyricList, error) + +// lyricFormats is the structured formats in content-sniff probe order; each +// row's suffixes drive sidecar dispatch. LRC/plain is the unlisted fallback floor. +var lyricFormats = []struct { + suffixes []string + parse lyricParser +}{ + {[]string{".ttml"}, parseTTML}, + {[]string{".srt"}, parseSRT}, + {[]string{".yaml", ".yml"}, parseLyricsfile}, +} + +// ParseLyrics is the single entry point for parsing lyrics. A known suffix routes +// to that format's parser; an empty or "auto" suffix content-sniffs. Either way, +// a structured parser that does not match falls back to the LRC/plain-text floor. +func ParseLyrics(suffix, lang string, contents []byte) (LyricList, error) { + contents = stripBOM(contents) + suffix = strings.ToLower(suffix) + sniff := suffix == "" || suffix == "auto" + + // Sniffing tries every format in order; a known suffix selects just its own. + // Unmatched suffixes leave no candidates, so parseFirstMatch falls to plain. + candidates := make([]lyricParser, 0, len(lyricFormats)) + for _, f := range lyricFormats { + if sniff || slices.Contains(f.suffixes, suffix) { + candidates = append(candidates, f.parse) + } + } + return parseFirstMatch(lang, contents, candidates...) +} + +func parseFirstMatch(lang string, contents []byte, candidates ...lyricParser) (LyricList, error) { + for _, parse := range candidates { + list, err := parse(lang, contents) + if err == nil && len(list) > 0 { + return list, nil + } + if err != nil { + log.Warn("Error parsing lyrics, falling back to plain text", "error", err) + } + } + return plainLRC(lang, contents) +} + +func plainLRC(lang string, contents []byte) (LyricList, error) { + lyric, err := parseLRC(lang, string(contents)) + if err != nil { + return nil, fmt.Errorf("parsing lyrics: %w", err) + } + if lyric == nil || lyric.IsEmpty() { + return nil, nil + } + return LyricList{*lyric}, nil +} + +func stripBOM(contents []byte) []byte { + return bytes.TrimPrefix(contents, []byte("\ufeff")) +} diff --git a/model/lyrics_parse_test.go b/model/lyrics_parse_test.go new file mode 100644 index 000000000..eb58a29ef --- /dev/null +++ b/model/lyrics_parse_test.go @@ -0,0 +1,213 @@ +package model + +import ( + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ParseLyrics", func() { + DescribeTable("known suffix routes to the matching parser", + func(suffix, contents string, wantSynced bool, wantFirst string) { + list, err := ParseLyrics(suffix, "eng", []byte(contents)) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Synced).To(Equal(wantSynced)) + Expect(list[0].Line[0].Value).To(Equal(wantFirst)) + }, + Entry(".lrc", ".lrc", "[00:01.00]lrc line", true, "lrc line"), + Entry(".txt plain", ".txt", "plain line", false, "plain line"), + Entry(".srt", ".srt", "1\n00:00:01,000 --> 00:00:02,000\nsrt line\n", true, "srt line"), + Entry(".ttml", ".ttml", `

ttml line

`, true, "ttml line"), + Entry(".yaml", ".yaml", "version: \"1.0\"\nmetadata:\n language: eng\nlines:\n - text: yaml line\n start_ms: 1000\n", true, "yaml line"), + ) + + It("empty suffix content-sniffs (TTML)", func() { + ttml := `

auto ttml

` + list, err := ParseLyrics("", "eng", []byte(ttml)) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line[0].Value).To(Equal("auto ttml")) + }) + + It("empty suffix content-sniffs (YAML)", func() { + yaml := "version: \"1.0\"\nmetadata:\n language: eng\nlines:\n - text: auto yaml\n start_ms: 1000\n" + list, err := ParseLyrics("auto", "eng", []byte(yaml)) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line[0].Value).To(Equal("auto yaml")) + }) + + It("falls back to plain text when a known suffix fails to parse structurally", func() { + list, err := ParseLyrics(".srt", "eng", []byte("not actually an srt file")) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Synced).To(BeFalse()) + Expect(list[0].Line[0].Value).To(Equal("not actually an srt file")) + }) +}) + +var _ = Describe("ParseLyrics content-sniffing", func() { + It("should parse embedded TTML with the tag language as the default", func() { + content := ` + + + + Lead Vocal + + + + +
+

+ Hello world +

+
+ +
` + + list, err := ParseLyrics("", "ENG", []byte(content)) + + // ParseLyrics's job is to detect TTML and apply the tag language as the + // default; the parser's cue/agent details are covered in lyrics_ttml_test.go. + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Kind).To(Equal("main")) + Expect(list[0].Lang).To(Equal("eng")) + Expect(list[0].Synced).To(BeTrue()) + Expect(list[0].Line[0].Value).To(Equal("Hello world")) + }) + + It("should preserve embedded TTML translation and pronunciation tracks", func() { + content := ` + + + + + + Hola + + + + + konni + + + + + + +
+

こんにちは

+
+ +
` + + list, err := ParseLyrics("", "eng", []byte(content)) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(3)) + Expect(list[0].Kind).To(Equal("main")) + Expect(list[0].Lang).To(Equal("ja")) + Expect(list[0].Line[0].Value).To(Equal("こんにちは")) + Expect(list[1].Kind).To(Equal("translation")) + Expect(list[1].Lang).To(Equal("es")) + Expect(list[1].Line[0].Value).To(Equal("Hola")) + Expect(list[2].Kind).To(Equal("pronunciation")) + Expect(list[2].Lang).To(Equal("ja-latn")) + Expect(list[2].Line[0].Value).To(Equal("konni")) + Expect(list[2].Line[0].Cue).To(HaveLen(2)) + }) + + It("should parse embedded SRT with the tag language", func() { + content := `1 +00:00:18,800 --> 00:00:22,800 +We're from subtitles + +2 +00:00:22,801 --> 00:00:26,000 +Another subtitle line` + + list, err := ParseLyrics("", "POR", []byte(content)) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(Equal(LyricList{ + { + Lang: "por", + Line: []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, + }, + })) + }) + + It("should parse embedded SRT blocks separated by whitespace-only blank lines", func() { + content := "1\n00:00:01,000 --> 00:00:02,000\nFirst subtitle\n \n2\n00:00:03,000 --> 00:00:04,000\nSecond subtitle" + + list, err := ParseLyrics("", "eng", []byte(content)) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(Equal([]Line{ + {Start: new(int64(1000)), End: new(int64(2000)), Value: "First subtitle"}, + {Start: new(int64(3000)), End: new(int64(4000)), Value: "Second subtitle"}, + })) + }) + + It("should keep embedded enhanced LRC cues", func() { + content := "[00:01.00]<00:01.00>Lead <00:01.50>words" + + list, err := ParseLyrics("", "eng", []byte(content)) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Lang).To(Equal("eng")) + Expect(list[0].Synced).To(BeTrue()) + Expect(list[0].Line[0].Value).To(Equal("Lead words")) + Expect(list[0].Line[0].Cue).To(HaveLen(2)) + }) + + It("should fall back to plain lyrics when embedded TTML is invalid", func() { + content := ` + +

Broken

+ +
` + + list, err := ParseLyrics("", "eng", []byte(content)) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Lang).To(Equal("eng")) + Expect(list[0].Synced).To(BeFalse()) + Expect(list[0].Line).ToNot(BeEmpty()) + values := make([]string, 0, len(list[0].Line)) + for _, line := range list[0].Line { + values = append(values, line.Value) + } + Expect(strings.Join(values, "\n")).To(ContainSubstring("Broken")) + }) + + It("detects a Lyricsfile YAML payload via content-sniffing", func() { + yaml := "version: \"1.0\"\nmetadata:\n title: Song\n language: eng\nlines:\n - text: sniffed yaml line\n start_ms: 1000\n" + + list, err := ParseLyrics("", "eng", []byte(yaml)) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Synced).To(BeTrue()) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Value).To(Equal("sniffed yaml line")) + }) +}) diff --git a/model/lyrics_srt.go b/model/lyrics_srt.go new file mode 100644 index 000000000..319a59961 --- /dev/null +++ b/model/lyrics_srt.go @@ -0,0 +1,158 @@ +package model + +import ( + "regexp" + "strconv" + "strings" + + "github.com/navidrome/navidrome/utils/str" +) + +var ( + srtTimeRegex = regexp.MustCompile(`^\s*(\d{1,2}):(\d{2}):(\d{2})[,.](\d{1,3})\s*$`) + srtBlockSeparatorRegex = regexp.MustCompile(`\n\s*\n`) +) + +func parseSRT(language string, contents []byte) (LyricList, error) { + raw := strings.ReplaceAll(string(contents), "\r\n", "\n") + raw = strings.ReplaceAll(raw, "\r", "\n") + + blocks := splitSRTBlocks(raw) + lines := make([]Line, 0, len(blocks)) + + for _, block := range blocks { + line, ok, err := parseSRTBlock(block) + if err != nil { + return nil, err + } + if ok { + lines = append(lines, line) + } + } + + if len(lines) == 0 { + return nil, nil + } + + lyrics := normalizeLyrics(Lyrics{ + Lang: normalizeLyricLang(language), + Line: lines, + Synced: true, + }) + return LyricList{lyrics}, nil +} + +func splitSRTBlocks(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + + parts := srtBlockSeparatorRegex.Split(raw, -1) + blocks := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + blocks = append(blocks, part) + } + } + return blocks +} + +func parseSRTBlock(block string) (Line, bool, error) { + rawLines := strings.Split(block, "\n") + lines := make([]string, 0, len(rawLines)) + for _, line := range rawLines { + lines = append(lines, strings.TrimSpace(line)) + } + + if len(lines) == 0 { + return Line{}, false, nil + } + + startIdx := 0 + if digitsOnly(lines[0]) { + startIdx = 1 + } + if startIdx >= len(lines) { + return Line{}, false, nil + } + + timing := strings.Split(lines[startIdx], "-->") + if len(timing) != 2 { + return Line{}, false, nil + } + + startMs, err := parseSRTTime(timing[0]) + if err != nil { + return Line{}, false, err + } + endMs, err := parseSRTTime(timing[1]) + if err != nil { + return Line{}, false, err + } + + textLines := make([]string, 0, len(lines)-startIdx-1) + for _, line := range lines[startIdx+1:] { + if line == "" { + continue + } + textLines = append(textLines, line) + } + + value := str.SanitizeText(strings.Join(textLines, "\n")) + if value == "" { + return Line{}, false, nil + } + + return Line{ + Start: &startMs, + End: &endMs, + Value: value, + }, true, nil +} + +func parseSRTTime(value string) (int64, error) { + match := srtTimeRegex.FindStringSubmatch(strings.TrimSpace(value)) + if match == nil { + return 0, strconv.ErrSyntax + } + + hours, err := strconv.ParseInt(match[1], 10, 64) + if err != nil { + return 0, err + } + minutes, err := strconv.ParseInt(match[2], 10, 64) + if err != nil { + return 0, err + } + seconds, err := strconv.ParseInt(match[3], 10, 64) + if err != nil { + return 0, err + } + millis, err := strconv.ParseInt(match[4], 10, 64) + if err != nil { + return 0, err + } + + switch len(match[4]) { + case 1: + millis *= 100 + case 2: + millis *= 10 + } + + return (((hours*60)+minutes)*60+seconds)*1000 + millis, nil +} + +func digitsOnly(value string) bool { + if value == "" { + return false + } + for _, ch := range value { + if ch < '0' || ch > '9' { + return false + } + } + return true +} diff --git a/model/lyrics_srt_test.go b/model/lyrics_srt_test.go new file mode 100644 index 000000000..2c0ab2242 --- /dev/null +++ b/model/lyrics_srt_test.go @@ -0,0 +1,30 @@ +package model + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("parseSRT", func() { + It("parses SRT blocks with the default language", func() { + content := []byte("1\n00:00:01,000 --> 00:00:02,000\nFirst subtitle\n\n2\n00:00:03,000 --> 00:00:04,000\nSecond subtitle") + + list, err := parseSRT("xxx", content) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Lang).To(Equal("xxx")) + Expect(list[0].Synced).To(BeTrue()) + Expect(list[0].Line).To(Equal([]Line{ + {Start: new(int64(1000)), End: new(int64(2000)), Value: "First subtitle"}, + {Start: new(int64(3000)), End: new(int64(4000)), Value: "Second subtitle"}, + })) + }) + + It("returns nil for input with no valid blocks", func() { + list, err := parseSRT("xxx", []byte("not actually an srt file")) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(BeNil()) + }) +}) diff --git a/model/lyrics_test.go b/model/lyrics_test.go index 644b85ad2..fd954ad26 100644 --- a/model/lyrics_test.go +++ b/model/lyrics_test.go @@ -1,111 +1,69 @@ -package model_test +package model import ( - . "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -var _ = Describe("ToLyrics", func() { - It("should parse tags with spaces", func() { - lyrics, err := ToLyrics("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Lang).To(Equal("eng")) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.DisplayArtist).To(Equal("An artist")) - Expect(lyrics.DisplayTitle).To(Equal("A title")) - Expect(lyrics.Offset).To(Equal(new(int64(1551)))) +var _ = Describe("Lyrics.EffectiveKind", func() { + It("defaults a blank kind to main", func() { + Expect(Lyrics{}.EffectiveKind()).To(Equal(LyricKindMain)) + Expect(Lyrics{Kind: " "}.EffectiveKind()).To(Equal(LyricKindMain)) }) - It("Should ignore bad offset", func() { - lyrics, err := ToLyrics("xxx", "[offset: NotANumber ]\n[00:00.00]Hi there") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Offset).To(BeNil()) - }) - - It("should accept lines with no text and weird times", func() { - lyrics, err := ToLyrics("xxx", "[00:00.00]Hi there\n\n\n[00:10.040]\n[00:40]Test\n[01:00:00]late") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "Hi there"}, - {Start: new(int64(10040)), Value: ""}, - {Start: new(int64(40000)), Value: "Test"}, - {Start: new(int64(1000 * 60 * 60)), Value: "late"}, - })) - }) - - It("Should support multiple timestamps per line", func() { - lyrics, err := ToLyrics("xxx", "[00:00.00] [00:10.00]Repeated\n[13:00][51:00:00.00]") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "Repeated"}, - {Start: new(int64(10000)), Value: "Repeated"}, - {Start: new(int64(13 * 60 * 1000)), Value: ""}, - {Start: new(int64(1000 * 60 * 60 * 51)), Value: ""}, - })) - }) - - It("Should support parsing multiline string", func() { - lyrics, err := ToLyrics("xxx", "[00:00.00]This is\na multiline \n\n [:0] string\n[10:00.001]This is\nalso one") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "This is\na multiline\n\n[:0] string"}, - {Start: new(int64(10*60*1000 + 1)), Value: "This is\nalso one"}, - })) - }) - - It("Does not match timestamp in middle of line", func() { - lyrics, err := ToLyrics("xxx", "This could [00:00:00] be a synced file") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeFalse()) - Expect(lyrics.Line).To(Equal([]Line{ - {Value: "This could [00:00:00] be a synced file"}, - })) - }) - - It("Allows timestamp in middle of line if also at beginning", func() { - lyrics, err := ToLyrics("xxx", " [00:00] This is [00:00:00] be a synced file\n [00:01]Line 2") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "This is [00:00:00] be a synced file"}, - {Start: new(int64(1000)), Value: "Line 2"}, - })) - }) - - It("Ignores lines in synchronized lyric prior to first timestamp", func() { - lyrics, err := ToLyrics("xxx", "This is some prelude\nThat doesn't\nmatter\n[00:00]Text") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "Text"}, - })) - }) - - It("Handles all possible ms cases", func() { - lyrics, err := ToLyrics("xxx", "[00:00.001]a\n[00:00.01]b\n[00:00.1]c") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(1)), Value: "a"}, - {Start: new(int64(10)), Value: "b"}, - {Start: new(int64(100)), Value: "c"}, - })) - }) - - It("Properly sorts repeated lyrics out of order", func() { - lyrics, err := ToLyrics("xxx", "[00:00.00] [13:00]Repeated\n[00:10.00][51:00:00.00]Test\n[00:40.00]Not repeated") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "Repeated"}, - {Start: new(int64(10000)), Value: "Test"}, - {Start: new(int64(40000)), Value: "Not repeated"}, - {Start: new(int64(13 * 60 * 1000)), Value: "Repeated"}, - {Start: new(int64(1000 * 60 * 60 * 51)), Value: "Test"}, - })) + It("returns the kind as-is when set", func() { + Expect(Lyrics{Kind: LyricKindTranslation}.EffectiveKind()).To(Equal(LyricKindTranslation)) + }) +}) + +var _ = Describe("Lyrics.IsMainKind", func() { + It("is true for a blank (untyped) kind", func() { + Expect(Lyrics{}.IsMainKind()).To(BeTrue()) + }) + + It("is true for the main kind", func() { + Expect(Lyrics{Kind: LyricKindMain}.IsMainKind()).To(BeTrue()) + }) + + It("is false for translation and pronunciation kinds", func() { + Expect(Lyrics{Kind: LyricKindTranslation}.IsMainKind()).To(BeFalse()) + Expect(Lyrics{Kind: LyricKindPronunciation}.IsMainKind()).To(BeFalse()) + }) +}) + +var _ = Describe("LyricList.Main", func() { + It("returns false when the list is empty", func() { + _, ok := LyricList{}.Main() + Expect(ok).To(BeFalse()) + }) + + It("returns the main-kind entry when present", func() { + list := LyricList{ + {Kind: LyricKindTranslation, Lang: "en"}, + {Kind: LyricKindMain, Lang: "xxx"}, + } + main, ok := list.Main() + Expect(ok).To(BeTrue()) + Expect(main.Kind).To(Equal(LyricKindMain)) + }) + + It("falls back to the first entry when no main kind exists", func() { + list := LyricList{ + {Kind: LyricKindTranslation, Lang: "en"}, + {Kind: LyricKindPronunciation, Lang: "ja"}, + } + main, ok := list.Main() + Expect(ok).To(BeTrue()) + Expect(main.Lang).To(Equal("en")) + }) + + It("treats a blank kind as main", func() { + list := LyricList{ + {Kind: LyricKindTranslation, Lang: "en"}, + {Lang: "xxx"}, + } + main, ok := list.Main() + Expect(ok).To(BeTrue()) + Expect(main.Lang).To(Equal("xxx")) }) }) diff --git a/model/lyrics_ttml.go b/model/lyrics_ttml.go new file mode 100644 index 000000000..95aab3485 --- /dev/null +++ b/model/lyrics_ttml.go @@ -0,0 +1,1272 @@ +package model + +import ( + "bytes" + "encoding/xml" + "errors" + "io" + "math" + "regexp" + "sort" + "strconv" + "strings" + "unicode" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/utils/gg" + "github.com/navidrome/navidrome/utils/str" +) + +const ( + defaultTTMLFrameRate = 30.0 + defaultTTMLSubFrameRate = 1.0 + defaultTTMLTickRate = 1.0 + + ttmlBackgroundAgentPrefix = "__nd_bg__|" +) + +var offsetTimeRegex = regexp.MustCompile(`^([0-9]+(?:\.[0-9]+)?)(h|m|s|ms|f|t)$`) +var xmlEncodingRegex = regexp.MustCompile(`(?i)<\?xml([^>]*?)encoding\s*=\s*["'][^"']+["']([^>]*)\?>`) + +type ttmlTimeKind int + +const ( + ttmlTimeAbsolute ttmlTimeKind = iota + ttmlTimeOffset + ttmlTimeAmbiguous +) + +type ttmlTimingParams struct { + frameRate float64 + subFrameRate float64 + tickRate float64 +} + +type ttmlTimingContext struct { + lang string + role string + agentID string + begin int64 + hasBegin bool + end int64 + hasEnd bool + invalid bool +} + +type ttmlLineRef struct { + order int + line Line +} + +type ttmlMetadataEntry struct { + key string + line Line + seq int +} + +type ttmlResolvedMetadataLine struct { + order int + seq int + line Line +} + +type ttmlDefinedAgent struct { + ID string + Type string + Name string +} + +type ttmlPiece struct { + raw string + cue *Cue +} + +type ttmlParser struct { + decoder *xml.Decoder + params ttmlTimingParams + + mainLangOrder []string + mainLinesByLang map[string][]Line + + mainLineRefsByKey map[string]ttmlLineRef + mainLineOrder int + + translationLangOrder []string + translationEntriesByLg map[string][]ttmlMetadataEntry + + pronunciationLangOrder []string + pronunciationEntriesByLg map[string][]ttmlMetadataEntry + + definedAgents map[string]ttmlDefinedAgent + + metadataSeq int +} + +func isTTMLDocument(contents []byte) bool { + decoder := xml.NewDecoder(bytes.NewReader(bytes.TrimSpace(contents))) + for { + token, err := decoder.Token() + if err != nil { + return false + } + if start, ok := token.(xml.StartElement); ok { + return strings.EqualFold(start.Name.Local, "tt") + } + } +} + +func parseTTML(defaultLang string, contents []byte) (LyricList, error) { + contents = xmlEncodingRegex.ReplaceAll(contents, []byte(``)) + + // Skip non-TTML content so sniffing doesn't run the full TTML parse on plain + // text — isTTMLDocument does a cheap decode that stops at the first element. + // Checked after the encoding fixup so UTF-16-declared documents are recognized. + if !isTTMLDocument(contents) { + return nil, nil + } + + p := ttmlParser{ + decoder: xml.NewDecoder(bytes.NewReader(contents)), + params: ttmlTimingParams{ + frameRate: defaultTTMLFrameRate, + subFrameRate: defaultTTMLSubFrameRate, + tickRate: defaultTTMLTickRate, + }, + mainLinesByLang: make(map[string][]Line), + mainLineRefsByKey: make(map[string]ttmlLineRef), + translationEntriesByLg: make(map[string][]ttmlMetadataEntry), + pronunciationEntriesByLg: make(map[string][]ttmlMetadataEntry), + definedAgents: make(map[string]ttmlDefinedAgent), + } + + root := ttmlTimingContext{lang: normalizeLyricLang(defaultLang)} + + for { + token, err := p.decoder.Token() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, err + } + + start, ok := token.(xml.StartElement) + if !ok { + continue + } + + if err := p.parseElement(start, root); err != nil { + return nil, err + } + } + + return p.toLyricList(), nil +} + +func (p *ttmlParser) parseElement(start xml.StartElement, parent ttmlTimingContext) error { + local := strings.ToLower(start.Name.Local) + if local == "tt" { + p.updateTimingParams(start.Attr) + } + + switch local { + case "translation": + return p.parseMetadataTrack(start, parent, LyricKindTranslation) + case "transliteration": + return p.parseMetadataTrack(start, parent, LyricKindPronunciation) + case "agent": + return p.parseAgentDefinition(start) + } + + ctx := p.childContext(start.Attr, parent) + if local == "p" { + lineText, tokens, err := p.parseParagraph(ctx) + if err != nil { + return err + } + if ctx.invalid || lineText == "" { + return nil + } + + parsedLine := Line{Value: lineText} + if ctx.hasBegin { + startMs := ctx.begin + parsedLine.Start = &startMs + } + if ctx.hasEnd { + endMs := ctx.end + parsedLine.End = &endMs + } + if len(tokens) > 0 { + parsedLine.Cue = tokens + } + parsedLine = normalizeLineTiming(parsedLine) + + lineKey, _ := attrValue(start.Attr, "key") + p.addMainLine(ctx.lang, lineKey, parsedLine) + return nil + } + + for { + token, err := p.decoder.Token() + if err != nil { + return err + } + + switch t := token.(type) { + case xml.StartElement: + nextParent := ctx + if ctx.invalid { + // Best effort: ignore invalid timing in container elements, and + // continue traversing descendants with parent context. + nextParent = parent + } + if err := p.parseElement(t, nextParent); err != nil { + return err + } + case xml.EndElement: + if strings.EqualFold(t.Name.Local, start.Name.Local) { + return nil + } + } + } +} + +func (p *ttmlParser) parseMetadataTrack(start xml.StartElement, parent ttmlTimingContext, kind string) error { + ctx := p.childContext(start.Attr, parent) + lang := normalizeLyricLang(ctx.lang) + + for { + token, err := p.decoder.Token() + if err != nil { + return err + } + + switch t := token.(type) { + case xml.StartElement: + if strings.EqualFold(t.Name.Local, "text") { + entry, ok, err := p.parseMetadataText(t, ctx) + if err != nil { + return err + } + if ok { + p.addMetadataEntry(kind, lang, entry) + } + continue + } + + nextParent := ctx + if ctx.invalid { + nextParent = parent + } + if err := p.parseElement(t, nextParent); err != nil { + return err + } + case xml.EndElement: + if strings.EqualFold(t.Name.Local, start.Name.Local) { + return nil + } + } + } +} + +func (p *ttmlParser) parseAgentDefinition(start xml.StartElement) error { + id, ok := attrValue(start.Attr, "id") + id = strings.TrimSpace(id) + if !ok || id == "" { + return p.skipElement(start) + } + + agent := ttmlDefinedAgent{ + ID: id, + Type: strings.ToLower(strings.TrimSpace(attrOrEmpty(start.Attr, "type"))), + } + + for { + token, err := p.decoder.Token() + if err != nil { + return err + } + + switch t := token.(type) { + case xml.StartElement: + if strings.EqualFold(t.Name.Local, "name") { + name, err := p.collectElementText(t) + if err != nil { + return err + } + name = sanitizeTTMLText(name) + if name != "" && agent.Name == "" { + agent.Name = name + } + continue + } + if err := p.skipElement(t); err != nil { + return err + } + case xml.EndElement: + if strings.EqualFold(t.Name.Local, start.Name.Local) { + p.definedAgents[agent.ID] = agent + return nil + } + } + } +} + +func (p *ttmlParser) parseMetadataText(start xml.StartElement, parent ttmlTimingContext) (ttmlMetadataEntry, bool, error) { + forKey, hasFor := attrValue(start.Attr, "for") + forKey = strings.TrimSpace(forKey) + + pieces, err := p.parseInlineElement(start, parent) + if err != nil { + return ttmlMetadataEntry{}, false, err + } + if !hasFor || forKey == "" { + return ttmlMetadataEntry{}, false, nil + } + + ctx := p.childContext(start.Attr, parent) + if ctx.invalid { + return ttmlMetadataEntry{}, false, nil + } + + value, tokens := buildTTMLLineFromPieces(pieces) + line := Line{Value: value} + if ctx.hasBegin { + startMs := ctx.begin + line.Start = &startMs + } + if ctx.hasEnd { + endMs := ctx.end + line.End = &endMs + } + if len(tokens) > 0 { + line.Cue = tokens + } + line = normalizeLineTiming(line) + + if line.Value == "" && len(line.Cue) == 0 { + return ttmlMetadataEntry{}, false, nil + } + + return ttmlMetadataEntry{key: forKey, line: line}, true, nil +} + +func (p *ttmlParser) parseParagraph(parent ttmlTimingContext) (string, []Cue, error) { + var pieces []ttmlPiece + + for { + token, err := p.decoder.Token() + if err != nil { + return "", nil, err + } + + switch t := token.(type) { + case xml.StartElement: + inlinePieces, err := p.parseInlineElement(t, parent) + if err != nil { + return "", nil, err + } + pieces = append(pieces, inlinePieces...) + case xml.EndElement: + if strings.EqualFold(t.Name.Local, "p") { + value, tokens := buildTTMLLineFromPieces(pieces) + return value, tokens, nil + } + case xml.CharData: + pieces = append(pieces, ttmlPiece{raw: string(t)}) + } + } +} + +func (p *ttmlParser) parseInlineElement(start xml.StartElement, parent ttmlTimingContext) ([]ttmlPiece, error) { + local := strings.ToLower(start.Name.Local) + if local == "br" { + return []ttmlPiece{{raw: "\n"}}, nil + } + + ctx := p.childContext(start.Attr, parent) + _, hasBegin := attrValue(start.Attr, "begin") + _, hasEnd := attrValue(start.Attr, "end") + _, hasDur := attrValue(start.Attr, "dur") + hasOwnTiming := hasBegin || hasEnd || hasDur + + var pieces []ttmlPiece + + for { + token, err := p.decoder.Token() + if err != nil { + return nil, err + } + + switch t := token.(type) { + case xml.StartElement: + inlinePieces, err := p.parseInlineElement(t, ctx) + if err != nil { + return nil, err + } + pieces = append(pieces, inlinePieces...) + case xml.EndElement: + if !strings.EqualFold(t.Name.Local, start.Name.Local) { + continue + } + + if local == "span" && hasOwnTiming && !ctx.invalid && !ttmlPiecesContainCue(pieces) { + rawValue := concatTTMLPieceRaw(pieces) + tokenText := sanitizeTTMLText(rawValue) + if tokenText != "" { + parsedToken := Cue{ + AgentID: p.resolveCueAgentID(ctx), + } + if ctx.hasBegin { + startMs := ctx.begin + parsedToken.Start = &startMs + } + if ctx.hasEnd { + endMs := ctx.end + parsedToken.End = &endMs + } + + return []ttmlPiece{{ + raw: rawValue, + cue: &parsedToken, + }}, nil + } + } + + return pieces, nil + case xml.CharData: + pieces = append(pieces, ttmlPiece{raw: string(t)}) + } + } +} + +func buildTTMLLineFromPieces(pieces []ttmlPiece) (string, []Cue) { + finalized := finalizeTTMLLines(splitTTMLPiecesByNewline(pieces)) + for len(finalized) > 0 && finalized[0].text == "" && len(finalized[0].cues) == 0 { + finalized = finalized[1:] + } + for len(finalized) > 0 { + last := finalized[len(finalized)-1] + if last.text != "" || len(last.cues) > 0 { + break + } + finalized = finalized[:len(finalized)-1] + } + + var value strings.Builder + cues := make([]Cue, 0, 8) + byteOffset := 0 + for i, line := range finalized { + if i > 0 { + value.WriteByte('\n') + byteOffset++ + } + value.WriteString(line.text) + for _, cue := range line.cues { + cue.ByteStart += byteOffset + cue.ByteEnd += byteOffset + cues = append(cues, cue) + } + byteOffset += len(line.text) + } + + return value.String(), cues +} + +type ttmlFinalLine struct { + text string + cues []Cue +} + +func finalizeTTMLLines(lines [][]ttmlPiece) []ttmlFinalLine { + finalized := make([]ttmlFinalLine, 0, len(lines)) + for _, line := range lines { + text, cues := finalizeTTMLLogicalLine(line) + finalized = append(finalized, ttmlFinalLine{text: text, cues: cues}) + } + return finalized +} + +func splitTTMLPiecesByNewline(pieces []ttmlPiece) [][]ttmlPiece { + lines := [][]ttmlPiece{{}} + for _, piece := range pieces { + raw := normalizeTTMLPieceRaw(piece.raw) + if raw == "" { + continue + } + + start := 0 + for i := 0; i < len(raw); i++ { + if raw[i] != '\n' { + continue + } + if start < i { + lines[len(lines)-1] = append(lines[len(lines)-1], ttmlPiece{ + raw: raw[start:i], + cue: gg.Clone(piece.cue), + }) + } + lines = append(lines, []ttmlPiece{}) + start = i + 1 + } + if start < len(raw) { + lines[len(lines)-1] = append(lines[len(lines)-1], ttmlPiece{ + raw: raw[start:], + cue: gg.Clone(piece.cue), + }) + } + } + return lines +} + +func finalizeTTMLLogicalLine(line []ttmlPiece) (string, []Cue) { + rawLine := concatTTMLPieceRaw(line) + if rawLine == "" { + return "", nil + } + + leftTrimBytes := len(rawLine) - len(strings.TrimLeftFunc(rawLine, unicode.IsSpace)) + rightTrimBytes := len(rawLine) - len(strings.TrimRightFunc(rawLine, unicode.IsSpace)) + trimmedEnd := len(rawLine) - rightTrimBytes + if trimmedEnd < leftTrimBytes { + trimmedEnd = leftTrimBytes + } + + trimmed := strings.TrimSpace(rawLine) + cues := make([]Cue, 0, len(line)) + cursor := 0 + for _, piece := range line { + pieceEnd := cursor + len(piece.raw) + if piece.cue != nil { + byteStart := max(cursor, leftTrimBytes) + byteEnd := min(pieceEnd, trimmedEnd) + if byteStart < byteEnd { + cue := *piece.cue + cue.Value = rawLine[byteStart:byteEnd] + cue.ByteStart = byteStart - leftTrimBytes + cue.ByteEnd = byteEnd - leftTrimBytes - 1 + cues = append(cues, cue) + } + } + cursor = pieceEnd + } + + return trimmed, cues +} + +func normalizeTTMLPieceRaw(raw string) string { + raw = str.SanitizeText(raw) + raw = strings.ReplaceAll(raw, "\r\n", "\n") + raw = strings.ReplaceAll(raw, "\r", "\n") + return raw +} + +func concatTTMLPieceRaw(pieces []ttmlPiece) string { + var raw strings.Builder + for _, piece := range pieces { + raw.WriteString(normalizeTTMLPieceRaw(piece.raw)) + } + return raw.String() +} + +func ttmlPiecesContainCue(pieces []ttmlPiece) bool { + for _, piece := range pieces { + if piece.cue != nil { + return true + } + } + return false +} + +func (p *ttmlParser) toLyricList() LyricList { + res := make(LyricList, 0, len(p.mainLangOrder)+len(p.translationLangOrder)+len(p.pronunciationLangOrder)) + for _, lang := range p.mainLangOrder { + lines := p.mainLinesByLang[lang] + if len(lines) == 0 { + continue + } + res = append(res, p.finalizeLyrics(Lyrics{ + Kind: LyricKindMain, + Lang: lang, + Line: lines, + Synced: linesAreSynced(lines), + })) + } + + res = append(res, p.buildMetadataLyrics(LyricKindTranslation, p.translationLangOrder, p.translationEntriesByLg)...) + res = append(res, p.buildMetadataLyrics(LyricKindPronunciation, p.pronunciationLangOrder, p.pronunciationEntriesByLg)...) + return res +} + +func (p *ttmlParser) buildMetadataLyrics(kind string, langOrder []string, entriesByLang map[string][]ttmlMetadataEntry) LyricList { + res := make(LyricList, 0, len(langOrder)) + + for _, lang := range langOrder { + entries := entriesByLang[lang] + if len(entries) == 0 { + continue + } + + seenKeys := make(map[string]struct{}, len(entries)) + resolved := make([]ttmlResolvedMetadataLine, 0, len(entries)) + for _, entry := range entries { + if _, exists := seenKeys[entry.key]; exists { + continue + } + seenKeys[entry.key] = struct{}{} + + ref, ok := p.mainLineRefsByKey[entry.key] + if !ok { + log.Warn("Skipping TTML metadata line without matching key", "kind", kind, "lang", lang, "key", entry.key) + continue + } + + line := entry.line + if line.Start == nil && ref.line.Start != nil { + startMs := *ref.line.Start + line.Start = &startMs + } + if line.End == nil && ref.line.End != nil { + endMs := *ref.line.End + line.End = &endMs + } + line = normalizeLineTiming(line) + + if line.Value == "" && len(line.Cue) == 0 { + continue + } + + resolved = append(resolved, ttmlResolvedMetadataLine{ + order: ref.order, + seq: entry.seq, + line: line, + }) + } + + if len(resolved) == 0 { + continue + } + + sort.SliceStable(resolved, func(i, j int) bool { + if resolved[i].order != resolved[j].order { + return resolved[i].order < resolved[j].order + } + return resolved[i].seq < resolved[j].seq + }) + + lines := make([]Line, len(resolved)) + for i := range resolved { + lines[i] = resolved[i].line + } + + res = append(res, p.finalizeLyrics(Lyrics{ + Kind: kind, + Lang: lang, + Line: lines, + Synced: linesAreSynced(lines), + })) + } + + return res +} + +func (p *ttmlParser) finalizeLyrics(lyrics Lyrics) Lyrics { + lyrics.Line, lyrics.Agents = p.resolveAgents(lyrics.Line) + return normalizeLyrics(lyrics) +} + +func (p *ttmlParser) resolveAgents(lines []Line) ([]Line, []Agent) { + if len(lines) == 0 { + return lines, nil + } + + usedOrder := make([]string, 0, 4) + usedSet := make(map[string]struct{}, 4) + sawEmptyCue := false + + for i := range lines { + for j := range lines[i].Cue { + agentID := strings.TrimSpace(lines[i].Cue[j].AgentID) + if agentID == "" { + sawEmptyCue = true + continue + } + if _, exists := usedSet[agentID]; !exists { + usedSet[agentID] = struct{}{} + usedOrder = append(usedOrder, agentID) + } + } + } + + if len(usedOrder) == 0 { + return lines, nil + } + + mainID := "" + for _, agentID := range usedOrder { + role := p.baseRoleForAgent(agentID) + if role != "bg" && role != "group" { + mainID = agentID + break + } + } + if mainID == "" && sawEmptyCue { + mainID = "main" + } + if mainID == "" { + for _, agentID := range usedOrder { + if p.baseRoleForAgent(agentID) != "bg" { + mainID = agentID + break + } + } + } + if mainID == "" { + mainID = usedOrder[0] + } + + if _, exists := usedSet[mainID]; !exists { + usedSet[mainID] = struct{}{} + usedOrder = append([]string{mainID}, usedOrder...) + } + + for i := range lines { + for j := range lines[i].Cue { + if strings.TrimSpace(lines[i].Cue[j].AgentID) == "" { + lines[i].Cue[j].AgentID = mainID + } + } + } + + agents := make([]Agent, 0, len(usedOrder)) + for _, agentID := range usedOrder { + role := p.baseRoleForAgent(agentID) + if agentID == mainID { + role = "main" + } + agent := Agent{ + ID: agentID, + Role: role, + Name: p.agentNameForID(agentID), + } + agents = append(agents, agent) + } + + return lines, agents +} + +func (p *ttmlParser) resolveCueAgentID(ctx ttmlTimingContext) string { + agentID := strings.TrimSpace(ctx.agentID) + if contextHasRole(ctx.role, "x-bg") { + if agentID == "" { + agentID = "main" + } + return backgroundAgentID(agentID) + } + return agentID +} + +func (p *ttmlParser) baseRoleForAgent(agentID string) string { + if isBackgroundAgentID(agentID) { + return "bg" + } + + if agent, ok := p.definedAgents[agentID]; ok { + switch agent.Type { + case "group": + return "group" + default: + return "voice" + } + } + + return "voice" +} + +func (p *ttmlParser) agentNameForID(agentID string) string { + if isBackgroundAgentID(agentID) { + baseID := strings.TrimPrefix(agentID, ttmlBackgroundAgentPrefix) + if baseID == "main" { + return "" + } + if agent, ok := p.definedAgents[baseID]; ok { + return agent.Name + } + return "" + } + + if agent, ok := p.definedAgents[agentID]; ok { + return agent.Name + } + + return "" +} + +func backgroundAgentID(agentID string) string { + return ttmlBackgroundAgentPrefix + agentID +} + +func isBackgroundAgentID(agentID string) bool { + return strings.HasPrefix(agentID, ttmlBackgroundAgentPrefix) +} + +func contextHasRole(roles string, role string) bool { + lowerRole := strings.ToLower(role) + for _, candidate := range strings.Fields(strings.ToLower(roles)) { + if candidate == lowerRole { + return true + } + } + return false +} + +func appendTTMLRoles(existing string, roles string) string { + for _, role := range strings.Fields(roles) { + if contextHasRole(existing, role) { + continue + } + if existing == "" { + existing = role + } else { + existing += " " + role + } + } + return existing +} + +func (p *ttmlParser) addMainLine(lang string, lineKey string, line Line) { + lang = normalizeLyricLang(lang) + if _, ok := p.mainLinesByLang[lang]; !ok { + p.mainLangOrder = append(p.mainLangOrder, lang) + } + p.mainLinesByLang[lang] = append(p.mainLinesByLang[lang], line) + + lineKey = strings.TrimSpace(lineKey) + if lineKey != "" { + if _, exists := p.mainLineRefsByKey[lineKey]; !exists { + p.mainLineRefsByKey[lineKey] = ttmlLineRef{ + order: p.mainLineOrder, + line: line, + } + } + } + p.mainLineOrder++ +} + +func (p *ttmlParser) addMetadataEntry(kind string, lang string, entry ttmlMetadataEntry) { + lang = normalizeLyricLang(lang) + entry.seq = p.metadataSeq + p.metadataSeq++ + + switch kind { + case LyricKindTranslation: + if _, ok := p.translationEntriesByLg[lang]; !ok { + p.translationLangOrder = append(p.translationLangOrder, lang) + } + p.translationEntriesByLg[lang] = append(p.translationEntriesByLg[lang], entry) + case LyricKindPronunciation: + if _, ok := p.pronunciationEntriesByLg[lang]; !ok { + p.pronunciationLangOrder = append(p.pronunciationLangOrder, lang) + } + p.pronunciationEntriesByLg[lang] = append(p.pronunciationEntriesByLg[lang], entry) + } +} + +func (p *ttmlParser) childContext(attrs []xml.Attr, parent ttmlTimingContext) ttmlTimingContext { + ctx := parent + + if lang, ok := attrValue(attrs, "lang"); ok { + ctx.lang = normalizeLyricLang(lang) + } + if agentID, ok := attrValue(attrs, "agent"); ok { + ctx.agentID = strings.TrimSpace(agentID) + } + if role, ok := attrValue(attrs, "role"); ok { + role = strings.TrimSpace(role) + if role != "" { + ctx.role = appendTTMLRoles(ctx.role, role) + } + } + + beginExpr, hasBegin := attrValue(attrs, "begin") + endExpr, hasEnd := attrValue(attrs, "end") + durExpr, hasDur := attrValue(attrs, "dur") + + if hasBegin { + begin, kind, ok := parseTTMLTimeExpression(beginExpr, p.params) + if !ok { + ctx.invalid = true + return ctx + } + + base := int64(0) + if parent.hasBegin { + base = parent.begin + } + ctx.begin = resolveTTMLTime(begin, kind, base, parent) + ctx.hasBegin = true + } else { + ctx.begin = parent.begin + ctx.hasBegin = parent.hasBegin + } + + var calculatedEnd int64 + calculatedHasEnd := false + + if hasEnd { + end, kind, ok := parseTTMLTimeExpression(endExpr, p.params) + if !ok { + ctx.invalid = true + return ctx + } + + base := ctx.begin + if !ctx.hasBegin { + base = parent.begin + } + calculatedEnd = resolveTTMLTime(end, kind, base, parent) + calculatedHasEnd = true + } + + if hasDur { + dur, ok := parseTTMLDurationExpression(durExpr, p.params) + if !ok { + ctx.invalid = true + return ctx + } + if ctx.hasBegin { + durEnd := ctx.begin + dur + if !calculatedHasEnd || durEnd < calculatedEnd { + calculatedEnd = durEnd + calculatedHasEnd = true + } + } + } + + if !calculatedHasEnd && parent.hasEnd { + calculatedEnd = parent.end + calculatedHasEnd = true + } + + ctx.end = calculatedEnd + ctx.hasEnd = calculatedHasEnd + return ctx +} + +func (p *ttmlParser) updateTimingParams(attrs []xml.Attr) { + frameRate := p.params.frameRate + if value, ok := attrValue(attrs, "frameRate"); ok { + if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 { + frameRate = parsed + } + } + + if value, ok := attrValue(attrs, "frameRateMultiplier"); ok { + parts := strings.Fields(value) + if len(parts) == 2 { + numerator, errA := strconv.ParseFloat(parts[0], 64) + denominator, errB := strconv.ParseFloat(parts[1], 64) + if errA == nil && errB == nil && denominator > 0 { + frameRate = frameRate * (numerator / denominator) + } + } + } + + subFrameRate := p.params.subFrameRate + if value, ok := attrValue(attrs, "subFrameRate"); ok { + if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 { + subFrameRate = parsed + } + } + + tickRate := p.params.tickRate + if value, ok := attrValue(attrs, "tickRate"); ok { + if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 { + tickRate = parsed + } + } + + p.params.frameRate = gg.If(frameRate > 0, frameRate, defaultTTMLFrameRate) + p.params.subFrameRate = gg.If(subFrameRate > 0, subFrameRate, defaultTTMLSubFrameRate) + p.params.tickRate = gg.If(tickRate > 0, tickRate, defaultTTMLTickRate) +} + +func parseTTMLDurationExpression(expr string, params ttmlTimingParams) (int64, bool) { + value, _, ok := parseTTMLTimeExpression(expr, params) + return value, ok +} + +func resolveTTMLTime(value int64, kind ttmlTimeKind, base int64, parent ttmlTimingContext) int64 { + switch kind { + case ttmlTimeAbsolute: + return value + case ttmlTimeOffset: + return base + value + case ttmlTimeAmbiguous: + absolute := value + offset := base + value + + // No parent timing context → no reference frame for offsets. + // Prefer absolute when offset differs (i.e., base > 0). + if !parent.hasBegin && !parent.hasEnd && base != 0 { + return absolute + } + + if parent.hasBegin && parent.hasEnd { + absoluteInParent := absolute >= parent.begin && absolute <= parent.end + offsetInParent := offset >= parent.begin && offset <= parent.end + if absoluteInParent && !offsetInParent { + return absolute + } + if offsetInParent && !absoluteInParent { + return offset + } + } + + if parent.hasBegin { + if absolute < parent.begin && offset >= parent.begin { + return offset + } + if absolute >= parent.begin && offset > absolute { + return absolute + } + } + return offset + default: + return base + value + } +} + +func parseTTMLTimeExpression(expr string, params ttmlTimingParams) (int64, ttmlTimeKind, bool) { + expr = strings.TrimSpace(expr) + if expr == "" { + return 0, ttmlTimeOffset, false + } + + lower := strings.ToLower(expr) + if strings.Contains(lower, "wallclock(") || + strings.Contains(lower, ".begin") || + strings.Contains(lower, ".end") { + log.Warn("Unsupported TTML time expression", "value", expr) + return 0, ttmlTimeOffset, false + } + + // Best-effort support for non-standard TTML seen in the wild where a + // bare decimal value is used (implicitly seconds), e.g. "0.170". + if value, err := strconv.ParseFloat(lower, 64); err == nil && value >= 0 { + return int64(math.Round(value * 1000)), ttmlTimeAmbiguous, true + } + + if matches := offsetTimeRegex.FindStringSubmatch(lower); len(matches) == 3 { + value, err := strconv.ParseFloat(matches[1], 64) + if err != nil { + return 0, ttmlTimeOffset, false + } + + unit := matches[2] + seconds := 0.0 + switch unit { + case "h": + seconds = value * 60 * 60 + case "m": + seconds = value * 60 + case "s": + seconds = value + case "ms": + seconds = value / 1000 + case "f": + seconds = value / params.frameRate + case "t": + seconds = value / params.tickRate + default: + return 0, ttmlTimeOffset, false + } + + return int64(math.Round(seconds * 1000)), ttmlTimeOffset, true + } + + colonCount := strings.Count(expr, ":") + switch colonCount { + case 1, 2: + clockMs, ok := parseTTMLClockTime(expr) + if !ok { + return 0, ttmlTimeAbsolute, false + } + return clockMs, ttmlTimeAbsolute, true + case 3: + framesMs, ok := parseTTMLFrameTime(expr, params) + if !ok { + return 0, ttmlTimeAbsolute, false + } + return framesMs, ttmlTimeAbsolute, true + default: + log.Warn("Unsupported TTML time expression", "value", expr) + return 0, ttmlTimeOffset, false + } +} + +func parseTTMLClockTime(value string) (int64, bool) { + parts := strings.Split(value, ":") + if len(parts) != 2 && len(parts) != 3 { + return 0, false + } + + hours := int64(0) + minutesIdx := 0 + if len(parts) == 3 { + h, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return 0, false + } + hours = h + minutesIdx = 1 + } + + minutes, err := strconv.ParseInt(parts[minutesIdx], 10, 64) + if err != nil { + return 0, false + } + + seconds, err := strconv.ParseFloat(parts[minutesIdx+1], 64) + if err != nil { + return 0, false + } + + totalSeconds := float64(hours*60*60+minutes*60) + seconds + return int64(math.Round(totalSeconds * 1000)), true +} + +func parseTTMLFrameTime(value string, params ttmlTimingParams) (int64, bool) { + parts := strings.Split(value, ":") + if len(parts) != 4 { + return 0, false + } + + hours, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return 0, false + } + + minutes, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return 0, false + } + + seconds, err := strconv.ParseInt(parts[2], 10, 64) + if err != nil { + return 0, false + } + + frameParts := strings.SplitN(parts[3], ".", 2) + frames, err := strconv.ParseFloat(frameParts[0], 64) + if err != nil { + return 0, false + } + + subFrames := 0.0 + if len(frameParts) == 2 { + subFrames, err = strconv.ParseFloat(frameParts[1], 64) + if err != nil { + return 0, false + } + } + + totalSeconds := float64(hours*60*60 + minutes*60 + seconds) + totalSeconds += frames / params.frameRate + totalSeconds += subFrames / (params.subFrameRate * params.frameRate) + + return int64(math.Round(totalSeconds * 1000)), true +} + +func attrValue(attrs []xml.Attr, key string) (string, bool) { + for _, attr := range attrs { + if strings.EqualFold(attr.Name.Local, key) { + return strings.TrimSpace(attr.Value), true + } + } + return "", false +} + +func attrOrEmpty(attrs []xml.Attr, key string) string { + value, _ := attrValue(attrs, key) + return value +} + +func (p *ttmlParser) collectElementText(start xml.StartElement) (string, error) { + var text strings.Builder + + for { + token, err := p.decoder.Token() + if err != nil { + return "", err + } + + switch t := token.(type) { + case xml.StartElement: + value, err := p.collectElementText(t) + if err != nil { + return "", err + } + text.WriteString(value) + case xml.EndElement: + if strings.EqualFold(t.Name.Local, start.Name.Local) { + return text.String(), nil + } + case xml.CharData: + text.WriteString(string(t)) + } + } +} + +func (p *ttmlParser) skipElement(_ xml.StartElement) error { + depth := 1 + for depth > 0 { + token, err := p.decoder.Token() + if err != nil { + return err + } + + switch token.(type) { + case xml.StartElement: + depth++ + case xml.EndElement: + depth-- + } + } + return nil +} + +func normalizeLyricLang(lang string) string { + lang = strings.ToLower(strings.TrimSpace(lang)) + if lang == "" { + return "xxx" + } + return lang +} + +func sanitizeTTMLText(raw string) string { + raw = str.SanitizeText(raw) + raw = strings.ReplaceAll(raw, "\r\n", "\n") + raw = strings.ReplaceAll(raw, "\r", "\n") + + lines := strings.Split(raw, "\n") + for i := range lines { + lines[i] = strings.TrimSpace(lines[i]) + } + return strings.TrimSpace(strings.Join(lines, "\n")) +} + +func linesAreSynced(lines []Line) bool { + for i := range lines { + if lines[i].Start != nil { + return true + } + for j := range lines[i].Cue { + if lines[i].Cue[j].Start != nil { + return true + } + } + } + return false +} diff --git a/model/lyrics_ttml_test.go b/model/lyrics_ttml_test.go new file mode 100644 index 000000000..a175eef0e --- /dev/null +++ b/model/lyrics_ttml_test.go @@ -0,0 +1,429 @@ +package model + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("parseTTML", func() { + Describe("Multi-language and timing", func() { + It("should parse multiple language divs with inherited offsets and frame/tick timing", func() { + content := []byte(` + + +
+

Line one

+

Line two
with break

+
+
+

Linha

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(2)) + + By("parsing the English track") + eng := list[0] + Expect(eng.Lang).To(Equal("eng")) + Expect(eng.Synced).To(BeTrue()) + Expect(eng.Line[0].Start).To(Equal(new(int64(3000)))) + Expect(eng.Line[0].Value).To(Equal("Line one")) + Expect(eng.Line[1].Start).To(Equal(new(int64(4517)))) + Expect(eng.Line[1].Value).To(Equal("Line two\nwith break")) + + By("parsing the Portuguese track") + por := list[1] + Expect(por.Lang).To(Equal("por")) + Expect(por.Line[0].Start).To(Equal(new(int64(4500)))) + Expect(por.Line[0].Value).To(Equal("Linha")) + }) + }) + + Describe("Unsupported cue handling", func() { + It("should skip wallclock cues and keep valid ones", func() { + content := []byte(` + + +
+

Skip me

+

Keep me

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Start).To(Equal(new(int64(1000)))) + Expect(list[0].Line[0].Value).To(Equal("Keep me")) + }) + }) + + Describe("Begin/End/Dur with inheritance", func() { + It("should correctly accumulate nested timing from body, div, and p elements", func() { + content := []byte(` + + +
+

First line

+

Second line

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Lang).To(Equal("eng")) + Expect(list[0].Line).To(HaveLen(2)) + Expect(list[0].Line[0].Start).To(Equal(new(int64(16000)))) + Expect(list[0].Line[0].Value).To(Equal("First line")) + Expect(list[0].Line[1].Start).To(Equal(new(int64(18000)))) + Expect(list[0].Line[1].Value).To(Equal("Second line")) + }) + }) + + Describe("Non-standard bare second offsets", func() { + It("should parse bare decimal numbers as seconds", func() { + content := []byte(` + + +
+

First line

+

Second line

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(HaveLen(2)) + Expect(list[0].Line[0].Start).To(Equal(new(int64(10170)))) + Expect(list[0].Line[0].Value).To(Equal("First line")) + Expect(list[0].Line[1].Start).To(Equal(new(int64(13710)))) + Expect(list[0].Line[1].Value).To(Equal("Second line")) + }) + }) + + Describe("Word timing tokens", func() { + It("should extract timed tokens from spans including background role", func() { + content := []byte(` + + +
+

+ Hello + echo +

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Agents).To(Equal([]Agent{ + {ID: "main", Role: "main"}, + {ID: "__nd_bg__|main", Role: "bg"}, + })) + Expect(list[0].Line).To(HaveLen(1)) + + line := list[0].Line[0] + Expect(line.Start).To(Equal(new(int64(1000)))) + Expect(line.Value).To(Equal("Hello\necho")) + Expect(line.End).To(Equal(new(int64(3000)))) + Expect(line.Cue).To(HaveLen(3)) + + Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(1000)), End: new(int64(1400)), Value: "He", ByteStart: 0, ByteEnd: 1, AgentID: "main"})) + Expect(line.Cue[1]).To(Equal(Cue{Start: new(int64(1400)), End: new(int64(1800)), Value: "llo", ByteStart: 2, ByteEnd: 4, AgentID: "main"})) + Expect(line.Cue[2]).To(Equal(Cue{Start: new(int64(2000)), End: new(int64(2500)), Value: "echo", ByteStart: 6, ByteEnd: 9, AgentID: "__nd_bg__|main"})) + }) + + It("should append role tokens exactly instead of using substring matches", func() { + content := []byte(` + + +
+

LeadEcho

+
+ +
`) + + list, err := parseTTML("xxx", content) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Agents).To(Equal([]Agent{ + {ID: "main", Role: "main"}, + {ID: "__nd_bg__|main", Role: "bg"}, + })) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Cue).To(HaveLen(2)) + Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("main")) + Expect(list[0].Line[0].Cue[1].AgentID).To(Equal("__nd_bg__|main")) + }) + + It("should parse named TTML agents into main, voice, and group roles", func() { + content := []byte(` + + + + Chris Martin + Jin + All + + + +
+

You

+

and

+

All

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Agents).To(Equal([]Agent{ + {ID: "v1", Role: "main", Name: "Chris Martin"}, + {ID: "v2", Role: "voice", Name: "Jin"}, + {ID: "v1000", Role: "group", Name: "All"}, + })) + Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("v1")) + Expect(list[0].Line[1].Cue[0].AgentID).To(Equal("v2")) + Expect(list[0].Line[2].Cue[0].AgentID).To(Equal("v1000")) + }) + + It("should avoid collisions between derived background agents and explicit TTML agent ids", func() { + content := []byte(` + + + + Lead + Existing Background Id + + + +
+

+ Lead + Echo +

+

+ Named +

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Agents).To(Equal([]Agent{ + {ID: "lead", Role: "main", Name: "Lead"}, + {ID: "__nd_bg__|lead", Role: "bg", Name: "Lead"}, + {ID: "lead__bg", Role: "voice", Name: "Existing Background Id"}, + })) + Expect(list[0].Line).To(HaveLen(2)) + Expect(list[0].Line[0].Cue).To(HaveLen(2)) + Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("lead")) + Expect(list[0].Line[0].Cue[1].AgentID).To(Equal("__nd_bg__|lead")) + Expect(list[0].Line[1].Cue).To(HaveLen(1)) + Expect(list[0].Line[1].Cue[0].AgentID).To(Equal("lead__bg")) + }) + + It("should fill missing cue agent ids with the resolved main agent", func() { + content := []byte(` + + + + Guest Vocal + + + +
+

+ Lead + Guest +

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Agents).To(Equal([]Agent{ + {ID: "guest", Role: "main", Name: "Guest Vocal"}, + })) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Cue).To(HaveLen(2)) + Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("guest")) + Expect(list[0].Line[0].Cue[1].AgentID).To(Equal("guest")) + }) + }) + + Describe("Ambiguous decimal timing", func() { + It("should prefer absolute timing when values fall inside parent window", func() { + content := []byte(` + + +
+

+ go + go +

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(HaveLen(1)) + + line := list[0].Line[0] + Expect(line.Start).To(Equal(new(int64(43444)))) + Expect(line.Value).To(Equal("go\ngo")) + Expect(line.End).To(Equal(new(int64(45570)))) + Expect(line.Cue).To(HaveLen(2)) + Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(43444)), End: new(int64(43716)), Value: "go", ByteStart: 0, ByteEnd: 1})) + Expect(line.Cue[1]).To(Equal(Cue{Start: new(int64(43716)), End: new(int64(43887)), Value: "go", ByteStart: 3, ByteEnd: 4})) + }) + }) + + Describe("Unsynced fallback", func() { + It("should return unsynced lyrics when no timing is present", func() { + content := []byte(` + + +
+

No timing here

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Lang).To(Equal("xxx")) + Expect(list[0].Synced).To(BeFalse()) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Start).To(BeNil()) + Expect(list[0].Line[0].Value).To(Equal("No timing here")) + }) + }) + + Describe("Metadata tracks", func() { + It("should produce main, translation, and pronunciation tracks from iTunesMetadata", func() { + content := []byte(` + + + + + + + Hola + Skip me + + + + + konni + + + + + + +
+

こんにちは

+

こんばんは

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(3)) + + By("checking the main track") + main := list[0] + Expect(main.Kind).To(Equal("main")) + Expect(main.Lang).To(Equal("ja")) + Expect(main.Line).To(HaveLen(2)) + + By("checking the translation track") + translation := list[1] + Expect(translation.Kind).To(Equal("translation")) + Expect(translation.Lang).To(Equal("es")) + Expect(translation.Line).To(HaveLen(1)) + Expect(translation.Line[0].Start).To(Equal(new(int64(1000)))) + Expect(translation.Line[0].Value).To(Equal("Hola")) + Expect(translation.Line[0].End).To(Equal(new(int64(1500)))) + + By("checking the pronunciation track") + pronunciation := list[2] + Expect(pronunciation.Kind).To(Equal("pronunciation")) + Expect(pronunciation.Lang).To(Equal("ja-latn")) + Expect(pronunciation.Line).To(HaveLen(1)) + Expect(pronunciation.Line[0].Start).To(Equal(new(int64(2000)))) + Expect(pronunciation.Line[0].Value).To(Equal("konni")) + Expect(pronunciation.Line[0].End).To(Equal(new(int64(2600)))) + Expect(pronunciation.Line[0].Cue).To(HaveLen(2)) + Expect(pronunciation.Line[0].Cue[0]).To(Equal(Cue{Start: new(int64(2000)), End: new(int64(2300)), Value: "ko", ByteStart: 0, ByteEnd: 1})) + Expect(pronunciation.Line[0].Cue[1]).To(Equal(Cue{Start: new(int64(2300)), End: new(int64(2600)), Value: "nni", ByteStart: 2, ByteEnd: 4})) + }) + }) + + Describe("Pronunciation with bare decimal end times", func() { + It("should correctly parse bare decimal times in transliteration spans", func() { + content := []byte(` + + + + + + + I woke up + + + + + + +
+

起きた

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + + var pronunciation *Lyrics + for i := range list { + if list[i].Kind == "pronunciation" { + pronunciation = &list[i] + break + } + } + Expect(pronunciation).ToNot(BeNil()) + Expect(pronunciation.Line).To(HaveLen(1)) + + line := pronunciation.Line[0] + Expect(line.Start).To(Equal(new(int64(2747)))) + Expect(line.Value).To(Equal("I woke up")) + Expect(line.Cue).To(HaveLen(3)) + Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(2747)), End: new(int64(3018)), Value: "I", ByteStart: 0, ByteEnd: 0})) + Expect(line.Cue[1]).To(Equal(Cue{Start: new(int64(3018)), End: new(int64(3179)), Value: "woke", ByteStart: 2, ByteEnd: 5})) + Expect(line.Cue[2]).To(Equal(Cue{Start: new(int64(3179)), End: new(int64(3582)), Value: "up", ByteStart: 7, ByteEnd: 8})) + }) + }) +}) diff --git a/model/mediafile.go b/model/mediafile.go index 718f0443d..d93060dba 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -138,7 +138,7 @@ func (mf MediaFile) AlbumCoverArtID() ArtworkID { } func (mf MediaFile) StructuredLyrics() (LyricList, error) { - lyrics := LyricList{} + var lyrics LyricList err := json.Unmarshal([]byte(mf.Lyrics), &lyrics) if err != nil { return nil, err @@ -439,6 +439,9 @@ type MediaFileRepository interface { Get(id string) (*MediaFile, error) GetWithParticipants(id string) (*MediaFile, error) GetAll(options ...QueryOptions) (MediaFiles, error) + // GetRandom returns up to options.Max media files in random order, applying the same + // filters as GetAll. Sort/Order are ignored. + GetRandom(options ...QueryOptions) (MediaFiles, error) GetAllByTags(tag TagName, values []string, options ...QueryOptions) (MediaFiles, error) GetCursor(options ...QueryOptions) (MediaFileCursor, error) Delete(id string) error diff --git a/model/metadata/map_mediafile.go b/model/metadata/map_mediafile.go index 966a545be..de2ba813e 100644 --- a/model/metadata/map_mediafile.go +++ b/model/metadata/map_mediafile.go @@ -143,13 +143,15 @@ func (md Metadata) mapLyrics() string { lang := raw.Key() text := raw.Value() - lyrics, err := model.ToLyrics(lang, text) + lyrics, err := model.ParseLyrics("", lang, []byte(text)) if err != nil { log.Warn("Unexpected failure occurred when parsing lyrics", "file", md.filePath, err) continue } - if !lyrics.IsEmpty() { - lyricList = append(lyricList, *lyrics) + for _, lyric := range lyrics { + if !lyric.IsEmpty() { + lyricList = append(lyricList, lyric) + } } } diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go index 350731b89..7ebe9fa4a 100644 --- a/model/metadata/metadata_test.go +++ b/model/metadata/metadata_test.go @@ -105,7 +105,7 @@ var _ = Describe("Metadata", func() { props.Tags = model.RawTags{ "Title": {strings.Repeat("a", 2048)}, "Comment": {strings.Repeat("a", 8192)}, - "lyrics:xxx": {strings.Repeat("a", 60000)}, + "lyrics:xxx": {strings.Repeat("a", 2_000_000)}, } md = metadata.New(filePath, props) @@ -116,9 +116,10 @@ var _ = Describe("Metadata", func() { Expect(pair).To(HaveLen(1)) Expect(pair[0].Key()).To(Equal("xxx")) + // Lyrics keep a much larger cap so word-timed karaoke survives. // Note: a total of 6 characters are lost from maxLength from - // the key portion and separator - Expect(pair[0].Value()).To(HaveLen(32762)) + // the key portion and separator. + Expect(pair[0].Value()).To(HaveLen(1048570)) }) It("should split multiple values", func() { diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index aa3bc0776..56843b911 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -353,6 +353,19 @@ func (r *artistRepository) purgeEmpty() error { return nil } +// markOrphansMissing flags as missing any non-missing artist with no library_artist row, keeping the +// search fast-path's `missing = false` filter correct (see searchCfg). Called wherever such a row can +// be dropped: RefreshStats cleanup and library deletion cascade. +func (r *artistRepository) markOrphansMissing() error { + _, err := r.executeSQL(Expr( + "update artist set missing = true where missing = false " + + "and not exists (select 1 from library_artist where library_artist.artist_id = artist.id)")) + if err != nil { + return fmt.Errorf("marking orphaned artists missing: %w", err) + } + return nil +} + // markMissing marks artists as missing if all their albums are missing. func (r *artistRepository) markMissing() error { q := Expr(` @@ -527,57 +540,156 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { totalRowsAffected += rowsAffected } - // // Remove library_artist entries for artists that no longer have any content in any library + // Remove library_artist entries for artists that no longer have any content in a library. cleanupSQL := Delete("library_artist").Where("stats = '{}'") cleanupRows, err := r.executeSQL(cleanupSQL) if err != nil { - log.Warn(r.ctx, "Failed to cleanup empty library_artist entries", "error", err) - } else if cleanupRows > 0 { - log.Debug(r.ctx, "Cleaned up empty library_artist entries", "rowsDeleted", cleanupRows) + log.Warn(r.ctx, "Failed to cleanup empty library_artist entries", err) + } else { + if cleanupRows > 0 { + log.Debug(r.ctx, "Cleaned up empty library_artist entries", "rowsDeleted", cleanupRows) + } + // Reconcile orphans whenever the cleanup removed rows, and on a full refresh so a full scan + // also heals any left by older versions. + if cleanupRows > 0 || allArtists { + if err := r.markOrphansMissing(); err != nil { + log.Warn(r.ctx, "Failed to mark orphaned artists missing after library_artist cleanup", err) + } + } } log.Debug(r.ctx, "RefreshStats: Successfully updated stats.", "totalArtistsProcessed", len(allTouchedArtistIDs), "totalDBRowsAffected", totalRowsAffected) return totalRowsAffected, nil } -// applyLibraryFilterToSearchQuery is applyLibraryFilterToArtistQuery with the join order -// pinned via CROSS JOIN (SQLite's explicit join-order override): the search Phase 1 paginates -// rowids by artist.id, and when the planner drives from library_artist it must sort every -// junction row on every page (temp b-tree over the whole table). Keeping artist as the outer -// table streams rows in artist.id order from its primary key index, so LIMIT/OFFSET -// short-circuits. Search-only: other artist queries keep the planner's freedom. -func (r *artistRepository) applyLibraryFilterToSearchQuery(query SelectBuilder) SelectBuilder { - user := loggedUser(r.ctx) - query = query.CrossJoin("library_artist on library_artist.artist_id = artist.id") - if user.ID != invalidUserId && !user.IsAdmin { - query = query.Join("user_library on user_library.library_id = library_artist.library_id AND user_library.user_id = ?", user.ID) - } - return query -} - -func (r *artistRepository) searchCfg() searchConfig { +// searchCfg builds the per-search config. scope is the set of library IDs the rowid Phase 1 must +// restrict artists to, or nil to skip the filter (fast-path). See [artistRepository.searchScope]. +func (r *artistRepository) searchCfg(scope []int) searchConfig { return searchConfig{ // Natural order for artists is more performant by ID, due to GROUP BY clause in selectArtist - NaturalOrder: "artist.id", - OrderBy: []string{"sum(json_extract(stats, '$.total.m')) desc", "name"}, - MBIDFields: []string{"mbz_artist_id"}, - LibraryFilter: r.applyLibraryFilterToSearchQuery, + NaturalOrder: "artist.id", + OrderBy: []string{"sum(json_extract(stats, '$.total.m')) desc", "name"}, + MBIDFields: []string{"mbz_artist_id"}, + // scope==nil is the fast-path: no filter (and orphans must not exist — see markOrphansMissing). + // Otherwise the join-free [artistLibraryFilter]. + LibraryFilter: func(query SelectBuilder) SelectBuilder { + if scope == nil { + return query + } + return query.Where(artistLibraryFilter(scope)) + }, } } +// artistLibraryFilter restricts artists to the given libraries via a correlated EXISTS over the +// library_artist junction, staying join-free so it can scope the join-free search Phase 1 (a JOIN +// would fan out rowids and corrupt offset pagination). The inner LIMIT 1 is load-bearing: it stops +// SQLite from flattening the EXISTS back into a fan-out join, while still using the +// (library_id, artist_id) UNIQUE autoindex. +func artistLibraryFilter(libraryIDs []int) Sqlizer { + if len(libraryIDs) == 0 { + return Eq{"1": 2} // match nothing, without a degenerate `IN ()` subquery + } + sub, args, _ := Select("1").From("library_artist"). + Where(And{ + Expr("library_artist.artist_id = artist.id"), + Eq{"library_artist.library_id": libraryIDs}, + }).Limit(1).ToSql() + return Expr("EXISTS ("+sub+")", args...) +} + func (r *artistRepository) Search(q string, options ...model.QueryOptions) (model.Artists, error) { var opts model.QueryOptions if len(options) > 0 { opts = options[0] } + // Artists have no library_id column, so the library_id filter callers pass (same as albums/songs) + // can't be applied directly: consume it and realize it as a join-free Phase-1 scope (searchCfg). + scope := r.searchScope(opts.Filters) + if isLibraryIDFilter(opts.Filters) { + opts.Filters = nil + } var res dbArtists - err := r.doSearch(r.selectArtist(options...), q, &res, r.searchCfg(), opts) + err := r.doSearch(r.selectArtist(opts), q, &res, r.searchCfg(scope), opts) if err != nil { return nil, fmt.Errorf("searching artist %q: %w", q, err) } return res.toModels(), nil } +// searchScope returns the library IDs the search must be restricted to, or nil to skip the filter +// entirely (the fast-path: the user sees everything the search could return, so a filter would be +// pure O(offset) overhead). It intersects the requested libraries with what the user can see. +func (r *artistRepository) searchScope(filter Sqlizer) []int { + visible, err := r.visibleLibraryIDs() + if err != nil { + return r.requestedLibraryIDs(filter) // fail safe: narrow to the request rather than widen + } + requested := r.requestedLibraryIDs(filter) + if requested == nil { + // No explicit request: scope to the visible set, unless the user sees everything. + if r.userSeesAllLibraries(visible) { + return nil + } + return visible + } + // Narrow unless the request already covers everything the user can see. Compare by membership, + // not length: the requested IDs may contain duplicates. + requestedSet := slice.ToSet(requested) + if slices.ContainsFunc(visible, func(id int) bool { _, ok := requestedSet[id]; return !ok }) { + return requested + } + return nil +} + +// requestedLibraryIDs extracts the []int from an Eq{"library_id": ids} filter, or nil if filter is +// not that shape. +func (r *artistRepository) requestedLibraryIDs(filter Sqlizer) []int { + eq, ok := filter.(Eq) + if !ok { + return nil + } + ids, _ := eq["library_id"].([]int) + return ids +} + +// isLibraryIDFilter reports whether the filter is an Eq carrying a library_id key, so Search can +// consume it before it reaches the bare artist table (which has no library_id column). +func isLibraryIDFilter(filter Sqlizer) bool { + eq, ok := filter.(Eq) + if !ok { + return false + } + _, ok = eq["library_id"] + return ok +} + +// userSeesAllLibraries reports whether the visible set already covers every library, so a search +// needs no library filter at all. +func (r *artistRepository) userSeesAllLibraries(visible []int) bool { + user := loggedUser(r.ctx) + if user.IsAdmin || user.ID == invalidUserId { + return true // visible is the whole library table + } + total, err := NewLibraryRepository(r.ctx, r.db).CountAll() + if err != nil || total == 0 { + return false + } + return int64(len(visible)) >= total +} + +// visibleLibraryIDs returns the libraries the current user can see: all libraries for admin and +// headless processes, otherwise the user's granted libraries. +func (r *artistRepository) visibleLibraryIDs() ([]int, error) { + user := loggedUser(r.ctx) + if user.IsAdmin || user.ID == invalidUserId { + var ids []int + err := r.queryAllSlice(Select("id").From("library"), &ids) + return ids, err + } + return slice.Map(user.Libraries, func(lib model.Library) int { return lib.ID }), nil +} + func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) { return r.CountAll(r.parseRestOptions(r.ctx, options...)) } diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index 7003efec3..603c5dd5e 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -111,6 +111,80 @@ var _ = Describe("ArtistRepository", func() { }) }) + Describe("searchScope", func() { + // Resolves the library IDs a search must be restricted to (nil = fast-path / no filter), + // the way Search() does, for a repo whose context carries the given user. + scope := func(user model.User, filter squirrel.Sqlizer) []int { + ctx := request.WithUser(GinkgoT().Context(), user) + r := NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) + return r.searchScope(filter) + } + subsetUser := model.User{ID: "u", Libraries: model.Libraries{{ID: 1}, {ID: 2}, {ID: 3}}} + + It("scopes to a strict subset of the user's libraries", func() { + Expect(scope(subsetUser, squirrel.Eq{"library_id": []int{1, 2}})).To(Equal([]int{1, 2})) + }) + + It("treats duplicate IDs as a set so a real subset still narrows", func() { + // {1,1,2} has 3 entries but is a strict subset of the user's 3 libraries. + Expect(scope(subsetUser, squirrel.Eq{"library_id": []int{1, 1, 2}})).To(Equal([]int{1, 1, 2})) + }) + + It("returns nil (fast-path) when the request covers all the user's libraries", func() { + Expect(scope(subsetUser, squirrel.Eq{"library_id": []int{1, 2, 3}})).To(BeNil()) + }) + + It("scopes to the user's libraries when no library filter is given", func() { + // A restricted user (strictly fewer libs than exist) with no musicFolderId is still + // confined to their granted libs. Build the user with total-1 libraries derived from + // the real DB total, so the "sees all" fast-path can't kick in regardless of count. + total, err := NewLibraryRepository(GinkgoT().Context(), GetDBXBuilder()).CountAll() + Expect(err).ToNot(HaveOccurred()) + Expect(total).To(BeNumerically(">", 0)) + libs := make(model.Libraries, 0, total-1) + for i := int64(1); i < total; i++ { // total-1 distinct libraries → a strict subset + libs = append(libs, model.Library{ID: int(i)}) + } + restricted := model.User{ID: "r", Libraries: libs} + got := scope(restricted, nil) + Expect(got).To(HaveLen(int(total) - 1)) + }) + + It("returns nil (fast-path) for an admin requesting all existing libraries", func() { + // Admins see every library, so the visible set is the whole library table — derive + // it from the DB rather than assuming a count. + var allLibs []int + Expect(NewLibraryRepository(GinkgoT().Context(), GetDBXBuilder()).(*libraryRepository). + queryAllSlice(squirrel.Select("id").From("library"), &allLibs)).To(Succeed()) + admin := model.User{ID: "a", IsAdmin: true} + Expect(scope(admin, squirrel.Eq{"library_id": allLibs})).To(BeNil()) + Expect(scope(admin, nil)).To(BeNil()) + }) + + It("narrows for an admin explicitly requesting a subset via musicFolderId", func() { + // An admin scoping to a single, non-existent-as-the-whole-set library must still be + // narrowed (regression: search3?musicFolderId=lib2 was leaking lib1 content). + admin := model.User{ID: "a", IsAdmin: true} + Expect(scope(admin, squirrel.Eq{"library_id": []int{-1}})).To(Equal([]int{-1})) + }) + + It("returns nil for a non-library_id filter (no library scoping requested)", func() { + // Such a filter carries no library intent; for this fully-granted-style user the + // search needs no extra library restriction. + allUser := model.User{ID: "u2", IsAdmin: true} + Expect(scope(allUser, squirrel.Eq{"name": "x"})).To(BeNil()) + }) + + It("falls back to the visible scope for a malformed library_id value (no crash)", func() { + // A library_id filter whose value isn't []int is still recognized as a library + // filter (so Search consumes it and it never reaches the bare artist table), and + // searchScope falls back to exactly the no-filter behavior rather than crashing. + malformed := squirrel.Eq{"library_id": "not-a-slice"} + Expect(isLibraryIDFilter(malformed)).To(BeTrue()) + Expect(scope(subsetUser, malformed)).To(Equal(scope(subsetUser, nil))) + }) + }) + Describe("dbArtist mapping", func() { var ( artist *model.Artist @@ -653,6 +727,38 @@ var _ = Describe("ArtistRepository", func() { _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": lib2Artist.ID})) } }) + + It("paginates a restricted user's visible artists without gaps", func() { + // ID "25" sorts between base fixtures "2" and "3", so this lib2-only artist lands + // inside the restricted user's visible range — exercising the no-gap guarantee. + lib2Artist := model.Artist{ID: "25", Name: "Restricted Lib2 Artist"} + Expect(repo.Put(&lib2Artist)).To(Succeed()) + Expect(lr.AddArtist(lib2.ID, lib2Artist.ID)).To(Succeed()) + DeferCleanup(func() { + if raw, ok := repo.(*artistRepository); ok { + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": lib2Artist.ID})) + } + }) + + all, err := restrictedRepo.Search("", model.QueryOptions{Max: 1000}) + Expect(err).ToNot(HaveOccurred()) + Expect(len(all)).To(BeNumerically(">", 1)) + for _, a := range all { + Expect(a.ID).ToNot(Equal(lib2Artist.ID)) + } + + var paged model.Artists + for offset := range len(all) { + page, err := restrictedRepo.Search("", model.QueryOptions{Max: 1, Offset: offset}) + Expect(err).ToNot(HaveOccurred()) + Expect(page).To(HaveLen(1), fmt.Sprintf("page at offset %d should be full", offset)) + paged = append(paged, page...) + } + Expect(paged).To(HaveLen(len(all))) + for i := range all { + Expect(paged[i].ID).To(Equal(all[i].ID)) + } + }) }) Context("Headless Processes (No User Context)", func() { @@ -891,6 +997,45 @@ var _ = Describe("ArtistRepository", func() { Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(0)) }) + + It("takes the unfiltered fast-path when the user can access every library", func() { + // The fixture DB has a single library and the user was granted it, so it has access + // to all libraries: search results must match what an admin sees. + adminRepo := NewArtistRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + adminAll, err := adminRepo.Search("", model.QueryOptions{Max: 1000}) + Expect(err).ToNot(HaveOccurred()) + + userAll, err := restrictedRepo.Search("", model.QueryOptions{Max: 1000}) + Expect(err).ToNot(HaveOccurred()) + + ids := func(artists model.Artists) []string { + out := make([]string, len(artists)) + for i, a := range artists { + out[i] = a.ID + } + return out + } + Expect(ids(userAll)).To(Equal(ids(adminAll))) + Expect(userAll).ToNot(BeEmpty()) + }) + + It("detects all-library access regardless of result equivalence", func() { + // userSeesAllLibraries drives the search fast-path for a non-admin: true when the + // visible-library count reaches the DB total. Derive the total from the DB so the + // assertion doesn't depend on how many libraries other specs left behind. + raw := restrictedRepo.(*artistRepository) // context carries a non-admin user + total, err := NewLibraryRepository(GinkgoT().Context(), GetDBXBuilder()).CountAll() + Expect(err).ToNot(HaveOccurred()) + Expect(total).To(BeNumerically(">", 0)) + + allLibs := make([]int, total) + for i := range allLibs { + allLibs[i] = i + 1 + } + Expect(raw.userSeesAllLibraries(allLibs)).To(BeTrue()) + Expect(raw.userSeesAllLibraries(allLibs[:total-1])).To(BeFalse()) + Expect(raw.userSeesAllLibraries([]int{})).To(BeFalse()) + }) }) }) @@ -976,6 +1121,66 @@ var _ = Describe("ArtistRepository", func() { Expect(err).ToNot(HaveOccurred()) }) }) + + Describe("RefreshStats", func() { + var repo *artistRepository + + missing := func(id string) bool { + var vals []bool + Expect(repo.queryAllSlice(squirrel.Select("missing").From("artist").Where(squirrel.Eq{"id": id}), &vals)).To(Succeed()) + Expect(vals).To(HaveLen(1)) + return vals[0] + } + + BeforeEach(func() { + ctx := request.WithUser(GinkgoT().Context(), adminUser) + repo = NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) + }) + + It("marks artists missing when the empty-stats cleanup drops their last library_artist row", func() { + // A library_artist row with stats '{}' (no content) gets deleted by the cleanup, + // which would orphan this non-missing artist. + emptyArtist := model.Artist{ID: "refresh-empty", Name: "No Content Artist"} + Expect(repo.Put(&emptyArtist)).To(Succeed()) + _, err := repo.executeSQL(squirrel.Insert("library_artist"). + SetMap(map[string]any{"library_id": 1, "artist_id": emptyArtist.ID, "stats": "{}"})) + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { + _, _ = repo.executeSQL(squirrel.Delete("library_artist").Where(squirrel.Eq{"artist_id": emptyArtist.ID})) + _ = repo.delete(squirrel.Eq{"id": emptyArtist.ID}) + }) + + Expect(missing(emptyArtist.ID)).To(BeFalse()) + + _, err = repo.RefreshStats(true) + Expect(err).ToNot(HaveOccurred()) + + Expect(missing(emptyArtist.ID)).To(BeTrue()) + var orphanIDs []string + Expect(repo.queryAllSlice(squirrel.Select("id").From("artist"). + Where("missing = false"). + Where("id not in (select artist_id from library_artist)"), &orphanIDs)).To(Succeed()) + Expect(orphanIDs).ToNot(ContainElement(emptyArtist.ID)) + }) + + It("heals a pre-existing orphan (no library_artist row) on a full refresh", func() { + // A legacy orphan left by an older version: non-missing, with no library_artist row at + // all. The cleanup deletes nothing for it, so a full refresh (allArtists) must still + // reconcile it. + legacyOrphan := model.Artist{ID: "refresh-legacy-orphan", Name: "Legacy Orphan"} + Expect(repo.Put(&legacyOrphan)).To(Succeed()) + DeferCleanup(func() { + _ = repo.delete(squirrel.Eq{"id": legacyOrphan.ID}) + }) + + Expect(missing(legacyOrphan.ID)).To(BeFalse()) + + _, err := repo.RefreshStats(true) + Expect(err).ToNot(HaveOccurred()) + + Expect(missing(legacyOrphan.ID)).To(BeTrue()) + }) + }) }) // Helper function to create an artist with proper library association. diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index fa769ef40..b74f498d0 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -140,7 +140,7 @@ func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqliz } and = append(and, cond) } - return and, nil + return mergeNegatedJsonConds(and), nil case criteria.Any: or := squirrel.Or{} for _, child := range e { @@ -454,6 +454,25 @@ const jsonCondBatchSize = 350 // This turns N separate correlated subqueries into ceil(N/batchSize), dramatically // improving performance for smart playlists with many patterns. func mergeJsonConds(or squirrel.Or) squirrel.Sqlizer { + if merged, ok := mergeSameFieldConds(or, false); ok { + return squirrel.Or(merged) + } + return or +} + +// mergeNegatedJsonConds is the AND-group counterpart to mergeJsonConds, merging negated +// conditions. By De Morgan, "NOT EXISTS(X) AND NOT EXISTS(Y)" == "NOT EXISTS(X OR Y)". +func mergeNegatedJsonConds(and squirrel.And) squirrel.Sqlizer { + if merged, ok := mergeSameFieldConds(and, true); ok { + return squirrel.And(merged) + } + return and +} + +// mergeSameFieldConds groups roleCond/tagCond entries that share a field and the requested +// polarity, replacing each group of 2+ with batched roleCondGroup/tagCondGroup subqueries. +// Returns the rewritten conditions and whether any merge happened. +func mergeSameFieldConds(conds []squirrel.Sqlizer, negated bool) ([]squirrel.Sqlizer, bool) { type condEntry struct { index int cond squirrel.Sqlizer @@ -465,10 +484,10 @@ func mergeJsonConds(or squirrel.Or) squirrel.Sqlizer { tag string } groups := make(map[string]*group) - for i, s := range or { + for i, s := range conds { switch c := s.(type) { case roleCond: - if c.not || c.cond == nil { + if c.not != negated || c.cond == nil { continue } g, exists := groups["role:"+c.role] @@ -478,7 +497,7 @@ func mergeJsonConds(or squirrel.Or) squirrel.Sqlizer { } g.entries = append(g.entries, condEntry{index: i, cond: c.cond}) case tagCond: - if c.not || c.cond == nil { + if c.not != negated || c.cond == nil { continue } g, exists := groups["tag:"+c.tag] @@ -490,7 +509,6 @@ func mergeJsonConds(or squirrel.Or) squirrel.Sqlizer { } } - merged := false remove := make(map[int]bool) var additions []squirrel.Sqlizer for _, key := range slices.Sorted(maps.Keys(groups)) { @@ -498,45 +516,42 @@ func mergeJsonConds(or squirrel.Or) squirrel.Sqlizer { if len(g.entries) < 2 { continue } - merged = true - for _, e := range g.entries { - remove[e.index] = true - } - conds := make([]squirrel.Sqlizer, len(g.entries)) + batchConds := make([]squirrel.Sqlizer, len(g.entries)) for i, e := range g.entries { - conds[i] = e.cond + remove[e.index] = true + batchConds[i] = e.cond } if g.isRole { role := key[len("role:"):] - for batch := range slices.Chunk(conds, jsonCondBatchSize) { - additions = append(additions, roleCondGroup{role: role, conds: batch}) + for batch := range slices.Chunk(batchConds, jsonCondBatchSize) { + additions = append(additions, roleCondGroup{role: role, conds: batch, not: negated}) } } else { - for batch := range slices.Chunk(conds, jsonCondBatchSize) { - additions = append(additions, tagCondGroup{tag: g.tag, numeric: g.numeric, conds: batch}) + for batch := range slices.Chunk(batchConds, jsonCondBatchSize) { + additions = append(additions, tagCondGroup{tag: g.tag, numeric: g.numeric, conds: batch, not: negated}) } } } - if !merged { - return or + if len(remove) == 0 { + return conds, false } - result := make(squirrel.Or, 0, len(or)-len(remove)+len(additions)) - for i, s := range or { + result := make([]squirrel.Sqlizer, 0, len(conds)-len(remove)+len(additions)) + for i, s := range conds { if !remove[i] { result = append(result, s) } } - result = append(result, additions...) - return result + return append(result, additions...), true } -// roleCondGroup represents multiple role conditions for the same role, merged into -// a single EXISTS subquery for performance. +// roleCondGroup represents multiple role conditions for the same role, merged into a single +// (optionally negated) EXISTS subquery for performance. type roleCondGroup struct { role string conds []squirrel.Sqlizer + not bool } func (g roleCondGroup) ToSql() (string, []any, error) { @@ -551,15 +566,19 @@ func (g roleCondGroup) ToSql() (string, []any, error) { allArgs = append(allArgs, args...) } cond := roleExistsSQL("(" + strings.Join(innerParts, " OR ") + ")") + if g.not { + cond = "not " + cond + } return cond, allArgs, nil } -// tagCondGroup represents multiple tag conditions for the same tag, merged into -// a single EXISTS subquery for performance. +// tagCondGroup represents multiple tag conditions for the same tag, merged into a single +// (optionally negated) EXISTS subquery for performance. type tagCondGroup struct { tag string numeric bool conds []squirrel.Sqlizer + not bool } func (g tagCondGroup) ToSql() (string, []any, error) { @@ -578,6 +597,9 @@ func (g tagCondGroup) ToSql() (string, []any, error) { } cond := fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and (%s))", g.tag, strings.Join(innerParts, " OR ")) + if g.not { + cond = "not " + cond + } return cond, allArgs, nil } diff --git a/persistence/criteria_sql_benchmark_test.go b/persistence/criteria_sql_benchmark_test.go index d901e9eda..1dcf97871 100644 --- a/persistence/criteria_sql_benchmark_test.go +++ b/persistence/criteria_sql_benchmark_test.go @@ -60,6 +60,61 @@ func BenchmarkSmartPlaylistRole(b *testing.B) { }) } +// BenchmarkSmartPlaylistNegatedRole compares performance for smart playlists with many +// negated role conditions ANDed together (e.g. 500 "isNot artist" rules, issue #5511) +// between the current implementation (merged NOT EXISTS via criteria pipeline) and the +// old baseline (one separate NOT EXISTS subquery per pattern). +func BenchmarkSmartPlaylistNegatedRole(b *testing.B) { + configtest.SetupConfig() + tmpDir := b.TempDir() + conf.Server.DbPath = filepath.Join(tmpDir, "bench-smartpl-neg.db") + cleanup := db.Init(context.Background()) + defer cleanup() + log.SetLevel(log.LevelFatal) + + conn := dbx.NewFromDB(db.Db(), db.Dialect) + ctx := log.NewContext(context.Background()) + user := model.User{ID: "bench-user", UserName: "bench", Name: "Bench User", IsAdmin: true} + ctx = request.WithUser(ctx, user) + + setupBenchData(b, ctx, conn, user) + criteria.AddRoles([]string{"artist"}) + + // Build the criteria expression: 500 "isNot artist" patterns in an AND group + allExprs := make(criteria.All, benchNumPatterns) + for i := range benchNumPatterns { + allExprs[i] = criteria.IsNot{"artist": fmt.Sprintf("Artist %04d", i)} + } + expr := criteria.Criteria{Expression: allExprs, Sort: "title", Limit: 500} + + b.Run("Current", func(b *testing.B) { + benchmarkCriteriaPipeline(b, ctx, expr) + }) + b.Run("Baseline_UnmergedNotExists", func(b *testing.B) { + benchmarkUnmergedNegatedJSONTree(b, ctx) + }) +} + +// benchmarkUnmergedNegatedJSONTree builds the old-style query with N separate negated +// json_tree EXISTS subqueries ANDed together (the pre-optimization baseline). +func benchmarkUnmergedNegatedJSONTree(b *testing.B, ctx context.Context) { + b.Helper() + + var sb strings.Builder + sb.WriteString("SELECT media_file.id FROM media_file WHERE (") + args := make([]any, 0, benchNumPatterns) + for i := range benchNumPatterns { + if i > 0 { + sb.WriteString(" AND ") + } + sb.WriteString("not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)") + args = append(args, fmt.Sprintf("Artist %04d", i)) + } + sb.WriteString(") ORDER BY media_file.title LIMIT 500") + + runBenchQuery(b, ctx, sb.String(), args) +} + // benchmarkCriteriaPipeline runs the criteria through the actual production code path: // newSmartPlaylistCriteria → Where() → ToSql(), then executes the resulting query. func benchmarkCriteriaPipeline(b *testing.B, ctx context.Context, expr criteria.Criteria) { diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index 0257fa0cf..9ff7f1f07 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -408,6 +408,97 @@ var _ = Describe("Smart playlist criteria SQL", func() { Expect(sql).To(ContainSubstring("value LIKE ? OR value LIKE ?")) Expect(args).To(HaveLen(2 + 2 + 1)) // 2 tag patterns + 2 role patterns + 1 role name }) + + It("merges negated role conditions in an AND group into a single NOT EXISTS", func() { + expr := criteria.All{ + criteria.IsNot{"artist": "Beatles"}, + criteria.IsNot{"artist": "Kraftwerk"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + // A single NOT EXISTS with both names ORed inside (De Morgan) + Expect(strings.Count(sql, "not exists")).To(Equal(1)) + Expect(sql).To(ContainSubstring("artist.name = ? OR artist.name = ?")) + Expect(args).To(HaveExactElements("artist", "Beatles", "Kraftwerk")) + }) + + It("merges negated notContains role conditions in an AND group", func() { + expr := criteria.All{ + criteria.NotContains{"artist": "Beatles"}, + criteria.NotContains{"artist": "Kraftwerk"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(strings.Count(sql, "not exists")).To(Equal(1)) + Expect(sql).To(ContainSubstring("artist.name LIKE ? OR artist.name LIKE ?")) + Expect(args).To(HaveExactElements("artist", "%Beatles%", "%Kraftwerk%")) + }) + + It("merges negated tag conditions in an AND group into a single NOT EXISTS", func() { + expr := criteria.All{ + criteria.NotContains{"genre": "Rock"}, + criteria.NotContains{"genre": "Metal"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(strings.Count(sql, "not exists")).To(Equal(1)) + Expect(sql).To(ContainSubstring("value LIKE ? OR value LIKE ?")) + Expect(args).To(HaveExactElements("%Rock%", "%Metal%")) + }) + + It("does not merge a single negated condition with a positive one of the same role in AND", func() { + // AND of mixed polarity must not be collapsed: NOT EXISTS(a) AND EXISTS(b) + // is not equivalent to any single merged subquery. + expr := criteria.All{ + criteria.Contains{"artist": "Beatles"}, + criteria.IsNot{"artist": "Kraftwerk"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, _, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + // One positive EXISTS and one negated NOT EXISTS, kept separate + Expect(strings.Count(sql, "not exists")).To(Equal(1)) + Expect(strings.Count(sql, "exists")).To(Equal(2)) // "not exists" contains "exists" + }) + + It("does not merge negated conditions of different roles in AND", func() { + expr := criteria.All{ + criteria.IsNot{"artist": "Beatles"}, + criteria.IsNot{"composer": "Lennon"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, _, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(strings.Count(sql, "not exists")).To(Equal(2)) + }) + + It("batches large negated AND groups to avoid SQLite expression tree depth limit", func() { + allExprs := make(criteria.All, jsonCondBatchSize+1) + for i := range allExprs { + allExprs[i] = criteria.IsNot{"artist": fmt.Sprintf("Artist%d", i)} + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: allExprs}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + // Two NOT EXISTS subqueries (one batch of jsonCondBatchSize, one of 1) + Expect(strings.Count(sql, "not exists")).To(Equal(2)) + Expect(args).To(HaveLen(2 + jsonCondBatchSize + 1)) + }) }) Describe("joins", func() { diff --git a/persistence/folder_repository.go b/persistence/folder_repository.go index 624ef21b8..8fb7f0296 100644 --- a/persistence/folder_repository.go +++ b/persistence/folder_repository.go @@ -250,6 +250,18 @@ func (r folderRepository) GetTouchedWithPlaylists() (model.FolderCursor, error) return wrapFolderCursor(cursor), nil } +func (r folderRepository) GetAllWithPlaylists() (model.FolderCursor, error) { + query := r.selectFolder().Where(And{ + Eq{"missing": false}, + Gt{"num_playlists": 0}, + }) + cursor, err := queryWithStableResults[dbFolder](r.sqlRepository, query) + if err != nil { + return nil, err + } + return wrapFolderCursor(cursor), nil +} + func wrapFolderCursor(cursor iter.Seq2[dbFolder, error]) model.FolderCursor { return func(yield func(model.Folder, error) bool) { for f, err := range cursor { diff --git a/persistence/folder_repository_test.go b/persistence/folder_repository_test.go index 413a6b38f..a8945dfee 100644 --- a/persistence/folder_repository_test.go +++ b/persistence/folder_repository_test.go @@ -317,4 +317,36 @@ var _ = Describe("FolderRepository", func() { Expect(folders[0].ID).To(Equal("f1")) }) }) + + Describe("GetAllWithPlaylists", func() { + It("returns all non-missing folders with playlists, ignoring the scan-timestamp gate", func() { + withPls := model.NewFolder(testLib, "TestAllPls/WithPls") + withPls.NumPlaylists = 2 + noPls := model.NewFolder(testLib, "TestAllPls/NoPls") + noPls.NumPlaylists = 0 + missingWithPls := model.NewFolder(testLib, "TestAllPls/Missing") + missingWithPls.NumPlaylists = 1 + missingWithPls.Missing = true + + Expect(repo.Put(withPls)).To(Succeed()) + Expect(repo.Put(noPls)).To(Succeed()) + Expect(repo.Put(missingWithPls)).To(Succeed()) + + // Force the folder's updated_at to the past so GetTouchedWithPlaylists + // (which gates on updated_at > last_scan_at) would NOT return it. + _, err := conn.NewQuery("UPDATE folder SET updated_at = {:t} WHERE id = {:id}"). + Bind(dbx.Params{"t": "2000-01-01 00:00:00", "id": withPls.ID}).Execute() + Expect(err).ToNot(HaveOccurred()) + + var ids []string + cursor, err := repo.GetAllWithPlaylists() + Expect(err).ToNot(HaveOccurred()) + for f, err := range cursor { + Expect(err).ToNot(HaveOccurred()) + ids = append(ids, f.ID) + } + + Expect(ids).To(ConsistOf(withPls.ID)) // only the non-missing folder with playlists + }) + }) }) diff --git a/persistence/library_repository.go b/persistence/library_repository.go index 1d8e6f35e..3789a71c9 100644 --- a/persistence/library_repository.go +++ b/persistence/library_repository.go @@ -261,6 +261,11 @@ func (r *libraryRepository) Delete(id int) error { return err } + // The cascade above can drop an artist's last library_artist row; reconcile any such orphans. + if err := NewArtistRepository(r.ctx, r.db).(*artistRepository).markOrphansMissing(); err != nil { + return fmt.Errorf("marking orphaned artists missing after deleting library %d: %w", id, err) + } + // Clear cache entry for this library only if DB operation was successful libLock.Lock() defer libLock.Unlock() diff --git a/persistence/library_repository_test.go b/persistence/library_repository_test.go index de7161643..1743df209 100644 --- a/persistence/library_repository_test.go +++ b/persistence/library_repository_test.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -206,4 +207,49 @@ var _ = Describe("LibraryRepository", func() { }) }) }) + + Describe("Delete", func() { + var adminRepo model.LibraryRepository + var artistRepo model.ArtistRepository + + artistMissing := func(id string) bool { + var missing bool + err := conn.NewQuery("SELECT missing FROM artist WHERE id = {:id}"). + Bind(dbx.Params{"id": id}).Row(&missing) + Expect(err).ToNot(HaveOccurred()) + return missing + } + + BeforeEach(func() { + adminCtx := request.WithUser(log.NewContext(context.TODO()), adminUser) + adminRepo = NewLibraryRepository(adminCtx, conn) + artistRepo = NewArtistRepository(adminCtx, conn) + }) + + It("marks artists orphaned by the delete as missing", func() { + lib := model.Library{Name: "Doomed Library", Path: "/doomed"} + Expect(adminRepo.Put(&lib)).To(Succeed()) + + orphanArtist := model.Artist{ID: "delete-orphan", Name: "Orphan To Be"} + sharedArtist := model.Artist{ID: "delete-shared", Name: "Shared Artist"} + Expect(artistRepo.Put(&orphanArtist)).To(Succeed()) + Expect(artistRepo.Put(&sharedArtist)).To(Succeed()) + Expect(adminRepo.AddArtist(lib.ID, orphanArtist.ID)).To(Succeed()) + Expect(adminRepo.AddArtist(lib.ID, sharedArtist.ID)).To(Succeed()) + Expect(adminRepo.AddArtist(1, sharedArtist.ID)).To(Succeed()) + DeferCleanup(func() { + if raw, ok := artistRepo.(*artistRepository); ok { + _, _ = raw.executeSQL(squirrel.Delete("artist"). + Where(squirrel.Eq{"id": []string{orphanArtist.ID, sharedArtist.ID}})) + } + }) + + Expect(artistMissing(orphanArtist.ID)).To(BeFalse()) + + Expect(adminRepo.Delete(lib.ID)).To(Succeed()) + + Expect(artistMissing(orphanArtist.ID)).To(BeTrue(), "orphaned artist should be marked missing") + Expect(artistMissing(sharedArtist.ID)).To(BeFalse(), "artist still in another library must stay visible") + }) + }) }) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 559378262..dd8145eae 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -207,6 +207,40 @@ func (r *mediaFileRepository) GetAll(options ...model.QueryOptions) (model.Media return res.toModels(), nil } +// GetRandom uses two passes so the random sort runs over a narrow rowid index instead of the +// wide media_file row: pick random rowids first, then hydrate only those. +func (r *mediaFileRepository) GetRandom(options ...model.QueryOptions) (model.MediaFiles, error) { + var opt model.QueryOptions + if len(options) > 0 { + opt = options[0] + } + + rowidQuery := Select("media_file.rowid").From(r.tableName) + rowidQuery = r.applyFilters(rowidQuery, model.QueryOptions{Filters: opt.Filters}) + rowidQuery = r.applyLibraryFilter(rowidQuery) + rowidQuery = rowidQuery.OrderBy("random()") + if opt.Max > 0 { + rowidQuery = rowidQuery.Limit(uint64(opt.Max)) + } + + var rowids []int64 + if err := r.queryAllSlice(rowidQuery, &rowids); err != nil { + return nil, err + } + if len(rowids) == 0 { + return model.MediaFiles{}, nil + } + + // Re-shuffle in Phase 2: `WHERE rowid IN (...)` returns rows in ascending rowid order, not + // the random order from Phase 1. Sorting only the (<=Max) hydrated rows is negligible. + sq := r.selectMediaFile().Where(Eq{"media_file.rowid": rowids}).OrderBy("random()") + var res dbMediaFiles + if err := r.queryAll(sq, &res); err != nil { + return nil, err + } + return res.toModels(), nil +} + func (r *mediaFileRepository) GetAllByTags(tag model.TagName, values []string, options ...model.QueryOptions) (model.MediaFiles, error) { placeholders := make([]string, len(values)) args := make([]any, len(values)) diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 4c2363e43..7989dc25a 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "reflect" "time" "github.com/Masterminds/squirrel" @@ -106,6 +107,102 @@ var _ = Describe("MediaRepository", func() { } }) + Describe("GetRandom", func() { + It("returns the requested number of distinct, fully-hydrated media files", func() { + results, err := mr.GetRandom(model.QueryOptions{Max: 5}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(5)) + + // Each returned row must match its GetAll counterpart exactly — proves Phase 2 + // hydrates full rows (not bare rowids) — and ids must be distinct. + byID := map[string]model.MediaFile{} + all, err := mr.GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, mf := range all { + byID[mf.ID] = mf + } + seen := map[string]bool{} + for _, mf := range results { + expected, ok := byID[mf.ID] + Expect(ok).To(BeTrue(), "returned id must be a real media file") + Expect(mf.Title).To(Equal(expected.Title), "row must be fully hydrated") + Expect(seen[mf.ID]).To(BeFalse(), "no duplicate rows") + seen[mf.ID] = true + } + }) + + It("returns all matching files when Max exceeds the total", func() { + results, err := mr.GetRandom(model.QueryOptions{Max: 1000}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(13)) + }) + + It("honors filters", func() { + results, err := mr.GetRandom(model.QueryOptions{ + Max: 10, + Filters: squirrel.Eq{"media_file.title": "Antenna"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(results).ToNot(BeEmpty()) + for _, mf := range results { + Expect(mf.Title).To(Equal("Antenna")) + } + }) + + It("returns varying results across calls", func() { + // Retry a few times: two random draws of 5 from 13 rows differ with near-certainty. + first, err := mr.GetRandom(model.QueryOptions{Max: 5}) + Expect(err).ToNot(HaveOccurred()) + firstIDs := func() []string { + ids := make([]string, len(first)) + for i, mf := range first { + ids[i] = mf.ID + } + return ids + }() + differed := false + for range 10 { + next, err := mr.GetRandom(model.QueryOptions{Max: 5}) + Expect(err).ToNot(HaveOccurred()) + nextIDs := make([]string, len(next)) + for i, mf := range next { + nextIDs[i] = mf.ID + } + if !reflect.DeepEqual(firstIDs, nextIDs) { + differed = true + break + } + } + Expect(differed).To(BeTrue(), "GetRandom should not return an identical set every call") + }) + + It("randomizes order even when Max exceeds the total", func() { + // Same set of rows every time (all 13), but the order must still be shuffled — + // guards against Phase 2's `rowid IN (...)` returning rows in rowid order. + first, err := mr.GetRandom(model.QueryOptions{Max: 100}) + Expect(err).ToNot(HaveOccurred()) + Expect(first).To(HaveLen(13)) + firstIDs := make([]string, len(first)) + for i, mf := range first { + firstIDs[i] = mf.ID + } + differed := false + for range 10 { + next, err := mr.GetRandom(model.QueryOptions{Max: 100}) + Expect(err).ToNot(HaveOccurred()) + nextIDs := make([]string, len(next)) + for i, mf := range next { + nextIDs[i] = mf.ID + } + if !reflect.DeepEqual(firstIDs, nextIDs) { + differed = true + break + } + } + Expect(differed).To(BeTrue(), "order must vary even when returning all rows") + }) + }) + Describe("Put CreatedAt behavior (#5050)", func() { It("sets CreatedAt to now when inserting a new file with zero CreatedAt", func() { before := time.Now().Add(-time.Second) diff --git a/persistence/sql_search.go b/persistence/sql_search.go index 19cbaf24f..3049baae7 100644 --- a/persistence/sql_search.go +++ b/persistence/sql_search.go @@ -21,10 +21,9 @@ type searchConfig struct { NaturalOrder string // ORDER BY for empty-query results (e.g. "album.rowid") OrderBy []string // ORDER BY for text search results (e.g. ["name"]) MBIDFields []string // columns to match when query is a UUID - // LibraryFilter overrides the default applyLibraryFilter for the rowid Phase 1 of - // two-phase searches (FTS and empty-query). Needed when library access goes through a - // junction table (e.g. artist → library_artist), whose JOIN can fan out rowids for - // entities in multiple libraries — Phase 1 dedups whenever this is set. + // LibraryFilter overrides the default applyLibraryFilter for the rowid Phase 1, for entities whose + // library access goes through a junction table (e.g. artist → library_artist). It MUST be join-free + // (Phase 1 has no DISTINCT, so a fan-out JOIN would corrupt offset pagination). See [artistLibraryFilter]. LibraryFilter func(sq SelectBuilder) SelectBuilder } @@ -102,10 +101,7 @@ func (r sqlRepository) executeTwoPhase(sq SelectBuilder, results any, rowidCore rowidQuery = rowidQuery.Offset(uint64(options.Offset)) } if cfg.LibraryFilter != nil { - // Junction-table library filters can repeat rowids for entities in multiple - // libraries, which would corrupt offset-based pagination — dedup before paginating. - // (DISTINCT, not GROUP BY: bm25() can't be evaluated in a grouped query.) - rowidQuery = cfg.LibraryFilter(rowidQuery).Distinct() + rowidQuery = cfg.LibraryFilter(rowidQuery) } else { rowidQuery = r.applyLibraryFilter(rowidQuery) } diff --git a/plugins/lyrics_adapter.go b/plugins/lyrics_adapter.go index c66b027d7..12d84f60d 100644 --- a/plugins/lyrics_adapter.go +++ b/plugins/lyrics_adapter.go @@ -31,8 +31,8 @@ type LyricsPlugin struct { plugin *plugin } -// GetLyrics calls the plugin to fetch lyrics, then parses the raw text responses -// using model.ToLyrics. +// GetLyrics calls the plugin to fetch lyrics, then content-sniffs each response +// via model.ParseLyrics (TTML/SRT/YAML/LRC/plain). func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { req := capabilities.GetLyricsRequest{ Track: mediaFileToTrackInfo(l.plugin, mf), @@ -50,13 +50,15 @@ func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (mode if lang == "" { lang = "xxx" } - parsed, err := model.ToLyrics(lang, lt.Text) + parsed, err := model.ParseLyrics("", lang, []byte(lt.Text)) if err != nil { log.Warn(ctx, "Error parsing plugin lyrics", "plugin", l.name, err) continue } - if parsed != nil && !parsed.IsEmpty() { - result = append(result, *parsed) + for _, lyric := range parsed { + if !lyric.IsEmpty() { + result = append(result, lyric) + } } } return result, nil diff --git a/plugins/lyrics_adapter_test.go b/plugins/lyrics_adapter_test.go index a1a6c1809..6e82dbfab 100644 --- a/plugins/lyrics_adapter_test.go +++ b/plugins/lyrics_adapter_test.go @@ -83,6 +83,32 @@ var _ = Describe("LyricsPlugin", Ordered, func() { _, err := p.GetLyrics(GinkgoT().Context(), track) Expect(err).To(HaveOccurred()) }) + + // Each DescribeTable entry proves that the adapter's content-sniffing routes + // the plugin's rich payload to the right parser rather than mangling it as plain text. + DescribeTable("content-sniffs plugin responses across all supported formats", + func(format string, wantSynced bool, wantLine string) { + manager, _ := createTestManagerWithPlugins(map[string]map[string]string{ + "test-lyrics": {"format": format}, + }, "test-lyrics"+PackageExtension) + + p, ok := manager.LoadLyricsProvider("test-lyrics") + Expect(ok).To(BeTrue()) + + track := &model.MediaFile{ID: "track-1", Title: "Test Song", Artist: "Test Artist"} + result, err := p.GetLyrics(GinkgoT().Context(), track) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Synced).To(Equal(wantSynced), "unexpected Synced value for format %s", format) + Expect(result[0].Line).To(HaveLen(1)) + Expect(result[0].Line[0].Value).To(Equal(wantLine)) + }, + Entry("ttml", "ttml", true, "plugin ttml line"), + Entry("srt", "srt", true, "plugin srt line"), + Entry("yaml", "yaml", true, "plugin yaml line"), + Entry("lrc", "lrc", true, "plugin lrc line"), + Entry("plain", "plain", false, "plugin plain line"), + ) }) Describe("PluginNames", func() { diff --git a/plugins/manager.go b/plugins/manager.go index 67e0ee987..a7649d47e 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -241,7 +241,7 @@ func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) { return loadPlugin(m, name, CapabilityScrobbler, newScrobblerPlugin) } -func (m *Manager) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) { +func (m *Manager) LoadLyricsProvider(name string) (lyrics.Provider, bool) { return loadPlugin(m, name, CapabilityLyrics, newLyricsPlugin) } diff --git a/plugins/testdata/test-lyrics/main.go b/plugins/testdata/test-lyrics/main.go index 0e485ceba..2ee2dabbf 100644 --- a/plugins/testdata/test-lyrics/main.go +++ b/plugins/testdata/test-lyrics/main.go @@ -15,12 +15,47 @@ func init() { type testLyrics struct{} func (t *testLyrics) GetLyrics(input lyrics.GetLyricsRequest) (lyrics.GetLyricsResponse, error) { - // Check for configured error errMsg, hasErr := pdk.GetConfig("error") if hasErr && errMsg != "" { return lyrics.GetLyricsResponse{}, fmt.Errorf("%s", errMsg) } + // Config-selected format lets tests exercise the adapter's content-sniffing per format. + format, hasFormat := pdk.GetConfig("format") + if hasFormat { + var text string + var lang string + switch format { + case "ttml": + lang = "eng" + text = ` + + +
+

plugin ttml line

+
+ +
` + case "srt": + lang = "eng" + text = "1\n00:00:01,000 --> 00:00:02,000\nplugin srt line\n" + case "yaml": + lang = "eng" + text = "version: \"1.0\"\nmetadata:\n language: eng\nlines:\n - text: \"plugin yaml line\"\n start_ms: 1000\n" + case "lrc": + lang = "eng" + text = "[00:01.00]plugin lrc line" + case "plain": + lang = "eng" + text = "plugin plain line" + } + if text != "" { + return lyrics.GetLyricsResponse{ + Lyrics: []lyrics.LyricsText{{Lang: lang, Text: text}}, + }, nil + } + } + // Check if we should omit language (to test default language handling) noLang, hasNoLang := pdk.GetConfig("no_lang") lang := "eng" diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index d9f29f5d4..b3b3bab2f 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -4,7 +4,7 @@ "song": { "name": "Música |||| Músicas", "fields": { - "albumArtist": "Artista", + "albumArtist": "Artista do Álbum", "duration": "Duração", "trackNumber": "#", "playCount": "Execuções", @@ -57,7 +57,7 @@ "album": { "name": "Álbum |||| Álbuns", "fields": { - "albumArtist": "Artista", + "albumArtist": "Artista do Álbum", "artist": "Artista", "duration": "Duração", "songCount": "Músicas", diff --git a/resources/mappings.yaml b/resources/mappings.yaml index 16dddd504..294654b6a 100644 --- a/resources/mappings.yaml +++ b/resources/mappings.yaml @@ -110,7 +110,9 @@ main: lyrics: # Note, @lyr and wm/lyrics have been removed. Taglib somehow appears to always populate `lyrics:xxx` aliases: [ uslt:description, lyrics, unsyncedlyrics ] - maxLength: 32768 + # Generous cap to fit word-timed TTML/Enhanced-LRC karaoke for a full song, + # while still bounding against pathological tags. + maxLength: 1048576 type: pair # ex: lyrics:eng, lyrics:xxx comment: aliases: [ comm:description, comment, ©cmt, description, icmt ] diff --git a/scanner/metadata_old/metadata.go b/scanner/metadata_old/metadata.go index 3ccbd8961..8cde586b9 100644 --- a/scanner/metadata_old/metadata.go +++ b/scanner/metadata_old/metadata.go @@ -205,13 +205,14 @@ func (t Tags) Lyrics() string { basicLyrics := t.getAllTagValues("lyrics", "unsynced_lyrics", "unsynced lyrics", "unsyncedlyrics") for _, value := range basicLyrics { - lyrics, err := model.ToLyrics("xxx", value) + parsed, err := model.ParseLyrics(".lrc", "xxx", []byte(value)) if err != nil { log.Warn("Unexpected failure occurred when parsing lyrics", "file", t.filePath, "error", err) continue } - - lyricList = append(lyricList, *lyrics) + if main, ok := parsed.Main(); ok { + lyricList = append(lyricList, main) + } } for tag, value := range t.Tags { @@ -223,13 +224,14 @@ func (t Tags) Lyrics() string { } for _, text := range value { - lyrics, err := model.ToLyrics(language, text) + parsed, err := model.ParseLyrics(".lrc", language, []byte(text)) if err != nil { log.Warn("Unexpected failure occurred when parsing lyrics", "file", t.filePath, "error", err) continue } - - lyricList = append(lyricList, *lyrics) + if main, ok := parsed.Main(); ok { + lyricList = append(lyricList, main) + } } } } diff --git a/scanner/phase_4_playlists.go b/scanner/phase_4_playlists.go index d52743966..8ba014235 100644 --- a/scanner/phase_4_playlists.go +++ b/scanner/phase_4_playlists.go @@ -2,6 +2,7 @@ package scanner import ( "context" + "errors" "fmt" "os" "strings" @@ -10,6 +11,7 @@ import ( ppl "github.com/google/go-pipeline/pkg/pipeline" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" @@ -18,12 +20,13 @@ import ( ) type phasePlaylists struct { - ctx context.Context - scanState *scanState - ds model.DataStore - pls playlists.Playlists - cw artwork.CacheWarmer - refreshed atomic.Uint32 + ctx context.Context + scanState *scanState + ds model.DataStore + pls playlists.Playlists + cw artwork.CacheWarmer + refreshed atomic.Uint32 + pendingImport bool } func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls playlists.Playlists, cw artwork.CacheWarmer) *phasePlaylists { @@ -49,22 +52,41 @@ func (p *phasePlaylists) produce(put func(entry *model.Folder)) error { log.Info(p.ctx, "Playlists will not be imported, AutoImportPlaylists is set to false") return nil } - u, _ := request.UserFrom(p.ctx) - if !u.IsAdmin || u.ID == "" { - log.Warn(p.ctx, "Playlists will not be imported, as there are no admin users yet, "+ - "Please create an admin user first, and then update the playlists for them to be imported") - return nil + + // Resolve the admin at phase time (the producer runs late in the scan), so an + // admin created while the scan was in progress is picked up. Assigned once, + // before any put() below, so the channel send synchronizes it with the stages. + admin, err := p.ds.User(p.ctx).FindFirstAdmin() + if err != nil && !errors.Is(err, model.ErrNotFound) { + return fmt.Errorf("finding admin user: %w", err) + } + noAdmin := admin == nil || admin.ID == "" + if noAdmin { + return p.deferImport() + } + p.ctx = request.WithUser(p.ctx, *admin) + + // When recovering a deferred import, scan all playlist folders, not just touched ones. + pending, err := p.importPending() + if err != nil { + return fmt.Errorf("checking pending playlist import: %w", err) + } + p.pendingImport = pending + var cursor model.FolderCursor + if p.pendingImport { + cursor, err = p.ds.Folder(p.ctx).GetAllWithPlaylists() + } else { + cursor, err = p.ds.Folder(p.ctx).GetTouchedWithPlaylists() + } + if err != nil { + return fmt.Errorf("loading folders with playlists: %w", err) } count := 0 - cursor, err := p.ds.Folder(p.ctx).GetTouchedWithPlaylists() - if err != nil { - return fmt.Errorf("loading touched folders: %w", err) - } - log.Debug(p.ctx, "Scanner: Checking playlists that may need refresh") + log.Debug(p.ctx, "Scanner: Checking playlists that may need refresh", "pendingImport", p.pendingImport) for folder, err := range cursor { if err != nil { - return fmt.Errorf("loading touched folder: %w", err) + return fmt.Errorf("loading folder with playlists: %w", err) } count++ put(&folder) @@ -78,6 +100,23 @@ func (p *phasePlaylists) produce(put func(entry *model.Folder)) error { return nil } +// deferImport records the pending-import flag so a later scan with an admin can +// import the playlists, and returns an error if the flag can't be persisted (so +// the scan does not complete as successful without recording the recovery). +func (p *phasePlaylists) deferImport() error { + if err := p.ds.Property(p.ctx).Put(consts.PlaylistsImportPendingFlagKey, "1"); err != nil { + return fmt.Errorf("recording pending playlist import: %w", err) + } + log.Warn(p.ctx, "Playlists will not be imported, as there are no admin users yet. "+ + "They will be imported automatically once an admin user is created.") + return nil +} + +func (p *phasePlaylists) importPending() (bool, error) { + v, err := p.ds.Property(p.ctx).DefaultGet(consts.PlaylistsImportPendingFlagKey, "0") + return v == "1", err +} + func (p *phasePlaylists) stages() []ppl.Stage[*model.Folder] { return []ppl.Stage[*model.Folder]{ ppl.NewStage(p.processPlaylistsInFolder, ppl.Name("process playlists in folder"), ppl.Concurrency(3)), @@ -123,6 +162,11 @@ func (p *phasePlaylists) finalize(err error) error { } else { p.scanState.changesDetected.Store(true) } + if p.pendingImport && err == nil { + if derr := p.ds.Property(p.ctx).Delete(consts.PlaylistsImportPendingFlagKey); derr != nil { + log.Warn(p.ctx, "Scanner: Could not clear pending playlist-import flag", derr) + } + } logF(p.ctx, "Scanner: Finished refreshing playlists", "refreshed", refreshed, err) return err } diff --git a/scanner/phase_4_playlists_test.go b/scanner/phase_4_playlists_test.go index 0e01a7549..49ffc7fb7 100644 --- a/scanner/phase_4_playlists_test.go +++ b/scanner/phase_4_playlists_test.go @@ -9,10 +9,10 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -30,14 +30,22 @@ var _ = Describe("phasePlaylists", func() { cw artwork.CacheWarmer ) + var userRepo *tests.MockedUserRepo + var propRepo *tests.MockedPropertyRepo + BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) conf.Server.AutoImportPlaylists = true ctx = context.Background() - ctx = request.WithUser(ctx, model.User{ID: "123", IsAdmin: true}) folderRepo = &mockFolderRepository{} + userRepo = tests.CreateMockUserRepo() + // An admin user exists by default, so playlist import proceeds. + Expect(userRepo.Put(&model.User{ID: "123", UserName: "admin", IsAdmin: true})).To(Succeed()) + propRepo = &tests.MockedPropertyRepo{} ds = &tests.MockDataStore{ - MockedFolder: folderRepo, + MockedFolder: folderRepo, + MockedUser: userRepo, + MockedProperty: propRepo, } pls = &mockPlaylists{} cw = artwork.NoopCacheWarmer() @@ -84,6 +92,81 @@ var _ = Describe("phasePlaylists", func() { Expect(called).To(BeFalse()) Expect(err).To(MatchError(ContainSubstring("error loading folders"))) }) + + It("sets the pending flag and imports nothing when no admin user exists", func() { + // Remove the admin user; produce resolves the admin at phase time. + userRepo.Data = map[string]*model.User{} + folderRepo.SetData(map[*model.Folder]error{ + {Path: "/path/to/folder1"}: nil, + }) + + called := false + err := phase.produce(func(folder *model.Folder) { called = true }) + + Expect(err).ToNot(HaveOccurred()) + Expect(called).To(BeFalse()) + v, _ := propRepo.Get(consts.PlaylistsImportPendingFlagKey) + Expect(v).To(Equal("1")) + }) + + It("returns an error (not a silent defer) on a datastore failure resolving the admin", func() { + userRepo.Error = errors.New("db is locked") + + err := phase.produce(func(folder *model.Folder) {}) + + Expect(err).To(MatchError(ContainSubstring("finding admin user"))) + // Must NOT have set the pending flag on a real error. + _, getErr := propRepo.Get(consts.PlaylistsImportPendingFlagKey) + Expect(getErr).To(HaveOccurred()) + }) + + It("returns an error when the pending flag cannot be persisted", func() { + userRepo.Data = map[string]*model.User{} // no admin -> defer path + propRepo.Error = errors.New("property table unavailable") + + err := phase.produce(func(folder *model.Folder) {}) + + Expect(err).To(MatchError(ContainSubstring("recording pending playlist import"))) + }) + + It("imports all playlist folders when the pending flag is set", func() { + Expect(propRepo.Put(consts.PlaylistsImportPendingFlagKey, "1")).To(Succeed()) + folderRepo.SetAllData(map[*model.Folder]error{ + {Path: "/path/to/folder1"}: nil, + {Path: "/path/to/folder2"}: nil, + }) + // Touched set is empty: proves selection used GetAllWithPlaylists. + folderRepo.SetData(map[*model.Folder]error{}) + + var produced []*model.Folder + err := phase.produce(func(folder *model.Folder) { produced = append(produced, folder) }) + + Expect(err).ToNot(HaveOccurred()) + Expect(produced).To(HaveLen(2)) + Expect(phase.pendingImport).To(BeTrue()) + }) + }) + + Describe("finalize", func() { + It("clears the pending flag after a successful pending import", func() { + Expect(propRepo.Put(consts.PlaylistsImportPendingFlagKey, "1")).To(Succeed()) + phase.pendingImport = true + + Expect(phase.finalize(nil)).To(Succeed()) + + _, err := propRepo.Get(consts.PlaylistsImportPendingFlagKey) + Expect(err).To(HaveOccurred()) // deleted + }) + + It("keeps the pending flag when the import failed", func() { + Expect(propRepo.Put(consts.PlaylistsImportPendingFlagKey, "1")).To(Succeed()) + phase.pendingImport = true + + Expect(phase.finalize(errors.New("boom"))).To(HaveOccurred()) + + v, _ := propRepo.Get(consts.PlaylistsImportPendingFlagKey) + Expect(v).To(Equal("1")) + }) }) Describe("processPlaylistsInFolder", func() { @@ -141,12 +224,13 @@ func (p *mockPlaylists) ImportFromFolder(ctx context.Context, folder *model.Fold type mockFolderRepository struct { model.FolderRepository - data map[*model.Folder]error + data map[*model.Folder]error + allData map[*model.Folder]error } -func (f *mockFolderRepository) GetTouchedWithPlaylists() (model.FolderCursor, error) { +func cursorFromData(data map[*model.Folder]error) model.FolderCursor { return func(yield func(model.Folder, error) bool) { - for folder, err := range f.data { + for folder, err := range data { if err != nil { if !yield(model.Folder{}, err) { return @@ -157,9 +241,21 @@ func (f *mockFolderRepository) GetTouchedWithPlaylists() (model.FolderCursor, er return } } - }, nil + } +} + +func (f *mockFolderRepository) GetTouchedWithPlaylists() (model.FolderCursor, error) { + return cursorFromData(f.data), nil +} + +func (f *mockFolderRepository) GetAllWithPlaylists() (model.FolderCursor, error) { + return cursorFromData(f.allData), nil } func (f *mockFolderRepository) SetData(m map[*model.Folder]error) { f.data = m } + +func (f *mockFolderRepository) SetAllData(m map[*model.Folder]error) { + f.allData = m +} diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 7bf91d64f..cc3732bc3 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -2,6 +2,7 @@ package scanner_test import ( "context" + "database/sql" "errors" "path/filepath" "testing/fstest" @@ -531,6 +532,69 @@ var _ = Describe("Scanner", Ordered, func() { })).To(Equal(int64(2))) }) + It("leaves no non-missing orphan artist after purging an artist's only content", func() { + // Guards the orphan case: with PurgeMissing on, removing an artist's last file hard-deletes + // its media_file_artists rows, RefreshStats recomputes its stats to '{}', and the cleanup + // drops its last library_artist row — leaving the artist row alive but orphaned. RefreshStats + // must then mark it missing (see markOrphansMissing). + DeferCleanup(configtest.SetupConfig()) + conf.Server.Scanner.PurgeMissing = consts.PurgeMissingAlways + + By("Starting from a library where Pink Floyd has its own single album") + floyd := template(_t{"artist": "Pink Floyd", "album": "The Wall", "year": 1979}) + fsys = createFS(fstest.MapFS{ + "The Beatles/Help!/01 - Help!.mp3": help(track(1, "Help!")), + "The Beatles/Help!/02 - The Night Before.mp3": help(track(2, "The Night Before")), + "The Beatles/Revolver/01 - Taxman.mp3": revolver(track(1, "Taxman")), + "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(track(2, "Eleanor Rigby")), + "Pink Floyd/The Wall/01 - Another Brick.mp3": floyd(track(1, "Another Brick in the Wall")), + }) + Expect(runScanner(ctx, true)).To(Succeed()) + + nonMissingArtists := func() []string { + aa, err := ds.Artist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"missing": false}}) + Expect(err).ToNot(HaveOccurred()) + return slice.Map(aa, func(a model.Artist) string { return a.Name }) + } + orphanCount := func() int64 { + var n int64 + Expect(db.Db().QueryRowContext(ctx, + "SELECT count(*) FROM artist WHERE missing = false "+ + "AND id NOT IN (SELECT artist_id FROM library_artist)").Scan(&n)).To(Succeed()) + return n + } + // Read the artist row directly: selectArtist inner-joins library_artist, so an orphan never + // surfaces through the repository. Returns a descriptive string for clear test failures. + floydState := func() string { + var m bool + err := db.Db().QueryRowContext(ctx, + "SELECT missing FROM artist WHERE name = 'Pink Floyd'").Scan(&m) + if errors.Is(err, sql.ErrNoRows) { + return "NOT_FOUND" + } + Expect(err).ToNot(HaveOccurred()) + if m { + return "MISSING" + } + return "PRESENT" + } + + By("Confirming Pink Floyd is visible after the import, with no orphan") + Expect(nonMissingArtists()).To(ContainElement("Pink Floyd")) + Expect(floydState()).To(Equal("PRESENT")) + Expect(orphanCount()).To(BeZero()) + + By("Removing all of Pink Floyd's files and rescanning") + fsys.Remove("Pink Floyd/The Wall/01 - Another Brick.mp3") + Expect(runScanner(ctx, true)).To(Succeed()) + + By("Checking Pink Floyd's row survives but is marked missing, leaving no orphan") + Expect(floydState()).To(Equal("MISSING")) + Expect(orphanCount()).To(BeZero()) + // The Beatles keep their content, so the fix must not over-mark them. + Expect(nonMissingArtists()).To(ContainElement("The Beatles")) + }) + It("does not override artist fields when importing an undertagged file", func() { By("Making sure artist in the DB contains MBID and sort name") aa, err := ds.Artist(ctx).GetAll(model.QueryOptions{ diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go index e6a694f2b..55bbab684 100644 --- a/scanner/walk_dir_tree.go +++ b/scanner/walk_dir_tree.go @@ -123,9 +123,6 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreC log.Trace(ctx, "Scanner: Ignoring entry", "path", entryPath) continue } - if isEntryIgnored(entry.Name()) { - continue - } if ctx.Err() != nil { return folder, children, ctx.Err() } @@ -135,7 +132,10 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreC log.Warn(ctx, "Scanner: Invalid symlink", "dir", entryPath, err) continue } - if isDir && !isDirIgnored(entry.Name()) && isDirReadable(ctx, job.fs, entryPath) { + if isIgnoredEntry(entry.Name(), isDir) { + continue + } + if isDir && isDirReadable(ctx, job.fs, entryPath) { children = append(children, entryPath) folder.numSubFolders++ } else { @@ -147,12 +147,16 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreC if fileInfo.ModTime().After(folder.modTime) { folder.modTime = fileInfo.ModTime() } + name, ok := resolveEntryName(ctx, job.fs, dirPath, entry) + if !ok { + continue + } switch { - case model.IsAudioFile(entry.Name()): + case model.IsAudioFile(name): folder.audioFiles[entry.Name()] = entry - case model.IsValidPlaylist(entry.Name()): + case model.IsValidPlaylist(name): folder.numPlaylists++ - case model.IsImageFile(entry.Name()): + case model.IsImageFile(name): folder.imageFiles[entry.Name()] = entry folder.imagesUpdatedAt = utils.TimeNewest(folder.imagesUpdatedAt, fileInfo.ModTime(), folder.modTime) } @@ -213,6 +217,45 @@ func isDirOrSymlinkToDir(fsys fs.FS, baseDir string, dirEnt fs.DirEntry) (bool, return fileInfo.IsDir(), nil } +const maxSymlinkHops = 40 + +// resolveEntryName returns the name to classify the entry by, and whether to +// consider it at all. Symlinks are resolved to their final target so the caller +// classifies by the target's extension, not the link's name. Returns ok=false +// when symlinks are disabled or the target can't be resolved. +func resolveEntryName(ctx context.Context, fsys fs.FS, dirPath string, entry fs.DirEntry) (string, bool) { + if entry.Type()&fs.ModeSymlink == 0 { + return entry.Name(), true + } + linkPath := path.Join(dirPath, entry.Name()) + if !conf.Server.Scanner.FollowSymlinks { + log.Trace(ctx, "Scanner: Skipping symlink, following is disabled", "path", linkPath) + return "", false + } + cur := linkPath + for hop := 0; hop < maxSymlinkHops; hop++ { + target, err := fs.ReadLink(fsys, cur) + if err != nil { + if hop == 0 { + log.Trace(ctx, "Scanner: Skipping symlink, cannot resolve target", "path", linkPath, err) + return "", false + } + resolved := path.Base(cur) + log.Trace(ctx, "Scanner: Resolved symlink", "path", linkPath, "target", cur, "name", resolved) + return resolved, true + } + if path.IsAbs(target) { + // Absolute targets are not valid fs.FS paths, so the next ReadLink fails and + // resolution stops here, leaving cur as the target to classify by name. + cur = target + } else { + cur = path.Join(path.Dir(cur), target) + } + } + log.Trace(ctx, "Scanner: Skipping symlink, too many hops (possible loop)", "path", linkPath) + return "", false +} + // isDirReadable returns true if the directory represented by dirEnt is readable func isDirReadable(ctx context.Context, fsys fs.FS, dirPath string) bool { dir, err := fsys.Open(dirPath) @@ -233,22 +276,35 @@ var ignoredDirs = []string{ "#snapshot", "@Recycle", "@Recently-Snapshot", + ".git", ".streams", "lost+found", } -// isDirIgnored returns true if the directory represented by dirEnt should be ignored -func isDirIgnored(name string) bool { - // allows Album folders for albums which eg start with ellipses - if strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..") { +// isIgnoredEntry returns true if a directory entry with the given name should be +// skipped during scanning. It centralizes all name- and type-based ignore policy: +// - special system directories in ignoredDirs are always ignored; +// - dot-prefixed files are always ignored; +// - dot-prefixed folders are ignored unless Scanner.IgnoreDotFolders is disabled, +// allowing albums like ".Hack Sign" to be scanned when the option is off. +func isIgnoredEntry(name string, isDir bool) bool { + if isDir && isDirIgnored(name) { return true } - if slices.ContainsFunc(ignoredDirs, func(s string) bool { return strings.EqualFold(s, name) }) { - return true - } - return false + return isDotEntry(name) && (!isDir || conf.Server.Scanner.IgnoreDotFolders) } -func isEntryIgnored(name string) bool { - return strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..") +// isDirIgnored returns true if the directory name is in the explicit ignoredDirs +// blocklist. Used both while walking the tree and by the file watcher. +func isDirIgnored(name string) bool { + return slices.ContainsFunc(ignoredDirs, func(s string) bool { return strings.EqualFold(s, name) }) +} + +// isDotEntry returns true only for names with exactly one leading dot (the +// convention for hidden entries), e.g. ".hidden". Names with two or more leading +// dots are not considered hidden: "." and ".." are the special self/parent +// references, and anything like "..foo" or "...Album" is a regular name (album +// folders sometimes start with ellipses), so all of these return false. +func isDotEntry(name string) bool { + return name != "." && strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..") } diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go index 42b7af7ba..f3b13a4ef 100644 --- a/scanner/walk_dir_tree_test.go +++ b/scanner/walk_dir_tree_test.go @@ -45,6 +45,14 @@ var _ = Describe("walk_dir_tree", func() { "root/d/f3.mp3": {}, "root/e/original/f1.mp3": {}, "root/e/symlink": {Mode: fs.ModeSymlink, Data: []byte("original")}, + "root/f/realsong.mp3": {Data: []byte("AUDIO")}, + "root/f/legit.mp3": {Mode: fs.ModeSymlink, Data: []byte("realsong.mp3")}, + "root/f/secret": {Data: []byte("TOPSECRET")}, + "root/f/evil.mp3": {Mode: fs.ModeSymlink, Data: []byte("secret")}, + "root/g/.Hack Sign Original Soundtrack/track.mp3": {}, + "root/h/.hidden.mp3": {}, + "root/i/.git/config": {}, + "root/i/.streams/stream.mp3": {}, }, } job = &scanJob{ @@ -93,15 +101,52 @@ var _ = Describe("walk_dir_tree", func() { Expect(folders["root/c"].imageFiles).To(BeEmpty()) Expect(folders).ToNot(HaveKey("root/d")) + // By default (Scanner.IgnoreDotFolders == true), dot-prefixed + // folders are skipped, dot-prefixed files are not indexed, and + // the special ignoredDirs (.git, .streams) are never traversed. + Expect(folders).ToNot(HaveKey("root/g/.Hack Sign Original Soundtrack")) + Expect(folders["root/h"].audioFiles).To(BeEmpty()) + Expect(folders).ToNot(HaveKey("root/i/.git")) + Expect(folders).ToNot(HaveKey("root/i/.streams")) + // Symlink specific checks if followSymlinks { Expect(folders["root/e/symlink"].audioFiles).To(HaveLen(1)) + Expect(folders["root/f"].audioFiles).To(HaveKey("legit.mp3")) + Expect(folders["root/f"].audioFiles).To(HaveKey("realsong.mp3")) + Expect(folders["root/f"].audioFiles).ToNot(HaveKey("evil.mp3")) } else { Expect(folders).ToNot(HaveKey("root/e/symlink")) + Expect(folders["root/f"].audioFiles).To(HaveKey("realsong.mp3")) + Expect(folders["root/f"].audioFiles).ToNot(HaveKey("legit.mp3")) + Expect(folders["root/f"].audioFiles).ToNot(HaveKey("evil.mp3")) } }, - Entry("with symlinks enabled", true, 7), - Entry("with symlinks disabled", false, 6), + Entry("with symlinks enabled", true, 11), + Entry("with symlinks disabled", false, 10), + ) + + DescribeTable("dot-prefixed folders with IgnoreDotFolders disabled", + func(followSymlinks bool) { + conf.Server.Scanner.FollowSymlinks = followSymlinks + conf.Server.Scanner.IgnoreDotFolders = false + folders := getFolders() + + // Dot-prefixed album folders are now traversed and indexed + Expect(folders["root/g/.Hack Sign Original Soundtrack"].audioFiles).To(SatisfyAll( + HaveLen(1), + HaveKey("track.mp3"), + )) + + // Dot-prefixed files are still ignored, even with the flag off + Expect(folders["root/h"].audioFiles).To(BeEmpty()) + + // Special ignoredDirs remain blocked regardless of the flag + Expect(folders).ToNot(HaveKey("root/i/.git")) + Expect(folders).ToNot(HaveKey("root/i/.streams")) + }, + Entry("with symlinks enabled", true), + Entry("with symlinks disabled", false), ) }) @@ -264,19 +309,237 @@ var _ = Describe("walk_dir_tree", func() { }) }) + Describe("resolveEntryName", func() { + var fsys fs.FS + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + fsys = fstest.MapFS{ + "dir/real.mp3": {Data: []byte("AUDIO")}, + "dir/mid.mp3": {Mode: fs.ModeSymlink, Data: []byte("real.mp3")}, + "dir/chain.mp3": {Mode: fs.ModeSymlink, Data: []byte("mid.mp3")}, + "dir/audio.mp3": {Mode: fs.ModeSymlink, Data: []byte("real.mp3")}, + "dir/evil.mp3": {Mode: fs.ModeSymlink, Data: []byte("../outside/passwd")}, + "dir/loop1.mp3": {Mode: fs.ModeSymlink, Data: []byte("loop2.mp3")}, + "dir/loop2.mp3": {Mode: fs.ModeSymlink, Data: []byte("loop1.mp3")}, + "dir/dangle.mp3": {Mode: fs.ModeSymlink, Data: []byte("missing.mp3")}, + } + }) + + resolve := func(name string) (string, bool) { + entries, err := fs.ReadDir(fsys, "dir") + Expect(err).ToNot(HaveOccurred()) + for _, e := range entries { + if e.Name() == name { + return resolveEntryName(GinkgoT().Context(), fsys, "dir", e) + } + } + Fail("entry not found: " + name) + return "", false + } + + Context("with symlinks enabled", func() { + BeforeEach(func() { conf.Server.Scanner.FollowSymlinks = true }) + + It("returns the entry name for a plain file", func() { + name, ok := resolve("real.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("real.mp3")) + }) + It("resolves a direct symlink to its audio target name", func() { + name, ok := resolve("audio.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("real.mp3")) + }) + It("resolves a symlink CHAIN to the final target name", func() { + name, ok := resolve("chain.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("real.mp3")) + }) + It("resolves a symlink to a non-audio target name (so caller can reject it)", func() { + name, ok := resolve("evil.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("passwd")) + }) + It("rejects a symlink loop", func() { + _, ok := resolve("loop1.mp3") + Expect(ok).To(BeFalse()) + }) + }) + + Context("with symlinks disabled", func() { + BeforeEach(func() { conf.Server.Scanner.FollowSymlinks = false }) + + It("returns the entry name for a plain file", func() { + name, ok := resolve("real.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("real.mp3")) + }) + It("skips any file symlink", func() { + _, ok := resolve("audio.mp3") + Expect(ok).To(BeFalse()) + }) + }) + }) + + Describe("symlink chain (real fs)", func() { + BeforeEach(func() { + tests.SkipOnWindows("symlink semantics") + DeferCleanup(configtest.SetupConfig()) + }) + + classify := func(fsys fs.FS, dirPath, name string) (string, bool) { + entries, err := fs.ReadDir(fsys, dirPath) + Expect(err).ToNot(HaveOccurred()) + for _, e := range entries { + if e.Name() == name { + return resolveEntryName(GinkgoT().Context(), fsys, dirPath, e) + } + } + Fail("entry not found: " + name) + return "", false + } + + Context("committed 3-level fixtures", func() { + // tests.Init chdirs to the repo root, so the committed fixtures are at "tests/fixtures". + var fsys fs.FS + BeforeEach(func() { + conf.Server.Scanner.FollowSymlinks = true + wd, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + fsys = os.DirFS(wd) + }) + + It("keeps a 3-level chain that resolves to real audio", func() { + name, ok := classify(fsys, "tests/fixtures/symlink_chain", "level3.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("test.mp3")) + Expect(model.IsAudioFile(name)).To(BeTrue()) + }) + + It("rejects a 3-level chain that resolves to a non-audio file", func() { + name, ok := classify(fsys, "tests/fixtures/symlink_chain", "evil3.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("index.html")) + Expect(model.IsAudioFile(name)).To(BeFalse()) + }) + + It("skips the chain entirely when FollowSymlinks is disabled", func() { + conf.Server.Scanner.FollowSymlinks = false + _, ok := classify(fsys, "tests/fixtures/symlink_chain", "level3.mp3") + Expect(ok).To(BeFalse()) + _, ok = classify(fsys, "tests/fixtures/symlink_chain", "evil3.mp3") + Expect(ok).To(BeFalse()) + }) + }) + + Context("out-of-tree escape (temp dir)", func() { + var root string + BeforeEach(func() { + conf.Server.Scanner.FollowSymlinks = true + root = GinkgoT().TempDir() + outside := GinkgoT().TempDir() + Expect(os.WriteFile(filepath.Join(outside, "passwd"), []byte("TOPSECRET"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(outside, "real.flac"), []byte("AUDIO"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(root, "song.mp3"), []byte("AUDIO"), 0600)).To(Succeed()) + // evil.mp3 escapes to a non-audio target; legit.flac is a valid out-of-tree audio symlink. + Expect(os.Symlink(filepath.Join(outside, "passwd"), filepath.Join(root, "evil.mp3"))).To(Succeed()) + Expect(os.Symlink(filepath.Join(outside, "real.flac"), filepath.Join(root, "legit.flac"))).To(Succeed()) + }) + + It("rejects the absolute-path escape but keeps legit out-of-tree audio", func() { + fsys := os.DirFS(root) + + name, ok := classify(fsys, ".", "song.mp3") + Expect(ok).To(BeTrue()) + Expect(model.IsAudioFile(name)).To(BeTrue()) + + name, ok = classify(fsys, ".", "legit.flac") + Expect(ok).To(BeTrue()) + Expect(model.IsAudioFile(name)).To(BeTrue()) + + name, ok = classify(fsys, ".", "evil.mp3") + Expect(ok).To(BeTrue()) + Expect(model.IsAudioFile(name)).To(BeFalse()) + }) + + It("skips all file symlinks when FollowSymlinks is disabled", func() { + conf.Server.Scanner.FollowSymlinks = false + fsys := os.DirFS(root) + entries, err := fs.ReadDir(fsys, ".") + Expect(err).ToNot(HaveOccurred()) + for _, e := range entries { + _, ok := resolveEntryName(GinkgoT().Context(), fsys, ".", e) + if e.Type()&fs.ModeSymlink != 0 { + Expect(ok).To(BeFalse(), e.Name()) + } else { + Expect(ok).To(BeTrue(), e.Name()) + } + } + }) + }) + }) + Describe("isDirIgnored", func() { DescribeTable("returns expected result", func(dirName string, expected bool) { Expect(isDirIgnored(dirName)).To(Equal(expected)) }, Entry("normal dir", "empty_folder", false), - Entry("hidden dir", ".hidden_folder", true), + Entry("dot-prefixed album dir", ".Hack Sign Original Soundtrack", false), + Entry("git dir", ".git", true), + Entry("streams dir", ".streams", true), Entry("dir starting with ellipsis", "...unhidden_folder", false), Entry("recycle bin", "$Recycle.Bin", true), Entry("snapshot dir", "#snapshot", true), ) }) + Describe("isIgnoredEntry", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + DescribeTable("with IgnoreDotFolders enabled (default)", + func(name string, isDir, expected bool) { + conf.Server.Scanner.IgnoreDotFolders = true + Expect(isIgnoredEntry(name, isDir)).To(Equal(expected)) + }, + Entry("normal dir", "Album", true, false), + Entry("normal file", "track.mp3", false, false), + Entry("dot folder", ".Hack Sign Original Soundtrack", true, true), + Entry("dot file", ".hidden.mp3", false, true), + Entry("blocklisted dir", ".git", true, true), + Entry("ellipsis dir", "...unhidden", true, false), + ) + + DescribeTable("with IgnoreDotFolders disabled", + func(name string, isDir, expected bool) { + conf.Server.Scanner.IgnoreDotFolders = false + Expect(isIgnoredEntry(name, isDir)).To(Equal(expected)) + }, + Entry("normal dir", "Album", true, false), + Entry("normal file", "track.mp3", false, false), + Entry("dot folder is allowed", ".Hack Sign Original Soundtrack", true, false), + Entry("dot file is still ignored", ".hidden.mp3", false, true), + Entry("blocklisted dir still ignored", ".git", true, true), + ) + }) + + Describe("isDotEntry", func() { + DescribeTable("returns expected result", + func(name string, expected bool) { + Expect(isDotEntry(name)).To(Equal(expected)) + }, + Entry("dot folder", ".Hidden", true), + Entry("dot file", ".hidden.mp3", true), + Entry("current dir", ".", false), + Entry("parent dir", "..", false), + Entry("two leading dots", "..foo", false), + Entry("ellipsis", "...unhidden", false), + Entry("normal name", "Album", false), + ) + }) + Describe("fullReadDir", func() { var ( fsys fakeFS @@ -414,3 +677,30 @@ func (m *mockMusicFS) ReadDir(name string) ([]fs.DirEntry, error) { } return nil, fmt.Errorf("not a directory") } + +// ReadLink returns the target of the named symbolic link (implements fs.ReadLinkFS). +func (m *mockMusicFS) ReadLink(name string) (string, error) { + mapFS := m.FS.(fstest.MapFS) + entry, ok := mapFS[name] + if !ok { + return "", &fs.PathError{Op: "readlink", Path: name, Err: fs.ErrNotExist} + } + if entry.Mode&fs.ModeSymlink == 0 { + return "", &fs.PathError{Op: "readlink", Path: name, Err: fmt.Errorf("not a symlink")} + } + return string(entry.Data), nil +} + +// Lstat returns FileInfo for the named file without following symlinks (implements fs.ReadLinkFS). +func (m *mockMusicFS) Lstat(name string) (fs.FileInfo, error) { + mapFS := m.FS.(fstest.MapFS) + if _, ok := mapFS[name]; !ok { + return nil, &fs.PathError{Op: "lstat", Path: name, Err: fs.ErrNotExist} + } + f, err := m.FS.Open(name) + if err != nil { + return nil, err + } + defer f.Close() + return f.Stat() +} diff --git a/scanner/watcher.go b/scanner/watcher.go index 376db910c..baf94b79b 100644 --- a/scanner/watcher.go +++ b/scanner/watcher.go @@ -5,6 +5,7 @@ import ( "fmt" "io/fs" "path/filepath" + "strings" "sync" "time" @@ -320,18 +321,35 @@ func (w *watcher) shouldIgnoreFolderPath(ctx context.Context, fsys storage.Music } func isIgnoredPath(_ context.Context, _ fs.FS, path string) bool { - baseDir, name := filepath.Split(path) + _, name := filepath.Split(path) + // A change anywhere inside an ignored directory (a dot-folder when + // Scanner.IgnoreDotFolders is enabled, or a special system folder) must not + // trigger a scan, even for media files: the scan would skip it anyway. + if isUnderIgnoredDir(path) { + return true + } switch { - case model.IsAudioFile(path): - return false - case model.IsValidPlaylist(path): - return false - case model.IsImageFile(path): - return false + case model.IsAudioFile(path), model.IsValidPlaylist(path), model.IsImageFile(path): + // A media file is normally not ignored, but a dot-prefixed one (e.g. + // ".hidden.mp3") is always skipped by the scanner, so don't scan for it. + return isDotEntry(name) case name == ".DS_Store": return true } - // As it can be a deletion and not a change, we cannot reliably know if the path is a file or directory. - // But at this point, we can assume it's a directory. If it's a file, it would be ignored anyway - return isDirIgnored(baseDir) + // As it can be a deletion and not a change, we cannot reliably know if the + // path is a file or directory. But at this point, we can assume it's a + // directory. If it's a file, it would be ignored anyway. + return isIgnoredEntry(name, true) +} + +// isUnderIgnoredDir returns true if any parent directory component of the given +// path is an ignored directory, reusing the same policy as the scanner walk. +func isUnderIgnoredDir(path string) bool { + dir, _ := filepath.Split(path) + for part := range strings.SplitSeq(filepath.ToSlash(dir), "/") { + if part != "" && isIgnoredEntry(part, true) { + return true + } + } + return false } diff --git a/scanner/watcher_test.go b/scanner/watcher_test.go index 9795129b0..ffe9f8b15 100644 --- a/scanner/watcher_test.go +++ b/scanner/watcher_test.go @@ -428,6 +428,50 @@ var _ = Describe("Watcher", func() { Expect(w.watcherNotify).To(BeEmpty(), "Expected no scan notification for file in ignored folder") }) }) + + }) +}) + +var _ = Describe("isIgnoredPath", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + Context("with IgnoreDotFolders enabled (default)", func() { + BeforeEach(func() { + conf.Server.Scanner.IgnoreDotFolders = true + }) + + DescribeTable("returns expected result", + func(p string, expected bool) { + Expect(isIgnoredPath(context.Background(), nil, filepath.FromSlash(p))).To(Equal(expected)) + }, + Entry("media file in normal folder", "rock/Album/track.mp3", false), + Entry("dot-prefixed media file", "rock/Album/.hidden.mp3", true), + Entry("media file inside a dot-folder", "rock/.Hidden Album/track.mp3", true), + Entry("media file inside a blocklisted folder", "rock/.streams/stream.mp3", true), + Entry("media file inside .git", "rock/.git/track.mp3", true), + Entry("dot-folder itself", "rock/.Hidden Album", true), + Entry("normal folder itself", "rock/Album", false), + Entry(".DS_Store file", "rock/Album/.DS_Store", true), + ) + }) + + Context("with IgnoreDotFolders disabled", func() { + BeforeEach(func() { + conf.Server.Scanner.IgnoreDotFolders = false + }) + + DescribeTable("returns expected result", + func(p string, expected bool) { + Expect(isIgnoredPath(context.Background(), nil, filepath.FromSlash(p))).To(Equal(expected)) + }, + Entry("media file inside a dot-folder is allowed", "rock/.Hidden Album/track.mp3", false), + Entry("dot-prefixed media file is still ignored", "rock/Album/.hidden.mp3", true), + Entry("dot-folder itself is allowed", "rock/.Hidden Album", false), + Entry("blocklisted folder still ignored", "rock/.streams/stream.mp3", true), + Entry(".git still ignored", "rock/.git/config", true), + ) }) }) diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 12a7c95e0..ac4aaa5f2 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -70,6 +70,20 @@ const ( mbidSomethingRec = "44444444-4444-4444-a444-444444444444" // mbz_recording_id ) +// lyricFixture reads a public-domain lyric fixture (the same files the parser +// benchmarks use) so the e2e fixtures stay in sync with real-world content, +// including the word-level timing carried by the .elrc and .yaml variants. +func lyricFixture(name string) string { + // tests.Init chdirs to the project root, so reference fixtures from there. + data, err := os.ReadFile(filepath.Join("tests", "fixtures", "lyrics", name)) + Expect(err).ToNot(HaveOccurred(), "reading lyric fixture %q", name) + return string(data) +} + +// firstFixtureLine is the opening lyric line shared by every auld-lang-syne +// fixture; tests assert against it regardless of source format. +const firstFixtureLine = "Should auld acquaintance be forgot," + // Shared test state var ( ctx context.Context @@ -128,6 +142,9 @@ func buildTestFS() storagetest.FakeFS { // Template for diverse-format transcode test tracks tcBase := _t{"albumartist": "Test Artist", "artist": "Test Artist", "album": "Transcode Formats", "year": 2024, "genre": "Test"} + // Template for lyrics e2e fixture tracks — isolated under Lyrics/ to keep other suite counts stable + lyricsAlbum := template(_t{"albumartist": "Lyric Tester", "artist": "Lyric Tester", "album": "Lyrics", "year": 2024, "genre": "Test"}) + return createFS(fstest.MapFS{ // Rock / The Beatles / Abbey Road (with MBIDs) // Note: "musicbrainz_trackid" is an alias for the musicbrainz_recordingid tag (populates MbzRecordingID), @@ -177,6 +194,31 @@ func buildTestFS() storagetest.FakeFS { "bitrate": 4500, "samplerate": 48000, "bitdepth": 24, "channels": 6, "duration": int64(180), }), + // Lyrics fixtures (isolated under Lyrics/ to keep other suite counts stable). + // Content comes from tests/fixtures/lyrics (the same public-domain files the + // parser benchmarks use); the .elrc and .yaml variants carry word-level + // timing, which drives the v1 (line-level) vs v2 (enhanced/word-level) tests. + // + // Embedded — lyrics delivered via the "lyrics" tag, parsed at scan time. + // "Enhanced LRC" embeds ELRC (word-level) content; the title is kept generic + // since ELRC is still valid LRC. + "Lyrics/Embedded/01 - Embedded Enhanced LRC.mp3": lyricsAlbum(track(1, "Embedded Enhanced LRC", + _t{"lyrics": lyricFixture("auld-lang-syne.elrc")})), + "Lyrics/Embedded/02 - Embedded Plain.mp3": lyricsAlbum(track(2, "Embedded Plain", + _t{"lyrics": lyricFixture("auld-lang-syne.txt")})), + "Lyrics/Embedded/03 - Embedded TTML.mp3": lyricsAlbum(track(3, "Embedded TTML", + _t{"lyrics": lyricFixture("auld-lang-syne.ttml")})), + + // Sidecar — raw lyric text files read from the library FS at request time via fromExternalFile. + // The scanner skips non-audio extensions (.lrc, .srt, .yaml), so placing them as raw MapFile + // entries is safe: they are visible to the fake FS but invisible to the scanner. + "Lyrics/Sidecar/01 - Sidecar LRC.mp3": lyricsAlbum(track(1, "Sidecar LRC")), + "Lyrics/Sidecar/01 - Sidecar LRC.lrc": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.lrc")), ModTime: time.Now()}, + "Lyrics/Sidecar/02 - Sidecar SRT.mp3": lyricsAlbum(track(2, "Sidecar SRT")), + "Lyrics/Sidecar/02 - Sidecar SRT.srt": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.srt")), ModTime: time.Now()}, + "Lyrics/Sidecar/03 - Sidecar YAML.mp3": lyricsAlbum(track(3, "Sidecar YAML")), + "Lyrics/Sidecar/03 - Sidecar YAML.yaml": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.yaml")), ModTime: time.Now()}, + // _empty folder (directory with no audio) "_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()}, }) @@ -409,6 +451,7 @@ var _ = BeforeSuite(func() { // Initial setup: schema, user, library, and full scan (runs once for the entire suite) conf.Server.MusicFolder = "fake:///music" + conf.Server.LyricsPriority = "embedded,.lrc,.srt,.yaml" conf.Server.DevExternalScanner = false db.Init(ctx) @@ -501,7 +544,7 @@ func setupTestDB() { core.NewShare(ds), playback.PlaybackServer(nil), metrics.NewNoopInstance(), - lyrics.NewLyrics(nil), + lyrics.NewLyrics(ds, nil), decider, nil, ) diff --git a/server/e2e/subsonic_album_lists_test.go b/server/e2e/subsonic_album_lists_test.go index d41d17dbc..6d32a3c88 100644 --- a/server/e2e/subsonic_album_lists_test.go +++ b/server/e2e/subsonic_album_lists_test.go @@ -19,7 +19,7 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(7)) + Expect(resp.AlbumList.Album).To(HaveLen(8)) }) It("type=alphabeticalByName sorts albums by name", func() { @@ -27,15 +27,16 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.AlbumList).ToNot(BeNil()) albums := resp.AlbumList.Album - Expect(albums).To(HaveLen(7)) - // Verify alphabetical order: Abbey Road, COWBOY BEBOP, Help!, IV, Kind of Blue, Pop, Transcode Formats + Expect(albums).To(HaveLen(8)) + // Verify alphabetical order: Abbey Road, COWBOY BEBOP, Help!, IV, Kind of Blue, Lyrics, Pop, Transcode Formats Expect(albums[0].Title).To(Equal("Abbey Road")) Expect(albums[1].Title).To(Equal("COWBOY BEBOP")) Expect(albums[2].Title).To(Equal("Help!")) Expect(albums[3].Title).To(Equal("IV")) Expect(albums[4].Title).To(Equal("Kind of Blue")) - Expect(albums[5].Title).To(Equal("Pop")) - Expect(albums[6].Title).To(Equal("Transcode Formats")) + Expect(albums[5].Title).To(Equal("Lyrics")) + Expect(albums[6].Title).To(Equal("Pop")) + Expect(albums[7].Title).To(Equal("Transcode Formats")) }) It("type=alphabeticalByArtist sorts albums by artist name", func() { @@ -43,23 +44,24 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.AlbumList).ToNot(BeNil()) albums := resp.AlbumList.Album - Expect(albums).To(HaveLen(7)) + Expect(albums).To(HaveLen(8)) // Articles like "The" are stripped for sorting, so "The Beatles" sorts as "Beatles" - // Non-compilations first: Beatles (x2), Led Zeppelin, Miles Davis, Test Artist, then compilations: Various, then CJK: シートベルツ + // Non-compilations: Beatles (x2), Led Zeppelin, Lyric Tester, Miles Davis, Test Artist, then compilations: Various, then CJK: シートベルツ Expect(albums[0].Artist).To(Equal("The Beatles")) Expect(albums[1].Artist).To(Equal("The Beatles")) Expect(albums[2].Artist).To(Equal("Led Zeppelin")) - Expect(albums[3].Artist).To(Equal("Miles Davis")) - Expect(albums[4].Artist).To(Equal("Test Artist")) - Expect(albums[5].Artist).To(Equal("Various")) - Expect(albums[6].Artist).To(Equal("シートベルツ")) + Expect(albums[3].Artist).To(Equal("Lyric Tester")) + Expect(albums[4].Artist).To(Equal("Miles Davis")) + Expect(albums[5].Artist).To(Equal("Test Artist")) + Expect(albums[6].Artist).To(Equal("Various")) + Expect(albums[7].Artist).To(Equal("シートベルツ")) }) It("type=random returns albums", func() { resp := doReq("getAlbumList", "type", "random") Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(7)) + Expect(resp.AlbumList.Album).To(HaveLen(8)) }) It("type=byGenre filters by genre parameter", func() { @@ -190,7 +192,7 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.AlbumList2).ToNot(BeNil()) albums := resp.AlbumList2.Album - Expect(albums).To(HaveLen(7)) + Expect(albums).To(HaveLen(8)) // Verify AlbumID3 format fields Expect(albums[0].Name).To(Equal("Abbey Road")) Expect(albums[0].Id).ToNot(BeEmpty()) @@ -201,7 +203,7 @@ var _ = Describe("Album List Endpoints", func() { resp := doReq("getAlbumList2", "type", "newest") Expect(resp.AlbumList2).ToNot(BeNil()) - Expect(resp.AlbumList2.Album).To(HaveLen(7)) + Expect(resp.AlbumList2.Album).To(HaveLen(8)) }) }) diff --git a/server/e2e/subsonic_lyrics_test.go b/server/e2e/subsonic_lyrics_test.go new file mode 100644 index 000000000..da8d513a3 --- /dev/null +++ b/server/e2e/subsonic_lyrics_test.go @@ -0,0 +1,124 @@ +package e2e + +import ( + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Lyrics endpoints", func() { + BeforeEach(func() { + setupTestDB() + }) + + // songID resolves a track title to its Subsonic ID via search3. + songID := func(title string) string { + resp := doReq("search3", "query", title, "songCount", "1", "artistCount", "0", "albumCount", "0") + Expect(resp.Status).To(Equal("ok")) + Expect(resp.SearchResult3).ToNot(BeNil()) + Expect(resp.SearchResult3.Song).ToNot(BeEmpty(), "expected to find song %q", title) + return resp.SearchResult3.Song[0].Id + } + + // firstLyric extracts the first StructuredLyric from a LyricsList response. + firstLyric := func(list *responses.LyricsList) responses.StructuredLyric { + Expect(list).ToNot(BeNil()) + Expect(list.StructuredLyrics).ToNot(BeEmpty()) + return list.StructuredLyrics[0] + } + + // songLyrics extension v1: getLyricsBySongId without the enhanced parameter + // returns line-level structured lyrics (line[], lang, synced) and must NOT + // emit any v2/enhanced fields — no cueLine, kind, or agents — even for + // formats that carry word-level timing (ELRC, Lyricsfile YAML). + Describe("getLyricsBySongId v1 (line-level, not enhanced)", func() { + DescribeTable("returns line-level lyrics without enhanced fields", + func(title string, wantSynced bool, wantLang string) { + resp := doReq("getLyricsBySongId", "id", songID(title)) + Expect(resp.Status).To(Equal("ok")) + got := firstLyric(resp.LyricsList) + + Expect(got.Synced).To(Equal(wantSynced)) + Expect(got.Lang).To(Equal(wantLang)) + Expect(got.Line).ToNot(BeEmpty()) + Expect(got.Line[0].Value).To(Equal(firstFixtureLine)) + + // v1 must not expose any enhanced (v2) data. + Expect(got.CueLine).To(BeEmpty()) + Expect(got.Kind).To(BeEmpty()) + Expect(got.Agents).To(BeEmpty()) + }, + // "xxx" is the ISO 639-2 code for "no language specified"; the .lrc/.elrc + // fixtures declare [lang:eng], the .ttml declares xml:lang, the .yaml sets + // language: eng, while .srt carries no language and the embedded plain + // text has none — so each format exercises a different language path. + Entry("embedded enhanced LRC (word-level)", "Embedded Enhanced LRC", true, "eng"), + Entry("embedded plain text", "Embedded Plain", false, "xxx"), + Entry("embedded TTML", "Embedded TTML", true, "eng"), + Entry("LRC sidecar", "Sidecar LRC", true, "eng"), + Entry("SRT sidecar", "Sidecar SRT", true, "xxx"), + Entry("YAML sidecar (word-level)", "Sidecar YAML", true, "eng"), + ) + }) + + // songLyrics extension v2: getLyricsBySongId?enhanced=true opts in to + // word/syllable-level timing (cueLine) and the kind classification. Every + // format gains kind="main" for a single untyped lyric layer; only formats + // that carry word-level timing (ELRC, TTML word spans, Lyricsfile YAML) + // surface a cueLine. Line-level formats (LRC, SRT, plain) still yield none. + Describe("getLyricsBySongId v2 (enhanced)", func() { + DescribeTable("returns enhanced lyrics, with cueLine only for word-level sources", + func(title string, wantCueLine bool) { + resp := doReq("getLyricsBySongId", "id", songID(title), "enhanced", "true") + Expect(resp.Status).To(Equal("ok")) + got := firstLyric(resp.LyricsList) + + Expect(got.Kind).To(Equal("main")) + if wantCueLine { + Expect(got.CueLine).ToNot(BeEmpty()) + // The first line has one cue per word: "Should auld acquaintance be forgot,". + Expect(got.CueLine[0].Cue).To(HaveLen(5)) + Expect(got.CueLine[0].Cue[0].Value).To(Equal("Should ")) + } else { + Expect(got.CueLine).To(BeEmpty()) + Expect(got.Line).ToNot(BeEmpty()) + } + }, + Entry("embedded enhanced LRC (word-level)", "Embedded Enhanced LRC", true), + Entry("embedded TTML (word-level spans)", "Embedded TTML", true), + Entry("YAML sidecar (word-level)", "Sidecar YAML", true), + Entry("embedded plain text (no timing)", "Embedded Plain", false), + Entry("LRC sidecar (line-level)", "Sidecar LRC", false), + Entry("SRT sidecar (line-level)", "Sidecar SRT", false), + ) + }) + + // getLyrics is the original Subsonic (pre-OpenSubsonic) endpoint. It looks up + // by artist/title and returns the main lyric flattened to plain text — every + // line's Value joined by newlines, with all timing/markup dropped. Synced and + // word-level formats (ELRC/TTML/SRT/YAML) all degrade to plain text here. + Describe("getLyrics (legacy artist/title)", func() { + DescribeTable("returns the main lyric as plain text across formats and sources", + func(title string) { + resp := doReq("getLyrics", "artist", "Lyric Tester", "title", title) + Expect(resp.Status).To(Equal("ok")) + Expect(resp.Lyrics).ToNot(BeNil()) + Expect(resp.Lyrics.Artist).To(Equal("Lyric Tester")) + Expect(resp.Lyrics.Title).To(Equal(title)) + Expect(resp.Lyrics.Value).To(ContainSubstring(firstFixtureLine)) + + // No timing markup leaks into the plain-text value, regardless of the + // source format: no LRC brackets/word markers, SRT arrows, or XML tags. + Expect(resp.Lyrics.Value).ToNot(ContainSubstring("[")) + Expect(resp.Lyrics.Value).ToNot(ContainSubstring("-->")) + Expect(resp.Lyrics.Value).ToNot(ContainSubstring("<")) + }, + Entry("embedded enhanced LRC", "Embedded Enhanced LRC"), + Entry("embedded plain text", "Embedded Plain"), + Entry("embedded TTML", "Embedded TTML"), + Entry("LRC sidecar", "Sidecar LRC"), + Entry("SRT sidecar", "Sidecar SRT"), + Entry("YAML sidecar", "Sidecar YAML"), + ) + }) +}) diff --git a/server/e2e/subsonic_multilibrary_test.go b/server/e2e/subsonic_multilibrary_test.go index a837da124..e652cf492 100644 --- a/server/e2e/subsonic_multilibrary_test.go +++ b/server/e2e/subsonic_multilibrary_test.go @@ -142,7 +142,7 @@ var _ = Describe("Multi-Library Support", Ordered, func() { resp := doReqWithUser(adminWithLibs, "getAlbumList", "type", "alphabeticalByName", "musicFolderId", fmt.Sprintf("%d", lib.ID)) Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(7)) + Expect(resp.AlbumList.Album).To(HaveLen(8)) for _, a := range resp.AlbumList.Album { Expect(a.Title).ToNot(Equal("Symphony No. 9")) } diff --git a/server/e2e/subsonic_searching_test.go b/server/e2e/subsonic_searching_test.go index e348bc6b9..00b60ad6f 100644 --- a/server/e2e/subsonic_searching_test.go +++ b/server/e2e/subsonic_searching_test.go @@ -115,9 +115,9 @@ var _ = Describe("Search Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Artist).To(HaveLen(6)) - Expect(resp.SearchResult3.Album).To(HaveLen(7)) - Expect(resp.SearchResult3.Song).To(HaveLen(14)) + Expect(resp.SearchResult3.Artist).To(HaveLen(7)) + Expect(resp.SearchResult3.Album).To(HaveLen(8)) + Expect(resp.SearchResult3.Song).To(HaveLen(20)) }) It("finds across all entity types simultaneously", func() { diff --git a/server/e2e/subsonic_sonic_similarity_test.go b/server/e2e/subsonic_sonic_similarity_test.go index 40161470b..1b8d34eb1 100644 --- a/server/e2e/subsonic_sonic_similarity_test.go +++ b/server/e2e/subsonic_sonic_similarity_test.go @@ -47,7 +47,7 @@ func buildSonicRouter(provider sonic.Provider) *subsonic.Router { core.NewShare(ds), playback.PlaybackServer(nil), metrics.NewNoopInstance(), - lyrics.NewLyrics(nil), + lyrics.NewLyrics(ds, nil), decider, sonicSvc, ) diff --git a/server/e2e/subsonic_transcode_test.go b/server/e2e/subsonic_transcode_test.go index ae3d6208c..afe7d52ca 100644 --- a/server/e2e/subsonic_transcode_test.go +++ b/server/e2e/subsonic_transcode_test.go @@ -159,11 +159,22 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { Expect(ds.Player(ctx).Put(player)).To(Succeed()) } + setPlayerForcedFormat := func(format string) { + doReq("ping") + player, err := ds.Player(ctx).FindMatch(adminUser.ID, "test-client", "") + Expect(err).ToNot(HaveOccurred()) + trc, err := ds.Transcoding(ctx).FindByFormat(format) + Expect(err).ToNot(HaveOccurred()) + player.TranscodingId = trc.ID + Expect(ds.Player(ctx).Put(player)).To(Succeed()) + } + AfterEach(func() { // Reset player MaxBitRate to 0 after each test player, err := ds.Player(ctx).FindMatch(adminUser.ID, "test-client", "") if err == nil { player.MaxBitRate = 0 + player.TranscodingId = "" _ = ds.Player(ctx).Put(player) } }) @@ -396,30 +407,34 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { }) }) - Describe("player MaxBitRate cap is ignored", func() { - It("allows direct play even when source bitrate exceeds player MaxBitRate", func() { + Describe("player MaxBitRate cap is enforced", func() { + It("forces transcode when source bitrate exceeds player MaxBitRate", func() { setPlayerMaxBitRate(320) // 320 kbps cap - // FLAC is 900kbps, player cap is 320, but getTranscodeDecision - // ignores server-side overrides — client profiles are used as-is + // FLAC is 900kbps. Player cap (320) < source → direct play is + // rejected and the file is transcoded down. resp := doPostReq("getTranscodeDecision", flacAndMp3Client, "mediaId", flacTrackID, "mediaType", "song") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue()) + Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + // Target bitrate is capped at the player MaxBitRate (320kbps). + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) }) - It("uses only client limit, not player MaxBitRate", func() { + It("uses the player cap when it is more restrictive than the client limit", func() { setPlayerMaxBitRate(192) // 192 kbps player cap - // Client caps at 320kbps (bitrateCapClient), player is more restrictive at 192 - // but getTranscodeDecision ignores player cap → client limit (320kbps) applies + // Client caps at 320kbps (bitrateCapClient); player is more + // restrictive at 192 → player cap wins. resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.TranscodeDecision).ToNot(BeNil()) Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - // Only client limit (320kbps) applies → 320000 bps - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) + // Player cap (192kbps) applies → 192000 bps. + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000))) }) }) @@ -475,38 +490,73 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { }) }) - Describe("player MaxBitRate is ignored by getTranscodeDecision", func() { - It("does not inject maxAudioBitrate from player cap", func() { + Describe("player MaxBitRate injected by getTranscodeDecision", func() { + It("injects the player cap as the transcode target when the client declares none", func() { setPlayerMaxBitRate(320) - // opusTranscodeClient has no client bitrate limits - // Player cap is 320, but getTranscodeDecision ignores it - // FLAC (900kbps) → can't direct play → transcode to opus using format default + // opusTranscodeClient has no client bitrate limits. The player + // cap (320) is injected, so FLAC (900kbps) → opus is capped at 320. resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.TranscodeDecision).ToNot(BeNil()) Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus")) - // Bitrate should be opus format default (128kbps), not player cap (320kbps) - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(128000))) + // Bitrate is the player cap (320kbps), not the opus format default. + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) }) - It("uses only client maxTranscodingAudioBitrate, ignoring player cap", func() { + It("keeps the lower client maxTranscodingAudioBitrate over a higher player cap", func() { setPlayerMaxBitRate(320) - // maxTranscodeBitrateClient: maxTranscodingAudioBitrate=192000 (192kbps) - // Player cap is 320, but getTranscodeDecision ignores it - // Only client maxTranscodingAudioBitrate=192 applies + // maxTranscodeBitrateClient: maxTranscodingAudioBitrate=192000 (192kbps). + // Player cap (320) is higher → the lower client limit wins. resp := doPostReq("getTranscodeDecision", maxTranscodeBitrateClient, "mediaId", flacTrackID, "mediaType", "song") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.TranscodeDecision).ToNot(BeNil()) Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - // maxTranscodingAudioBitrate=192 → 192000 bps + // Client limit (192kbps) wins → 192000 bps. Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000))) }) }) + + Describe("player forced format", func() { + It("transcodes a FLAC to the forced opus format when the client supports it", func() { + setPlayerForcedFormat("opus") + + resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus")) + }) + + It("falls back to negotiation when the client does not support the forced format", func() { + setPlayerForcedFormat("opus") + + resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3")) + }) + + It("applies maxBitRate on top of the forced format", func() { + setPlayerForcedFormat("opus") + setPlayerMaxBitRate(96) + + resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus")) + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(96000))) + }) + }) }) Describe("getTranscodeStream", func() { diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 0d82c8be9..24bbca960 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -240,10 +240,11 @@ func (api *Router) GetRandomSongs(r *http.Request) (*responses.Subsonic, error) if err != nil { return nil, err } - opts := filter.SongsByRandom(genre, fromYear, toYear) + opts := filter.SongsByGenreAndYearRange(genre, fromYear, toYear) opts = filter.ApplyLibraryFilter(opts, musicFolderIds) + opts.Max = size - songs, err := api.getSongs(r.Context(), 0, size, opts) + songs, err := api.ds.MediaFile(r.Context()).GetRandom(opts) if err != nil { log.Error(r, "Error retrieving random songs", err) return nil, err diff --git a/server/subsonic/api_suite_test.go b/server/subsonic/api_suite_test.go index a83f2f0eb..485daca58 100644 --- a/server/subsonic/api_suite_test.go +++ b/server/subsonic/api_suite_test.go @@ -1,9 +1,13 @@ package subsonic 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,16 @@ func TestSubsonicApi(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Subsonic API Suite") } + +// newLocalStorage fatals if the default extractor is not registered. +// Register a no-op so storage.For works in sidecar-lyrics tests. +var _ = BeforeSuite(func() { + local.RegisterExtractor(consts.DefaultScannerExtractor, func(fs.FS, string) local.Extractor { + return &subsonicNoopExtractor{} + }) +}) + +type subsonicNoopExtractor struct{} + +func (e *subsonicNoopExtractor) Parse(_ ...string) (map[string]metadata.Info, error) { return nil, nil } +func (e *subsonicNoopExtractor) Version() string { return "noop" } diff --git a/server/subsonic/filter/filters.go b/server/subsonic/filter/filters.go index 856870a6c..d19e163dd 100644 --- a/server/subsonic/filter/filters.go +++ b/server/subsonic/filter/filters.go @@ -90,10 +90,8 @@ func SongsByAlbum(albumId string) Options { }) } -func SongsByRandom(genre string, fromYear, toYear int) Options { - options := Options{ - Sort: "random()", - } +func SongsByGenreAndYearRange(genre string, fromYear, toYear int) Options { + options := Options{} ff := And{} if genre != "" { ff = append(ff, filterByGenre(genre)) @@ -108,21 +106,6 @@ func SongsByRandom(genre string, fromYear, toYear int) Options { return addDefaultFilters(options) } -func SongsByArtistTitleWithLyricsFirst(artist, title string) Options { - return addDefaultFilters(Options{ - Sort: "lyrics, updated_at", - Order: "desc", - Max: 1, - Filters: And{ - Eq{"title": title}, - Or{ - persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artist}), - persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artist}), - }, - }, - }) -} - func ApplyLibraryFilter(opts Options, musicFolderIds []int) Options { if len(musicFolderIds) == 0 { return opts diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index e6c6f9114..4027ba8b6 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -495,48 +495,6 @@ func mapExplicitStatus(explicitStatus string) string { return "" } -func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics) responses.StructuredLyric { - lines := make([]responses.Line, len(lyrics.Line)) - - for i, line := range lyrics.Line { - lines[i] = responses.Line{ - Start: line.Start, - Value: line.Value, - } - } - - structured := responses.StructuredLyric{ - DisplayArtist: lyrics.DisplayArtist, - DisplayTitle: lyrics.DisplayTitle, - Lang: lyrics.Lang, - Line: lines, - Offset: lyrics.Offset, - Synced: lyrics.Synced, - } - - if structured.DisplayArtist == "" { - structured.DisplayArtist = mf.Artist - } - if structured.DisplayTitle == "" { - structured.DisplayTitle = mf.Title - } - - return structured -} - -func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList) *responses.LyricsList { - lyricList := make(responses.StructuredLyrics, len(lyricsList)) - - for i, lyrics := range lyricsList { - lyricList[i] = buildStructuredLyric(mf, lyrics) - } - - res := &responses.LyricsList{ - StructuredLyrics: lyricList, - } - return res -} - // getUserAccessibleLibraries returns the list of libraries the current user has access to. func getUserAccessibleLibraries(ctx context.Context) []model.Library { user := getUser(ctx) diff --git a/server/subsonic/lyrics.go b/server/subsonic/lyrics.go new file mode 100644 index 000000000..ce3c3fae4 --- /dev/null +++ b/server/subsonic/lyrics.go @@ -0,0 +1,181 @@ +package subsonic + +import ( + "slices" + "sort" + "strings" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" +) + +// agentRoleMain is the OpenSubsonic agent role that marks the primary vocal +// layer; its cue line is emitted before other agents sharing the same index. +const agentRoleMain = "main" + +func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList, enhanced bool) *responses.LyricsList { + filtered := lyricsList + if !enhanced { + // Without enhanced, only return main-kind entries (a blank kind is main). + filtered = nil + for _, l := range lyricsList { + if l.IsMainKind() { + filtered = append(filtered, l) + } + } + } + + lyricList := make(responses.StructuredLyrics, len(filtered)) + for i, lyrics := range filtered { + lyricList[i] = buildStructuredLyric(mf, lyrics, enhanced) + } + return &responses.LyricsList{StructuredLyrics: lyricList} +} + +func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics, enhanced bool) responses.StructuredLyric { + agents := newLyricAgents(lyrics.Agents) + + lines := make([]responses.Line, len(lyrics.Line)) + var cueLines []responses.CueLine + for i, line := range lyrics.Line { + lines[i] = responses.Line{Start: line.Start, Value: line.Value} + if enhanced && len(line.Cue) > 0 { + cueLines = append(cueLines, buildCueLines(line, int32(i), agents)...) + } + } + + structured := responses.StructuredLyric{ + DisplayArtist: lyrics.DisplayArtist, + DisplayTitle: lyrics.DisplayTitle, + Lang: lyrics.Lang, + Line: lines, + CueLine: cueLines, + Offset: lyrics.Offset, + Synced: lyrics.Synced, + } + + if enhanced { + structured.Kind = lyrics.EffectiveKind() + if len(cueLines) > 0 && len(agents.response) > 0 { + structured.Agents = agents.response + } + } + + if structured.DisplayArtist == "" { + structured.DisplayArtist = mf.Artist + } + if structured.DisplayTitle == "" { + structured.DisplayTitle = mf.Title + } + return structured +} + +// lyricAgents indexes a lyric's agents by ID so cue lines can be ordered and +// the response agent list reused without rescanning the slice per line. +type lyricAgents struct { + orderByID map[string]int + roleByID map[string]string + response []responses.Agent +} + +func newLyricAgents(agents []model.Agent) lyricAgents { + a := lyricAgents{ + orderByID: make(map[string]int, len(agents)), + roleByID: make(map[string]string, len(agents)), + response: make([]responses.Agent, 0, len(agents)), + } + for i, agent := range agents { + a.orderByID[agent.ID] = i + a.roleByID[agent.ID] = agent.Role + a.response = append(a.response, responses.Agent{ID: agent.ID, Role: agent.Role, Name: agent.Name}) + } + return a +} + +// buildCueLines splits a line's cues by agent and emits one CueLine per agent, +// ordered main-role first then by the agent's declared order. +func buildCueLines(line model.Line, index int32, agents lyricAgents) []responses.CueLine { + agentOrder := make([]string, 0, 2) + cuesByAgent := make(map[string][]model.Cue) + for _, cue := range line.Cue { + if cue.Start == nil { + continue + } + agentID := strings.TrimSpace(cue.AgentID) + if _, exists := cuesByAgent[agentID]; !exists { + agentOrder = append(agentOrder, agentID) + } + cuesByAgent[agentID] = append(cuesByAgent[agentID], cue) + } + + sort.SliceStable(agentOrder, func(i, j int) bool { + return agents.less(agentOrder[i], agentOrder[j], i, j) + }) + + cueLines := make([]responses.CueLine, 0, len(agentOrder)) + for _, agentID := range agentOrder { + cueLine := responses.CueLine{ + Index: index, + Start: line.Start, + End: line.End, + Value: line.Value, + Cue: buildLyricCues(cuesByAgent[agentID], line.End), + } + if agentID != "" { + cueLine.AgentID = agentID + } + cueLines = append(cueLines, cueLine) + } + return cueLines +} + +// less orders two agent IDs: the main role wins, then the declared agent order, +// then known-before-unknown, then the original encounter order (origI/origJ). +func (a lyricAgents) less(left, right string, origI, origJ int) bool { + leftMain := a.roleByID[left] == agentRoleMain + rightMain := a.roleByID[right] == agentRoleMain + if leftMain != rightMain { + return leftMain + } + + leftOrder, leftOK := a.orderByID[left] + rightOrder, rightOK := a.orderByID[right] + if leftOK && rightOK && leftOrder != rightOrder { + return leftOrder < rightOrder + } + if leftOK != rightOK { + return leftOK + } + return origI < origJ +} + +func buildLyricCues(cues []model.Cue, lineEnd *int64) []responses.LyricCue { + if len(cues) == 0 { + return nil + } + + // Only resolve end times when at least one cue carries one; otherwise the + // group is start-only and must stay that way. + hasAnyEnd := slices.ContainsFunc(cues, func(c model.Cue) bool { return c.End != nil }) + if hasAnyEnd { + cues = model.NormalizeCueEnds(cues, lineEnd) + } + + out := make([]responses.LyricCue, 0, len(cues)) + for i := range cues { + if cues[i].Start == nil { + continue + } + cue := responses.LyricCue{ + Start: *cues[i].Start, + Value: cues[i].Value, + ByteStart: cues[i].ByteStart, + ByteEnd: cues[i].ByteEnd, + } + if hasAnyEnd { + cue.End = cues[i].End + } + out = append(out, cue) + } + return out +} diff --git a/server/subsonic/lyrics_test.go b/server/subsonic/lyrics_test.go new file mode 100644 index 000000000..f4ba6208a --- /dev/null +++ b/server/subsonic/lyrics_test.go @@ -0,0 +1,628 @@ +package subsonic + +import ( + "encoding/json" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/lyrics" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("GetLyricsBySongId", func() { + var router *Router + var ds model.DataStore + mockRepo := &mockedMediaFile{MockMediaFileRepo: tests.MockMediaFileRepo{}} + + BeforeEach(func() { + ds = &tests.MockDataStore{ + MockedMediaFile: mockRepo, + } + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil) + DeferCleanup(configtest.SetupConfig()) + conf.Server.LyricsPriority = "embedded,.lrc" + }) + + 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" + const metadata = "[ar:Rick Astley]\n[ti:That one song]\n[offset:-100]" + var times = []int64{18800, 22801} + + compareResponses := func(actual *responses.LyricsList, expected responses.LyricsList) { + Expect(actual).ToNot(BeNil()) + Expect(actual.StructuredLyrics).To(HaveLen(len(expected.StructuredLyrics))) + for i, realLyric := range actual.StructuredLyrics { + expectedLyric := expected.StructuredLyrics[i] + + Expect(realLyric.DisplayArtist).To(Equal(expectedLyric.DisplayArtist)) + Expect(realLyric.DisplayTitle).To(Equal(expectedLyric.DisplayTitle)) + Expect(realLyric.Kind).To(Equal(expectedLyric.Kind)) + Expect(realLyric.Lang).To(Equal(expectedLyric.Lang)) + Expect(realLyric.Synced).To(Equal(expectedLyric.Synced)) + Expect(realLyric.Agents).To(Equal(expectedLyric.Agents)) + + if expectedLyric.Offset == nil { + Expect(realLyric.Offset).To(BeNil()) + } else { + Expect(*realLyric.Offset).To(Equal(*expectedLyric.Offset)) + } + + Expect(realLyric.Line).To(HaveLen(len(expectedLyric.Line))) + for j, realLine := range realLyric.Line { + expectedLine := expectedLyric.Line[j] + Expect(realLine.Value).To(Equal(expectedLine.Value)) + + if expectedLine.Start == nil { + Expect(realLine.Start).To(BeNil()) + } else { + Expect(*realLine.Start).To(Equal(*expectedLine.Start)) + } + } + + Expect(realLyric.CueLine).To(HaveLen(len(expectedLyric.CueLine))) + for j, realCueLine := range realLyric.CueLine { + expectedCueLine := expectedLyric.CueLine[j] + Expect(realCueLine.Index).To(Equal(expectedCueLine.Index)) + Expect(realCueLine.Value).To(Equal(expectedCueLine.Value)) + Expect(realCueLine.AgentID).To(Equal(expectedCueLine.AgentID)) + if expectedCueLine.Start == nil { + Expect(realCueLine.Start).To(BeNil()) + } else { + Expect(*realCueLine.Start).To(Equal(*expectedCueLine.Start)) + } + if expectedCueLine.End == nil { + Expect(realCueLine.End).To(BeNil()) + } else { + Expect(*realCueLine.End).To(Equal(*expectedCueLine.End)) + } + + Expect(realCueLine.Cue).To(HaveLen(len(expectedCueLine.Cue))) + for k, realCue := range realCueLine.Cue { + expectedCue := expectedCueLine.Cue[k] + Expect(realCue.Value).To(Equal(expectedCue.Value)) + Expect(realCue.Start).To(Equal(expectedCue.Start)) + Expect(realCue.ByteStart).To(Equal(expectedCue.ByteStart)) + Expect(realCue.ByteEnd).To(Equal(expectedCue.ByteEnd)) + if expectedCue.End == nil { + Expect(realCue.End).To(BeNil()) + } else { + Expect(*realCue.End).To(Equal(*expectedCue.End)) + } + } + } + } + } + + It("should return mixed lyrics", func() { + r := newGetRequest("id=1") + syncedList, _ := model.ParseLyrics(".lrc", "eng", []byte(syncedLyrics)) + unsyncedList, _ := model.ParseLyrics(".lrc", "xxx", []byte(unsyncedLyrics)) + synced, _ := syncedList.Main() + unsynced, _ := unsyncedList.Main() + lyricsJson, err := json.Marshal(model.LyricList{ + synced, unsynced, + }) + Expect(err).ToNot(HaveOccurred()) + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJson), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + Lang: "eng", + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Synced: true, + Line: []responses.Line{ + { + Start: ×[0], + Value: "We're no strangers to love", + }, + { + Start: ×[1], + Value: "You know the rules and so do I", + }, + }, + }, + { + Lang: "xxx", + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Synced: false, + Line: []responses.Line{ + { + Value: "We're no strangers to love", + }, + { + Value: "You know the rules and so do I", + }, + }, + }, + }, + }) + }) + + It("should parse lrc metadata", func() { + r := newGetRequest("id=1") + syncedList, _ := model.ParseLyrics(".lrc", "eng", []byte(metadata+"\n"+syncedLyrics)) + synced, _ := syncedList.Main() + lyricsJson, err := json.Marshal(model.LyricList{ + synced, + }) + Expect(err).ToNot(HaveOccurred()) + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJson), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "That one song", + Lang: "eng", + Synced: true, + Line: []responses.Line{ + { + Start: ×[0], + Value: "We're no strangers to love", + }, + { + Start: ×[1], + Value: "You know the rules and so do I", + }, + }, + Offset: new(int64(-100)), + }, + }, + }) + }) + + It("should return multilingual TTML sidecar lyrics", func() { + conf.Server.LyricsPriority = ".ttml,embedded" + r := newGetRequest("id=1") + + fixturesDir, err := filepath.Abs("tests/fixtures") + Expect(err).ToNot(HaveOccurred()) + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + LibraryPath: fixturesDir, + Path: "test.mp3", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: "[]", + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + + porTime := int64(18800) + ttmlTime := int64(22800) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Lang: "eng", + Synced: true, + Line: []responses.Line{ + { + Start: ×[0], + Value: "We're no strangers to love", + }, + { + Start: &ttmlTime, + Value: "You know the rules and so do I", + }, + }, + }, + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Lang: "por", + Synced: true, + Line: []responses.Line{ + { + Start: &porTime, + Value: "Nao somos estranhos ao amor", + }, + }, + }, + }, + }) + }) + + It("should return metadata-linked translation and pronunciation tracks from TTML", func() { + conf.Server.LyricsPriority = ".ttml,embedded" + r := newGetRequest("id=1&enhanced=true") + + fixturesDir, err := filepath.Abs("tests/fixtures") + Expect(err).ToNot(HaveOccurred()) + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + LibraryPath: fixturesDir, + Path: "test-metadata.mp3", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: "[]", + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + + mainStartA := int64(1000) + mainStartB := int64(2000) + tokenStartA := int64(2000) + tokenEndA := int64(2300) + tokenStartB := int64(2300) + tokenEndB := int64(2600) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "main", + Lang: "ja", + Synced: true, + Line: []responses.Line{ + { + Start: &mainStartA, + Value: "こんにちは", + }, + { + Start: &mainStartB, + Value: "こんばんは", + }, + }, + }, + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "translation", + Lang: "es", + Synced: true, + Line: []responses.Line{ + { + Start: &mainStartA, + Value: "Hola", + }, + }, + }, + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "pronunciation", + Lang: "ja-latn", + Synced: true, + Line: []responses.Line{ + { + Start: &mainStartB, + Value: "konni", + }, + }, + CueLine: []responses.CueLine{ + { + Index: 0, + Start: &mainStartB, + End: &tokenEndB, + Value: "konni", + Cue: []responses.LyricCue{ + { + Start: tokenStartA, + End: &tokenEndA, + ByteStart: 0, + ByteEnd: 1, + Value: "ko", + }, + { + Start: tokenStartB, + End: &tokenEndB, + ByteStart: 2, + ByteEnd: 4, + Value: "nni", + }, + }, + }, + }, + }, + }, + }) + }) + + It("should return cue lines for songLyrics v2 clients with enhanced=true", func() { + r := newGetRequest("id=1&enhanced=true") + + lineStart := int64(1000) + lineEnd := int64(3000) + tokenStartA := int64(1000) + tokenEndA := int64(1400) + tokenStartB := int64(2000) + tokenEndB := int64(2500) + lyricsJson, err := json.Marshal(model.LyricList{ + { + Lang: "eng", + Agents: []model.Agent{{ID: "lead", Role: "main"}, {ID: "__nd_bg__|lead", Role: "bg"}}, + Synced: true, + Line: []model.Line{ + { + Start: &lineStart, + End: &lineEnd, + Value: "Hello echo", + Cue: []model.Cue{ + { + Start: &tokenStartA, + End: &tokenEndA, + Value: "Hello", + ByteStart: 0, + ByteEnd: 4, + AgentID: "lead", + }, + { + Start: &tokenStartB, + End: &tokenEndB, + Value: "echo", + ByteStart: 6, + ByteEnd: 9, + AgentID: "__nd_bg__|lead", + }, + }, + }, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJson), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "main", + Lang: "eng", + Synced: true, + Agents: []responses.Agent{ + {ID: "lead", Role: "main"}, + {ID: "__nd_bg__|lead", Role: "bg"}, + }, + Line: []responses.Line{ + { + Start: &lineStart, + Value: "Hello echo", + }, + }, + CueLine: []responses.CueLine{ + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "Hello echo", + AgentID: "lead", + Cue: []responses.LyricCue{ + { + Start: tokenStartA, + End: &tokenEndA, + ByteStart: 0, + ByteEnd: 4, + Value: "Hello", + }, + }, + }, + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "Hello echo", + AgentID: "__nd_bg__|lead", + Cue: []responses.LyricCue{ + { + Start: tokenStartB, + End: &tokenEndB, + ByteStart: 6, + ByteEnd: 9, + Value: "echo", + }, + }, + }, + }, + }, + }, + }) + }) + + It("should keep enhanced line-level lyrics when no cue data is available", func() { + r := newGetRequest("id=1&enhanced=true") + + lineStart := int64(1000) + lineEnd := int64(3000) + lyricsJSON, err := json.Marshal(model.LyricList{ + { + Kind: "main", + Lang: "eng", + Synced: true, + Line: []model.Line{ + { + Start: &lineStart, + End: &lineEnd, + Value: "Line without word timing", + }, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJSON), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "main", + Lang: "eng", + Synced: true, + Line: []responses.Line{ + { + Start: &lineStart, + Value: "Line without word timing", + }, + }, + }, + }, + }) + }) + + It("should return required cue byte offsets for ambiguous and multibyte cue lines", func() { + r := newGetRequest("id=1&enhanced=true") + + asciiLineStart := int64(0) + asciiLineEnd := int64(2400) + asciiCueStartA := int64(0) + asciiCueEndA := int64(300) + asciiCueStartB := int64(900) + asciiCueEndB := int64(1300) + asciiCueStartC := int64(1300) + asciiCueEndC := int64(1600) + asciiCueStartD := int64(1600) + + utfLineStart := int64(2747) + utfLineEnd := int64(6214) + utfCueStartA := int64(2747) + utfCueEndA := int64(3018) + utfCueStartB := int64(3018) + utfCueEndB := int64(3179) + utfCueStartC := int64(3582) + utfCueEndC := int64(4100) + utfCueStartD := int64(4500) + utfCueEndD := int64(6214) + + lyricsJSON, err := json.Marshal(model.LyricList{ + { + Lang: "eng", + Synced: true, + Line: []model.Line{ + { + Start: &asciiLineStart, + End: &asciiLineEnd, + Value: "Oh love love me tonight", + Cue: []model.Cue{ + {Start: &asciiCueStartA, End: &asciiCueEndA, Value: "Oh", ByteStart: 0, ByteEnd: 1}, + {Start: &asciiCueStartB, End: &asciiCueEndB, Value: "love", ByteStart: 8, ByteEnd: 11}, + {Start: &asciiCueStartC, End: &asciiCueEndC, Value: "me", ByteStart: 13, ByteEnd: 14}, + {Start: &asciiCueStartD, Value: "tonight", ByteStart: 16, ByteEnd: 22}, + }, + }, + { + Start: &utfLineStart, + End: &utfLineEnd, + Value: "눈을 뜬 순간", + Cue: []model.Cue{ + {Start: &utfCueStartA, End: &utfCueEndA, Value: "눈", ByteStart: 0, ByteEnd: 2}, + {Start: &utfCueStartB, End: &utfCueEndB, Value: "을", ByteStart: 3, ByteEnd: 5}, + {Start: &utfCueStartC, End: &utfCueEndC, Value: "뜬", ByteStart: 7, ByteEnd: 9}, + {Start: &utfCueStartD, End: &utfCueEndD, Value: "순간", ByteStart: 11, ByteEnd: 16}, + }, + }, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJSON), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "main", + Lang: "eng", + Synced: true, + Line: []responses.Line{ + {Start: &asciiLineStart, Value: "Oh love love me tonight"}, + {Start: &utfLineStart, Value: "눈을 뜬 순간"}, + }, + CueLine: []responses.CueLine{ + { + Index: 0, + Start: &asciiLineStart, + End: &asciiLineEnd, + Value: "Oh love love me tonight", + Cue: []responses.LyricCue{ + {Start: asciiCueStartA, End: &asciiCueEndA, Value: "Oh", ByteStart: 0, ByteEnd: 1}, + {Start: asciiCueStartB, End: &asciiCueEndB, Value: "love", ByteStart: 8, ByteEnd: 11}, + {Start: asciiCueStartC, End: &asciiCueEndC, Value: "me", ByteStart: 13, ByteEnd: 14}, + {Start: asciiCueStartD, End: &asciiLineEnd, Value: "tonight", ByteStart: 16, ByteEnd: 22}, + }, + }, + { + Index: 1, + Start: &utfLineStart, + End: &utfLineEnd, + Value: "눈을 뜬 순간", + Cue: []responses.LyricCue{ + {Start: utfCueStartA, End: &utfCueEndA, Value: "눈", ByteStart: 0, ByteEnd: 2}, + {Start: utfCueStartB, End: &utfCueEndB, Value: "을", ByteStart: 3, ByteEnd: 5}, + {Start: utfCueStartC, End: &utfCueEndC, Value: "뜬", ByteStart: 7, ByteEnd: 9}, + {Start: utfCueStartD, End: &utfCueEndD, Value: "순간", ByteStart: 11, ByteEnd: 16}, + }, + }, + }, + }, + }, + }) + }) +}) diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index 9ab3a20b0..089a1fdda 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -13,7 +13,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/resources" - "github.com/navidrome/navidrome/server/subsonic/filter" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/gravatar" "github.com/navidrome/navidrome/utils/req" @@ -98,22 +97,13 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) { response := newResponse() lyricsResponse := responses.Lyrics{} response.Lyrics = &lyricsResponse - mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongsByArtistTitleWithLyricsFirst(artist, title)) - + structuredLyrics, err := api.lyrics.GetLyricsByArtistTitle(r.Context(), artist, title) if err != nil { return nil, err } - if len(mediaFiles) == 0 { - return response, nil - } - - structuredLyrics, err := api.lyrics.GetLyrics(r.Context(), &mediaFiles[0]) - if err != nil { - return nil, err - } - - if len(structuredLyrics) == 0 { + mainLyric, ok := structuredLyrics.Main() + if !ok { return response, nil } @@ -121,10 +111,9 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) { lyricsResponse.Title = title var lyricsText strings.Builder - for _, line := range structuredLyrics[0].Line { + for _, line := range mainLyric.Line { lyricsText.WriteString(line.Value + "\n") } - lyricsResponse.Value = lyricsText.String() return response, nil @@ -146,8 +135,10 @@ func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, erro return nil, err } + enhanced, _ := req.Params(r).Bool("enhanced") + response := newResponse() - response.LyricsList = buildLyricsList(mediaFile, structuredLyrics) + response.LyricsList = buildLyricsList(mediaFile, structuredLyrics, enhanced) return response, nil } diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 12c0dff56..228427d5a 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -8,6 +8,7 @@ import ( "errors" "io" "net/http/httptest" + "path/filepath" "slices" "time" @@ -16,7 +17,6 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -34,7 +34,7 @@ var _ = Describe("MediaRetrievalController", func() { MockedMediaFile: mockRepo, } artwork = &fakeArtwork{data: "image data"} - router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil), nil, nil) + router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil) w = httptest.NewRecorder() DeferCleanup(configtest.SetupConfig()) conf.Server.LyricsPriority = "embedded,.lrc" @@ -113,34 +113,19 @@ var _ = Describe("MediaRetrievalController", func() { Describe("GetLyrics", func() { It("should return data for given artist & title", func() { r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up") - lyrics, _ := model.ToLyrics("eng", "[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I") + lyricsList, _ := model.ParseLyrics(".lrc", "eng", []byte("[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I")) + lyrics, _ := lyricsList.Main() lyricsJson, err := json.Marshal(model.LyricList{ - *lyrics, + lyrics, }) Expect(err).ToNot(HaveOccurred()) - baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) mockRepo.SetData(model.MediaFiles{ { - ID: "2", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: "[]", - UpdatedAt: baseTime.Add(2 * time.Hour), // No lyrics, newer - }, - { - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJson), - UpdatedAt: baseTime.Add(1 * time.Hour), // Has lyrics, older - }, - { - ID: "3", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: "[]", - UpdatedAt: baseTime.Add(3 * time.Hour), // No lyrics, newest + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJson), }, }) response, err := router.GetLyrics(r) @@ -149,6 +134,26 @@ var _ = Describe("MediaRetrievalController", func() { Expect(response.Lyrics.Title).To(Equal("Never Gonna Give You Up")) Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n")) }) + It("should surface the main-kind track when translation tracks are present", func() { + r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up") + start := int64(0) + lyricsJSON, err := json.Marshal(model.LyricList{ + {Kind: model.LyricKindTranslation, Lang: "por", Line: []model.Line{{Start: &start, Value: "Nunca vou te decepcionar"}}}, + {Kind: model.LyricKindMain, Lang: "eng", Line: []model.Line{{Start: &start, Value: "Never gonna let you down"}}}, + }) + Expect(err).ToNot(HaveOccurred()) + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJSON), + }, + }) + response, err := router.GetLyrics(r) + Expect(err).ToNot(HaveOccurred()) + Expect(response.Lyrics.Value).To(Equal("Never gonna let you down\n")) + }) It("should return empty subsonic response if the record corresponding to the given artist & title is not found", func() { r := newGetRequest("artist=Dheeraj", "title=Rinkiya+Ke+Papa") mockRepo.SetData(model.MediaFiles{}) @@ -160,18 +165,15 @@ var _ = Describe("MediaRetrievalController", func() { }) It("should return lyric file when finding mediafile with no embedded lyrics but present on filesystem", func() { r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up") + fixturesDir, err := filepath.Abs("tests/fixtures") + Expect(err).ToNot(HaveOccurred()) mockRepo.SetData(model.MediaFiles{ { - Path: "tests/fixtures/test.mp3", - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - }, - { - Path: "tests/fixtures/test.mp3", - ID: "2", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", + LibraryPath: fixturesDir, + Path: "test.mp3", + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", }, }) response, err := router.GetLyrics(r) @@ -181,142 +183,6 @@ var _ = Describe("MediaRetrievalController", func() { Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n")) }) }) - - Describe("GetLyricsBySongId", 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" - const metadata = "[ar:Rick Astley]\n[ti:That one song]\n[offset:-100]" - var times = []int64{18800, 22801} - - compareResponses := func(actual *responses.LyricsList, expected responses.LyricsList) { - Expect(actual).ToNot(BeNil()) - Expect(actual.StructuredLyrics).To(HaveLen(len(expected.StructuredLyrics))) - for i, realLyric := range actual.StructuredLyrics { - expectedLyric := expected.StructuredLyrics[i] - - Expect(realLyric.DisplayArtist).To(Equal(expectedLyric.DisplayArtist)) - Expect(realLyric.DisplayTitle).To(Equal(expectedLyric.DisplayTitle)) - Expect(realLyric.Lang).To(Equal(expectedLyric.Lang)) - Expect(realLyric.Synced).To(Equal(expectedLyric.Synced)) - - if expectedLyric.Offset == nil { - Expect(realLyric.Offset).To(BeNil()) - } else { - Expect(*realLyric.Offset).To(Equal(*expectedLyric.Offset)) - } - - Expect(realLyric.Line).To(HaveLen(len(expectedLyric.Line))) - for j, realLine := range realLyric.Line { - expectedLine := expectedLyric.Line[j] - Expect(realLine.Value).To(Equal(expectedLine.Value)) - - if expectedLine.Start == nil { - Expect(realLine.Start).To(BeNil()) - } else { - Expect(*realLine.Start).To(Equal(*expectedLine.Start)) - } - } - } - } - - It("should return mixed lyrics", func() { - r := newGetRequest("id=1") - synced, _ := model.ToLyrics("eng", syncedLyrics) - unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics) - lyricsJson, err := json.Marshal(model.LyricList{ - *synced, *unsynced, - }) - Expect(err).ToNot(HaveOccurred()) - - mockRepo.SetData(model.MediaFiles{ - { - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJson), - }, - }) - - response, err := router.GetLyricsBySongId(r) - Expect(err).ToNot(HaveOccurred()) - compareResponses(response.LyricsList, responses.LyricsList{ - StructuredLyrics: responses.StructuredLyrics{ - { - Lang: "eng", - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Synced: true, - Line: []responses.Line{ - { - Start: ×[0], - Value: "We're no strangers to love", - }, - { - Start: ×[1], - Value: "You know the rules and so do I", - }, - }, - }, - { - Lang: "xxx", - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Synced: false, - Line: []responses.Line{ - { - Value: "We're no strangers to love", - }, - { - Value: "You know the rules and so do I", - }, - }, - }, - }, - }) - }) - - It("should parse lrc metadata", func() { - r := newGetRequest("id=1") - synced, _ := model.ToLyrics("eng", metadata+"\n"+syncedLyrics) - lyricsJson, err := json.Marshal(model.LyricList{ - *synced, - }) - Expect(err).ToNot(HaveOccurred()) - mockRepo.SetData(model.MediaFiles{ - { - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJson), - }, - }) - - response, err := router.GetLyricsBySongId(r) - Expect(err).ToNot(HaveOccurred()) - - compareResponses(response.LyricsList, responses.LyricsList{ - StructuredLyrics: responses.StructuredLyrics{ - { - DisplayArtist: "Rick Astley", - DisplayTitle: "That one song", - Lang: "eng", - Synced: true, - Line: []responses.Line{ - { - Start: ×[0], - Value: "We're no strangers to love", - }, - { - Start: ×[1], - Value: "You know the rules and so do I", - }, - }, - Offset: new(int64(-100)), - }, - }, - }) - }) - }) }) type fakeArtwork struct { diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index 85edb1012..97b3cafcc 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -11,7 +11,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson extensions := responses.OpenSubsonicExtensions{ {Name: "transcodeOffset", Versions: []int32{1}}, {Name: "formPost", Versions: []int32{1}}, - {Name: "songLyrics", Versions: []int32{1}}, + {Name: "songLyrics", Versions: []int32{1, 2}}, {Name: "indexBasedQueue", Versions: []int32{1}}, {Name: "transcoding", Versions: []int32{1}}, {Name: "playbackReport", Versions: []int32{1}}, diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index 3ccbf232e..e4217303f 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -58,7 +58,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { HaveLen(6), ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}), ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), @@ -88,7 +88,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { HaveLen(7), ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}), ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index dcb458932..7e41a1daa 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -547,13 +547,39 @@ type Line struct { Value string `xml:",chardata" json:"value"` } +type LyricCue struct { + Start int64 `xml:"start,attr" json:"start"` + End *int64 `xml:"end,attr,omitempty" json:"end,omitempty"` + ByteStart int `xml:"byteStart,attr" json:"byteStart"` + ByteEnd int `xml:"byteEnd,attr" json:"byteEnd"` + Value string `xml:",chardata" json:"value"` +} + +type Agent struct { + ID string `xml:"id,attr" json:"id"` + Role string `xml:"role,attr" json:"role"` + Name string `xml:"name,attr,omitempty" json:"name,omitempty"` +} + +type CueLine struct { + Index int32 `xml:"index,attr" json:"index"` + Start *int64 `xml:"start,attr,omitempty" json:"start,omitempty"` + End *int64 `xml:"end,attr,omitempty" json:"end,omitempty"` + Value string `xml:"value,attr" json:"value"` + AgentID string `xml:"agentId,attr,omitempty" json:"agentId,omitempty"` + Cue []LyricCue `xml:"cue,omitempty" json:"cue,omitempty"` +} + type StructuredLyric struct { - DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist,omitempty"` - DisplayTitle string `xml:"displayTitle,attr,omitempty" json:"displayTitle,omitempty"` - Lang string `xml:"lang,attr" json:"lang"` - Line []Line `xml:"line" json:"line"` - Offset *int64 `xml:"offset,attr,omitempty" json:"offset,omitempty"` - Synced bool `xml:"synced,attr" json:"synced"` + DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist,omitempty"` + DisplayTitle string `xml:"displayTitle,attr,omitempty" json:"displayTitle,omitempty"` + Kind string `xml:"kind,attr,omitempty" json:"kind,omitempty"` + Lang string `xml:"lang,attr" json:"lang"` + Line []Line `xml:"line" json:"line"` + Agents []Agent `xml:"agent,omitempty" json:"agents,omitempty"` + CueLine []CueLine `xml:"cueLine,omitempty" json:"cueLine,omitempty"` + Offset *int64 `xml:"offset,attr,omitempty" json:"offset,omitempty"` + Synced bool `xml:"synced,attr" json:"synced"` } type StructuredLyrics []StructuredLyric diff --git a/server/subsonic/searching.go b/server/subsonic/searching.go index fd7e29587..5d4989ae5 100644 --- a/server/subsonic/searching.go +++ b/server/subsonic/searching.go @@ -74,7 +74,7 @@ func (api *Router) searchAll(ctx context.Context, sp *searchParams, musicFolderI if len(musicFolderIds) > 0 { songOpts.Filters = Eq{"library_id": musicFolderIds} albumOpts.Filters = Eq{"library_id": musicFolderIds} - artistOpts.Filters = Eq{"library_artist.library_id": musicFolderIds} + artistOpts.Filters = Eq{"library_id": musicFolderIds} } // Run searches in parallel diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go index 9a9c6af6f..4e72bd2e6 100644 --- a/server/subsonic/searching_test.go +++ b/server/subsonic/searching_test.go @@ -39,12 +39,17 @@ var _ = Describe("Search", func() { } Describe("Search2", func() { - It("should accept musicFolderId parameter", func() { + It("scopes all entity types to the requested libraries", func() { + // The subsonic layer passes the same library_id filter to all three repos; the + // artist repository translates it to the join-free library_artist predicate itself. r := newGetRequest("query=test", "musicFolderId=1") ctx := request.WithUser(r.Context(), model.User{ - ID: "user1", - UserName: "testuser", - Libraries: []model.Library{{ID: 1, Name: "Library 1"}}, + ID: "user1", + UserName: "testuser", + Libraries: []model.Library{ + {ID: 1, Name: "Library 1"}, + {ID: 2, Name: "Library 2"}, + }, }) r = r.WithContext(ctx) @@ -54,14 +59,13 @@ var _ = Describe("Search", func() { Expect(resp).ToNot(BeNil()) Expect(resp.SearchResult2).ToNot(BeNil()) - // Verify that library filter was applied to all repositories assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?)", 1) - assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1) assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?)", 1) + assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1) }) - It("should return results from all accessible libraries when musicFolderId is not provided", func() { - r := newGetRequest("query=test") + It("applies no library filter when musicFolderId is not provided", func() { + r := newGetRequest("query=test") // no musicFolderId → all accessible libraries ctx := request.WithUser(r.Context(), model.User{ ID: "user1", UserName: "testuser", @@ -79,10 +83,9 @@ var _ = Describe("Search", func() { Expect(resp).ToNot(BeNil()) Expect(resp.SearchResult2).ToNot(BeNil()) - // Verify that library filter was applied to all repositories with all accessible libraries assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) - assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) + assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) }) It("should return empty results when user has no accessible libraries", func() { @@ -122,12 +125,15 @@ var _ = Describe("Search", func() { }) Describe("Search3", func() { - It("should accept musicFolderId parameter", func() { + It("scopes all entity types to the requested libraries", func() { r := newGetRequest("query=test", "musicFolderId=1") ctx := request.WithUser(r.Context(), model.User{ - ID: "user1", - UserName: "testuser", - Libraries: []model.Library{{ID: 1, Name: "Library 1"}}, + ID: "user1", + UserName: "testuser", + Libraries: []model.Library{ + {ID: 1, Name: "Library 1"}, + {ID: 2, Name: "Library 2"}, + }, }) r = r.WithContext(ctx) @@ -137,14 +143,13 @@ var _ = Describe("Search", func() { Expect(resp).ToNot(BeNil()) Expect(resp.SearchResult3).ToNot(BeNil()) - // Verify that library filter was applied to all repositories assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?)", 1) - assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1) assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?)", 1) + assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1) }) - It("should return results from all accessible libraries when musicFolderId is not provided", func() { - r := newGetRequest("query=test") + It("applies no library filter when musicFolderId is not provided", func() { + r := newGetRequest("query=test") // no musicFolderId → all accessible libraries ctx := request.WithUser(r.Context(), model.User{ ID: "user1", UserName: "testuser", @@ -162,10 +167,9 @@ var _ = Describe("Search", func() { Expect(resp).ToNot(BeNil()) Expect(resp.SearchResult3).ToNot(BeNil()) - // Verify that library filter was applied to all repositories with all accessible libraries assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) - assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) + assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) }) It("should return empty results when user has no accessible libraries", func() { diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index 578ad44fc..511db2b85 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/req" ) @@ -278,6 +279,28 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) return stream.IsAACCodec(p.Container) }) + // Honor the player's forced transcoding format, falling back to normal + // negotiation when the client can't play it (issue #5583). + if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { + if !clientInfo.ForceFormat(trc.TargetFormat) { + clientName := clientInfo.Name + if player, ok := request.PlayerFrom(ctx); ok && player.Client != "" { + clientName = player.Client + } + log.Debug(ctx, "Player forced format not supported by client; falling back to negotiation", + "forcedFormat", trc.TargetFormat, "client", clientName) + } + } + + // Apply the player's MaxBitRate as a ceiling on the client's declared + // limits (issue #5583). Both fields are capped because the client sends + // them independently here; capping only MaxAudioBitrate would let an + // independent MaxTranscodingAudioBitrate slip through computeBitrate. + if player, ok := request.PlayerFrom(ctx); ok && clientInfo.CapBitrate(player.MaxBitRate) { + log.Debug(ctx, "Applied player MaxBitRate cap to transcode decision", + "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name) + } + // Get media file mf, err := api.ds.MediaFile(ctx).Get(mediaID) if err != nil { diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index 29a883ac1..7e36ab243 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -234,6 +235,143 @@ var _ = Describe("Transcode endpoints", func() { Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3")) }) + + Describe("player MaxBitRate cap", func() { + withPlayer := func(r *http.Request, maxBitRate int) *http.Request { + ctx := request.WithPlayer(r.Context(), model.Player{Client: "NavidromeUI", MaxBitRate: maxBitRate}) + return r.WithContext(ctx) + } + + BeforeEach(func() { + mockMFRepo.SetData(model.MediaFiles{ + {ID: "song-1", Suffix: "flac", Codec: "FLAC", BitRate: 900, Channels: 2, SampleRate: 44100}, + }) + mockTD.decision = &stream.TranscodeDecision{MediaID: "song-1", CanDirectPlay: true} + mockTD.token = "token" + }) + + It("caps client MaxAudioBitrate at the player MaxBitRate when client declares none", func() { + body := `{"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}` + r := withPlayer(newJSONPostRequest("mediaId=song-1&mediaType=song", body), 320) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient).ToNot(BeNil()) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320)) + Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(320)) + }) + + It("does not raise a lower client-declared limit", func() { + // Client declares 192 kbps (192000 bps); player cap is 320 — client wins. + body := `{"maxAudioBitrate":192000,"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}` + r := withPlayer(newJSONPostRequest("mediaId=song-1&mediaType=song", body), 320) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(192)) + }) + + It("lowers a higher client-declared limit to the player cap", func() { + // Client declares 320 kbps (320000 bps); player cap is 192 — player wins. + body := `{"maxAudioBitrate":320000,"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}` + r := withPlayer(newJSONPostRequest("mediaId=song-1&mediaType=song", body), 192) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(192)) + Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(192)) + }) + + It("does nothing when no player is in context", func() { + body := `{"maxAudioBitrate":320000,"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}` + r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320)) + }) + + It("does nothing when player MaxBitRate is 0", func() { + body := `{"maxAudioBitrate":320000,"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}` + r := withPlayer(newJSONPostRequest("mediaId=song-1&mediaType=song", body), 0) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320)) + }) + }) + + Describe("player forced format", func() { + withForcedFormat := func(r *http.Request, format string, maxBitRate int) *http.Request { + ctx := r.Context() + ctx = request.WithTranscoding(ctx, model.Transcoding{TargetFormat: format}) + if maxBitRate > 0 { + ctx = request.WithPlayer(ctx, model.Player{Client: "NavidromeUI", MaxBitRate: maxBitRate}) + } + return r.WithContext(ctx) + } + + BeforeEach(func() { + mockMFRepo.SetData(model.MediaFiles{ + {ID: "song-1", Suffix: "flac", Codec: "FLAC", BitRate: 900, Channels: 2, SampleRate: 44100}, + }) + mockTD.decision = &stream.TranscodeDecision{MediaID: "song-1", CanTranscode: true} + mockTD.token = "token" + }) + + It("forces a supported format and clears direct play", func() { + body := `{"directPlayProfiles":[{"containers":["flac"],"audioCodecs":["flac"],"protocols":["http"]}], + "transcodingProfiles":[{"container":"ogg","audioCodec":"opus","protocol":"http"}, + {"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` + r := withForcedFormat(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 0) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(1)) + Expect(mockTD.capturedClient.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) + Expect(mockTD.capturedClient.DirectPlayProfiles).To(BeEmpty()) + }) + + It("falls back to negotiation when the forced format is unsupported", func() { + // Forced format is opus, but the client only declares mp3 and flac. + // Should fall back to negotiating among the client's own profiles. + body := `{"directPlayProfiles":[{"containers":["flac"],"audioCodecs":["flac"],"protocols":["http"]}], + "transcodingProfiles":[ + {"container":"flac","audioCodec":"flac","protocol":"http"}, + {"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` + r := withForcedFormat(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 0) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + // Profiles left intact for normal negotiation (forced format not applied). + Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(2)) + Expect(mockTD.capturedClient.DirectPlayProfiles).ToNot(BeEmpty()) + }) + + It("applies the maxBitRate cap on top of the forced format", func() { + // Client supports opus + mp3; forced format opus must be selected, + // and the maxBitRate cap applied on top. + body := `{"transcodingProfiles":[ + {"container":"ogg","audioCodec":"opus","protocol":"http"}, + {"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` + r := withForcedFormat(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 128) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(1)) + Expect(mockTD.capturedClient.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(128)) + Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(128)) + }) + }) }) Describe("GetTranscodeStream", func() { diff --git a/tests/fixtures/bom-test.ttml b/tests/fixtures/bom-test.ttml new file mode 100644 index 000000000..319ab1f07 --- /dev/null +++ b/tests/fixtures/bom-test.ttml @@ -0,0 +1,2 @@ + +

BOM test line

diff --git a/tests/fixtures/bom-utf16-test.ttml b/tests/fixtures/bom-utf16-test.ttml new file mode 100644 index 000000000..a5621ef5d Binary files /dev/null and b/tests/fixtures/bom-utf16-test.ttml differ diff --git a/tests/fixtures/lyrics/auld-lang-syne.elrc b/tests/fixtures/lyrics/auld-lang-syne.elrc new file mode 100644 index 000000000..41342d910 --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.elrc @@ -0,0 +1,27 @@ +[ar:Robert Burns] +[ti:Auld Lang Syne] +[lang:eng] +[00:00.00]<00:00.00>Should <00:00.90>auld <00:01.80>acquaintance <00:02.70>be <00:03.60>forgot, +[00:04.50]<00:04.50>And <00:05.40>never <00:06.30>brought <00:07.20>to <00:08.10>mind? +[00:09.00]<00:09.00>Should <00:09.90>auld <00:10.80>acquaintance <00:11.70>be <00:12.60>forgot, +[00:13.50]<00:13.50>And <00:14.62>auld <00:15.75>lang <00:16.88>syne? +[00:18.00]<00:18.00>For <00:18.75>auld <00:19.50>lang <00:20.25>syne, <00:21.00>my <00:21.75>dear, +[00:22.50]<00:22.50>For <00:23.62>auld <00:24.75>lang <00:25.88>syne, +[00:27.00]<00:27.00>We'll <00:27.64>tak <00:28.29>a <00:28.93>cup <00:29.57>o' <00:30.21>kindness <00:30.86>yet, +[00:31.50]<00:31.50>For <00:32.62>auld <00:33.75>lang <00:34.88>syne. +[00:36.00]<00:36.00>And <00:36.75>surely <00:37.50>ye'll <00:38.25>be <00:39.00>your <00:39.75>pint-stowp, +[00:40.50]<00:40.50>And <00:41.40>surely <00:42.30>I'll <00:43.20>be <00:44.10>mine, +[00:45.00]<00:45.00>And <00:45.56>we'll <00:46.12>tak <00:46.69>a <00:47.25>cup <00:47.81>o' <00:48.38>kindness <00:48.94>yet, +[00:49.50]<00:49.50>For <00:50.62>auld <00:51.75>lang <00:52.88>syne. +[00:54.00]<00:54.00>We <00:54.64>twa <00:55.29>hae <00:55.93>run <00:56.57>about <00:57.21>the <00:57.86>braes, +[00:58.50]<00:58.50>And <00:59.40>pou'd <01:00.30>the <01:01.20>gowans <01:02.10>fine, +[01:03.00]<01:03.00>But <01:03.64>we've <01:04.29>wander'd <01:04.93>mony <01:05.57>a <01:06.21>weary <01:06.86>fit, +[01:07.50]<01:07.50>Sin <01:08.62>auld <01:09.75>lang <01:10.88>syne. +[01:12.00]<01:12.00>We <01:12.64>twa <01:13.29>hae <01:13.93>paidl'd <01:14.57>in <01:15.21>the <01:15.86>burn, +[01:16.50]<01:16.50>Frae <01:17.40>morning <01:18.30>sun <01:19.20>till <01:20.10>dine, +[01:21.00]<01:21.00>But <01:21.64>seas <01:22.29>between <01:22.93>us <01:23.57>braid <01:24.21>hae <01:24.86>roar'd +[01:25.50]<01:25.50>Sin <01:26.62>auld <01:27.75>lang <01:28.88>syne. +[01:30.00]<01:30.00>And <01:30.64>there's <01:31.29>a <01:31.93>hand, <01:32.57>my <01:33.21>trusty <01:33.86>fiere, +[01:34.50]<01:34.50>And <01:35.25>gie's <01:36.00>a <01:36.75>hand <01:37.50>o' <01:38.25>thine, +[01:39.00]<01:39.00>And <01:39.64>we'll <01:40.29>tak <01:40.93>a <01:41.57>right <01:42.21>gude-willie <01:42.86>waught, +[01:43.50]<01:43.50>For <01:44.62>auld <01:45.75>lang <01:46.88>syne. diff --git a/tests/fixtures/lyrics/auld-lang-syne.lrc b/tests/fixtures/lyrics/auld-lang-syne.lrc new file mode 100644 index 000000000..56021870a --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.lrc @@ -0,0 +1,28 @@ +[ar:Robert Burns] +[ti:Auld Lang Syne] +[al:Traditional] +[lang:eng] +[00:00.00]Should auld acquaintance be forgot, +[00:04.50]And never brought to mind? +[00:09.00]Should auld acquaintance be forgot, +[00:13.50]And auld lang syne? +[00:18.00]For auld lang syne, my dear, +[00:22.50]For auld lang syne, +[00:27.00]We'll tak a cup o' kindness yet, +[00:31.50]For auld lang syne. +[00:36.00]And surely ye'll be your pint-stowp, +[00:40.50]And surely I'll be mine, +[00:45.00]And we'll tak a cup o' kindness yet, +[00:49.50]For auld lang syne. +[00:54.00]We twa hae run about the braes, +[00:58.50]And pou'd the gowans fine, +[01:03.00]But we've wander'd mony a weary fit, +[01:07.50]Sin auld lang syne. +[01:12.00]We twa hae paidl'd in the burn, +[01:16.50]Frae morning sun till dine, +[01:21.00]But seas between us braid hae roar'd +[01:25.50]Sin auld lang syne. +[01:30.00]And there's a hand, my trusty fiere, +[01:34.50]And gie's a hand o' thine, +[01:39.00]And we'll tak a right gude-willie waught, +[01:43.50]For auld lang syne. diff --git a/tests/fixtures/lyrics/auld-lang-syne.srt b/tests/fixtures/lyrics/auld-lang-syne.srt new file mode 100644 index 000000000..116bec0bf --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.srt @@ -0,0 +1,95 @@ +1 +00:00:00,000 --> 00:00:04,500 +Should auld acquaintance be forgot, + +2 +00:00:04,500 --> 00:00:09,000 +And never brought to mind? + +3 +00:00:09,000 --> 00:00:13,500 +Should auld acquaintance be forgot, + +4 +00:00:13,500 --> 00:00:18,000 +And auld lang syne? + +5 +00:00:18,000 --> 00:00:22,500 +For auld lang syne, my dear, + +6 +00:00:22,500 --> 00:00:27,000 +For auld lang syne, + +7 +00:00:27,000 --> 00:00:31,500 +We'll tak a cup o' kindness yet, + +8 +00:00:31,500 --> 00:00:36,000 +For auld lang syne. + +9 +00:00:36,000 --> 00:00:40,500 +And surely ye'll be your pint-stowp, + +10 +00:00:40,500 --> 00:00:45,000 +And surely I'll be mine, + +11 +00:00:45,000 --> 00:00:49,500 +And we'll tak a cup o' kindness yet, + +12 +00:00:49,500 --> 00:00:54,000 +For auld lang syne. + +13 +00:00:54,000 --> 00:00:58,500 +We twa hae run about the braes, + +14 +00:00:58,500 --> 00:01:03,000 +And pou'd the gowans fine, + +15 +00:01:03,000 --> 00:01:07,500 +But we've wander'd mony a weary fit, + +16 +00:01:07,500 --> 00:01:12,000 +Sin auld lang syne. + +17 +00:01:12,000 --> 00:01:16,500 +We twa hae paidl'd in the burn, + +18 +00:01:16,500 --> 00:01:21,000 +Frae morning sun till dine, + +19 +00:01:21,000 --> 00:01:25,500 +But seas between us braid hae roar'd + +20 +00:01:25,500 --> 00:01:30,000 +Sin auld lang syne. + +21 +00:01:30,000 --> 00:01:34,500 +And there's a hand, my trusty fiere, + +22 +00:01:34,500 --> 00:01:39,000 +And gie's a hand o' thine, + +23 +00:01:39,000 --> 00:01:43,500 +And we'll tak a right gude-willie waught, + +24 +00:01:43,500 --> 00:01:48,000 +For auld lang syne. diff --git a/tests/fixtures/lyrics/auld-lang-syne.ttml b/tests/fixtures/lyrics/auld-lang-syne.ttml new file mode 100644 index 000000000..a08be29e2 --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.ttml @@ -0,0 +1,31 @@ + + + +
+

Should auld acquaintance be forgot,

+

And never brought to mind?

+

Should auld acquaintance be forgot,

+

And auld lang syne?

+

For auld lang syne, my dear,

+

For auld lang syne,

+

We'll tak a cup o' kindness yet,

+

For auld lang syne.

+

And surely ye'll be your pint-stowp,

+

And surely I'll be mine,

+

And we'll tak a cup o' kindness yet,

+

For auld lang syne.

+

We twa hae run about the braes,

+

And pou'd the gowans fine,

+

But we've wander'd mony a weary fit,

+

Sin auld lang syne.

+

We twa hae paidl'd in the burn,

+

Frae morning sun till dine,

+

But seas between us braid hae roar'd

+

Sin auld lang syne.

+

And there's a hand, my trusty fiere,

+

And gie's a hand o' thine,

+

And we'll tak a right gude-willie waught,

+

For auld lang syne.

+
+ +
diff --git a/tests/fixtures/lyrics/auld-lang-syne.txt b/tests/fixtures/lyrics/auld-lang-syne.txt new file mode 100644 index 000000000..42ab8330e --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.txt @@ -0,0 +1,24 @@ +Should auld acquaintance be forgot, +And never brought to mind? +Should auld acquaintance be forgot, +And auld lang syne? +For auld lang syne, my dear, +For auld lang syne, +We'll tak a cup o' kindness yet, +For auld lang syne. +And surely ye'll be your pint-stowp, +And surely I'll be mine, +And we'll tak a cup o' kindness yet, +For auld lang syne. +We twa hae run about the braes, +And pou'd the gowans fine, +But we've wander'd mony a weary fit, +Sin auld lang syne. +We twa hae paidl'd in the burn, +Frae morning sun till dine, +But seas between us braid hae roar'd +Sin auld lang syne. +And there's a hand, my trusty fiere, +And gie's a hand o' thine, +And we'll tak a right gude-willie waught, +For auld lang syne. diff --git a/tests/fixtures/lyrics/auld-lang-syne.yaml b/tests/fixtures/lyrics/auld-lang-syne.yaml new file mode 100644 index 000000000..ca2a3d32e --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.yaml @@ -0,0 +1,95 @@ +version: '1.0' +metadata: + title: 'Auld Lang Syne' + artist: 'Robert Burns' + album: 'Traditional' + language: 'eng' +lines: + - text: "Should auld acquaintance be forgot," + start_ms: 0 + end_ms: 4500 + words: + - text: "Should " + start_ms: 0 + end_ms: 900 + - text: "auld " + start_ms: 900 + end_ms: 1800 + - text: "acquaintance " + start_ms: 1800 + end_ms: 2700 + - text: "be " + start_ms: 2700 + end_ms: 3600 + - text: "forgot," + start_ms: 3600 + end_ms: 4500 + - text: "And never brought to mind?" + start_ms: 4500 + end_ms: 9000 + - text: "Should auld acquaintance be forgot," + start_ms: 9000 + end_ms: 13500 + - text: "And auld lang syne?" + start_ms: 13500 + end_ms: 18000 + - text: "For auld lang syne, my dear," + start_ms: 18000 + end_ms: 22500 + - text: "For auld lang syne," + start_ms: 22500 + end_ms: 27000 + - text: "We'll tak a cup o' kindness yet," + start_ms: 27000 + end_ms: 31500 + - text: "For auld lang syne." + start_ms: 31500 + end_ms: 36000 + - text: "And surely ye'll be your pint-stowp," + start_ms: 36000 + end_ms: 40500 + - text: "And surely I'll be mine," + start_ms: 40500 + end_ms: 45000 + - text: "And we'll tak a cup o' kindness yet," + start_ms: 45000 + end_ms: 49500 + - text: "For auld lang syne." + start_ms: 49500 + end_ms: 54000 + - text: "We twa hae run about the braes," + start_ms: 54000 + end_ms: 58500 + - text: "And pou'd the gowans fine," + start_ms: 58500 + end_ms: 63000 + - text: "But we've wander'd mony a weary fit," + start_ms: 63000 + end_ms: 67500 + - text: "Sin auld lang syne." + start_ms: 67500 + end_ms: 72000 + - text: "We twa hae paidl'd in the burn," + start_ms: 72000 + end_ms: 76500 + - text: "Frae morning sun till dine," + start_ms: 76500 + end_ms: 81000 + - text: "But seas between us braid hae roar'd" + start_ms: 81000 + end_ms: 85500 + - text: "Sin auld lang syne." + start_ms: 85500 + end_ms: 90000 + - text: "And there's a hand, my trusty fiere," + start_ms: 90000 + end_ms: 94500 + - text: "And gie's a hand o' thine," + start_ms: 94500 + end_ms: 99000 + - text: "And we'll tak a right gude-willie waught," + start_ms: 99000 + end_ms: 103500 + - text: "For auld lang syne." + start_ms: 103500 + end_ms: 108000 diff --git a/tests/fixtures/symlink_chain/evil1.mp3 b/tests/fixtures/symlink_chain/evil1.mp3 new file mode 120000 index 000000000..79c5d6f02 --- /dev/null +++ b/tests/fixtures/symlink_chain/evil1.mp3 @@ -0,0 +1 @@ +../index.html \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/evil2.mp3 b/tests/fixtures/symlink_chain/evil2.mp3 new file mode 120000 index 000000000..56d18ad24 --- /dev/null +++ b/tests/fixtures/symlink_chain/evil2.mp3 @@ -0,0 +1 @@ +evil1.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/evil3.mp3 b/tests/fixtures/symlink_chain/evil3.mp3 new file mode 120000 index 000000000..e1cac02e9 --- /dev/null +++ b/tests/fixtures/symlink_chain/evil3.mp3 @@ -0,0 +1 @@ +evil2.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/level1.mp3 b/tests/fixtures/symlink_chain/level1.mp3 new file mode 120000 index 000000000..887033521 --- /dev/null +++ b/tests/fixtures/symlink_chain/level1.mp3 @@ -0,0 +1 @@ +../test.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/level2.mp3 b/tests/fixtures/symlink_chain/level2.mp3 new file mode 120000 index 000000000..eca2115ee --- /dev/null +++ b/tests/fixtures/symlink_chain/level2.mp3 @@ -0,0 +1 @@ +level1.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/level3.mp3 b/tests/fixtures/symlink_chain/level3.mp3 new file mode 120000 index 000000000..dd72f3cca --- /dev/null +++ b/tests/fixtures/symlink_chain/level3.mp3 @@ -0,0 +1 @@ +level2.mp3 \ No newline at end of file diff --git a/tests/fixtures/test-enhanced.lrc b/tests/fixtures/test-enhanced.lrc new file mode 100644 index 000000000..8f7b60f8c --- /dev/null +++ b/tests/fixtures/test-enhanced.lrc @@ -0,0 +1,6 @@ +[ar:Test Artist] +[ti:Enhanced Test] +[lang:eng] +[00:01.00]<00:01.00>Some <00:01.50>lyrics <00:02.00>here +[00:03.00]<00:03.00>More <00:03.50>words +[00:05.00]Plain line without inline markers diff --git a/tests/fixtures/test-instrumental.yaml b/tests/fixtures/test-instrumental.yaml new file mode 100644 index 000000000..84190a3b0 --- /dev/null +++ b/tests/fixtures/test-instrumental.yaml @@ -0,0 +1,6 @@ +version: '1.0' +metadata: + title: 'Solo Piano' + artist: 'Composer' + language: 'eng' + instrumental: true diff --git a/tests/fixtures/test-metadata.ttml b/tests/fixtures/test-metadata.ttml new file mode 100644 index 000000000..c0243c18f --- /dev/null +++ b/tests/fixtures/test-metadata.ttml @@ -0,0 +1,25 @@ + + + + + + + + Hola + + + + + konni + + + + + + +
+

こんにちは

+

こんばんは

+
+ +
diff --git a/tests/fixtures/test-overlapping.yaml b/tests/fixtures/test-overlapping.yaml new file mode 100644 index 000000000..c1f95a87b --- /dev/null +++ b/tests/fixtures/test-overlapping.yaml @@ -0,0 +1,24 @@ +version: '1.0' +metadata: + title: 'Duet' + artist: 'Lead and Echo' + language: 'eng' + +lines: + - text: "Lead vocal" + start_ms: 1000 + end_ms: 4000 + words: + - text: "Lead " + start_ms: 1000 + end_ms: 2000 + - text: "vocal" + start_ms: 2000 + end_ms: 4000 + - text: "echo" + start_ms: 2000 + end_ms: 3000 + words: + - text: "echo" + start_ms: 2000 + end_ms: 3000 diff --git a/tests/fixtures/test-words.yaml b/tests/fixtures/test-words.yaml new file mode 100644 index 000000000..625098d6a --- /dev/null +++ b/tests/fixtures/test-words.yaml @@ -0,0 +1,17 @@ +version: '1.0' +metadata: + title: 'Karaoke Test' + artist: 'Test Artist' + language: 'eng' + +lines: + - text: "Hello world" + start_ms: 1000 + end_ms: 3000 + words: + - text: "Hello " + start_ms: 1000 + end_ms: 1500 + - text: "world" + start_ms: 1500 + end_ms: 3000 diff --git a/tests/fixtures/test.elrc b/tests/fixtures/test.elrc new file mode 100644 index 000000000..01c3d2cdd --- /dev/null +++ b/tests/fixtures/test.elrc @@ -0,0 +1,5 @@ +[ar:ELRC Artist] +[ti:ELRC Song] +[lang:eng] +[00:01.00]<00:01.00>Lead <00:01.50>words +[00:03.00]Fallback line diff --git a/tests/fixtures/test.srt b/tests/fixtures/test.srt new file mode 100644 index 000000000..3c9c09a39 --- /dev/null +++ b/tests/fixtures/test.srt @@ -0,0 +1,7 @@ +1 +00:00:18,800 --> 00:00:22,800 +We're from subtitles + +2 +00:00:22,801 --> 00:00:26,000 +Another subtitle line diff --git a/tests/fixtures/test.ttml b/tests/fixtures/test.ttml new file mode 100644 index 000000000..a85673a1b --- /dev/null +++ b/tests/fixtures/test.ttml @@ -0,0 +1,12 @@ + + + +
+

We're no strangers to love

+

You know the rules and so do I

+
+
+

Nao somos estranhos ao amor

+
+ +
diff --git a/tests/fixtures/test.yaml b/tests/fixtures/test.yaml new file mode 100644 index 000000000..bc5022b75 --- /dev/null +++ b/tests/fixtures/test.yaml @@ -0,0 +1,12 @@ +version: '1.0' +metadata: + title: 'Sample Track' + artist: 'Test Artist' + language: 'eng' + offset_ms: -100 + +lines: + - text: "We're no strangers to love" + start_ms: 18800 + - text: "You know the rules and so do I" + start_ms: 22801 diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 01eacae30..f15ba1bc6 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -98,6 +98,17 @@ func (m *MockMediaFileRepo) GetAll(qo ...model.QueryOptions) (model.MediaFiles, return result, nil } +func (m *MockMediaFileRepo) GetRandom(qo ...model.QueryOptions) (model.MediaFiles, error) { + res, err := m.GetAll(qo...) + if err != nil { + return nil, err + } + if len(qo) > 0 && qo[0].Max > 0 && len(res) > qo[0].Max { + res = res[:qo[0].Max] + } + return res, nil +} + func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error { if m.Err { return errors.New("error") diff --git a/tests/mock_user_repo.go b/tests/mock_user_repo.go index 1c8e26e61..112435eb3 100644 --- a/tests/mock_user_repo.go +++ b/tests/mock_user_repo.go @@ -60,6 +60,18 @@ func (u *MockedUserRepo) FindByUsernameWithPassword(username string) (*model.Use return u.FindByUsername(username) } +func (u *MockedUserRepo) FindFirstAdmin() (*model.User, error) { + if u.Error != nil { + return nil, u.Error + } + for _, usr := range u.Data { + if usr.IsAdmin { + return usr, nil + } + } + return nil, model.ErrNotFound +} + func (u *MockedUserRepo) Get(id string) (*model.User, error) { if u.Error != nil { return nil, u.Error diff --git a/ui/embed.go b/ui/embed.go index 3e2c413b3..2d5fcd979 100644 --- a/ui/embed.go +++ b/ui/embed.go @@ -5,7 +5,7 @@ import ( "io/fs" ) -//go:embed build/* +//go:embed all:build var filesystem embed.FS func BuildAssets() fs.FS { diff --git a/utils/gg/gg.go b/utils/gg/gg.go index 674cacf20..837f56339 100644 --- a/utils/gg/gg.go +++ b/utils/gg/gg.go @@ -16,3 +16,13 @@ func If[T any](cond bool, v1, v2 T) T { } return v2 } + +// Clone returns a pointer to a fresh copy of *p, or nil if p is nil. Use it to +// avoid aliasing the pointed-to value when a separate *T is needed. +func Clone[T any](p *T) *T { + if p == nil { + return nil + } + v := *p + return &v +} diff --git a/utils/gg/gg_test.go b/utils/gg/gg_test.go index a2dd8154f..bb6fae867 100644 --- a/utils/gg/gg_test.go +++ b/utils/gg/gg_test.go @@ -46,4 +46,25 @@ var _ = Describe("GG", func() { Expect(gg.If(false, 1.1, 2.2)).To(Equal(2.2)) }) }) + + Describe("Clone", func() { + It("returns a pointer to a copy of the value", func() { + original := 123 + cloned := gg.Clone(&original) + Expect(cloned).To(HaveValue(Equal(123))) + Expect(cloned).NotTo(BeIdenticalTo(&original)) + }) + + It("does not alias the original value", func() { + original := 123 + cloned := gg.Clone(&original) + original = 456 + Expect(*cloned).To(Equal(123)) + }) + + It("returns nil when the input is nil", func() { + var v *int + Expect(gg.Clone(v)).To(BeNil()) + }) + }) }) diff --git a/utils/slice/slice.go b/utils/slice/slice.go index e87ac5388..73537c8f8 100644 --- a/utils/slice/slice.go +++ b/utils/slice/slice.go @@ -42,6 +42,16 @@ func ToMap[T any, K comparable, V any](s []T, transformFunc func(T) (K, V)) map[ return m } +// ToSet builds a set (a map keyed by the slice's elements) for O(1) membership tests. Duplicate +// elements collapse to a single key. +func ToSet[T comparable](s []T) map[T]struct{} { + m := make(map[T]struct{}, len(s)) + for _, item := range s { + m[item] = struct{}{} + } + return m +} + func CompactByFrequency[T comparable](list []T) []T { counters := make(map[T]int) for _, item := range list { diff --git a/utils/slice/slice_test.go b/utils/slice/slice_test.go index 64cb89d53..27548d693 100644 --- a/utils/slice/slice_test.go +++ b/utils/slice/slice_test.go @@ -81,6 +81,20 @@ var _ = Describe("Slice Utils", func() { }) }) + Describe("ToSet", func() { + It("returns empty set for an empty input", func() { + Expect(slice.ToSet([]int{})).To(BeEmpty()) + }) + + It("builds a set with one key per distinct element", func() { + result := slice.ToSet([]int{1, 2, 2, 3, 3, 3}) + Expect(result).To(HaveLen(3)) + Expect(result).To(HaveKey(1)) + Expect(result).To(HaveKey(2)) + Expect(result).To(HaveKey(3)) + }) + }) + Describe("CompactByFrequency", func() { It("returns empty slice for an empty input", func() { Expect(slice.CompactByFrequency([]int{})).To(BeEmpty())