More work on Shares

This commit is contained in:
Deluan 2023-01-20 19:53:53 -05:00
parent ab04e33da6
commit 84aa094e56
19 changed files with 150 additions and 167 deletions

View File

@ -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())
}

View File

@ -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 (

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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"`
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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

View File

@ -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)
}
}

File diff suppressed because one or more lines are too long

View File

@ -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 <ShareApp />
return <SharePlayer />
}
return (
<HotKeys keyMap={keyMap}>

View File

@ -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 <ReactJkMusicPlayer {...options} />
}
export default ShareApp
export default SharePlayer

View File

@ -133,6 +133,7 @@ const AlbumActions = ({
close={shareDialog.close}
ids={[record.id]}
resource={'album'}
title={`Share album '${record.name}'`}
/>
</TopToolbar>
)
@ -146,7 +147,6 @@ AlbumActions.propTypes = {
AlbumActions.defaultProps = {
record: {},
selectedIds: [],
onUnselectItems: () => null,
}
export default AlbumActions

View File

@ -28,7 +28,6 @@ const defaultConfig = {
enableCoverAnimation: true,
devShowArtistPage: true,
enableReplayGain: true,
shareBaseUrl: '/s',
publicBaseUrl: '/p',
}

View File

@ -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'}
>
<DialogTitle id="info-dialog-album">
Create a link to share your music with friends
</DialogTitle>
<DialogTitle id="share-dialog">{title}</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)
}}
<FormControlLabel
control={<Switch checked={originalFormat} />}
label={'Share in original format'}
onChange={handleOriginal}
/>
{!originalFormat && (
<SelectInput
source="format"
choices={formatOptions}
resettable
onChange={(event) => {
setFormat(event.target.value)
}}
/>
)}
{!originalFormat && (
<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>

View File

@ -33,6 +33,9 @@ const ShareList = (props) => {
label="URL"
target="_blank"
rel="noopener noreferrer"
onClick={(e) => {
e.stopPropagation()
}}
>
{r.id}
</Link>

View File

@ -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
}