Initial work on Shares

This commit is contained in:
Deluan 2023-01-19 22:52:55 -05:00
parent 5331de17c2
commit ab04e33da6
36 changed files with 841 additions and 84 deletions

View File

@ -84,6 +84,9 @@ func startServer(ctx context.Context) func() error {
a.MountRouter("Native API", consts.URLPathNativeAPI, CreateNativeAPIRouter())
a.MountRouter("Subsonic API", consts.URLPathSubsonicAPI, CreateSubsonicAPIRouter())
a.MountRouter("Public Endpoints", consts.URLPathPublic, CreatePublicRouter())
if conf.Server.DevEnableShare {
a.MountRouter("Share Endpoint", consts.URLPathShares, CreateSharesRouter())
}
if conf.Server.LastFM.Enabled {
a.MountRouter("LastFM Auth", consts.URLPathNativeAPI+"/lastfm", CreateLastFMRouter())
}

View File

@ -22,6 +22,7 @@ import (
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/nativeapi"
"github.com/navidrome/navidrome/server/public"
"github.com/navidrome/navidrome/server/shares"
"github.com/navidrome/navidrome/server/subsonic"
"sync"
)
@ -72,7 +73,17 @@ func CreatePublicRouter() *public.Router {
agentsAgents := agents.New(dataStore)
externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
router := public.New(artworkArtwork)
transcodingCache := core.GetTranscodingCache()
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
router := public.New(artworkArtwork, mediaStreamer)
return router
}
func CreateSharesRouter() *shares.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
share := core.NewShare(dataStore)
router := shares.New(dataStore, share)
return router
}
@ -107,7 +118,7 @@ func createScanner() scanner.Scanner {
// wire_injectors.go:
var allProviders = wire.NewSet(core.Set, artwork.Set, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, db.Db)
var allProviders = wire.NewSet(core.Set, artwork.Set, subsonic.New, nativeapi.New, public.New, shares.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, db.Db)
// Scanner must be a Singleton
var (

View File

@ -17,6 +17,7 @@ import (
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/nativeapi"
"github.com/navidrome/navidrome/server/public"
"github.com/navidrome/navidrome/server/shares"
"github.com/navidrome/navidrome/server/subsonic"
)
@ -26,6 +27,7 @@ var allProviders = wire.NewSet(
subsonic.New,
nativeapi.New,
public.New,
shares.New,
persistence.New,
lastfm.NewRouter,
listenbrainz.NewRouter,
@ -59,6 +61,12 @@ func CreatePublicRouter() *public.Router {
))
}
func CreateSharesRouter() *shares.Router {
panic(wire.Build(
allProviders,
))
}
func CreateLastFMRouter() *lastfm.Router {
panic(wire.Build(
allProviders,

View File

@ -34,6 +34,7 @@ const (
URLPathSubsonicAPI = "/rest"
URLPathPublic = "/p"
URLPathPublicImages = URLPathPublic + "/img"
URLPathShares = "/s"
// DefaultUILoginBackgroundURL uses Navidrome curated background images collection,
// available at https://unsplash.com/collections/20072696/navidrome

View File

@ -49,6 +49,19 @@ func CreatePublicToken(claims map[string]any) (string, error) {
return token, err
}
func CreateExpiringPublicToken(exp time.Time, claims map[string]any) (string, error) {
tokenClaims := createBaseClaims()
if !exp.IsZero() {
tokenClaims[jwt.ExpirationKey] = exp.UTC().Unix()
}
for k, v := range claims {
tokenClaims[k] = v
}
_, token, err := TokenAuth.Encode(tokenClaims)
return token, err
}
func CreateToken(u *model.User) (string, error) {
claims := createBaseClaims()
claims[jwt.SubjectKey] = u.UserName

View File

@ -2,13 +2,19 @@ package core
import (
"context"
"strings"
"time"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
gonanoid "github.com/matoous/go-nanoid"
gonanoid "github.com/matoous/go-nanoid/v2"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
)
type Share interface {
Load(ctx context.Context, id string) (*model.Share, error)
NewRepository(ctx context.Context) rest.Repository
}
@ -22,6 +28,50 @@ type shareService struct {
ds model.DataStore
}
func (s *shareService) Load(ctx context.Context, id string) (*model.Share, error) {
repo := s.ds.Share(ctx)
entity, err := repo.(rest.Repository).Read(id)
if err != nil {
return nil, err
}
share := entity.(*model.Share)
now := time.Now()
share.LastVisitedAt = &now
share.VisitCount++
err = repo.(rest.Persistable).Update(id, share, "last_visited_at", "visit_count")
if err != nil {
log.Warn(ctx, "Could not increment visit count for share", "share", share.ID)
}
idList := strings.Split(share.ResourceIDs, ",")
switch share.ResourceType {
case "album":
share.Tracks, err = s.loadMediafiles(ctx, squirrel.Eq{"album_id": idList}, "album")
}
if err != nil {
return nil, err
}
return entity.(*model.Share), nil
}
func (s *shareService) loadMediafiles(ctx context.Context, filter squirrel.Eq, sort string) ([]model.ShareTrack, error) {
all, err := s.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: filter, Sort: sort})
if err != nil {
return nil, err
}
return slice.Map(all, func(mf model.MediaFile) model.ShareTrack {
return model.ShareTrack{
ID: mf.ID,
Title: mf.Title,
Artist: mf.Artist,
Album: mf.Album,
Duration: mf.Duration,
UpdatedAt: mf.UpdatedAt,
}
}), nil
}
func (s *shareService) NewRepository(ctx context.Context) rest.Repository {
repo := s.ds.Share(ctx)
wrapper := &shareRepositoryWrapper{
@ -38,17 +88,37 @@ type shareRepositoryWrapper struct {
rest.Persistable
}
func (r *shareRepositoryWrapper) newId() (string, error) {
for {
id, err := gonanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 10)
if err != nil {
return "", err
}
exists, err := r.Exists(id)
if err != nil {
return "", err
}
if !exists {
return id, nil
}
}
}
func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
s := entity.(*model.Share)
id, err := gonanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 9)
id, err := r.newId()
if err != nil {
return "", err
}
s.Name = id
s.ID = id
if s.ExpiresAt.IsZero() {
exp := time.Now().Add(365 * 24 * time.Hour)
s.ExpiresAt = &exp
}
id, err = r.Persistable.Save(s)
return id, err
}
func (r *shareRepositoryWrapper) Update(id string, entity interface{}, _ ...string) error {
return r.Persistable.Update(id, entity, "description")
return r.Persistable.Update(id, entity, "description", "expires_at")
}

View File

@ -34,7 +34,7 @@ var _ = Describe("Share", func() {
id, err := repo.Save(entity)
Expect(err).ToNot(HaveOccurred())
Expect(id).ToNot(BeEmpty())
Expect(entity.Name).ToNot(BeEmpty())
Expect(entity.ID).To(Equal(id))
})
})

View File

@ -0,0 +1,41 @@
package migrations
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(upAddMissingShareInfo, downAddMissingShareInfo)
}
func upAddMissingShareInfo(tx *sql.Tx) error {
_, err := tx.Exec(`
drop table if exists share;
create table share
(
id varchar(255) not null
primary key,
description varchar(255),
expires_at datetime,
last_visited_at datetime,
resource_ids varchar not null,
resource_type varchar(255) not null,
contents varchar,
format varchar,
max_bit_rate integer,
visit_count integer default 0,
created_at datetime,
updated_at datetime,
user_id varchar(255) not null
constraint share_user_id_fk
references user
);
`)
return err
}
func downAddMissingShareInfo(tx *sql.Tx) error {
return nil
}

2
go.mod
View File

@ -28,7 +28,7 @@ require (
github.com/hashicorp/go-multierror v1.1.1
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v2 v2.0.8
github.com/matoous/go-nanoid v1.5.0
github.com/matoous/go-nanoid/v2 v2.0.0
github.com/mattn/go-sqlite3 v1.14.16
github.com/mattn/go-zglob v0.0.3
github.com/microcosm-cc/bluemonday v1.0.21

3
go.sum
View File

@ -450,8 +450,9 @@ github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s
github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE=
github.com/maratori/testpackage v1.1.0 h1:GJY4wlzQhuBusMF1oahQCBtUV/AQ/k69IZ68vxaac2Q=
github.com/maratori/testpackage v1.1.0/go.mod h1:PeAhzU8qkCwdGEMTEupsHJNlQu2gZopMC6RjbhmHeDc=
github.com/matoous/go-nanoid v1.5.0 h1:VRorl6uCngneC4oUQqOYtO3S0H5QKFtKuKycFG3euek=
github.com/matoous/go-nanoid v1.5.0/go.mod h1:zyD2a71IubI24efhpvkJz+ZwfwagzgSO6UNiFsZKN7U=
github.com/matoous/go-nanoid/v2 v2.0.0 h1:d19kur2QuLeHmJBkvYkFdhFBzLoo1XVm2GgTpL+9Tj0=
github.com/matoous/go-nanoid/v2 v2.0.0/go.mod h1:FtS4aGPVfEkxKxhdWPAspZpZSh1cOjtM7Ej/So3hR0g=
github.com/matoous/godox v0.0.0-20210227103229-6504466cf951 h1:pWxk9e//NbPwfxat7RXkts09K+dEBJWakUWwICVqYbA=
github.com/matoous/godox v0.0.0-20210227103229-6504466cf951/go.mod h1:1BELzlh859Sh1c6+90blK8lbYy0kwQf1bYlBhBysy1s=
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=

View File

@ -5,20 +5,35 @@ import (
)
type Share struct {
ID string `structs:"id" json:"id" orm:"column(id)"`
Name string `structs:"name" json:"name"`
Description string `structs:"description" json:"description"`
ExpiresAt time.Time `structs:"expires_at" json:"expiresAt"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
LastVisitedAt time.Time `structs:"last_visited_at" json:"lastVisitedAt"`
ResourceIDs string `structs:"resource_ids" json:"resourceIds" orm:"column(resource_ids)"`
ResourceType string `structs:"resource_type" json:"resourceType"`
VisitCount int `structs:"visit_count" json:"visitCount"`
ID string `structs:"id" json:"id,omitempty" orm:"column(id)"`
UserID string `structs:"user_id" json:"userId,omitempty" orm:"column(user_id)"`
Username string `structs:"-" json:"username,omitempty" orm:"-"`
Description string `structs:"description" json:"description,omitempty"`
ExpiresAt *time.Time `structs:"expires_at" json:"expiresAt,omitempty"`
LastVisitedAt *time.Time `structs:"last_visited_at" json:"lastVisitedAt,omitempty"`
ResourceIDs string `structs:"resource_ids" json:"resourceIds,omitempty" orm:"column(resource_ids)"`
ResourceType string `structs:"resource_type" json:"resourceType,omitempty"`
Contents string `structs:"contents" json:"contents,omitempty"`
Format string `structs:"format" json:"format,omitempty"`
MaxBitRate int `structs:"max_bit_rate" json:"maxBitRate,omitempty"`
VisitCount int `structs:"visit_count" json:"visitCount,omitempty"`
CreatedAt *time.Time `structs:"created_at" json:"createdAt,omitempty"`
UpdatedAt *time.Time `structs:"updated_at" json:"updatedAt,omitempty"`
Tracks []ShareTrack `structs:"-" json:"tracks,omitempty"`
}
type ShareTrack struct {
ID string `json:"id,omitempty"`
Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
Duration float32 `json:"duration,omitempty"`
}
type Shares []Share
type ShareRepository interface {
Put(s *Share) error
Exists(id string) (bool, error)
GetAll(options ...QueryOptions) (Shares, error)
}

View File

@ -3,6 +3,7 @@ package persistence
import (
"context"
"errors"
"time"
. "github.com/Masterminds/squirrel"
"github.com/beego/beego/v2/client/orm"
@ -32,9 +33,13 @@ func (r *shareRepository) Delete(id string) error {
}
func (r *shareRepository) selectShare(options ...model.QueryOptions) SelectBuilder {
return r.newSelect(options...).Columns("*")
return r.newSelect(options...).Join("user u on u.id = share.user_id").
Columns("share.*", "user_name as username")
}
func (r *shareRepository) Exists(id string) (bool, error) {
return r.exists(Select().Where(Eq{"id": id}))
}
func (r *shareRepository) GetAll(options ...model.QueryOptions) (model.Shares, error) {
sq := r.selectShare(options...)
res := model.Shares{}
@ -42,14 +47,13 @@ func (r *shareRepository) GetAll(options ...model.QueryOptions) (model.Shares, e
return res, err
}
func (r *shareRepository) Put(s *model.Share) error {
_, err := r.put(s.ID, s)
return err
}
func (r *shareRepository) Update(id string, entity interface{}, cols ...string) error {
s := entity.(*model.Share)
// TODO Validate record
s.ID = id
now := time.Now()
s.UpdatedAt = &now
cols = append(cols, "updated_at")
_, err := r.put(id, s, cols...)
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound
@ -59,6 +63,14 @@ func (r *shareRepository) Update(id string, entity interface{}, cols ...string)
func (r *shareRepository) Save(entity interface{}) (string, error) {
s := entity.(*model.Share)
// TODO Validate record
u := loggedUser(r.ctx)
if s.UserID == "" {
s.UserID = u.ID
}
now := time.Now()
s.CreatedAt = &now
s.UpdatedAt = &now
id, err := r.put(s.ID, s)
if errors.Is(err, model.ErrNotFound) {
return "", rest.ErrNotFound
@ -83,7 +95,7 @@ func (r *shareRepository) NewInstance() interface{} {
}
func (r *shareRepository) Get(id string) (*model.Share, error) {
sel := r.newSelect().Columns("*").Where(Eq{"id": id})
sel := r.selectShare().Columns("*").Where(Eq{"share.id": id})
var res model.Share
err := r.queryOne(sel, &res)
return &res, err

View File

@ -71,7 +71,7 @@ var _ = Describe("Auth", func() {
It("sets auth data if IPv4 matches whitelist", func() {
req.RemoteAddr = "192.168.0.42:25293"
serveIndex(ds, fs)(resp, req)
serveIndex(ds, fs, nil)(resp, req)
config := extractAppConfig(resp.Body.String())
parsed := config["auth"].(map[string]interface{})
@ -81,7 +81,7 @@ var _ = Describe("Auth", func() {
It("sets no auth data if IPv4 does not match whitelist", func() {
req.RemoteAddr = "8.8.8.8:25293"
serveIndex(ds, fs)(resp, req)
serveIndex(ds, fs, nil)(resp, req)
config := extractAppConfig(resp.Body.String())
Expect(config["auth"]).To(BeNil())
@ -89,7 +89,7 @@ var _ = Describe("Auth", func() {
It("sets auth data if IPv6 matches whitelist", func() {
req.RemoteAddr = "[2001:4860:4860:1234:5678:0000:4242:8888]:25293"
serveIndex(ds, fs)(resp, req)
serveIndex(ds, fs, nil)(resp, req)
config := extractAppConfig(resp.Body.String())
parsed := config["auth"].(map[string]interface{})
@ -99,7 +99,7 @@ var _ = Describe("Auth", func() {
It("sets no auth data if IPv6 does not match whitelist", func() {
req.RemoteAddr = "[5005:0:3003]:25293"
serveIndex(ds, fs)(resp, req)
serveIndex(ds, fs, nil)(resp, req)
config := extractAppConfig(resp.Body.String())
Expect(config["auth"]).To(BeNil())
@ -107,7 +107,7 @@ var _ = Describe("Auth", func() {
It("sets no auth data if user does not exist", func() {
req.Header.Set("Remote-User", "INVALID_USER")
serveIndex(ds, fs)(resp, req)
serveIndex(ds, fs, nil)(resp, req)
config := extractAppConfig(resp.Body.String())
Expect(config["auth"]).To(BeNil())
@ -115,7 +115,7 @@ var _ = Describe("Auth", func() {
It("sets auth data if user exists", func() {
req.RemoteAddr = "192.168.0.42:25293"
serveIndex(ds, fs)(resp, req)
serveIndex(ds, fs, nil)(resp, req)
config := extractAppConfig(resp.Body.String())
parsed := config["auth"].(map[string]interface{})

View File

@ -34,5 +34,10 @@ func DecodeArtworkID(tokenString string) (model.ArtworkID, error) {
if !ok {
return model.ArtworkID{}, errors.New("invalid id type")
}
return model.ParseArtworkID(id)
artID, err := model.ParseArtworkID(id)
if err == nil {
return artID, nil
}
// Try to default to mediafile artworkId
return model.ParseArtworkID("mf-" + id)
}

View File

@ -8,6 +8,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@ -17,11 +18,12 @@ import (
type Router struct {
http.Handler
artwork artwork.Artwork
artwork artwork.Artwork
streamer core.MediaStreamer
}
func New(artwork artwork.Artwork) *Router {
p := &Router{artwork: artwork}
func New(artwork artwork.Artwork, streamer core.MediaStreamer) *Router {
p := &Router{artwork: artwork, streamer: streamer}
p.Handler = p.routes()
return p
@ -32,6 +34,7 @@ func (p *Router) routes() http.Handler {
r.Group(func(r chi.Router) {
r.Use(server.URLParamsMiddleware)
r.HandleFunc("/s/{id}", p.handleStream)
r.HandleFunc("/img/{id}", p.handleImages)
})
return r

View File

@ -0,0 +1,104 @@
package public
import (
"context"
"errors"
"io"
"net/http"
"strconv"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils"
)
func (p *Router) handleStream(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
tokenId := r.URL.Query().Get(":id")
info, err := decodeStreamInfo(tokenId)
if err != nil {
log.Error(ctx, "Error parsing shared stream info", err)
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
stream, err := p.streamer.NewStream(ctx, info.id, info.format, info.bitrate)
if err != nil {
log.Error(ctx, "Error starting shared stream", err)
http.Error(w, "invalid request", http.StatusInternalServerError)
}
// Make sure the stream will be closed at the end, to avoid leakage
defer func() {
if err := stream.Close(); err != nil && log.CurrentLevel() >= log.LevelDebug {
log.Error("Error closing shared stream", "id", info.id, "file", stream.Name(), err)
}
}()
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32))
if stream.Seekable() {
http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream)
} else {
// If the stream doesn't provide a size (i.e. is not seekable), we can't support ranges/content-length
w.Header().Set("Accept-Ranges", "none")
w.Header().Set("Content-Type", stream.ContentType())
estimateContentLength := utils.ParamBool(r, "estimateContentLength", false)
// if Client requests the estimated content-length, send it
if estimateContentLength {
length := strconv.Itoa(stream.EstimatedContentLength())
log.Trace(ctx, "Estimated content-length", "contentLength", length)
w.Header().Set("Content-Length", length)
}
if r.Method == http.MethodHead {
go func() { _, _ = io.Copy(io.Discard, stream) }()
} else {
c, err := io.Copy(w, stream)
if log.CurrentLevel() >= log.LevelDebug {
if err != nil {
log.Error(ctx, "Error sending shared transcoded file", "id", info.id, err)
} else {
log.Trace(ctx, "Success sending shared transcode file", "id", info.id, "size", c)
}
}
}
}
}
type shareTrackInfo struct {
id string
format string
bitrate int
}
func decodeStreamInfo(tokenString string) (shareTrackInfo, error) {
token, err := auth.TokenAuth.Decode(tokenString)
if err != nil {
return shareTrackInfo{}, err
}
if token == nil {
return shareTrackInfo{}, errors.New("unauthorized")
}
err = jwt.Validate(token, jwt.WithRequiredClaim("id"))
if err != nil {
return shareTrackInfo{}, err
}
claims, err := token.AsMap(context.Background())
if err != nil {
return shareTrackInfo{}, err
}
id, ok := claims["id"].(string)
if !ok {
return shareTrackInfo{}, errors.New("invalid id type")
}
resp := shareTrackInfo{}
resp.id = id
resp.format, ok = claims["f"].(string)
resp.bitrate, ok = claims["b"].(int)
return resp, nil
}

View File

@ -16,8 +16,16 @@ import (
"github.com/navidrome/navidrome/utils"
)
func Index(ds model.DataStore, fs fs.FS) http.HandlerFunc {
return serveIndex(ds, fs, nil)
}
func IndexWithShare(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.HandlerFunc {
return serveIndex(ds, fs, shareInfo)
}
// Injects the config in the `index.html` template
func serveIndex(ds model.DataStore, fs fs.FS) http.HandlerFunc {
func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c, err := ds.User(r.Context()).CountAll()
firstTime := c == 0 && err == nil
@ -61,11 +69,18 @@ func serveIndex(ds model.DataStore, fs fs.FS) http.HandlerFunc {
if auth != nil {
appConfig["auth"] = auth
}
j, err := json.Marshal(appConfig)
appConfigJson, err := json.Marshal(appConfig)
if err != nil {
log.Error(r, "Error converting config to JSON", "config", appConfig, err)
} else {
log.Trace(r, "Injecting config in index.html", "config", string(j))
log.Trace(r, "Injecting config in index.html", "config", string(appConfigJson))
}
shareInfoJson, err := json.Marshal(shareInfo)
if err != nil {
log.Error(r, "Error converting shareInfo to JSON", "config", shareInfo, err)
} else {
log.Trace(r, "Injecting shareInfo in index.html", "config", string(shareInfoJson))
}
log.Debug("UI configuration", "appConfig", appConfig)
@ -74,7 +89,8 @@ func serveIndex(ds model.DataStore, fs fs.FS) http.HandlerFunc {
version = "v" + version
}
data := map[string]interface{}{
"AppConfig": string(j),
"AppConfig": string(appConfigJson),
"ShareInfo": string(shareInfoJson),
"Version": version,
}
w.Header().Set("Content-Type", "text/html")

View File

@ -32,7 +32,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
Expect(w.Code).To(Equal(200))
config := extractAppConfig(w.Body.String())
@ -44,7 +44,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("firstTime", true))
@ -55,7 +55,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("variousArtistsId", consts.VariousArtistsID))
@ -66,7 +66,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("firstTime", false))
@ -77,7 +77,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("baseURL", "base_url_test"))
@ -88,7 +88,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("welcomeMessage", "Hello"))
@ -99,7 +99,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("enableTranscodingConfig", true))
@ -110,7 +110,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("enableDownloads", true))
@ -121,7 +121,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("enableFavourites", true))
@ -132,7 +132,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("enableStarRating", true))
@ -143,7 +143,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("defaultTheme", "Light"))
@ -154,7 +154,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("defaultLanguage", "pt"))
@ -165,7 +165,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("defaultUIVolume", float64(45)))
@ -176,7 +176,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("enableCoverAnimation", true))
@ -187,7 +187,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("gaTrackingId", "UA-12345"))
@ -197,7 +197,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("version", consts.Version))
@ -207,7 +207,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
expected := strings.ToUpper(strings.Join(consts.LosslessFormats, ","))
@ -218,7 +218,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("enableUserEditing", true))
@ -228,7 +228,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("devEnableShare", false))
@ -240,7 +240,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("devSidebarPlaylists", true))
@ -250,7 +250,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("lastFMEnabled", true))
@ -261,7 +261,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("lastFMApiKey", "APIKEY-123"))
@ -272,7 +272,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("devShowArtistPage", true))
@ -283,7 +283,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("listenBrainzEnabled", true))
@ -294,7 +294,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("enableReplayGain", true))
@ -311,7 +311,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", consts.DefaultUILoginBackgroundURL))
@ -323,7 +323,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", consts.DefaultUILoginBackgroundURLOffline))
@ -335,7 +335,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", "https://example.com/images/1.jpg"))
@ -352,7 +352,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", "/music"+consts.DefaultUILoginBackgroundURL))
@ -364,7 +364,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", consts.DefaultUILoginBackgroundURLOffline))
@ -376,7 +376,7 @@ var _ = Describe("serveIndex", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", "https://example.com/images/1.jpg"))

View File

@ -133,7 +133,7 @@ func (s *Server) initRoutes() {
func (s *Server) frontendAssetsHandler() http.Handler {
r := chi.NewRouter()
r.Handle("/", serveIndex(s.ds, ui.BuildAssets()))
r.Handle("/", Index(s.ds, ui.BuildAssets()))
r.Handle("/*", http.StripPrefix(s.appRoot, http.FileServer(http.FS(ui.BuildAssets()))))
return r
}

View File

@ -0,0 +1,96 @@
package shares
import (
"errors"
"net/http"
"path"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/ui"
)
type Router struct {
http.Handler
ds model.DataStore
share core.Share
assetsHandler http.Handler
streamer core.MediaStreamer
}
func New(ds model.DataStore, share core.Share) *Router {
p := &Router{ds: ds, share: share}
shareRoot := path.Join(conf.Server.BaseURL, consts.URLPathShares)
p.assetsHandler = http.StripPrefix(shareRoot, http.FileServer(http.FS(ui.BuildAssets())))
p.Handler = p.routes()
return p
}
func (p *Router) routes() http.Handler {
r := chi.NewRouter()
r.Group(func(r chi.Router) {
r.Use(server.URLParamsMiddleware)
r.HandleFunc("/{id}", p.handleShares)
r.Handle("/*", p.assetsHandler)
})
return r
}
func (p *Router) handleShares(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get(":id")
if id == "" {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
// If requested file is a UI asset, just serve it
_, err := ui.BuildAssets().Open(id)
if err == nil {
p.assetsHandler.ServeHTTP(w, r)
return
}
// If it is not, consider it a share ID
s, err := p.share.Load(r.Context(), id)
switch {
case errors.Is(err, model.ErrNotFound):
log.Error(r, "Share not found", "id", id, err)
http.Error(w, "Share not found", http.StatusNotFound)
case err != nil:
log.Error(r, "Error retrieving share", "id", id, err)
http.Error(w, "Error retrieving share", http.StatusInternalServerError)
}
if err != nil {
return
}
s = p.mapShareInfo(s)
server.IndexWithShare(p.ds, ui.BuildAssets(), s)(w, r)
}
func (p *Router) mapShareInfo(s *model.Share) *model.Share {
mapped := &model.Share{
Description: s.Description,
Tracks: s.Tracks,
}
for i := range s.Tracks {
claims := map[string]any{"id": s.Tracks[i].ID}
if s.Format != "" {
claims["f"] = s.Format
}
if s.MaxBitRate != 0 {
claims["b"] = s.MaxBitRate
}
id, _ := auth.CreateExpiringPublicToken(*s.ExpiresAt, claims)
mapped.Tracks[i].ID = id
}
return mapped
}

File diff suppressed because one or more lines are too long

View File

@ -15,6 +15,7 @@ import album from './album'
import artist from './artist'
import playlist from './playlist'
import radio from './radio'
import share from './share'
import { Player } from './audioplayer'
import customRoutes from './routes'
import {
@ -31,10 +32,11 @@ import {
} from './reducers'
import createAdminStore from './store/createAdminStore'
import { i18nProvider } from './i18n'
import config from './config'
import config, { shareInfo } from './config'
import { setDispatch, startEventStream, stopEventStream } from './eventStream'
import { keyMap } from './hotkeys'
import useChangeThemeColor from './useChangeThemeColor'
import ShareApp from './ShareApp'
const history = createHashHistory()
@ -106,6 +108,7 @@ const Admin = (props) => {
name="radio"
{...(permissions === 'admin' ? radio.admin : radio.all)}
/>,
config.devEnableShare && <Resource name="share" {...share} />,
<Resource
name="playlist"
{...playlist}
@ -136,10 +139,15 @@ const Admin = (props) => {
)
}
const AppWithHotkeys = () => (
<HotKeys keyMap={keyMap}>
<App />
</HotKeys>
)
const AppWithHotkeys = () => {
if (config.devEnableShare && shareInfo) {
return <ShareApp />
}
return (
<HotKeys keyMap={keyMap}>
<App />
</HotKeys>
)
}
export default AppWithHotkeys

26
ui/src/ShareApp.js Normal file
View File

@ -0,0 +1,26 @@
import ReactJkMusicPlayer from 'navidrome-music-player'
import config, { shareInfo } from './config'
import { baseUrl } from './utils'
const ShareApp = (props) => {
const list = shareInfo?.tracks.map((s) => {
return {
name: s.title,
musicSrc: baseUrl(config.publicBaseUrl + '/s/' + s.id),
cover: baseUrl(config.publicBaseUrl + '/img/' + s.id),
singer: s.artist,
duration: s.duration,
}
})
const options = {
audioLists: list,
mode: 'full',
mobileMediaQuery: '',
showDownload: false,
showReload: false,
showMediaSession: true,
}
return <ReactJkMusicPlayer {...options} />
}
export default ShareApp

View File

@ -25,6 +25,9 @@ import { formatBytes } from '../utils'
import { useMediaQuery, makeStyles } from '@material-ui/core'
import config from '../config'
import { ToggleFieldsMenu } from '../common'
import { useDialog } from '../dialogs/useDialog'
import { ShareDialog } from '../dialogs/ShareDialog'
import ShareIcon from '@material-ui/icons/Share'
const useStyles = makeStyles({
toolbar: { display: 'flex', justifyContent: 'space-between', width: '100%' },
@ -43,6 +46,7 @@ const AlbumActions = ({
const classes = useStyles()
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm'))
const shareDialog = useDialog()
const handlePlay = React.useCallback(() => {
dispatch(playTracks(data, ids))
@ -102,6 +106,14 @@ const AlbumActions = ({
>
<PlaylistAddIcon />
</Button>
{config.devEnableShare && (
<Button
onClick={shareDialog.open}
label={translate('resources.album.actions.share')}
>
<ShareIcon />
</Button>
)}
{config.enableDownloads && (
<Button
onClick={handleDownload}
@ -116,6 +128,12 @@ const AlbumActions = ({
</div>
<div>{isNotSmall && <ToggleFieldsMenu resource="albumSong" />}</div>
</div>
<ShareDialog
{...shareDialog.props}
close={shareDialog.close}
ids={[record.id]}
resource={'album'}
/>
</TopToolbar>
)
}

View File

@ -27,7 +27,7 @@ export const QualityInfo = ({ record, size, gainMode, preAmp, className }) => {
if (suffix) {
suffix = suffix.toUpperCase()
info = suffix
if (!llFormats.has(suffix)) {
if (!llFormats.has(suffix) && bitRate > 0) {
info += ' ' + bitRate
}
}

View File

@ -28,6 +28,8 @@ const defaultConfig = {
enableCoverAnimation: true,
devShowArtistPage: true,
enableReplayGain: true,
shareBaseUrl: '/s',
publicBaseUrl: '/p',
}
let config
@ -42,4 +44,12 @@ try {
config = defaultConfig
}
export let shareInfo
try {
shareInfo = JSON.parse(window.__SHARE_INFO__)
} catch (e) {
shareInfo = null
}
export default config

View File

@ -0,0 +1,134 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
} from '@material-ui/core'
import {
SelectInput,
SimpleForm,
useCreate,
useGetList,
useNotify,
} from 'react-admin'
import { useMemo, useState } from 'react'
import { shareUrl } from '../utils'
import Typography from '@material-ui/core/Typography'
export const ShareDialog = ({ open, close, onClose, ids, resource }) => {
const notify = useNotify()
const [format, setFormat] = useState('')
const [maxBitRate, setMaxBitRate] = useState(0)
const { data: formats, loading } = useGetList(
'transcoding',
{
page: 1,
perPage: 1000,
},
{ field: 'name', order: 'ASC' }
)
const formatOptions = useMemo(
() =>
loading
? []
: Object.values(formats).map((f) => {
return { id: f.targetFormat, name: f.targetFormat }
}),
[formats, loading]
)
const [createShare] = useCreate(
'share',
{
resourceType: resource,
resourceIds: ids?.join(','),
format,
maxBitRate,
},
{
onSuccess: (res) => {
const url = shareUrl(res?.data?.id)
close()
navigator.clipboard
.writeText(url)
.then(() => {
notify(`URL copied to clipboard: ${url}`, {
type: 'info',
multiLine: true,
duration: 0,
})
})
.catch((err) => {
notify(`Error copying URL ${url} to clipboard: ${err.message}`, {
type: 'warning',
multiLine: true,
duration: 0,
})
})
},
onFailure: (error) =>
notify(`Error sharing media: ${error.message}`, { type: 'warning' }),
}
)
return (
<Dialog
open={open}
onClose={onClose}
onBackdropClick={onClose}
aria-labelledby="info-dialog-album"
fullWidth={true}
maxWidth={'sm'}
>
<DialogTitle id="info-dialog-album">
Create a link to share your music with friends
</DialogTitle>
<DialogContent>
<SimpleForm toolbar={null} variant={'outlined'}>
<Typography variant="body1">Select transcoding options:</Typography>
<Typography variant="caption">
(Leave options empty for original quality)
</Typography>
<SelectInput
source="format"
choices={formatOptions}
resettable
onChange={(event) => {
setFormat(event.target.value)
}}
/>
<SelectInput
source="bitrate"
choices={[
{ id: 32, name: '32' },
{ id: 48, name: '48' },
{ id: 64, name: '64' },
{ id: 80, name: '80' },
{ id: 96, name: '96' },
{ id: 112, name: '112' },
{ id: 128, name: '128' },
{ id: 160, name: '160' },
{ id: 192, name: '192' },
{ id: 256, name: '256' },
{ id: 320, name: '320' },
]}
resettable
onChange={(event) => {
setMaxBitRate(event.target.value)
}}
/>
</SimpleForm>
</DialogContent>
<DialogActions>
<Button onClick={createShare} color="primary">
Share
</Button>
<Button onClick={onClose} color="primary">
Cancel
</Button>
</DialogActions>
</Dialog>
)
}

View File

@ -0,0 +1,30 @@
import { useCallback, useMemo, useState } from 'react'
// Idea from https://blog.bitsrc.io/new-react-design-pattern-return-component-from-hooks-79215c3eac00
export const useDialog = () => {
const [anchorEl, setAnchorEl] = useState(null)
const open = useCallback((event) => {
event?.stopPropagation()
setAnchorEl(event.currentTarget)
}, [])
const close = useCallback((event) => {
event?.stopPropagation()
setAnchorEl(null)
}, [])
const props = useMemo(() => {
return {
anchorEl,
open: Boolean(anchorEl),
onClose: close,
}
}, [anchorEl, close])
return {
open,
close,
props,
}
}

View File

@ -60,6 +60,7 @@
"playAll": "Play",
"playNext": "Play Next",
"addToQueue": "Play Later",
"share": "Share",
"shuffle": "Shuffle",
"addToPlaylist": "Add to Playlist",
"download": "Download",
@ -180,6 +181,24 @@
"actions": {
"playNow": "Play Now"
}
},
"share": {
"name": "Share |||| Shares",
"fields": {
"username": "Shared By",
"url": "URL",
"description": "Description",
"contents": "Contents",
"expiresAt": "Expires at",
"lastVisitedAt": "Last Visited at",
"visitCount": "Visits",
"updatedAt": "Updated at",
"createdAt": "Created at"
},
"notifications": {
},
"actions": {
}
}
},
"ra": {
@ -433,4 +452,4 @@
"toggle_love": "Add this track to favourites"
}
}
}
}

View File

@ -20,6 +20,9 @@ const initialState = {
savedPlayIndex: 0,
}
const timestampRegex =
/(\[([0-9]{1,2}:)?([0-9]{1,2}:)([0-9]{1,2})(\.[0-9]{1,2})?\])/g
const mapToAudioLists = (item) => {
// If item comes from a playlist, trackId is mediaFileId
const trackId = item.mediaFileId || item.id
@ -37,8 +40,6 @@ const mapToAudioLists = (item) => {
}
const { lyrics } = item
const timestampRegex =
/(\[([0-9]{1,2}:)?([0-9]{1,2}:)([0-9]{1,2})(\.[0-9]{1,2})?\])/g
return {
trackId,
uuid: uuidv4(),

33
ui/src/share/ShareEdit.js Normal file
View File

@ -0,0 +1,33 @@
import {
DateField,
DateInput,
Edit,
NumberField,
SimpleForm,
TextInput,
} from 'react-admin'
import { shareUrl } from '../utils'
import { Link } from '@material-ui/core'
export const ShareEdit = (props) => {
const { id } = props
const url = shareUrl(id)
return (
<Edit {...props}>
<SimpleForm>
<Link source="URL" href={url} target="_blank" rel="noopener noreferrer">
{url}
</Link>
<TextInput source="description" />
<TextInput source="contents" disabled />
<TextInput source="format" disabled />
<TextInput source="maxBitRate" disabled />
<DateInput source="expiresAt" disabled />
<TextInput source="username" disabled />
<NumberField source="visitCount" disabled />
<DateField source="lastVisitedAt" disabled />
<DateField source="createdAt" disabled />
</SimpleForm>
</Edit>
)
}

53
ui/src/share/ShareList.js Normal file
View File

@ -0,0 +1,53 @@
import {
Datagrid,
FunctionField,
List,
NumberField,
TextField,
} from 'react-admin'
import React from 'react'
import { DateField, QualityInfo } from '../common'
import { shareUrl } from '../utils'
import { Link } from '@material-ui/core'
export const FormatInfo = ({ record, size }) => {
const r = { suffix: record.format, bitRate: record.maxBitRate }
// TODO Get DefaultDownsamplingFormat
r.suffix = r.suffix || (r.bitRate ? 'opus' : 'Original')
return <QualityInfo record={r} size={size} />
}
const ShareList = (props) => {
return (
<List
{...props}
sort={{ field: 'createdAt', order: 'DESC' }}
exporter={false}
>
<Datagrid rowClick="edit">
<FunctionField
source={'id'}
render={(r) => (
<Link
href={shareUrl(r.id)}
label="URL"
target="_blank"
rel="noopener noreferrer"
>
{r.id}
</Link>
)}
/>
<TextField source="username" />
<TextField source="description" />
<DateField source="contents" />
<FormatInfo source="format" />
<NumberField source="visitCount" />
<DateField source="expiresAt" showTime />
<DateField source="lastVisitedAt" showTime sortByOrder={'DESC'} />
</Datagrid>
</List>
)
}
export default ShareList

9
ui/src/share/index.js Normal file
View File

@ -0,0 +1,9 @@
import ShareList from './ShareList'
import { ShareEdit } from './ShareEdit'
import ShareIcon from '@material-ui/icons/Share'
export default {
list: ShareList,
edit: ShareEdit,
icon: <ShareIcon />,
}

View File

@ -69,8 +69,13 @@ const getAlbumInfo = (id) => {
return httpClient(url('getAlbumInfo', id))
}
const streamUrl = (id) => {
return baseUrl(url('stream', id, { ts: true }))
const streamUrl = (id, options) => {
return baseUrl(
url('stream', id, {
ts: true,
...options,
})
)
}
export default {

View File

@ -4,3 +4,4 @@ export * from './formatters'
export * from './intersperse'
export * from './notifications'
export * from './openInNewTab'
export * from './shareUrl'

6
ui/src/utils/shareUrl.js Normal file
View File

@ -0,0 +1,6 @@
import config from '../config'
export const shareUrl = (path) => {
const url = new URL(config.shareBaseUrl + '/' + path, window.location.href)
return url.href
}