352 lines
11 KiB
Go
352 lines
11 KiB
Go
package ctrlsubsonic
|
|
|
|
import (
|
|
"context"
|
|
"crypto/md5"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"go.senan.xyz/gonic/db"
|
|
"go.senan.xyz/gonic/handlerutil"
|
|
"go.senan.xyz/gonic/infocache/albuminfocache"
|
|
"go.senan.xyz/gonic/infocache/artistinfocache"
|
|
"go.senan.xyz/gonic/jukebox"
|
|
"go.senan.xyz/gonic/lastfm"
|
|
"go.senan.xyz/gonic/playlist"
|
|
"go.senan.xyz/gonic/podcast"
|
|
"go.senan.xyz/gonic/scanner"
|
|
"go.senan.xyz/gonic/scrobble"
|
|
"go.senan.xyz/gonic/server/ctrlsubsonic/params"
|
|
"go.senan.xyz/gonic/server/ctrlsubsonic/spec"
|
|
"go.senan.xyz/gonic/transcode"
|
|
)
|
|
|
|
type CtxKey int
|
|
|
|
const (
|
|
CtxUser CtxKey = iota
|
|
CtxSession
|
|
CtxParams
|
|
)
|
|
|
|
type MusicPath struct {
|
|
Alias, Path string
|
|
}
|
|
|
|
func MusicPaths(paths []MusicPath) []string {
|
|
var r []string
|
|
for _, p := range paths {
|
|
r = append(r, p.Path)
|
|
}
|
|
return r
|
|
}
|
|
|
|
type ProxyPathResolver func(in string) string
|
|
|
|
type Controller struct {
|
|
*http.ServeMux
|
|
|
|
dbc *db.DB
|
|
scanner *scanner.Scanner
|
|
musicPaths []MusicPath
|
|
podcastsPath string
|
|
cacheAudioPath string
|
|
cacheCoverPath string
|
|
jukebox *jukebox.Jukebox
|
|
playlistStore *playlist.Store
|
|
scrobblers []scrobble.Scrobbler
|
|
podcasts *podcast.Podcasts
|
|
transcoder transcode.Transcoder
|
|
lastFMClient *lastfm.Client
|
|
artistInfoCache *artistinfocache.ArtistInfoCache
|
|
albumInfoCache *albuminfocache.AlbumInfoCache
|
|
resolveProxyPath ProxyPathResolver
|
|
}
|
|
|
|
func New(dbc *db.DB, scannr *scanner.Scanner, musicPaths []MusicPath, podcastsPath string, cacheAudioPath string, cacheCoverPath string, jukebox *jukebox.Jukebox, playlistStore *playlist.Store, scrobblers []scrobble.Scrobbler, podcasts *podcast.Podcasts, transcoder transcode.Transcoder, lastFMClient *lastfm.Client, artistInfoCache *artistinfocache.ArtistInfoCache, albumInfoCache *albuminfocache.AlbumInfoCache, resolveProxyPath ProxyPathResolver) (*Controller, error) {
|
|
c := Controller{
|
|
ServeMux: http.NewServeMux(),
|
|
|
|
dbc: dbc,
|
|
scanner: scannr,
|
|
musicPaths: musicPaths,
|
|
podcastsPath: podcastsPath,
|
|
cacheAudioPath: cacheAudioPath,
|
|
cacheCoverPath: cacheCoverPath,
|
|
jukebox: jukebox,
|
|
playlistStore: playlistStore,
|
|
scrobblers: scrobblers,
|
|
podcasts: podcasts,
|
|
transcoder: transcoder,
|
|
lastFMClient: lastFMClient,
|
|
artistInfoCache: artistInfoCache,
|
|
albumInfoCache: albumInfoCache,
|
|
resolveProxyPath: resolveProxyPath,
|
|
}
|
|
|
|
chain := handlerutil.Chain(
|
|
withParams,
|
|
withRequiredParams,
|
|
withUser(dbc),
|
|
)
|
|
chainRaw := handlerutil.Chain(
|
|
chain,
|
|
slow,
|
|
)
|
|
|
|
c.Handle("/getLicense", chain(resp(c.ServeGetLicence)))
|
|
c.Handle("/ping", chain(resp(c.ServePing)))
|
|
c.Handle("/getOpenSubsonicExtensions", chain(resp(c.ServeGetOpenSubsonicExtensions)))
|
|
|
|
c.Handle("/getMusicFolders", chain(resp(c.ServeGetMusicFolders)))
|
|
c.Handle("/getScanStatus", chain(resp(c.ServeGetScanStatus)))
|
|
c.Handle("/scrobble", chain(resp(c.ServeScrobble)))
|
|
c.Handle("/startScan", chain(resp(c.ServeStartScan)))
|
|
c.Handle("/getUser", chain(resp(c.ServeGetUser)))
|
|
c.Handle("/getPlaylists", chain(resp(c.ServeGetPlaylists)))
|
|
c.Handle("/getPlaylist", chain(resp(c.ServeGetPlaylist)))
|
|
c.Handle("/createPlaylist", chain(resp(c.ServeCreateOrUpdatePlaylist)))
|
|
c.Handle("/updatePlaylist", chain(resp(c.ServeUpdatePlaylist)))
|
|
c.Handle("/deletePlaylist", chain(resp(c.ServeDeletePlaylist)))
|
|
c.Handle("/savePlayQueue", chain(resp(c.ServeSavePlayQueue)))
|
|
c.Handle("/getPlayQueue", chain(resp(c.ServeGetPlayQueue)))
|
|
c.Handle("/getSong", chain(resp(c.ServeGetSong)))
|
|
c.Handle("/getRandomSongs", chain(resp(c.ServeGetRandomSongs)))
|
|
c.Handle("/getSongsByGenre", chain(resp(c.ServeGetSongsByGenre)))
|
|
c.Handle("/jukeboxControl", chain(resp(c.ServeJukebox)))
|
|
c.Handle("/getBookmarks", chain(resp(c.ServeGetBookmarks)))
|
|
c.Handle("/createBookmark", chain(resp(c.ServeCreateBookmark)))
|
|
c.Handle("/deleteBookmark", chain(resp(c.ServeDeleteBookmark)))
|
|
c.Handle("/getTopSongs", chain(resp(c.ServeGetTopSongs)))
|
|
c.Handle("/getSimilarSongs", chain(resp(c.ServeGetSimilarSongs)))
|
|
c.Handle("/getSimilarSongs2", chain(resp(c.ServeGetSimilarSongsTwo)))
|
|
c.Handle("/getLyrics", chain(resp(c.ServeGetLyrics)))
|
|
|
|
// raw
|
|
c.Handle("/getCoverArt", chainRaw(respRaw(c.ServeGetCoverArt)))
|
|
c.Handle("/stream", chainRaw(respRaw(c.ServeStream)))
|
|
c.Handle("/download", chainRaw(respRaw(c.ServeStream)))
|
|
c.Handle("/getAvatar", chainRaw(respRaw(c.ServeGetAvatar)))
|
|
|
|
// browse by tag
|
|
c.Handle("/getAlbum", chain(resp(c.ServeGetAlbum)))
|
|
c.Handle("/getAlbumList2", chain(resp(c.ServeGetAlbumListTwo)))
|
|
c.Handle("/getArtist", chain(resp(c.ServeGetArtist)))
|
|
c.Handle("/getArtists", chain(resp(c.ServeGetArtists)))
|
|
c.Handle("/search3", chain(resp(c.ServeSearchThree)))
|
|
c.Handle("/getStarred2", chain(resp(c.ServeGetStarredTwo)))
|
|
c.Handle("/getArtistInfo2", chain(resp(c.ServeGetArtistInfoTwo)))
|
|
c.Handle("/getAlbumInfo2", chain(resp(c.ServeGetAlbumInfoTwo)))
|
|
|
|
// browse by folder
|
|
c.Handle("/getIndexes", chain(resp(c.ServeGetIndexes)))
|
|
c.Handle("/getMusicDirectory", chain(resp(c.ServeGetMusicDirectory)))
|
|
c.Handle("/getAlbumList", chain(resp(c.ServeGetAlbumList)))
|
|
c.Handle("/search2", chain(resp(c.ServeSearchTwo)))
|
|
c.Handle("/getGenres", chain(resp(c.ServeGetGenres)))
|
|
c.Handle("/getArtistInfo", chain(resp(c.ServeGetArtistInfo)))
|
|
c.Handle("/getStarred", chain(resp(c.ServeGetStarred)))
|
|
|
|
// star / rating
|
|
c.Handle("/star", chain(resp(c.ServeStar)))
|
|
c.Handle("/unstar", chain(resp(c.ServeUnstar)))
|
|
c.Handle("/setRating", chain(resp(c.ServeSetRating)))
|
|
|
|
// podcasts
|
|
c.Handle("/getPodcasts", chain(resp(c.ServeGetPodcasts)))
|
|
c.Handle("/getNewestPodcasts", chain(resp(c.ServeGetNewestPodcasts)))
|
|
c.Handle("/downloadPodcastEpisode", chain(resp(c.ServeDownloadPodcastEpisode)))
|
|
c.Handle("/createPodcastChannel", chain(resp(c.ServeCreatePodcastChannel)))
|
|
c.Handle("/refreshPodcasts", chain(resp(c.ServeRefreshPodcasts)))
|
|
c.Handle("/deletePodcastChannel", chain(resp(c.ServeDeletePodcastChannel)))
|
|
c.Handle("/deletePodcastEpisode", chain(resp(c.ServeDeletePodcastEpisode)))
|
|
|
|
// internet radio
|
|
c.Handle("/getInternetRadioStations", chain(resp(c.ServeGetInternetRadioStations)))
|
|
c.Handle("/createInternetRadioStation", chain(resp(c.ServeCreateInternetRadioStation)))
|
|
c.Handle("/updateInternetRadioStation", chain(resp(c.ServeUpdateInternetRadioStation)))
|
|
c.Handle("/deleteInternetRadioStation", chain(resp(c.ServeDeleteInternetRadioStation)))
|
|
|
|
c.Handle("/", chain(resp(c.ServeNotFound)))
|
|
|
|
return &c, nil
|
|
}
|
|
|
|
type (
|
|
handlerSubsonic func(r *http.Request) *spec.Response
|
|
handlerSubsonicRaw func(w http.ResponseWriter, r *http.Request) *spec.Response
|
|
)
|
|
|
|
func resp(h handlerSubsonic) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if err := writeResp(w, r, h(r)); err != nil {
|
|
log.Printf("error writing subsonic response: %v\n", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func respRaw(h handlerSubsonicRaw) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if err := writeResp(w, r, h(w, r)); err != nil {
|
|
log.Printf("error writing raw subsonic response: %v\n", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func withParams(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
params := params.New(r)
|
|
withParams := context.WithValue(r.Context(), CtxParams, params)
|
|
next.ServeHTTP(w, r.WithContext(withParams))
|
|
})
|
|
}
|
|
|
|
func withRequiredParams(next http.Handler) http.Handler {
|
|
requiredParameters := []string{
|
|
"u", "c",
|
|
}
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
params := r.Context().Value(CtxParams).(params.Params)
|
|
for _, req := range requiredParameters {
|
|
if _, err := params.Get(req); err != nil {
|
|
_ = writeResp(w, r, spec.NewError(10, "please provide a %q parameter", req))
|
|
return
|
|
}
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func withUser(dbc *db.DB) handlerutil.Middleware {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
params := r.Context().Value(CtxParams).(params.Params)
|
|
// ignoring errors here, a middleware has already ensured they exist
|
|
username, _ := params.Get("u")
|
|
password, _ := params.Get("p")
|
|
token, _ := params.Get("t")
|
|
salt, _ := params.Get("s")
|
|
|
|
passwordAuth := token == "" && salt == ""
|
|
tokenAuth := password == ""
|
|
if tokenAuth == passwordAuth {
|
|
_ = writeResp(w, r, spec.NewError(10,
|
|
"please provide `t` and `s`, or just `p`"))
|
|
return
|
|
}
|
|
user := dbc.GetUserByName(username)
|
|
if user == nil {
|
|
_ = writeResp(w, r, spec.NewError(40,
|
|
"invalid username %q", username))
|
|
return
|
|
}
|
|
var credsOk bool
|
|
if tokenAuth {
|
|
credsOk = checkCredsToken(user.Password, token, salt)
|
|
} else {
|
|
credsOk = checkCredsBasic(user.Password, password)
|
|
}
|
|
if !credsOk {
|
|
_ = writeResp(w, r, spec.NewError(40, "invalid password"))
|
|
return
|
|
}
|
|
withUser := context.WithValue(r.Context(), CtxUser, user)
|
|
next.ServeHTTP(w, r.WithContext(withUser))
|
|
})
|
|
}
|
|
}
|
|
|
|
func slow(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
rc := http.NewResponseController(w) //nolint:bodyclose
|
|
_ = rc.SetWriteDeadline(time.Time{}) // set no deadline, since we're probably streaming
|
|
_ = rc.SetReadDeadline(time.Time{}) // set no deadline, since we're probably streaming
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func checkCredsToken(password, token, salt string) bool {
|
|
toHash := fmt.Sprintf("%s%s", password, salt)
|
|
hash := md5.Sum([]byte(toHash))
|
|
expToken := hex.EncodeToString(hash[:])
|
|
return token == expToken
|
|
}
|
|
|
|
func checkCredsBasic(password, given string) bool {
|
|
if len(given) >= 4 && given[:4] == "enc:" {
|
|
bytes, _ := hex.DecodeString(given[4:])
|
|
given = string(bytes)
|
|
}
|
|
return password == given
|
|
}
|
|
|
|
type errWriter struct {
|
|
w io.Writer
|
|
err error
|
|
}
|
|
|
|
func (ew *errWriter) write(buf []byte) {
|
|
if ew.err != nil {
|
|
return
|
|
}
|
|
_, ew.err = ew.w.Write(buf)
|
|
}
|
|
|
|
func writeResp(w http.ResponseWriter, r *http.Request, resp *spec.Response) error {
|
|
if resp == nil {
|
|
return nil
|
|
}
|
|
if resp.Error != nil {
|
|
log.Printf("subsonic error code %d: %s", resp.Error.Code, resp.Error.Message)
|
|
}
|
|
|
|
var res struct {
|
|
XMLName xml.Name `xml:"subsonic-response" json:"-"`
|
|
*spec.Response `json:"subsonic-response"`
|
|
}
|
|
res.Response = resp
|
|
|
|
params := r.Context().Value(CtxParams).(params.Params)
|
|
|
|
ew := &errWriter{w: w}
|
|
switch v, _ := params.Get("f"); v {
|
|
case "json":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
data, err := json.Marshal(res)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal to json: %w", err)
|
|
}
|
|
ew.write(data)
|
|
|
|
case "jsonp":
|
|
w.Header().Set("Content-Type", "application/javascript")
|
|
data, err := json.Marshal(res)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal to jsonp: %w", err)
|
|
}
|
|
// TODO: error if no callback provided instead of using a default
|
|
pCall := params.GetOr("callback", "cb")
|
|
ew.write([]byte(pCall))
|
|
ew.write([]byte("("))
|
|
ew.write(data)
|
|
ew.write([]byte(");"))
|
|
|
|
default:
|
|
w.Header().Set("Content-Type", "application/xml")
|
|
data, err := xml.MarshalIndent(res, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal to xml: %w", err)
|
|
}
|
|
|
|
ew.write(data)
|
|
}
|
|
|
|
return ew.err
|
|
}
|