Compare commits

...

18 Commits

Author SHA1 Message Date
Kartik Ohri
63c125db99
Merge aecf959d98e968ad5c3c036b72c8a6d0241b4bd3 into 4f7dc105b0414cd202fc7e560d51b8abc27ad7ef 2025-11-06 18:19:15 -05:00
Deluan Quintão
4f7dc105b0
fix(ui): correct track ordering when sorting playlists by album (#4657)
* fix(deps): update wazero dependencies to resolve issues

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(deps): update wazero dependency to latest version

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: correct track ordering when sorting playlists by album

Fixed issue #3177 where tracks within multi-disc albums were displayed out of order when sorting playlists by album. The playlist track repository was using an incomplete sort mapping that only sorted by album name and artist, missing the critical disc_number and track_number fields.

Changed the album sort mapping in playlist_track_repository from:
  order_album_name, order_album_artist_name
to:
  order_album_name, order_album_artist_name, disc_number, track_number, order_artist_name, title

This now matches the sorting used in the media file repository, ensuring tracks are sorted by:
1. Album name (groups by album)
2. Album artist (handles compilations)
3. Disc number (multi-disc album discs in order)
4. Track number (tracks within disc in order)
5. Artist name and title (edge cases with missing metadata)

Added comprehensive tests with a multi-disc test album to verify correct sorting behavior.

* chore: sync go.mod and go.sum with master

* chore: align playlist album sort order with mediafile_repository (use album_id)

* fix: clean up test playlist to prevent state leakage in randomized test runs

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-06 16:50:54 -05:00
Deluan Quintão
e918e049e2
fix: update wazero dependency to resolve ARM64 SIGILL crash (#4655)
* fix(deps): update wazero dependencies to resolve issues

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(deps): update wazero dependency to latest version

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(deps): update wazero dependency to latest version for issue resolution

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-06 15:07:09 -05:00
Deluan Quintão
1e8d28ff46
fix: qualify user id filter to avoid ambiguous column (#4511) 2025-11-06 14:54:01 -05:00
Kendall Garner
a128b3cf98
fix(db): make playqueue position field an integer (#4481) 2025-11-06 14:41:09 -05:00
Deluan Quintão
290a9fdeaa
test: fix locale-dependent tests by making formatNumber locale-aware (#4619)
- Add optional locale parameter to formatNumber function
- Update tests to explicitly pass 'en-US' locale for deterministic results
- Maintains backward compatibility: defaults to system locale when no locale specified
- No need for cross-env or environment variable manipulation
- Tests now pass consistently regardless of system locale

Related to #4417
2025-11-06 14:34:00 -05:00
Deluan
58b5ed86df refactor: extract TruncateRunes function for safe string truncation with suffix
Signed-off-by: Deluan <deluan@navidrome.org>

# Conflicts:
#	core/share.go
#	core/share_test.go
2025-11-06 14:27:38 -05:00
beerpsi
fe1cee0159
fix(share): slice content label by utf-8 runes (#4634)
* fix(share): slice content label by utf-8 runes

* Apply suggestions about avoiding allocations

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* lint: remove unused import

* test: add test cases for CJK truncation

* test: add tests for ASCII labels too

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-11-06 14:24:07 -05:00
Deluan
3dfaa8cca1 ci: go mod tidy
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-06 12:53:41 -05:00
Deluan
0a5abfc1b1 chore: update actions/upload-artifact and actions/download-artifact to latest versions
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-06 12:43:35 -05:00
Deluan
c501bc6996 chore(deps): update ginkgo to version 2.27.2
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-06 12:41:16 -05:00
Deluan
0c71842b12 chore: update Go version to 1.25.4
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-06 12:40:44 -05:00
Kartik Ohri
aecf959d98 Move api key to player model 2025-08-14 02:23:48 +05:30
Kartik Ohri
d5b47383ae Associate API keys with players instead of users
# Conflicts:
#	tests/mock_user_repo.go
2025-08-14 02:19:01 +05:30
Kartik Ohri
c655a91a5d Update tests/mock_user_repo.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-14 02:18:14 +05:30
Kartik Ohri
5756ec89ea Add the toggle visibility button and copy to the clipboard button for API keys 2025-08-14 02:18:14 +05:30
Kartik Ohri
b139f87c7e Apply suggestions from code review
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-14 02:18:14 +05:30
Kartik Ohri
362a8826b9 Implement OpenSubsonic's API Key authentication
Ref: https://opensubsonic.netlify.app/docs/extensions/apikeyauth/.

Add functionality for users to create, manage, and use API keys for authentication.

*   **Database:**
    *   Added a new `api_key`(`20250415111500_add_api_key_table.go`) table to store API keys associated with users.
*   **Backend:**
    *   Defined the `APIKey` model.
    *   Implemented `APIKeyRepository` for database operations.
    *   Added `FindByAPIKey` method to `UserRepository` to allow user lookup via API key.
    *   Updated Subsonic API error responses to include API key-related errors and messaging.
    *   Integrated API key authentication into the subsonic authentication middleware.
*   **UI:**
    *   Added new React components for API Key CRUD operations: `ApiKeyList`, `ApiKeyCreate`, and `ApiKeyEdit`.
    *   The API Key management section can be accessed from the settings menu.

# Conflicts:
#	model/user.go
#	server/subsonic/middlewares.go
#	server/subsonic/middlewares_test.go
#	tests/mock_data_store.go
#	tests/mock_user_repo.go
2025-08-14 02:18:11 +05:30
38 changed files with 1206 additions and 143 deletions

View File

@ -217,7 +217,7 @@ jobs:
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
- name: Upload Binaries
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: navidrome-${{ env.PLATFORM }}
path: ./output
@ -248,7 +248,7 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
with:
name: digests-${{ env.PLATFORM }}
@ -267,7 +267,7 @@ jobs:
- uses: actions/checkout@v5
- name: Download digests
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
path: /tmp/digests
pattern: digests-*
@ -320,7 +320,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- uses: actions/download-artifact@v5
- uses: actions/download-artifact@v6
with:
path: ./binaries
pattern: navidrome-windows*
@ -339,7 +339,7 @@ jobs:
du -h binaries/msi/*.msi
- name: Upload MSI files
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: navidrome-windows-installers
path: binaries/msi/*.msi
@ -357,7 +357,7 @@ jobs:
fetch-depth: 0
fetch-tags: true
- uses: actions/download-artifact@v5
- uses: actions/download-artifact@v6
with:
path: ./binaries
pattern: navidrome-*
@ -383,7 +383,7 @@ jobs:
rm ./dist/*.tar.gz ./dist/*.zip
- name: Upload all-packages artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: packages
path: dist/navidrome_0*
@ -406,13 +406,13 @@ jobs:
item: ${{ fromJson(needs.release.outputs.package_list) }}
steps:
- name: Download all-packages artifact
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: packages
path: ./dist
- name: Upload all-packages artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: navidrome_linux_${{ matrix.item }}
path: dist/navidrome_0*_linux_${{ matrix.item }}

View File

@ -13,6 +13,7 @@ import (
"github.com/navidrome/navidrome/model"
. "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/utils/str"
)
type Share interface {
@ -119,9 +120,8 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
log.Error(r.ctx, "Invalid Resource ID", "id", firstId)
return "", model.ErrNotFound
}
if len(s.Contents) > 30 {
s.Contents = s.Contents[:26] + "..."
}
s.Contents = str.TruncateRunes(s.Contents, 30, "...")
id, err = r.Persistable.Save(s)
return id, err

View File

@ -38,6 +38,38 @@ var _ = Describe("Share", func() {
Expect(id).ToNot(BeEmpty())
Expect(entity.ID).To(Equal(id))
})
It("does not truncate ASCII labels shorter than 30 characters", func() {
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "Example Media File"})
entity := &model.Share{Description: "test", ResourceIDs: "456"}
_, err := repo.Save(entity)
Expect(err).ToNot(HaveOccurred())
Expect(entity.Contents).To(Equal("Example Media File"))
})
It("truncates ASCII labels longer than 30 characters", func() {
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "Example Media File But The Title Is Really Long For Testing Purposes"})
entity := &model.Share{Description: "test", ResourceIDs: "789"}
_, err := repo.Save(entity)
Expect(err).ToNot(HaveOccurred())
Expect(entity.Contents).To(Equal("Example Media File But The ..."))
})
It("does not truncate CJK labels shorter than 30 runes", func() {
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "青春コンプレックス"})
entity := &model.Share{Description: "test", ResourceIDs: "456"}
_, err := repo.Save(entity)
Expect(err).ToNot(HaveOccurred())
Expect(entity.Contents).To(Equal("青春コンプレックス"))
})
It("truncates CJK labels longer than 30 runes", func() {
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "私の中の幻想的世界観及びその顕現を想起させたある現実での出来事に関する一考察"})
entity := &model.Share{Description: "test", ResourceIDs: "789"}
_, err := repo.Save(entity)
Expect(err).ToNot(HaveOccurred())
Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実で..."))
})
})
Describe("Update", func() {

View File

@ -0,0 +1,34 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddAPIKeyToPlayer, downDropAPIKeyFromPlayer)
}
func upAddAPIKeyToPlayer(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
-- Add nullable api_key column to player table
ALTER TABLE player ADD COLUMN api_key VARCHAR(255);
-- Add index on api_key for faster lookups
CREATE INDEX IF NOT EXISTS player_api_key ON player(api_key);
`)
return err
}
func downDropAPIKeyFromPlayer(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
-- Drop the index first
DROP INDEX IF EXISTS player_api_key;
-- Then drop the column
ALTER TABLE player DROP COLUMN api_key;
`)
return err
}

View File

@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE playqueue ADD COLUMN position_int integer;
UPDATE playqueue SET position_int = CAST(position as INTEGER) ;
ALTER TABLE playqueue DROP COLUMN position;
ALTER TABLE playqueue RENAME COLUMN position_int TO position;
-- +goose StatementEnd
-- +goose Down

13
go.mod
View File

@ -1,9 +1,13 @@
module github.com/navidrome/navidrome
go 1.25.3
go 1.25.4
// Fork to fix https://github.com/navidrome/navidrome/pull/3254
replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
replace (
// Fork to fix https://github.com/navidrome/navidrome/issues/3254
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
// Using version from main that fixes https://github.com/navidrome/navidrome/issues/4396
github.com/tetratelabs/wazero v1.9.0 => github.com/tetratelabs/wazero v0.0.0-20251106165119-514cdb337684
)
require (
github.com/Masterminds/squirrel v1.5.4
@ -43,7 +47,7 @@ require (
github.com/mattn/go-sqlite3 v1.14.32
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
github.com/onsi/ginkgo/v2 v2.27.1
github.com/onsi/ginkgo/v2 v2.27.2
github.com/onsi/gomega v1.38.2
github.com/pelletier/go-toml/v2 v2.2.4
github.com/pocketbase/dbx v1.11.0
@ -124,7 +128,6 @@ require (
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect

12
go.sum
View File

@ -186,8 +186,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
github.com/onsi/ginkgo/v2 v2.27.1 h1:0LJC8MpUSQnfnp4n/3W3GdlmJP3ENGF0ZPzjQGLPP7s=
github.com/onsi/ginkgo/v2 v2.27.1/go.mod h1:wmy3vCqiBjirARfVhAqFpYt8uvX0yaFe+GudAqqcCqA=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
@ -201,8 +201,6 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
@ -267,8 +265,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
github.com/tetratelabs/wazero v0.0.0-20251106165119-514cdb337684 h1:ugT1JTRsK1Jhn95BWilCugyZ1Svsyxm9xSiflOa2e7E=
github.com/tetratelabs/wazero v0.0.0-20251106165119-514cdb337684/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@ -286,8 +284,6 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=

View File

@ -18,6 +18,7 @@ type Player struct {
MaxBitRate int `structs:"max_bit_rate" json:"maxBitRate"`
ReportRealPath bool `structs:"report_real_path" json:"reportRealPath"`
ScrobbleEnabled bool `structs:"scrobble_enabled" json:"scrobbleEnabled"`
APIKey string `structs:"api_key" json:"apiKey" db:"api_key"`
}
type Players []Player
@ -28,4 +29,6 @@ type PlayerRepository interface {
Put(p *Player) error
CountAll(...QueryOptions) (int64, error)
CountByClient(...QueryOptions) (map[string]int64, error)
FindByAPIKey(key string) (*Player, error)
GenerateAPIKey(playerId string) (string, error)
}

View File

@ -55,6 +55,7 @@ var _ = Describe("AlbumRepository", func() {
It("returns all records sorted", func() {
Expect(GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{
albumAbbeyRoad,
albumMultiDisc,
albumRadioactivity,
albumSgtPeppers,
}))
@ -64,6 +65,7 @@ var _ = Describe("AlbumRepository", func() {
Expect(GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{
albumSgtPeppers,
albumRadioactivity,
albumMultiDisc,
albumAbbeyRoad,
}))
})

View File

@ -38,7 +38,7 @@ var _ = Describe("MediaRepository", func() {
})
It("counts the number of mediafiles in the DB", func() {
Expect(mr.CountAll()).To(Equal(int64(6)))
Expect(mr.CountAll()).To(Equal(int64(10)))
})
It("returns songs ordered by lyrics with a specific title/artist", func() {

View File

@ -69,10 +69,12 @@ var (
albumSgtPeppers = al(model.Album{ID: "101", Name: "Sgt Peppers", AlbumArtist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967})
albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969})
albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("/kraft/radio/radio.mp3"), SongCount: 2})
albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("/test/multi/disc1/track1.mp3"), SongCount: 4})
testAlbums = model.Albums{
albumSgtPeppers,
albumAbbeyRoad,
albumRadioactivity,
albumMultiDisc,
}
)
@ -94,13 +96,22 @@ var (
Lyrics: `[{"lang":"xxx","line":[{"value":"This is a set of lyrics"}],"synced":false}]`,
})
songAntenna2 = mf(model.MediaFile{ID: "1006", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103"})
testSongs = model.MediaFiles{
// Multi-disc album tracks (intentionally out of order to test sorting)
songDisc2Track11 = mf(model.MediaFile{ID: "2001", Title: "Disc 2 Track 11", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 11, Path: p("/test/multi/disc2/track11.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("/test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("/test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("/test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
testSongs = model.MediaFiles{
songDayInALife,
songComeTogether,
songRadioactivity,
songAntenna,
songAntennaWithLyrics,
songAntenna2,
songDisc2Track11,
songDisc1Track01,
songDisc2Track01,
songDisc1Track02,
}
)

View File

@ -7,6 +7,7 @@ import (
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/pocketbase/dbx"
)
@ -57,6 +58,19 @@ func (r *playerRepository) FindMatch(userId, client, userAgent string) (*model.P
return &res, err
}
func (r *playerRepository) FindByAPIKey(key string) (*model.Player, error) {
if key == "" {
return nil, model.ErrNotFound
}
sel := r.selectPlayer().Where(Eq{"api_key": key})
var res model.Player
err := r.queryOne(sel, &res)
if err != nil {
return nil, err
}
return &res, nil
}
func (r *playerRepository) newRestSelect(options ...model.QueryOptions) SelectBuilder {
s := r.selectPlayer(options...)
return s.Where(r.addRestriction())
@ -130,25 +144,37 @@ func (r *playerRepository) isPermitted(p *model.Player) bool {
return u.IsAdmin || p.UserId == u.ID
}
func (r *playerRepository) preparePlayerForSave(p *model.Player) *model.Player {
playerCopy := *p
if playerCopy.UserId == "" {
user := loggedUser(r.ctx)
playerCopy.UserId = user.ID
}
playerCopy.APIKey = ""
return &playerCopy
}
func (r *playerRepository) Save(entity interface{}) (string, error) {
t := entity.(*model.Player)
if !r.isPermitted(t) {
playerToSave := r.preparePlayerForSave(t)
if !r.isPermitted(playerToSave) {
return "", rest.ErrPermissionDenied
}
id, err := r.put(t.ID, t)
playerId, err := r.put(playerToSave.ID, playerToSave)
if errors.Is(err, model.ErrNotFound) {
return "", rest.ErrNotFound
}
return id, err
return playerId, err
}
func (r *playerRepository) Update(id string, entity interface{}, cols ...string) error {
t := entity.(*model.Player)
t.ID = id
if !r.isPermitted(t) {
playerToUpdate := r.preparePlayerForSave(t)
if !r.isPermitted(playerToUpdate) {
return rest.ErrPermissionDenied
}
_, err := r.put(id, t, cols...)
_, err := r.put(id, playerToUpdate, cols...)
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound
}
@ -164,6 +190,33 @@ func (r *playerRepository) Delete(id string) error {
return err
}
// GenerateAPIKey generates a new API key for the specified player
func (r *playerRepository) GenerateAPIKey(playerID string) (string, error) {
player, err := r.Get(playerID)
if errors.Is(err, model.ErrNotFound) {
return "", rest.ErrNotFound
}
if err != nil {
return "", err
}
if !r.isPermitted(player) {
return "", rest.ErrPermissionDenied
}
player.APIKey = generateAPIKey()
err = r.Put(player)
if err != nil {
return "", err
}
return player.APIKey, nil
}
// generateAPIKey creates a new random API key
func generateAPIKey() string {
return "nav_" + id.NewRandom()
}
var _ model.PlayerRepository = (*playerRepository)(nil)
var _ rest.Repository = (*playerRepository)(nil)
var _ rest.Persistable = (*playerRepository)(nil)

View File

@ -13,8 +13,12 @@ import (
)
var _ = Describe("PlayerRepository", func() {
var adminRepo *playerRepository
var database *dbx.DB
var (
adminRepo *playerRepository
database *dbx.DB
adminCtx context.Context
regularCtx context.Context
)
var (
adminPlayer1 = model.Player{ID: "1", Name: "NavidromeUI [Firefox/Linux]", UserAgent: "Firefox/Linux", UserId: adminUser.ID, Username: adminUser.UserName, Client: "NavidromeUI", IP: "127.0.0.1", ReportRealPath: true, ScrobbleEnabled: true}
@ -25,11 +29,14 @@ var _ = Describe("PlayerRepository", func() {
)
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, adminUser)
adminCtx = log.NewContext(context.TODO())
adminCtx = request.WithUser(adminCtx, adminUser)
regularCtx = log.NewContext(context.TODO())
regularCtx = request.WithUser(regularCtx, regularUser)
database = GetDBXBuilder()
adminRepo = NewPlayerRepository(ctx, database).(*playerRepository)
adminRepo = NewPlayerRepository(adminCtx, database).(*playerRepository)
for idx := range players {
err := adminRepo.Put(&players[idx])
@ -86,6 +93,28 @@ var _ = Describe("PlayerRepository", func() {
})
})
Describe("Save", func() {
var repo *playerRepository
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, regularUser)
repo = NewPlayerRepository(ctx, database).(*playerRepository)
})
It("fails if different user id is set", func() {
newPlayer := model.Player{
Name: "Test Player",
Client: "TestClient",
UserAgent: "TestAgent",
UserId: adminUser.ID,
}
id, err := repo.Save(&newPlayer)
Expect(err).To(Equal(rest.ErrPermissionDenied))
_, err = repo.Get(id)
Expect(err).To(Equal(rest.ErrNotFound))
})
})
DescribeTableSubtree("per context", func(admin bool, players model.Players, userPlayer model.Player, otherPlayer model.Player) {
var repo *playerRepository
@ -171,41 +200,37 @@ var _ = Describe("PlayerRepository", func() {
})
Describe("Save", func() {
DescribeTable("item type", func(player model.Player) {
clone := player
clone.ID = ""
clone.IP = "192.168.1.1"
id, err := repo.Save(&clone)
if clone.UserId == "" {
Expect(err).To(HaveOccurred())
} else if !admin && player.Username == adminPlayer1.Username {
Expect(err).To(Equal(rest.ErrPermissionDenied))
clone.UserId = ""
} else {
Expect(err).To(BeNil())
Expect(id).ToNot(BeEmpty())
It("excludes API key from being saved", func() {
newPlayer := model.Player{
Name: "Test Player",
Client: "TestClient",
UserAgent: "TestAgent",
APIKey: "should-not-be-saved",
}
count, err := repo.Count()
id, err := repo.Save(&newPlayer)
Expect(err).To(BeNil())
clone.ID = id
newItem, err := repo.Get(id)
savedPlayer, err := repo.Get(id)
Expect(err).To(BeNil())
Expect(savedPlayer.APIKey).To(BeEmpty())
})
if clone.UserId == "" {
Expect(count).To(Equal(baseCount))
Expect(err).To(Equal(model.ErrNotFound))
} else {
Expect(count).To(Equal(baseCount + 1))
Expect(err).To(BeNil())
Expect(*newItem).To(Equal(clone))
It("sets user ID from context", func() {
repo := NewPlayerRepository(regularCtx, database).(*playerRepository)
newPlayer := model.Player{
Name: "Test Player",
Client: "TestClient",
UserAgent: "TestAgent",
}
},
Entry("same user", userPlayer),
Entry("other item", otherPlayer),
Entry("fake item", model.Player{}),
)
id, err := repo.Save(&newPlayer)
Expect(err).To(BeNil())
savedPlayer, err := repo.Get(id)
Expect(err).To(BeNil())
Expect(savedPlayer.UserId).To(Equal(regularUser.ID))
})
})
Describe("Update", func() {
@ -215,9 +240,7 @@ var _ = Describe("PlayerRepository", func() {
clone.MaxBitRate = 10000
err := repo.Update(clone.ID, &clone, "ip")
if clone.UserId == "" {
Expect(err).To(HaveOccurred())
} else if !admin && player.Username == adminPlayer1.Username {
if !admin && player.Username == adminPlayer1.Username {
Expect(err).To(Equal(rest.ErrPermissionDenied))
clone.IP = player.IP
} else {

View File

@ -219,4 +219,37 @@ var _ = Describe("PlaylistRepository", func() {
})
})
})
Describe("Playlist Track Sorting", func() {
var testPlaylistID string
AfterEach(func() {
if testPlaylistID != "" {
Expect(repo.Delete(testPlaylistID)).To(BeNil())
testPlaylistID = ""
}
})
It("sorts tracks correctly by album (disc and track number)", func() {
By("creating a playlist with multi-disc album tracks in arbitrary order")
newPls := model.Playlist{Name: "Multi-Disc Test", OwnerID: "userid"}
// Add tracks in intentionally scrambled order
newPls.AddMediaFilesByID([]string{"2001", "2002", "2003", "2004"})
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("retrieving tracks sorted by album")
tracksRepo := repo.Tracks(newPls.ID, false)
tracks, err := tracksRepo.GetAll(model.QueryOptions{Sort: "album", Order: "asc"})
Expect(err).ToNot(HaveOccurred())
By("verifying tracks are sorted by disc number then track number")
Expect(tracks).To(HaveLen(4))
// Expected order: Disc 1 Track 1, Disc 1 Track 2, Disc 2 Track 1, Disc 2 Track 11
Expect(tracks[0].MediaFileID).To(Equal("2002")) // Disc 1, Track 1
Expect(tracks[1].MediaFileID).To(Equal("2004")) // Disc 1, Track 2
Expect(tracks[2].MediaFileID).To(Equal("2003")) // Disc 2, Track 1
Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11
})
})
})

View File

@ -55,7 +55,7 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
"id": "playlist_tracks.id",
"artist": "order_artist_name",
"album_artist": "order_album_artist_name",
"album": "order_album_name, order_album_artist_name",
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title",
"title": "order_title",
// To make sure these fields will be whitelisted
"duration": "duration",

View File

@ -57,6 +57,7 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository
r.db = db
r.tableName = "user"
r.registerModel(&model.User{}, map[string]filterFunc{
"id": idFilter(r.tableName),
"password": invalidFilter(ctx),
"name": r.withTableName(startsWithFilter),
})

View File

@ -559,4 +559,15 @@ var _ = Describe("UserRepository", func() {
Expect(user.Libraries[0].ID).To(Equal(1))
})
})
Describe("filters", func() {
It("qualifies id filter with table name", func() {
r := repo.(*userRepository)
qo := r.parseRestOptions(r.ctx, rest.QueryOptions{Filters: map[string]any{"id": "123"}})
sel := r.selectUserWithLibraries(qo)
query, _, err := r.toSQL(sel)
Expect(err).NotTo(HaveOccurred())
Expect(query).To(ContainSubstring("user.id = {:p0}"))
})
})
})

View File

@ -3,6 +3,7 @@ package nativeapi
import (
"context"
"encoding/json"
"errors"
"html"
"net/http"
"strconv"
@ -51,7 +52,7 @@ func (n *Router) routes() http.Handler {
n.R(r, "/album", model.Album{}, false)
n.R(r, "/artist", model.Artist{}, false)
n.R(r, "/genre", model.Genre{}, false)
n.R(r, "/player", model.Player{}, true)
n.addPlayerRoute(r)
n.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
n.R(r, "/radio", model.Radio{}, true)
n.R(r, "/tag", model.Tag{}, true)
@ -102,6 +103,25 @@ func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.Repository
})
}
func (n *Router) addPlayerRoute(r chi.Router) {
constructor := func(ctx context.Context) rest.Repository {
return n.ds.Resource(ctx, model.Player{})
}
r.Route("/player", func(r chi.Router) {
r.Get("/", rest.GetAll(constructor))
r.Post("/", rest.Post(constructor))
r.Route("/{id}", func(r chi.Router) {
r.Use(server.URLParamsMiddleware)
r.Get("/", rest.Get(constructor))
r.Put("/", rest.Put(constructor))
r.Delete("/", rest.Delete(constructor))
r.Post("/apiKey", generatePlayerApiKey(n.ds))
})
})
}
func (n *Router) addPlaylistRoute(r chi.Router) {
constructor := func(ctx context.Context) rest.Repository {
return n.ds.Resource(ctx, model.Playlist{})
@ -198,6 +218,28 @@ func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []strin
}
}
func generatePlayerApiKey(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
playerId := chi.URLParam(r, "id")
err := ds.WithTxImmediate(func(tx model.DataStore) error {
repo := tx.Player(ctx)
apiKey, err := repo.GenerateAPIKey(playerId)
if err == nil {
resp := []byte(`{"apiKey":"` + html.EscapeString(apiKey) + `"}`)
_, err = w.Write(resp)
}
return err
})
if errors.Is(err, model.ErrNotFound) {
http.Error(w, "not found", http.StatusNotFound)
} else if err != nil {
log.Error(ctx, "Error retrieving player from DB", "id", playerId, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func (n *Router) addInspectRoute(r chi.Router) {
if conf.Server.Inspect.Enabled {
r.Group(func(r chi.Router) {

View File

@ -63,14 +63,16 @@ func checkRequiredParameters(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var requiredParameters []string
p := req.Params(r)
username, _ := fromInternalOrProxyAuth(r)
if username != "" {
requiredParameters = []string{"v", "c"}
} else if apiKey, _ := p.String("apiKey"); apiKey != "" {
requiredParameters = []string{"v", "c"}
} else {
requiredParameters = []string{"u", "v", "c"}
}
p := req.Params(r)
for _, param := range requiredParameters {
if _, err := p.String(param); err != nil {
log.Warn(r, err)
@ -123,21 +125,69 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
token, _ := p.String("t")
salt, _ := p.String("s")
jwt, _ := p.String("jwt")
apiKey, _ := p.String("apiKey")
usr, err = ds.User(ctx).FindByUsernameWithPassword(username)
if errors.Is(err, context.Canceled) {
log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
// When an API key is provided, username should not be provided
if apiKey != "" && username != "" {
log.Warn(ctx, "API: Invalid login - username provided with API key", "auth", "subsonic", "remoteAddr", r.RemoteAddr)
sendError(w, r, newError(responses.ErrorMultipleAuthMechanismsProvided))
return
}
switch {
case errors.Is(err, model.ErrNotFound):
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
case err != nil:
log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
default:
err = validateCredentials(usr, pass, token, salt, jwt)
if err != nil {
// Check for conflicting authentication mechanisms
authMechanismsCount := 0
if apiKey != "" {
authMechanismsCount++
}
if pass != "" {
authMechanismsCount++
}
if token != "" && salt != "" {
authMechanismsCount++
}
if jwt != "" {
authMechanismsCount++
}
if authMechanismsCount > 1 {
log.Warn(ctx, "API: Invalid login - multiple authentication mechanisms", "auth", "subsonic", "remoteAddr", r.RemoteAddr)
sendError(w, r, newError(responses.ErrorMultipleAuthMechanismsProvided))
return
}
if apiKey != "" {
var player *model.Player
player, err = ds.Player(ctx).FindByAPIKey(apiKey)
if errors.Is(err, context.Canceled) {
log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "remoteAddr", r.RemoteAddr, err)
return
}
switch {
case errors.Is(err, model.ErrNotFound):
log.Warn(ctx, "API: Invalid login - API key not found", "auth", "subsonic", "apikey", apiKey, "remoteAddr", r.RemoteAddr, err)
case err != nil:
log.Error(ctx, "API: Error authenticating with API key", "auth", "subsonic", "apikey", apiKey, "remoteAddr", r.RemoteAddr, err)
default:
usr, err = ds.User(ctx).Get(player.UserId)
if err != nil {
log.Error(ctx, "API: Error retrieving user from API key", "auth", "subsonic", "apikey", apiKey, "remoteAddr", r.RemoteAddr, err)
}
}
} else {
usr, err = ds.User(ctx).FindByUsernameWithPassword(username)
if errors.Is(err, context.Canceled) {
log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
return
}
switch {
case errors.Is(err, model.ErrNotFound):
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
case err != nil:
log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
default:
err = validateCredentials(usr, pass, token, salt, jwt)
if err != nil {
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
}
}
}
}

View File

@ -306,6 +306,74 @@ var _ = Describe("Middlewares", func() {
Expect(next.called).To(BeFalse())
})
})
When("using api key authentication", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ur := ds.User(context.TODO())
user := &model.User{
UserName: "user-api",
NewPassword: "wordpass-api",
}
_ = ur.Put(user)
pr := ds.Player(context.TODO())
player := &model.Player{
ID: "player1",
Name: "Test Player",
UserAgent: "Test/1.0",
UserId: user.ID,
Client: "test-client",
IP: "127.0.0.1",
LastSeen: time.Now(),
TranscodingId: "",
MaxBitRate: 320,
ReportRealPath: false,
ScrobbleEnabled: true,
APIKey: "nav_12345",
}
_ = pr.Put(player)
})
It("passes authentication with correct api key", func() {
r := newGetRequest("apiKey=nav_12345")
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(next.called).To(BeTrue())
user, _ := request.UserFrom(next.req.Context())
Expect(user.UserName).To(Equal("user-api"))
})
It("fails authentication with invalid api key", func() {
r := newGetRequest("apiKey=invalid-api-key")
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
Expect(next.called).To(BeFalse())
})
It("fails authentication with empty api key", func() {
r := newGetRequest("apiKey=")
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
Expect(next.called).To(BeFalse())
})
It("fails authentication if both api key and username are provided", func() {
r := newGetRequest("apiKey=api-key", "u=user-api")
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(w.Body.String()).To(ContainSubstring(`code="43"`))
Expect(next.called).To(BeFalse())
})
})
})
Describe("GetPlayer", func() {

View File

@ -1,25 +1,31 @@
package responses
const (
ErrorGeneric int32 = 0
ErrorMissingParameter int32 = 10
ErrorClientTooOld int32 = 20
ErrorServerTooOld int32 = 30
ErrorAuthenticationFail int32 = 40
ErrorAuthorizationFail int32 = 50
ErrorTrialExpired int32 = 60
ErrorDataNotFound int32 = 70
ErrorGeneric int32 = 0
ErrorMissingParameter int32 = 10
ErrorClientTooOld int32 = 20
ErrorServerTooOld int32 = 30
ErrorAuthenticationFail int32 = 40
ErrorTokenAuthNotSupported int32 = 41
ErrorAuthMechanismNotSupported int32 = 42
ErrorMultipleAuthMechanismsProvided int32 = 43
ErrorAuthorizationFail int32 = 50
ErrorTrialExpired int32 = 60
ErrorDataNotFound int32 = 70
)
var errors = map[int32]string{
ErrorGeneric: "A generic error",
ErrorMissingParameter: "Required parameter is missing",
ErrorClientTooOld: "Incompatible Subsonic REST protocol version. Client must upgrade",
ErrorServerTooOld: "Incompatible Subsonic REST protocol version. Server must upgrade",
ErrorAuthenticationFail: "Wrong username or password",
ErrorAuthorizationFail: "User is not authorized for the given operation",
ErrorTrialExpired: "The trial period for the Subsonic server is over. Please upgrade to Subsonic Premium. Visit subsonic.org for details",
ErrorDataNotFound: "The requested data was not found",
ErrorGeneric: "A generic error",
ErrorMissingParameter: "Required parameter is missing",
ErrorClientTooOld: "Incompatible Subsonic REST protocol version. Client must upgrade",
ErrorServerTooOld: "Incompatible Subsonic REST protocol version. Server must upgrade",
ErrorAuthenticationFail: "Invalid username, password, or API key",
ErrorTokenAuthNotSupported: "Token authentication not supported",
ErrorAuthMechanismNotSupported: "Provided authentication mechanism not supported",
ErrorMultipleAuthMechanismsProvided: "Multiple conflicting authentication mechanisms provided",
ErrorAuthorizationFail: "User is not authorized for the given operation",
ErrorTrialExpired: "The trial period for the Subsonic server is over. Please upgrade to Subsonic Premium. Visit subsonic.org for details",
ErrorDataNotFound: "The requested data was not found",
}
func ErrorMsg(code int32) string {

View File

@ -191,7 +191,7 @@ func (db *MockDataStore) Player(ctx context.Context) model.PlayerRepository {
if db.RealDS != nil {
db.MockedPlayer = db.RealDS.Player(ctx)
} else {
db.MockedPlayer = struct{ model.PlayerRepository }{}
db.MockedPlayer = CreateMockPlayerRepo()
}
}
return db.MockedPlayer

88
tests/mock_player_repo.go Normal file
View File

@ -0,0 +1,88 @@
package tests
import (
"encoding/base64"
"github.com/navidrome/navidrome/model"
)
func CreateMockPlayerRepo() *MockedPlayerRepo {
return &MockedPlayerRepo{
Data: make(map[string]*model.Player),
}
}
type MockedPlayerRepo struct {
model.PlayerRepository
Error error
Data map[string]*model.Player
}
func (m *MockedPlayerRepo) Get(id string) (*model.Player, error) {
if m.Error != nil {
return nil, m.Error
}
player, exists := m.Data[id]
if !exists {
return nil, model.ErrNotFound
}
return player, nil
}
func (m *MockedPlayerRepo) FindMatch(userId, client, userAgent string) (*model.Player, error) {
if m.Error != nil {
return nil, m.Error
}
for _, player := range m.Data {
if player.UserId == userId && player.Client == client && player.UserAgent == userAgent {
return player, nil
}
}
return nil, model.ErrNotFound
}
func (m *MockedPlayerRepo) Put(p *model.Player) error {
if m.Error != nil {
return m.Error
}
if p.ID == "" {
p.ID = base64.StdEncoding.EncodeToString([]byte(p.Name + "_" + p.UserId + "_" + p.Client))
}
m.Data[p.ID] = p
return nil
}
func (m *MockedPlayerRepo) CountAll(_ ...model.QueryOptions) (int64, error) {
if m.Error != nil {
return 0, m.Error
}
return int64(len(m.Data)), nil
}
func (m *MockedPlayerRepo) CountByClient(_ ...model.QueryOptions) (map[string]int64, error) {
if m.Error != nil {
return nil, m.Error
}
result := make(map[string]int64)
for _, player := range m.Data {
result[player.Client]++
}
return result, nil
}
func (m *MockedPlayerRepo) FindByAPIKey(key string) (*model.Player, error) {
if m.Error != nil {
return nil, m.Error
}
for _, player := range m.Data {
if player.APIKey == key {
return player, nil
}
}
return nil, model.ErrNotFound
}

View File

@ -24,7 +24,7 @@ type MockedUserRepo struct {
UserLibraries map[string][]int // userID -> libraryIDs
}
func (u *MockedUserRepo) CountAll(qo ...model.QueryOptions) (int64, error) {
func (u *MockedUserRepo) CountAll(_ ...model.QueryOptions) (int64, error) {
if u.Error != nil {
return 0, u.Error
}

View File

@ -0,0 +1,57 @@
import React, { useCallback } from 'react'
import {
Create,
SimpleForm,
TextInput,
required,
useTranslate,
useMutation,
useNotify,
useRedirect,
} from 'react-admin'
import { Title } from '../common'
const ApiKeyCreate = (props) => {
const translate = useTranslate()
const [mutate] = useMutation()
const notify = useNotify()
const redirect = useRedirect()
const resourceName = translate('resources.apikey.name', { smart_count: 1 })
const title = translate('ra.page.create', {
name: `${resourceName}`,
})
const save = useCallback(
async (values) => {
try {
await mutate(
{
type: 'create',
resource: 'apikey',
payload: { data: values },
},
{ returnPromise: true },
)
notify('resources.apikey.notifications.created', 'info', {
smart_count: 1,
})
redirect('/apikey')
} catch (error) {
if (error.body.errors) {
return error.body.errors
}
}
},
[mutate, notify, redirect],
)
return (
<Create title={<Title subTitle={title} />} {...props}>
<SimpleForm save={save} variant={'outlined'}>
<TextInput source="name" validate={[required()]} autoFocus />
</SimpleForm>
</Create>
)
}
export default ApiKeyCreate

View File

@ -0,0 +1,75 @@
import React, { useCallback } from 'react'
import {
Edit,
SimpleForm,
TextInput,
required,
Toolbar,
SaveButton,
DeleteButton,
useTranslate,
useMutation,
useNotify,
useRefresh,
DateField,
} from 'react-admin'
import { Title } from '../common'
const ApiKeyTitle = ({ record }) => {
const translate = useTranslate()
const resourceName = translate('resources.apikey.name', { smart_count: 1 })
return <Title subTitle={`${resourceName} ${record ? record.name : ''}`} />
}
const ApiKeyEditToolbar = (props) => (
<Toolbar {...props}>
<SaveButton />
<DeleteButton />
</Toolbar>
)
const ApiKeyEdit = (props) => {
const [mutate] = useMutation()
const notify = useNotify()
const refresh = useRefresh()
const save = useCallback(
async (values) => {
try {
await mutate(
{
type: 'update',
resource: 'apikey',
payload: { id: values.id, data: values },
},
{ returnPromise: true },
)
notify('resources.apikey.notifications.updated', 'info', {
smart_count: 1,
})
refresh()
} catch (error) {
if (error.body.errors) {
return error.body.errors
}
}
},
[mutate, notify, refresh],
)
return (
<Edit title={<ApiKeyTitle />} {...props}>
<SimpleForm
toolbar={<ApiKeyEditToolbar />}
save={save}
variant={'outlined'}
>
<TextInput source="name" validate={[required()]} />
<TextInput source="key" disabled fullWidth />
<DateField variant="body1" source="createdAt" showTime />
</SimpleForm>
</Edit>
)
}
export default ApiKeyEdit

View File

@ -0,0 +1,167 @@
import React, { useState } from 'react'
import {
CreateButton,
Datagrid,
DateField,
Filter,
FunctionField,
List,
SearchInput,
TextField,
useNotify,
useTranslate,
} from 'react-admin'
import {
IconButton,
makeStyles,
Tooltip,
useMediaQuery,
} from '@material-ui/core'
import { SimpleList } from '../common'
import AddIcon from '@material-ui/icons/Add'
import VisibilityIcon from '@material-ui/icons/Visibility'
import VisibilityOffIcon from '@material-ui/icons/VisibilityOff'
import FileCopyIcon from '@material-ui/icons/FileCopy'
const useStyles = makeStyles({
actionContainer: {
display: 'flex',
alignItems: 'center',
},
keyContainer: {
display: 'flex',
alignItems: 'center',
flex: 1,
},
visibilityButton: {
padding: 4,
},
copyButton: {
padding: 4,
},
})
const ApiKeyFilter = (props) => (
<Filter {...props} variant={'outlined'}>
<SearchInput id="search" source="name" alwaysOn />
</Filter>
)
const MaskedKeyField = ({ record, isSimpleMode }) => {
const [visible, setVisible] = useState(false)
const classes = useStyles()
const notify = useNotify()
if (!record || !record.key) return null
const maskKey = (key) => {
return '*'.repeat(key.length)
}
const handleCopy = (e) => {
if (isSimpleMode) {
e.preventDefault()
} else {
e.stopPropagation()
}
navigator.clipboard.writeText(record.key)
notify('API key copied to clipboard', 'info')
}
const toggleVisibility = (e) => {
if (isSimpleMode) {
e.preventDefault()
} else {
e.stopPropagation()
}
setVisible(!visible)
}
const keyVisibilityButton = (
<IconButton
className={classes.visibilityButton}
onClick={toggleVisibility}
size="small"
>
{visible ? (
<VisibilityOffIcon fontSize="small" />
) : (
<VisibilityIcon fontSize="small" />
)}
</IconButton>
)
const copyButton = (
<IconButton
className={classes.copyButton}
onClick={handleCopy}
size="small"
>
<FileCopyIcon fontSize="small" />
</IconButton>
)
return (
<div className={classes.actionContainer}>
<div className={classes.keyContainer}>
{visible ? record.key : maskKey(record.key)}
</div>
{isSimpleMode ? (
<>{keyVisibilityButton}</>
) : (
<Tooltip title={visible ? 'Hide key' : 'Show key'}>
{keyVisibilityButton}
</Tooltip>
)}
{isSimpleMode ? (
<>{copyButton}</>
) : (
<Tooltip title="Copy to clipboard">{copyButton}</Tooltip>
)}
</div>
)
}
const ApiKeyList = (props) => {
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
const translate = useTranslate()
return (
<List
{...props}
actions={
<CreateButton
basePath="/apikey"
icon={<AddIcon />}
label={translate('resources.apikey.actions.add')}
/>
}
sort={{ field: 'createdAt', order: 'DESC' }}
exporter={false}
bulkActionButtons={false}
filters={<ApiKeyFilter />}
>
{isXsmall ? (
<SimpleList
primaryText={(r) => r.name}
secondaryText={(r) => (
<MaskedKeyField record={r} isSimpleMode={true} />
)}
tertiaryText={(r) => <DateField record={r} source="createdAt" />}
linkType={'edit'}
/>
) : (
<Datagrid rowClick="edit">
<TextField source="name" />
<FunctionField
label="Key"
render={(record) => (
<MaskedKeyField isSimpleMode={false} record={record} />
)}
/>
<DateField source="createdAt" showTime />
</Datagrid>
)}
</List>
)
}
export default ApiKeyList

11
ui/src/apikey/index.js Normal file
View File

@ -0,0 +1,11 @@
import VpnKeyIcon from '@material-ui/icons/VpnKey'
import ApiKeyList from './ApiKeyList'
import ApiKeyCreate from './ApiKeyCreate'
import ApiKeyEdit from './ApiKeyEdit'
export default {
list: ApiKeyList,
create: ApiKeyCreate,
edit: ApiKeyEdit,
icon: VpnKeyIcon,
}

View File

@ -181,7 +181,8 @@
"userName": "Username",
"lastSeen": "Last Seen At",
"reportRealPath": "Report Real Path",
"scrobbleEnabled": "Send Scrobbles to external services"
"scrobbleEnabled": "Send Scrobbles to external services",
"apiKey": "API Key"
}
},
"transcoding": {
@ -508,7 +509,8 @@
"shareSuccess": "URL copied to clipboard: %{url}",
"shareFailure": "Error copying URL %{url} to clipboard",
"downloadDialogTitle": "Download %{resource} '%{name}' (%{size})",
"downloadOriginalFormat": "Download in original format"
"downloadOriginalFormat": "Download in original format",
"apiKeyGenerated": "API key generated successfully"
},
"menu": {
"library": "Library",

View File

@ -63,6 +63,7 @@ AboutMenuItem.displayName = 'AboutMenuItem'
const settingsResources = (resource) =>
resource.name !== 'user' &&
resource.name !== 'apikey' &&
resource.hasList &&
resource.options &&
resource.options.subMenu === 'settings'
@ -95,6 +96,14 @@ const CustomUserMenu = ({ onClick, ...rest }) => {
)
}
const renderApiKeyMenuItemLink = () => {
const apiKeyResource = resourceDefinition('apikey')
if (!apiKeyResource) {
return null
}
return renderSettingsMenuItemLink(apiKeyResource)
}
const renderSettingsMenuItemLink = (resource, id) => {
const label = translate(`resources.${resource.name}.name`, {
smart_count: id ? 1 : 2,
@ -128,6 +137,7 @@ const CustomUserMenu = ({ onClick, ...rest }) => {
<PersonalMenu sidebarIsOpen={true} onClick={onClick} />
<Divider />
{renderUserMenuItemLink()}
{renderApiKeyMenuItemLink()}
{resources
.filter(settingsResources)
.map((r) => renderSettingsMenuItemLink(r))}

View File

@ -103,7 +103,10 @@ const Menu = ({ dense = false }) => {
}
const subItems = (subMenu) => (resource) =>
resource.hasList && resource.options && resource.options.subMenu === subMenu
resource.hasList &&
resource.options &&
resource.options.subMenu === subMenu &&
resource.name !== 'apikey'
return (
<div

View File

@ -0,0 +1,44 @@
import React from 'react'
import {
TextInput,
BooleanInput,
Create,
required,
SimpleForm,
SelectInput,
ReferenceInput,
} from 'react-admin'
import { BITRATE_CHOICES } from '../consts'
import config from '../config.js'
import { Title } from '../common'
import { useTranslate } from 'react-admin'
const PlayerCreateTitle = () => {
const translate = useTranslate()
const resourceName = translate('resources.player.name', { smart_count: 1 })
return <Title subTitle={`${translate('ra.action.create')} ${resourceName}`} />
}
const PlayerCreate = (props) => {
return (
<Create title={<PlayerCreateTitle />} {...props}>
<SimpleForm variant="outlined">
<TextInput source="name" validate={[required()]} />
<ReferenceInput
source="transcodingId"
reference="transcoding"
sort={{ field: 'name', order: 'ASC' }}
>
<SelectInput source="name" resettable />
</ReferenceInput>
<SelectInput source="maxBitRate" resettable choices={BITRATE_CHOICES} />
<BooleanInput source="reportRealPath" fullWidth />
{(config.lastFMEnabled || config.listenBrainzEnabled) && (
<BooleanInput source="scrobbleEnabled" fullWidth />
)}
</SimpleForm>
</Create>
)
}
export default PlayerCreate

View File

@ -1,3 +1,4 @@
import React, { useState, useCallback } from 'react'
import {
TextInput,
BooleanInput,
@ -8,10 +9,25 @@ import {
SelectInput,
ReferenceInput,
useTranslate,
useNotify,
Button,
useRecordContext,
} from 'react-admin'
import FileCopyIcon from '@material-ui/icons/FileCopy'
import VpnKeyIcon from '@material-ui/icons/VpnKey'
import VisibilityIcon from '@material-ui/icons/Visibility'
import VisibilityOffIcon from '@material-ui/icons/VisibilityOff'
import { Title } from '../common'
import config from '../config'
import { BITRATE_CHOICES } from '../consts'
import { BITRATE_CHOICES, REST_URL } from '../consts'
import { makeStyles } from '@material-ui/core/styles'
import {
IconButton,
InputAdornment,
Tooltip,
TextField as MuiTextField,
} from '@material-ui/core'
import httpClient from '../dataProvider/httpClient.js'
import config from '../config.js'
const PlayerTitle = ({ record }) => {
const translate = useTranslate()
@ -19,26 +35,148 @@ const PlayerTitle = ({ record }) => {
return <Title subTitle={`${resourceName} ${record ? record.name : ''}`} />
}
const PlayerEdit = (props) => (
<Edit title={<PlayerTitle />} {...props}>
<SimpleForm variant={'outlined'}>
<TextInput source="name" validate={[required()]} />
<ReferenceInput
source="transcodingId"
reference="transcoding"
sort={{ field: 'name', order: 'ASC' }}
>
<SelectInput source="name" resettable />
</ReferenceInput>
<SelectInput source="maxBitRate" resettable choices={BITRATE_CHOICES} />
<BooleanInput source="reportRealPath" fullWidth />
{(config.lastFMEnabled || config.listenBrainzEnabled) && (
<BooleanInput source="scrobbleEnabled" fullWidth />
const useStyles = makeStyles({
apiKeyField: {
marginTop: '16px',
marginBottom: '8px',
},
generateButton: {
marginTop: '16px',
marginBottom: '8px',
},
copyButton: {
padding: 4,
},
})
const ApiKeySection = () => {
const record = useRecordContext()
const recordId = record ? record.id : null
const initialApiKey = record ? record.apiKey : null
const classes = useStyles()
const notify = useNotify()
const [showApiKey, setShowApiKey] = useState(false)
const [loading, setLoading] = useState(false)
const [apiKey, setApiKey] = useState(initialApiKey)
const generateApiKey = useCallback(async () => {
if (!recordId) {
notify('Player ID not available', 'error')
return
}
try {
setLoading(true)
const { json } = await httpClient(
`${REST_URL}/player/${recordId}/apiKey`,
{
method: 'POST',
},
)
setApiKey(json.apiKey)
setShowApiKey(true)
notify('message.apiKeyGenerated', 'info')
} catch (error) {
notify(error.message || 'Error generating API key', 'error')
} finally {
setLoading(false)
}
}, [recordId, notify])
const copyToClipboard = () => {
if (apiKey) {
navigator.clipboard.writeText(apiKey)
notify('API key copied to clipboard', 'info')
}
}
if (!recordId) {
return <></>
}
return (
<div style={{ display: 'flex', flexDirection: 'column', marginTop: 16 }}>
{apiKey ? (
<MuiTextField
label="API Key"
value={showApiKey ? apiKey : '*'.repeat(apiKey.length)}
InputProps={{
readOnly: true,
disableUnderline: true,
endAdornment: (
<InputAdornment position="end">
<Tooltip title={showApiKey ? 'Hide' : 'Show'}>
<IconButton
aria-label="toggle api key visibility"
onClick={() => setShowApiKey(!showApiKey)}
size="small"
>
{showApiKey ? <VisibilityOffIcon /> : <VisibilityIcon />}
</IconButton>
</Tooltip>
<Tooltip title="Copy to clipboard">
<IconButton
aria-label="copy api key"
onClick={copyToClipboard}
className={classes.copyButton}
size="small"
>
<FileCopyIcon />
</IconButton>
</Tooltip>
</InputAdornment>
),
}}
style={{ width: 320, color: 'black' }}
/>
) : (
!loading && (
<MuiTextField
label="API Key"
value="No API Key Found"
disabled
style={{ width: 320 }}
/>
)
)}
<TextField source="client" />
<TextField source="userName" />
</SimpleForm>
</Edit>
)
<Button
style={{ marginTop: 12, alignSelf: 'flex-start' }}
onClick={generateApiKey}
label="Generate New API Key"
startIcon={<VpnKeyIcon />}
variant="outlined"
disabled={loading}
/>
</div>
)
}
const PlayerEdit = (props) => {
return (
<Edit title={<PlayerTitle />} {...props}>
<SimpleForm variant={'outlined'}>
<TextInput source="name" validate={[required()]} />
<ReferenceInput
source="transcodingId"
reference="transcoding"
sort={{ field: 'name', order: 'ASC' }}
>
<SelectInput source="name" resettable />
</ReferenceInput>
<SelectInput source="maxBitRate" resettable choices={BITRATE_CHOICES} />
<BooleanInput source="reportRealPath" fullWidth />
{(config.lastFMEnabled || config.listenBrainzEnabled) && (
<BooleanInput source="scrobbleEnabled" fullWidth />
)}
<TextField source="client" />
<TextField source="userName" />
<ApiKeySection />
</SimpleForm>
</Edit>
)
}
export default PlayerEdit

View File

@ -1,8 +1,10 @@
import { BsFillMusicPlayerFill } from 'react-icons/bs'
import PlayerList from './PlayerList'
import PlayerEdit from './PlayerEdit'
import PlayerCreate from './PlayerCreate.jsx'
export default {
create: PlayerCreate,
list: PlayerList,
edit: PlayerEdit,
icon: BsFillMusicPlayerFill,

View File

@ -95,7 +95,7 @@ export const formatFullDate = (date, locale) => {
return new Date(date).toLocaleDateString(locale, options)
}
export const formatNumber = (value) => {
export const formatNumber = (value, locale) => {
if (value === null || value === undefined) return '0'
return value.toLocaleString()
return value.toLocaleString(locale)
}

View File

@ -121,35 +121,35 @@ describe('formatDuration2', () => {
describe('formatNumber', () => {
it('handles null and undefined values', () => {
expect(formatNumber(null)).toEqual('0')
expect(formatNumber(undefined)).toEqual('0')
expect(formatNumber(null, 'en-CA')).toEqual('0')
expect(formatNumber(undefined, 'en-CA')).toEqual('0')
})
it('formats integers', () => {
expect(formatNumber(0)).toEqual('0')
expect(formatNumber(1)).toEqual('1')
expect(formatNumber(123)).toEqual('123')
expect(formatNumber(1000)).toEqual('1,000')
expect(formatNumber(1234567)).toEqual('1,234,567')
expect(formatNumber(0, 'en-CA')).toEqual('0')
expect(formatNumber(1, 'en-CA')).toEqual('1')
expect(formatNumber(123, 'en-CA')).toEqual('123')
expect(formatNumber(1000, 'en-CA')).toEqual('1,000')
expect(formatNumber(1234567, 'en-CA')).toEqual('1,234,567')
})
it('formats decimal numbers', () => {
expect(formatNumber(123.45)).toEqual('123.45')
expect(formatNumber(1234.567)).toEqual('1,234.567')
expect(formatNumber(123.45, 'en-CA')).toEqual('123.45')
expect(formatNumber(1234.567, 'en-CA')).toEqual('1,234.567')
})
it('formats negative numbers', () => {
expect(formatNumber(-123)).toEqual('-123')
expect(formatNumber(-1234)).toEqual('-1,234')
expect(formatNumber(-123.45)).toEqual('-123.45')
expect(formatNumber(-123, 'en-CA')).toEqual('-123')
expect(formatNumber(-1234, 'en-CA')).toEqual('-1,234')
expect(formatNumber(-123.45, 'en-CA')).toEqual('-123.45')
})
})
describe('formatFullDate', () => {
it('format dates', () => {
expect(formatFullDate('2011', 'en-US')).toEqual('2011')
expect(formatFullDate('2011-06', 'en-US')).toEqual('Jun 2011')
expect(formatFullDate('1985-01-01', 'en-US')).toEqual('Jan 1, 1985')
expect(formatFullDate('2011', 'en-CA')).toEqual('2011')
expect(formatFullDate('2011-06', 'en-CA')).toEqual('Jun 2011')
expect(formatFullDate('1985-01-01', 'en-CA')).toEqual('Jan 1, 1985')
expect(formatFullDate('199704')).toEqual('')
})
})

View File

@ -2,6 +2,7 @@ package str
import (
"strings"
"unicode/utf8"
)
var utf8ToAscii = func() *strings.Replacer {
@ -39,3 +40,25 @@ func LongestCommonPrefix(list []string) string {
}
return list[0]
}
// TruncateRunes truncates a string to a maximum number of runes, adding a suffix if truncated.
// The suffix is included in the rune count, so if maxRunes is 30 and suffix is "...", the actual
// string content will be truncated to fit within the maxRunes limit including the suffix.
func TruncateRunes(s string, maxRunes int, suffix string) string {
if utf8.RuneCountInString(s) <= maxRunes {
return s
}
suffixRunes := utf8.RuneCountInString(suffix)
truncateAt := maxRunes - suffixRunes
if truncateAt < 0 {
truncateAt = 0
}
runes := []rune(s)
if truncateAt >= len(runes) {
return s + suffix
}
return string(runes[:truncateAt]) + suffix
}

View File

@ -31,6 +31,72 @@ var _ = Describe("String Utils", func() {
Expect(str.LongestCommonPrefix(albums)).To(Equal("/artist/album"))
})
})
Describe("TruncateRunes", func() {
It("returns string unchanged if under max runes", func() {
Expect(str.TruncateRunes("hello", 10, "...")).To(Equal("hello"))
})
It("returns string unchanged if exactly at max runes", func() {
Expect(str.TruncateRunes("hello", 5, "...")).To(Equal("hello"))
})
It("truncates and adds suffix when over max runes", func() {
Expect(str.TruncateRunes("hello world", 8, "...")).To(Equal("hello..."))
})
It("handles unicode characters correctly", func() {
// 6 emoji characters, maxRunes=5, suffix="..." (3 runes)
// So content gets 5-3=2 runes
Expect(str.TruncateRunes("😀😁😂😃😄😅", 5, "...")).To(Equal("😀😁..."))
})
It("handles multi-byte UTF-8 characters", func() {
// Characters like é are single runes
Expect(str.TruncateRunes("Café au Lait", 5, "...")).To(Equal("Ca..."))
})
It("works with empty suffix", func() {
Expect(str.TruncateRunes("hello world", 5, "")).To(Equal("hello"))
})
It("accounts for suffix length in truncation", func() {
// maxRunes=10, suffix="..." (3 runes) -> leaves 7 runes for content
result := str.TruncateRunes("hello world this is long", 10, "...")
Expect(result).To(Equal("hello w..."))
// Verify total rune count is <= maxRunes
runeCount := len([]rune(result))
Expect(runeCount).To(BeNumerically("<=", 10))
})
It("handles very long suffix gracefully", func() {
// If suffix is longer than maxRunes, we still add it
// but the content will be truncated to 0
result := str.TruncateRunes("hello world", 5, "... (truncated)")
// Result will be just the suffix (since truncateAt=0)
Expect(result).To(Equal("... (truncated)"))
})
It("handles empty string", func() {
Expect(str.TruncateRunes("", 10, "...")).To(Equal(""))
})
It("uses custom suffix", func() {
// maxRunes=11, suffix=" [...]" (6 runes) -> content gets 5 runes
// "hello world" is 11 runes exactly, so we need a longer string
Expect(str.TruncateRunes("hello world extra", 11, " [...]")).To(Equal("hello [...]"))
})
DescribeTable("truncates at rune boundaries (not byte boundaries)",
func(input string, maxRunes int, suffix string, expected string) {
Expect(str.TruncateRunes(input, maxRunes, suffix)).To(Equal(expected))
},
Entry("ASCII", "abcdefghij", 5, "...", "ab..."),
Entry("Mixed ASCII and Unicode", "ab😀cd", 4, ".", "ab😀."),
Entry("All emoji", "😀😁😂😃😄", 3, "…", "😀😁…"),
Entry("Japanese", "こんにちは世界", 3, "…", "こん…"),
)
})
})
var testPaths = []string{