diff --git a/cmd/root.go b/cmd/root.go index e7b694a24..da088e767 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -84,9 +84,6 @@ 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()) } diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index f1df1ad17..abe023cfb 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -22,7 +22,6 @@ 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" ) @@ -75,15 +74,8 @@ func CreatePublicRouter() *public.Router { artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata) 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) + router := public.New(dataStore, artworkArtwork, mediaStreamer, share) return router } @@ -118,7 +110,7 @@ func createScanner() scanner.Scanner { // wire_injectors.go: -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) +var allProviders = wire.NewSet(core.Set, artwork.Set, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, db.Db) // Scanner must be a Singleton var ( diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index 1f41e1a36..cc896421f 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -17,7 +17,6 @@ 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" ) @@ -27,7 +26,6 @@ var allProviders = wire.NewSet( subsonic.New, nativeapi.New, public.New, - shares.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, @@ -61,12 +59,6 @@ func CreatePublicRouter() *public.Router { )) } -func CreateSharesRouter() *shares.Router { - panic(wire.Build( - allProviders, - )) -} - func CreateLastFMRouter() *lastfm.Router { panic(wire.Build( allProviders, diff --git a/consts/consts.go b/consts/consts.go index 2a3f5956b..9bad1ee46 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -34,7 +34,6 @@ 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 diff --git a/core/share.go b/core/share.go index 6a696a799..736a15772 100644 --- a/core/share.go +++ b/core/share.go @@ -35,8 +35,7 @@ func (s *shareService) Load(ctx context.Context, id string) (*model.Share, error return nil, err } share := entity.(*model.Share) - now := time.Now() - share.LastVisitedAt = &now + share.LastVisitedAt = time.Now() share.VisitCount++ err = repo.(rest.Persistable).Update(id, share, "last_visited_at", "visit_count") @@ -112,8 +111,7 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) { } s.ID = id if s.ExpiresAt.IsZero() { - exp := time.Now().Add(365 * 24 * time.Hour) - s.ExpiresAt = &exp + s.ExpiresAt = time.Now().Add(365 * 24 * time.Hour) } id, err = r.Persistable.Save(s) return id, err diff --git a/model/share.go b/model/share.go index 40b25e526..b689f1556 100644 --- a/model/share.go +++ b/model/share.go @@ -9,16 +9,16 @@ type Share struct { 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"` + 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"` + CreatedAt time.Time `structs:"created_at" json:"createdAt,omitempty"` + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt,omitempty"` Tracks []ShareTrack `structs:"-" json:"tracks,omitempty"` } diff --git a/persistence/share_repository.go b/persistence/share_repository.go index 5023c7365..aa0720d12 100644 --- a/persistence/share_repository.go +++ b/persistence/share_repository.go @@ -51,8 +51,7 @@ func (r *shareRepository) Update(id string, entity interface{}, cols ...string) s := entity.(*model.Share) // TODO Validate record s.ID = id - now := time.Now() - s.UpdatedAt = &now + s.UpdatedAt = time.Now() cols = append(cols, "updated_at") _, err := r.put(id, s, cols...) if errors.Is(err, model.ErrNotFound) { @@ -68,9 +67,8 @@ func (r *shareRepository) Save(entity interface{}) (string, error) { if s.UserID == "" { s.UserID = u.ID } - now := time.Now() - s.CreatedAt = &now - s.UpdatedAt = &now + s.CreatedAt = time.Now() + s.UpdatedAt = time.Now() id, err := r.put(s.ID, s) if errors.Is(err, model.ErrNotFound) { return "", rest.ErrNotFound diff --git a/server/public/handle_images.go b/server/public/handle_images.go new file mode 100644 index 000000000..0b0455331 --- /dev/null +++ b/server/public/handle_images.go @@ -0,0 +1,53 @@ +package public + +import ( + "context" + "errors" + "io" + "net/http" + "time" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" +) + +func (p *Router) handleImages(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + id := r.URL.Query().Get(":id") + if id == "" { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + artId, err := DecodeArtworkID(id) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + size := utils.ParamInt(r, "size", 0) + imgReader, lastUpdate, err := p.artwork.Get(ctx, artId.String(), size) + + switch { + case errors.Is(err, context.Canceled): + return + case errors.Is(err, model.ErrNotFound): + log.Error(r, "Couldn't find coverArt", "id", id, err) + http.Error(w, "Artwork not found", http.StatusNotFound) + return + case err != nil: + log.Error(r, "Error retrieving coverArt", "id", id, err) + http.Error(w, "Error retrieving coverArt", http.StatusInternalServerError) + return + } + + defer imgReader.Close() + w.Header().Set("Cache-Control", "public, max-age=315360000") + w.Header().Set("Last-Modified", lastUpdate.Format(time.RFC1123)) + cnt, err := io.Copy(w, imgReader) + if err != nil { + log.Warn(ctx, "Error sending image", "count", cnt, err) + } +} diff --git a/server/shares/share_endpoint.go b/server/public/handle_shares.go similarity index 63% rename from server/shares/share_endpoint.go rename to server/public/handle_shares.go index 822b06e33..e0d7dde2d 100644 --- a/server/shares/share_endpoint.go +++ b/server/public/handle_shares.go @@ -1,14 +1,9 @@ -package shares +package public 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" @@ -16,34 +11,6 @@ import ( "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 == "" { @@ -82,6 +49,7 @@ func (p *Router) mapShareInfo(s *model.Share) *model.Share { Tracks: s.Tracks, } for i := range s.Tracks { + // TODO Use Encode(Artwork)ID? claims := map[string]any{"id": s.Tracks[i].ID} if s.Format != "" { claims["f"] = s.Format @@ -89,7 +57,7 @@ func (p *Router) mapShareInfo(s *model.Share) *model.Share { if s.MaxBitRate != 0 { claims["b"] = s.MaxBitRate } - id, _ := auth.CreateExpiringPublicToken(*s.ExpiresAt, claims) + id, _ := auth.CreateExpiringPublicToken(s.ExpiresAt, claims) mapped.Tracks[i].ID = id } return mapped diff --git a/server/public/share_stream.go b/server/public/handle_streams.go similarity index 100% rename from server/public/share_stream.go rename to server/public/handle_streams.go diff --git a/server/public/public_endpoints.go b/server/public/public_endpoints.go index 0afab8ecc..e6e2551f5 100644 --- a/server/public/public_endpoints.go +++ b/server/public/public_endpoints.go @@ -1,29 +1,32 @@ package public import ( - "context" - "errors" - "io" "net/http" - "time" + "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/artwork" - "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/ui" ) type Router struct { http.Handler - artwork artwork.Artwork - streamer core.MediaStreamer + artwork artwork.Artwork + streamer core.MediaStreamer + share core.Share + assetsHandler http.Handler + ds model.DataStore } -func New(artwork artwork.Artwork, streamer core.MediaStreamer) *Router { - p := &Router{artwork: artwork, streamer: streamer} +func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, share core.Share) *Router { + p := &Router{ds: ds, artwork: artwork, streamer: streamer, share: share} + shareRoot := path.Join(conf.Server.BaseURL, consts.URLPathPublic) + p.assetsHandler = http.StripPrefix(shareRoot, http.FileServer(http.FS(ui.BuildAssets()))) p.Handler = p.routes() return p @@ -34,48 +37,12 @@ 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) + if conf.Server.DevEnableShare { + r.HandleFunc("/s/{id}", p.handleStream) + r.HandleFunc("/{id}", p.handleShares) + r.Handle("/*", p.assetsHandler) + } }) return r } - -func (p *Router) handleImages(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - id := r.URL.Query().Get(":id") - if id == "" { - http.Error(w, "invalid id", http.StatusBadRequest) - return - } - - artId, err := DecodeArtworkID(id) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - size := utils.ParamInt(r, "size", 0) - imgReader, lastUpdate, err := p.artwork.Get(ctx, artId.String(), size) - - switch { - case errors.Is(err, context.Canceled): - return - case errors.Is(err, model.ErrNotFound): - log.Error(r, "Couldn't find coverArt", "id", id, err) - http.Error(w, "Artwork not found", http.StatusNotFound) - return - case err != nil: - log.Error(r, "Error retrieving coverArt", "id", id, err) - http.Error(w, "Error retrieving coverArt", http.StatusInternalServerError) - return - } - - defer imgReader.Close() - w.Header().Set("Cache-Control", "public, max-age=315360000") - w.Header().Set("Last-Modified", lastUpdate.Format(time.RFC1123)) - cnt, err := io.Copy(w, imgReader) - if err != nil { - log.Warn(ctx, "Error sending image", "count", cnt, err) - } -} diff --git a/ui/public/index.html b/ui/public/index.html index 2ffa2037b..c78d7e308 100644 --- a/ui/public/index.html +++ b/ui/public/index.html @@ -34,7 +34,6 @@ window.__APP_CONFIG__ = {{ .AppConfig }} diff --git a/ui/src/App.js b/ui/src/App.js index 77ef41751..db2fe06d2 100644 --- a/ui/src/App.js +++ b/ui/src/App.js @@ -36,7 +36,7 @@ import config, { shareInfo } from './config' import { setDispatch, startEventStream, stopEventStream } from './eventStream' import { keyMap } from './hotkeys' import useChangeThemeColor from './useChangeThemeColor' -import ShareApp from './ShareApp' +import SharePlayer from './SharePlayer' const history = createHashHistory() @@ -141,7 +141,7 @@ const Admin = (props) => { const AppWithHotkeys = () => { if (config.devEnableShare && shareInfo) { - return + return } return ( diff --git a/ui/src/ShareApp.js b/ui/src/SharePlayer.js similarity index 75% rename from ui/src/ShareApp.js rename to ui/src/SharePlayer.js index fa6980558..74df7b95b 100644 --- a/ui/src/ShareApp.js +++ b/ui/src/SharePlayer.js @@ -2,12 +2,12 @@ import ReactJkMusicPlayer from 'navidrome-music-player' import config, { shareInfo } from './config' import { baseUrl } from './utils' -const ShareApp = (props) => { +const SharePlayer = () => { const list = shareInfo?.tracks.map((s) => { return { name: s.title, musicSrc: baseUrl(config.publicBaseUrl + '/s/' + s.id), - cover: baseUrl(config.publicBaseUrl + '/img/' + s.id), + cover: baseUrl(config.publicBaseUrl + '/img/' + s.id + '?size=300'), singer: s.artist, duration: s.duration, } @@ -19,8 +19,10 @@ const ShareApp = (props) => { showDownload: false, showReload: false, showMediaSession: true, + theme: 'auto', + showThemeSwitch: false, } return } -export default ShareApp +export default SharePlayer diff --git a/ui/src/album/AlbumActions.js b/ui/src/album/AlbumActions.js index 62875c16b..c6593f578 100644 --- a/ui/src/album/AlbumActions.js +++ b/ui/src/album/AlbumActions.js @@ -133,6 +133,7 @@ const AlbumActions = ({ close={shareDialog.close} ids={[record.id]} resource={'album'} + title={`Share album '${record.name}'`} /> ) @@ -146,7 +147,6 @@ AlbumActions.propTypes = { AlbumActions.defaultProps = { record: {}, selectedIds: [], - onUnselectItems: () => null, } export default AlbumActions diff --git a/ui/src/config.js b/ui/src/config.js index cb01f98d3..1692e5792 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -28,7 +28,6 @@ const defaultConfig = { enableCoverAnimation: true, devShowArtistPage: true, enableReplayGain: true, - shareBaseUrl: '/s', publicBaseUrl: '/p', } diff --git a/ui/src/dialogs/ShareDialog.js b/ui/src/dialogs/ShareDialog.js index bd6b63e98..ef751db76 100644 --- a/ui/src/dialogs/ShareDialog.js +++ b/ui/src/dialogs/ShareDialog.js @@ -4,6 +4,8 @@ import { DialogActions, DialogContent, DialogTitle, + FormControlLabel, + Switch, } from '@material-ui/core' import { SelectInput, @@ -14,12 +16,12 @@ import { } 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 }) => { +export const ShareDialog = ({ open, close, onClose, ids, resource, title }) => { const notify = useNotify() const [format, setFormat] = useState('') const [maxBitRate, setMaxBitRate] = useState(0) + const [originalFormat, setUseOriginalFormat] = useState(true) const { data: formats, loading } = useGetList( 'transcoding', { @@ -39,6 +41,17 @@ export const ShareDialog = ({ open, close, onClose, ids, resource }) => { [formats, loading] ) + const handleOriginal = (e) => { + const original = e.target.checked + + setUseOriginalFormat(original) + + if (original) { + setFormat('') + setMaxBitRate(0) + } + } + const [createShare] = useCreate( 'share', { @@ -78,47 +91,50 @@ export const ShareDialog = ({ open, close, onClose, ids, resource }) => { open={open} onClose={onClose} onBackdropClick={onClose} - aria-labelledby="info-dialog-album" + aria-labelledby="share-dialog" fullWidth={true} maxWidth={'sm'} > - - Create a link to share your music with friends - + {title} - Select transcoding options: - - (Leave options empty for original quality) - - { - setFormat(event.target.value) - }} - /> - { - setMaxBitRate(event.target.value) - }} + } + label={'Share in original format'} + onChange={handleOriginal} /> + {!originalFormat && ( + { + setFormat(event.target.value) + }} + /> + )} + {!originalFormat && ( + { + setMaxBitRate(event.target.value) + }} + /> + )} diff --git a/ui/src/share/ShareList.js b/ui/src/share/ShareList.js index 54244a350..0c3f5e470 100644 --- a/ui/src/share/ShareList.js +++ b/ui/src/share/ShareList.js @@ -33,6 +33,9 @@ const ShareList = (props) => { label="URL" target="_blank" rel="noopener noreferrer" + onClick={(e) => { + e.stopPropagation() + }} > {r.id} diff --git a/ui/src/utils/shareUrl.js b/ui/src/utils/shareUrl.js index 0b481ce88..d93add9ce 100644 --- a/ui/src/utils/shareUrl.js +++ b/ui/src/utils/shareUrl.js @@ -1,6 +1,6 @@ import config from '../config' export const shareUrl = (path) => { - const url = new URL(config.shareBaseUrl + '/' + path, window.location.href) + const url = new URL(config.publicBaseUrl + '/' + path, window.location.href) return url.href }