mirror of
https://github.com/navidrome/navidrome.git
synced 2026-04-03 06:41:01 +00:00
Compare commits
18 Commits
beca898cc4
...
63c125db99
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63c125db99 | ||
|
|
4f7dc105b0 | ||
|
|
e918e049e2 | ||
|
|
1e8d28ff46 | ||
|
|
a128b3cf98 | ||
|
|
290a9fdeaa | ||
|
|
58b5ed86df | ||
|
|
fe1cee0159 | ||
|
|
3dfaa8cca1 | ||
|
|
0a5abfc1b1 | ||
|
|
c501bc6996 | ||
|
|
0c71842b12 | ||
|
|
aecf959d98 | ||
|
|
d5b47383ae | ||
|
|
c655a91a5d | ||
|
|
5756ec89ea | ||
|
|
b139f87c7e | ||
|
|
362a8826b9 |
18
.github/workflows/pipeline.yml
vendored
18
.github/workflows/pipeline.yml
vendored
@ -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 }}
|
||||
|
||||
@ -13,6 +13,7 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
type Share interface {
|
||||
@ -119,9 +120,8 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
|
||||
log.Error(r.ctx, "Invalid Resource ID", "id", firstId)
|
||||
return "", model.ErrNotFound
|
||||
}
|
||||
if len(s.Contents) > 30 {
|
||||
s.Contents = s.Contents[:26] + "..."
|
||||
}
|
||||
|
||||
s.Contents = str.TruncateRunes(s.Contents, 30, "...")
|
||||
|
||||
id, err = r.Persistable.Save(s)
|
||||
return id, err
|
||||
|
||||
@ -38,6 +38,38 @@ var _ = Describe("Share", func() {
|
||||
Expect(id).ToNot(BeEmpty())
|
||||
Expect(entity.ID).To(Equal(id))
|
||||
})
|
||||
|
||||
It("does not truncate ASCII labels shorter than 30 characters", func() {
|
||||
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "Example Media File"})
|
||||
entity := &model.Share{Description: "test", ResourceIDs: "456"}
|
||||
_, err := repo.Save(entity)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(entity.Contents).To(Equal("Example Media File"))
|
||||
})
|
||||
|
||||
It("truncates ASCII labels longer than 30 characters", func() {
|
||||
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "Example Media File But The Title Is Really Long For Testing Purposes"})
|
||||
entity := &model.Share{Description: "test", ResourceIDs: "789"}
|
||||
_, err := repo.Save(entity)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(entity.Contents).To(Equal("Example Media File But The ..."))
|
||||
})
|
||||
|
||||
It("does not truncate CJK labels shorter than 30 runes", func() {
|
||||
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "青春コンプレックス"})
|
||||
entity := &model.Share{Description: "test", ResourceIDs: "456"}
|
||||
_, err := repo.Save(entity)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(entity.Contents).To(Equal("青春コンプレックス"))
|
||||
})
|
||||
|
||||
It("truncates CJK labels longer than 30 runes", func() {
|
||||
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "私の中の幻想的世界観及びその顕現を想起させたある現実での出来事に関する一考察"})
|
||||
entity := &model.Share{Description: "test", ResourceIDs: "789"}
|
||||
_, err := repo.Save(entity)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実で..."))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Update", func() {
|
||||
|
||||
34
db/migrations/20250730104020_add_api_key_to_player_table.go
Normal file
34
db/migrations/20250730104020_add_api_key_to_player_table.go
Normal 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
|
||||
}
|
||||
@ -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
13
go.mod
@ -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
12
go.sum
@ -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=
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}))
|
||||
})
|
||||
|
||||
@ -38,7 +38,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
})
|
||||
|
||||
It("counts the number of mediafiles in the DB", func() {
|
||||
Expect(mr.CountAll()).To(Equal(int64(6)))
|
||||
Expect(mr.CountAll()).To(Equal(int64(10)))
|
||||
})
|
||||
|
||||
It("returns songs ordered by lyrics with a specific title/artist", func() {
|
||||
|
||||
@ -69,10 +69,12 @@ var (
|
||||
albumSgtPeppers = al(model.Album{ID: "101", Name: "Sgt Peppers", AlbumArtist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967})
|
||||
albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969})
|
||||
albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("/kraft/radio/radio.mp3"), SongCount: 2})
|
||||
albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("/test/multi/disc1/track1.mp3"), SongCount: 4})
|
||||
testAlbums = model.Albums{
|
||||
albumSgtPeppers,
|
||||
albumAbbeyRoad,
|
||||
albumRadioactivity,
|
||||
albumMultiDisc,
|
||||
}
|
||||
)
|
||||
|
||||
@ -94,13 +96,22 @@ var (
|
||||
Lyrics: `[{"lang":"xxx","line":[{"value":"This is a set of lyrics"}],"synced":false}]`,
|
||||
})
|
||||
songAntenna2 = mf(model.MediaFile{ID: "1006", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103"})
|
||||
testSongs = model.MediaFiles{
|
||||
// Multi-disc album tracks (intentionally out of order to test sorting)
|
||||
songDisc2Track11 = mf(model.MediaFile{ID: "2001", Title: "Disc 2 Track 11", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 11, Path: p("/test/multi/disc2/track11.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
||||
songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("/test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
||||
songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("/test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
||||
songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("/test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
||||
testSongs = model.MediaFiles{
|
||||
songDayInALife,
|
||||
songComeTogether,
|
||||
songRadioactivity,
|
||||
songAntenna,
|
||||
songAntennaWithLyrics,
|
||||
songAntenna2,
|
||||
songDisc2Track11,
|
||||
songDisc1Track01,
|
||||
songDisc2Track01,
|
||||
songDisc1Track02,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -219,4 +219,37 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Playlist Track Sorting", func() {
|
||||
var testPlaylistID string
|
||||
|
||||
AfterEach(func() {
|
||||
if testPlaylistID != "" {
|
||||
Expect(repo.Delete(testPlaylistID)).To(BeNil())
|
||||
testPlaylistID = ""
|
||||
}
|
||||
})
|
||||
|
||||
It("sorts tracks correctly by album (disc and track number)", func() {
|
||||
By("creating a playlist with multi-disc album tracks in arbitrary order")
|
||||
newPls := model.Playlist{Name: "Multi-Disc Test", OwnerID: "userid"}
|
||||
// Add tracks in intentionally scrambled order
|
||||
newPls.AddMediaFilesByID([]string{"2001", "2002", "2003", "2004"})
|
||||
Expect(repo.Put(&newPls)).To(Succeed())
|
||||
testPlaylistID = newPls.ID
|
||||
|
||||
By("retrieving tracks sorted by album")
|
||||
tracksRepo := repo.Tracks(newPls.ID, false)
|
||||
tracks, err := tracksRepo.GetAll(model.QueryOptions{Sort: "album", Order: "asc"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
By("verifying tracks are sorted by disc number then track number")
|
||||
Expect(tracks).To(HaveLen(4))
|
||||
// Expected order: Disc 1 Track 1, Disc 1 Track 2, Disc 2 Track 1, Disc 2 Track 11
|
||||
Expect(tracks[0].MediaFileID).To(Equal("2002")) // Disc 1, Track 1
|
||||
Expect(tracks[1].MediaFileID).To(Equal("2004")) // Disc 1, Track 2
|
||||
Expect(tracks[2].MediaFileID).To(Equal("2003")) // Disc 2, Track 1
|
||||
Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -55,7 +55,7 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
|
||||
"id": "playlist_tracks.id",
|
||||
"artist": "order_artist_name",
|
||||
"album_artist": "order_album_artist_name",
|
||||
"album": "order_album_name, order_album_artist_name",
|
||||
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title",
|
||||
"title": "order_title",
|
||||
// To make sure these fields will be whitelisted
|
||||
"duration": "duration",
|
||||
|
||||
@ -57,6 +57,7 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository
|
||||
r.db = db
|
||||
r.tableName = "user"
|
||||
r.registerModel(&model.User{}, map[string]filterFunc{
|
||||
"id": idFilter(r.tableName),
|
||||
"password": invalidFilter(ctx),
|
||||
"name": r.withTableName(startsWithFilter),
|
||||
})
|
||||
|
||||
@ -559,4 +559,15 @@ var _ = Describe("UserRepository", func() {
|
||||
Expect(user.Libraries[0].ID).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("filters", func() {
|
||||
It("qualifies id filter with table name", func() {
|
||||
r := repo.(*userRepository)
|
||||
qo := r.parseRestOptions(r.ctx, rest.QueryOptions{Filters: map[string]any{"id": "123"}})
|
||||
sel := r.selectUserWithLibraries(qo)
|
||||
query, _, err := r.toSQL(sel)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(query).To(ContainSubstring("user.id = {:p0}"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
88
tests/mock_player_repo.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
57
ui/src/apikey/ApiKeyCreate.jsx
Normal file
57
ui/src/apikey/ApiKeyCreate.jsx
Normal 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
|
||||
75
ui/src/apikey/ApiKeyEdit.jsx
Normal file
75
ui/src/apikey/ApiKeyEdit.jsx
Normal 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
|
||||
167
ui/src/apikey/ApiKeyList.jsx
Normal file
167
ui/src/apikey/ApiKeyList.jsx
Normal 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
11
ui/src/apikey/index.js
Normal 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,
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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))}
|
||||
|
||||
@ -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
|
||||
|
||||
44
ui/src/player/PlayerCreate.jsx
Normal file
44
ui/src/player/PlayerCreate.jsx
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -95,7 +95,7 @@ export const formatFullDate = (date, locale) => {
|
||||
return new Date(date).toLocaleDateString(locale, options)
|
||||
}
|
||||
|
||||
export const formatNumber = (value) => {
|
||||
export const formatNumber = (value, locale) => {
|
||||
if (value === null || value === undefined) return '0'
|
||||
return value.toLocaleString()
|
||||
return value.toLocaleString(locale)
|
||||
}
|
||||
|
||||
@ -121,35 +121,35 @@ describe('formatDuration2', () => {
|
||||
|
||||
describe('formatNumber', () => {
|
||||
it('handles null and undefined values', () => {
|
||||
expect(formatNumber(null)).toEqual('0')
|
||||
expect(formatNumber(undefined)).toEqual('0')
|
||||
expect(formatNumber(null, 'en-CA')).toEqual('0')
|
||||
expect(formatNumber(undefined, 'en-CA')).toEqual('0')
|
||||
})
|
||||
|
||||
it('formats integers', () => {
|
||||
expect(formatNumber(0)).toEqual('0')
|
||||
expect(formatNumber(1)).toEqual('1')
|
||||
expect(formatNumber(123)).toEqual('123')
|
||||
expect(formatNumber(1000)).toEqual('1,000')
|
||||
expect(formatNumber(1234567)).toEqual('1,234,567')
|
||||
expect(formatNumber(0, 'en-CA')).toEqual('0')
|
||||
expect(formatNumber(1, 'en-CA')).toEqual('1')
|
||||
expect(formatNumber(123, 'en-CA')).toEqual('123')
|
||||
expect(formatNumber(1000, 'en-CA')).toEqual('1,000')
|
||||
expect(formatNumber(1234567, 'en-CA')).toEqual('1,234,567')
|
||||
})
|
||||
|
||||
it('formats decimal numbers', () => {
|
||||
expect(formatNumber(123.45)).toEqual('123.45')
|
||||
expect(formatNumber(1234.567)).toEqual('1,234.567')
|
||||
expect(formatNumber(123.45, 'en-CA')).toEqual('123.45')
|
||||
expect(formatNumber(1234.567, 'en-CA')).toEqual('1,234.567')
|
||||
})
|
||||
|
||||
it('formats negative numbers', () => {
|
||||
expect(formatNumber(-123)).toEqual('-123')
|
||||
expect(formatNumber(-1234)).toEqual('-1,234')
|
||||
expect(formatNumber(-123.45)).toEqual('-123.45')
|
||||
expect(formatNumber(-123, 'en-CA')).toEqual('-123')
|
||||
expect(formatNumber(-1234, 'en-CA')).toEqual('-1,234')
|
||||
expect(formatNumber(-123.45, 'en-CA')).toEqual('-123.45')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatFullDate', () => {
|
||||
it('format dates', () => {
|
||||
expect(formatFullDate('2011', 'en-US')).toEqual('2011')
|
||||
expect(formatFullDate('2011-06', 'en-US')).toEqual('Jun 2011')
|
||||
expect(formatFullDate('1985-01-01', 'en-US')).toEqual('Jan 1, 1985')
|
||||
expect(formatFullDate('2011', 'en-CA')).toEqual('2011')
|
||||
expect(formatFullDate('2011-06', 'en-CA')).toEqual('Jun 2011')
|
||||
expect(formatFullDate('1985-01-01', 'en-CA')).toEqual('Jan 1, 1985')
|
||||
expect(formatFullDate('199704')).toEqual('')
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,6 +2,7 @@ package str
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var utf8ToAscii = func() *strings.Replacer {
|
||||
@ -39,3 +40,25 @@ func LongestCommonPrefix(list []string) string {
|
||||
}
|
||||
return list[0]
|
||||
}
|
||||
|
||||
// TruncateRunes truncates a string to a maximum number of runes, adding a suffix if truncated.
|
||||
// The suffix is included in the rune count, so if maxRunes is 30 and suffix is "...", the actual
|
||||
// string content will be truncated to fit within the maxRunes limit including the suffix.
|
||||
func TruncateRunes(s string, maxRunes int, suffix string) string {
|
||||
if utf8.RuneCountInString(s) <= maxRunes {
|
||||
return s
|
||||
}
|
||||
|
||||
suffixRunes := utf8.RuneCountInString(suffix)
|
||||
truncateAt := maxRunes - suffixRunes
|
||||
if truncateAt < 0 {
|
||||
truncateAt = 0
|
||||
}
|
||||
|
||||
runes := []rune(s)
|
||||
if truncateAt >= len(runes) {
|
||||
return s + suffix
|
||||
}
|
||||
|
||||
return string(runes[:truncateAt]) + suffix
|
||||
}
|
||||
|
||||
@ -31,6 +31,72 @@ var _ = Describe("String Utils", func() {
|
||||
Expect(str.LongestCommonPrefix(albums)).To(Equal("/artist/album"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("TruncateRunes", func() {
|
||||
It("returns string unchanged if under max runes", func() {
|
||||
Expect(str.TruncateRunes("hello", 10, "...")).To(Equal("hello"))
|
||||
})
|
||||
|
||||
It("returns string unchanged if exactly at max runes", func() {
|
||||
Expect(str.TruncateRunes("hello", 5, "...")).To(Equal("hello"))
|
||||
})
|
||||
|
||||
It("truncates and adds suffix when over max runes", func() {
|
||||
Expect(str.TruncateRunes("hello world", 8, "...")).To(Equal("hello..."))
|
||||
})
|
||||
|
||||
It("handles unicode characters correctly", func() {
|
||||
// 6 emoji characters, maxRunes=5, suffix="..." (3 runes)
|
||||
// So content gets 5-3=2 runes
|
||||
Expect(str.TruncateRunes("😀😁😂😃😄😅", 5, "...")).To(Equal("😀😁..."))
|
||||
})
|
||||
|
||||
It("handles multi-byte UTF-8 characters", func() {
|
||||
// Characters like é are single runes
|
||||
Expect(str.TruncateRunes("Café au Lait", 5, "...")).To(Equal("Ca..."))
|
||||
})
|
||||
|
||||
It("works with empty suffix", func() {
|
||||
Expect(str.TruncateRunes("hello world", 5, "")).To(Equal("hello"))
|
||||
})
|
||||
|
||||
It("accounts for suffix length in truncation", func() {
|
||||
// maxRunes=10, suffix="..." (3 runes) -> leaves 7 runes for content
|
||||
result := str.TruncateRunes("hello world this is long", 10, "...")
|
||||
Expect(result).To(Equal("hello w..."))
|
||||
// Verify total rune count is <= maxRunes
|
||||
runeCount := len([]rune(result))
|
||||
Expect(runeCount).To(BeNumerically("<=", 10))
|
||||
})
|
||||
|
||||
It("handles very long suffix gracefully", func() {
|
||||
// If suffix is longer than maxRunes, we still add it
|
||||
// but the content will be truncated to 0
|
||||
result := str.TruncateRunes("hello world", 5, "... (truncated)")
|
||||
// Result will be just the suffix (since truncateAt=0)
|
||||
Expect(result).To(Equal("... (truncated)"))
|
||||
})
|
||||
|
||||
It("handles empty string", func() {
|
||||
Expect(str.TruncateRunes("", 10, "...")).To(Equal(""))
|
||||
})
|
||||
|
||||
It("uses custom suffix", func() {
|
||||
// maxRunes=11, suffix=" [...]" (6 runes) -> content gets 5 runes
|
||||
// "hello world" is 11 runes exactly, so we need a longer string
|
||||
Expect(str.TruncateRunes("hello world extra", 11, " [...]")).To(Equal("hello [...]"))
|
||||
})
|
||||
|
||||
DescribeTable("truncates at rune boundaries (not byte boundaries)",
|
||||
func(input string, maxRunes int, suffix string, expected string) {
|
||||
Expect(str.TruncateRunes(input, maxRunes, suffix)).To(Equal(expected))
|
||||
},
|
||||
Entry("ASCII", "abcdefghij", 5, "...", "ab..."),
|
||||
Entry("Mixed ASCII and Unicode", "ab😀cd", 4, ".", "ab😀."),
|
||||
Entry("All emoji", "😀😁😂😃😄", 3, "…", "😀😁…"),
|
||||
Entry("Japanese", "こんにちは世界", 3, "…", "こん…"),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
var testPaths = []string{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user