mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-06 05:48:09 -05:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
220ffd5324 | ||
|
|
e33d2305a1 | ||
|
|
7815b57920 | ||
|
|
18cbb153f3 | ||
|
|
9f086b5f7b | ||
|
|
c8d6f2d506 | ||
|
|
6619b0986a | ||
|
|
2dbd645292 | ||
|
|
6978790e96 | ||
|
|
e0308acef3 | ||
|
|
5fbde33b97 | ||
|
|
19fb29e520 | ||
|
|
e5e35516d7 | ||
|
|
28bad95e66 | ||
|
|
9260957271 | ||
|
|
79b0f1f57b | ||
|
|
4dffcb7b46 | ||
|
|
d1f8d39866 | ||
|
|
0996272943 | ||
|
|
d093191659 | ||
|
|
998323b364 | ||
|
|
6dfe56c1c4 | ||
|
|
fd5548f890 | ||
|
|
6e2454f6cc | ||
|
|
8372dee000 | ||
|
|
41fd5862b8 | ||
|
|
a6b5be7b0a |
@@ -6,7 +6,6 @@ Dockerfile
|
||||
data
|
||||
*.db
|
||||
testDB
|
||||
*_test.go
|
||||
navidrome
|
||||
navidrome.db
|
||||
navidrome.toml
|
||||
|
||||
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -24,7 +24,8 @@ jobs:
|
||||
- name: Fetch tags
|
||||
run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
|
||||
- name: Run GoReleaser
|
||||
uses: docker://bepsays/ci-goreleaser:1.13-4
|
||||
uses: docker://bepsays/ci-goreleaser:latest
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -19,3 +19,4 @@ navidrome.db
|
||||
*_gen.go
|
||||
dist
|
||||
music
|
||||
docker-compose.override.yml
|
||||
|
||||
@@ -83,6 +83,3 @@ changelog:
|
||||
filters:
|
||||
exclude:
|
||||
- '^docs:'
|
||||
- '^test:'
|
||||
- '^chore:'
|
||||
- '^ci:'
|
||||
|
||||
5
Makefile
5
Makefile
@@ -63,7 +63,7 @@ build: check_go_env
|
||||
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=master"
|
||||
|
||||
.PHONY: buildall
|
||||
buildall: check_env assets/embedded_gen.go
|
||||
buildall: check_env
|
||||
@(cd ./ui && npm run build)
|
||||
go-bindata -fs -prefix "ui/build" -tags embed -nocompress -pkg assets -o assets/embedded_gen.go ui/build/...
|
||||
go build -ldflags="-X github.com/deluan/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/deluan/navidrome/consts.gitTag=master" -tags=embed
|
||||
@@ -72,11 +72,10 @@ buildall: check_env assets/embedded_gen.go
|
||||
release:
|
||||
@if [[ ! "${V}" =~ ^[0-9]+\.[0-9]+\.[0-9]+.*$$ ]]; then echo "Usage: make release V=X.X.X"; exit 1; fi
|
||||
go mod tidy
|
||||
make test
|
||||
@if [ -n "`git status -s`" ]; then echo "\n\nThere are pending changes. Please commit or stash first"; exit 1; fi
|
||||
make test
|
||||
git tag v${V}
|
||||
git push origin v${V}
|
||||
git push origin master
|
||||
|
||||
.PHONY: dist
|
||||
dist:
|
||||
|
||||
21
README.md
21
README.md
@@ -3,11 +3,10 @@
|
||||
[](https://github.com/deluan/navidrome/actions)
|
||||
[](https://github.com/deluan/navidrome/releases)
|
||||
[](https://hub.docker.com/r/deluan/navidrome)
|
||||
[](https://discord.gg/xh7j7yF)
|
||||
|
||||
Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your
|
||||
music collection from any browser or mobile device.
|
||||
|
||||
This is a fully functional _alpha quality_ software. Expect some changes in the feature set and the way it works.
|
||||
music collection from any browser or mobile device. It's like your personal Spotify!
|
||||
|
||||
__Any feedback is welcome!__ If you need/want a new feature, find a bug or think of any way to improve Navidrome,
|
||||
please fill a [GitHub issue](https://github.com/deluan/navidrome/issues) or join the chat in our [Discord server](https://discord.gg/xh7j7yF)
|
||||
@@ -26,31 +25,35 @@ please fill a [GitHub issue](https://github.com/deluan/navidrome/issues) or join
|
||||
- Compatible with the huge selection of clients for [Subsonic](http://www.subsonic.org),
|
||||
[Airsonic](https://airsonic.github.io/) and [Madsonic](https://www.madsonic.org/).
|
||||
See the [complete list of available mobile and web apps](https://airsonic.github.io/docs/apps/)
|
||||
- Transcoding/Downsampling on-the-fly (WIP. Experimental support is available)
|
||||
|
||||
|
||||
## Road map
|
||||
|
||||
This project is being actively worked on. Expect a more polished experience and new features/releases
|
||||
on a frequent basis. Some upcoming features planned:
|
||||
|
||||
- Transcoding/Downsampling on-the-fly
|
||||
- Last.FM integration
|
||||
- Integrated music player
|
||||
- Last.FM integration
|
||||
- Pre-build binaries for Raspberry Pi
|
||||
- Smart/dynamic playlists (similar to iTunes)
|
||||
- Support for audiobooks (bookmarking)
|
||||
- Jukebox mode
|
||||
- Sharing links to albums/songs/playlists
|
||||
- Podcasts
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
Various options are available:
|
||||
|
||||
### Pre-build executables
|
||||
### Pre-built executables
|
||||
|
||||
Just head to the [releases page](https://github.com/deluan/navidrome/releases) and download the latest version for you
|
||||
platform. There are builds available for Linux, macOS and Windows (32 and 64 bits).
|
||||
|
||||
Remember to install [ffmpeg](https://ffmpeg.org/download.html) in your system, a requirement for Navidrome to work properly.
|
||||
You may find the latest static build for your platform here: https://johnvansickle.com/ffmpeg/
|
||||
|
||||
If you have any issues with these binaries, or need a binary for a different platform, please
|
||||
[open an issue](https://github.com/deluan/navidrome/issues)
|
||||
@@ -77,9 +80,11 @@ services:
|
||||
ND_PORT: 4533
|
||||
volumes:
|
||||
- "./data:/data"
|
||||
- "/Users/deluan/Music/iTunes/iTunes Media/Music:/music"
|
||||
- "/path/to/your/music/folder:/music"
|
||||
```
|
||||
|
||||
To get the cutting-edge, latest version from master, use the image `deluan/navidrome:develop`
|
||||
|
||||
### Build from source
|
||||
|
||||
You will need to install [Go 1.13](https://golang.org/dl/) and [Node 13.7.0](http://nodejs.org).
|
||||
@@ -95,7 +100,7 @@ $ make setup # Install tools required for Navidrome's development
|
||||
$ make buildall # Build UI and server, generates a single executable
|
||||
```
|
||||
|
||||
This will generate the `navidrome` binary executable in the project's root folder.
|
||||
This will generate the `navidrome` executable binary in the project's root folder.
|
||||
|
||||
### Running for the first time
|
||||
|
||||
|
||||
@@ -22,10 +22,11 @@ type nd struct {
|
||||
IgnoredArticles string `default:"The El La Los Las Le Les Os As O A"`
|
||||
IndexGroups string `default:"A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)"`
|
||||
|
||||
DisableDownsampling bool `default:"false"`
|
||||
DownsampleCommand string `default:"ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -"`
|
||||
ProbeCommand string `default:"ffmpeg %s -f ffmetadata"`
|
||||
ScanInterval string `default:"1m"`
|
||||
EnableDownsampling bool `default:"false"`
|
||||
MaxBitRate int `default:"0"`
|
||||
DownsampleCommand string `default:"ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -"`
|
||||
ProbeCommand string `default:"ffmpeg -i %s -f ffmetadata"`
|
||||
ScanInterval string `default:"1m"`
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
DevDisableBanner bool `default:"false"`
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package main
|
||||
package consts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/static"
|
||||
)
|
||||
|
||||
@@ -13,8 +12,8 @@ func getBanner() string {
|
||||
return strings.TrimSuffix(string(data), "\n")
|
||||
}
|
||||
|
||||
func ShowBanner() {
|
||||
version := "Version: " + consts.Version()
|
||||
func Banner() string {
|
||||
version := "Version: " + Version()
|
||||
padding := strings.Repeat(" ", 52-len(version))
|
||||
fmt.Printf("%s%s%s\n\n", getBanner(), padding, version)
|
||||
return fmt.Sprintf("%s%s%s\n", getBanner(), padding, version)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
package server
|
||||
package consts
|
||||
|
||||
import "mime"
|
||||
|
||||
func initMimeTypes() {
|
||||
func init() {
|
||||
mt := map[string]string{
|
||||
".mp3": "audio/mpeg",
|
||||
".ogg": "audio/ogg",
|
||||
@@ -11,6 +11,7 @@ func initMimeTypes() {
|
||||
".ogx": "application/ogg",
|
||||
".aac": "audio/mp4",
|
||||
".m4a": "audio/mp4",
|
||||
".m4b": "audio/mp4",
|
||||
".flac": "audio/flac",
|
||||
".wav": "audio/x-wav",
|
||||
".wma": "audio/x-ms-wma",
|
||||
@@ -3,14 +3,16 @@
|
||||
version: "3"
|
||||
services:
|
||||
navidrome:
|
||||
build: .
|
||||
image: deluan/navidrome:latest
|
||||
ports:
|
||||
- "4533:4533"
|
||||
environment:
|
||||
# See all options and defaults in conf/configuration.go
|
||||
# All options with their default values:
|
||||
ND_MUSICFOLDER: /music
|
||||
ND_DATAFOLDER: /data
|
||||
ND_SCANINTERVAL: 1m
|
||||
ND_LOGLEVEL: info
|
||||
ND_PORT: 4533
|
||||
ND_SCANINTERVAL: 5s
|
||||
ND_LOGLEVEL: debug
|
||||
volumes:
|
||||
- "./data:/data"
|
||||
- "/Users/deluan/Music/iTunes/iTunes Media/Music:/music"
|
||||
- "./music:/music"
|
||||
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestEngine(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelCritical)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Engine Suite")
|
||||
|
||||
220
engine/media_streamer.go
Normal file
220
engine/media_streamer.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"mime"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
)
|
||||
|
||||
type MediaStreamer interface {
|
||||
NewStream(ctx context.Context, id string, maxBitRate int, format string) (mediaStream, error)
|
||||
}
|
||||
|
||||
func NewMediaStreamer(ds model.DataStore) MediaStreamer {
|
||||
return &mediaStreamer{ds: ds}
|
||||
}
|
||||
|
||||
type mediaStream interface {
|
||||
io.ReadSeeker
|
||||
ContentType() string
|
||||
Name() string
|
||||
ModTime() time.Time
|
||||
Close() error
|
||||
}
|
||||
|
||||
type mediaStreamer struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, maxBitRate int, format string) (mediaStream, error) {
|
||||
mf, err := ms.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var bitRate int
|
||||
|
||||
if format == "raw" || !conf.Server.EnableDownsampling {
|
||||
bitRate = mf.BitRate
|
||||
format = mf.Suffix
|
||||
} else {
|
||||
if maxBitRate == 0 {
|
||||
bitRate = mf.BitRate
|
||||
} else {
|
||||
bitRate = utils.MinInt(mf.BitRate, maxBitRate)
|
||||
}
|
||||
format = mf.Suffix
|
||||
}
|
||||
if conf.Server.MaxBitRate != 0 {
|
||||
bitRate = utils.MinInt(bitRate, conf.Server.MaxBitRate)
|
||||
}
|
||||
|
||||
var stream mediaStream
|
||||
|
||||
if bitRate == mf.BitRate && mime.TypeByExtension("."+format) == mf.ContentType() {
|
||||
log.Debug(ctx, "Streaming raw file", "id", mf.ID, "path", mf.Path,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
|
||||
|
||||
f, err := os.Open(mf.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stream = &rawMediaStream{ctx: ctx, mf: mf, file: f}
|
||||
return stream, nil
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Streaming transcoded file", "id", mf.ID, "path", mf.Path,
|
||||
"requestBitrate", bitRate, "requestFormat", format,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
|
||||
|
||||
f := &transcodedMediaStream{ctx: ctx, mf: mf, bitRate: bitRate, format: format}
|
||||
return f, err
|
||||
}
|
||||
|
||||
type rawMediaStream struct {
|
||||
file *os.File
|
||||
ctx context.Context
|
||||
mf *model.MediaFile
|
||||
}
|
||||
|
||||
func (m *rawMediaStream) Read(p []byte) (n int, err error) {
|
||||
return m.file.Read(p)
|
||||
}
|
||||
|
||||
func (m *rawMediaStream) Seek(offset int64, whence int) (int64, error) {
|
||||
return m.file.Seek(offset, whence)
|
||||
}
|
||||
|
||||
func (m *rawMediaStream) ContentType() string {
|
||||
return m.mf.ContentType()
|
||||
}
|
||||
|
||||
func (m *rawMediaStream) Name() string {
|
||||
return m.mf.Path
|
||||
}
|
||||
|
||||
func (m *rawMediaStream) ModTime() time.Time {
|
||||
return m.mf.UpdatedAt
|
||||
}
|
||||
|
||||
func (m *rawMediaStream) Close() error {
|
||||
log.Trace(m.ctx, "Closing file", "id", m.mf.ID, "path", m.mf.Path)
|
||||
return m.file.Close()
|
||||
}
|
||||
|
||||
type transcodedMediaStream struct {
|
||||
ctx context.Context
|
||||
mf *model.MediaFile
|
||||
pipe io.ReadCloser
|
||||
bitRate int
|
||||
format string
|
||||
skip int64
|
||||
pos int64
|
||||
}
|
||||
|
||||
func (m *transcodedMediaStream) Read(p []byte) (n int, err error) {
|
||||
// Open the pipe and optionally skip a initial chunk of the stream (to simulate a Seek)
|
||||
if m.pipe == nil {
|
||||
m.pipe, err = newTranscode(m.ctx, m.mf.Path, m.bitRate, m.format)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if m.skip > 0 {
|
||||
_, err := io.CopyN(ioutil.Discard, m.pipe, m.skip)
|
||||
m.pos = m.skip
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
n, err = m.pipe.Read(p)
|
||||
m.pos += int64(n)
|
||||
if err == io.EOF {
|
||||
m.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// This is an attempt to make a pipe seekable. It is very wasteful, restarting the stream every time
|
||||
// a Seek happens. This is ok-ish for audio, but would kill the server for video.
|
||||
func (m *transcodedMediaStream) Seek(offset int64, whence int) (int64, error) {
|
||||
size := int64((m.mf.Duration)*m.bitRate*1000) / 8
|
||||
log.Trace(m.ctx, "Seeking file", "path", m.mf.Path, "offset", offset, "whence", whence, "size", size)
|
||||
|
||||
switch whence {
|
||||
case io.SeekEnd:
|
||||
m.skip = size - offset
|
||||
offset = size
|
||||
case io.SeekStart:
|
||||
m.skip = offset
|
||||
case io.SeekCurrent:
|
||||
io.CopyN(ioutil.Discard, m.pipe, offset)
|
||||
m.pos += offset
|
||||
offset = m.pos
|
||||
}
|
||||
|
||||
// If need to Seek to a previous position, close the pipe (will be restarted on next Read)
|
||||
var err error
|
||||
if whence != io.SeekCurrent {
|
||||
if m.pipe != nil {
|
||||
err = m.Close()
|
||||
}
|
||||
}
|
||||
return offset, err
|
||||
}
|
||||
|
||||
func (m *transcodedMediaStream) ContentType() string {
|
||||
return mime.TypeByExtension(".mp3")
|
||||
}
|
||||
|
||||
func (m *transcodedMediaStream) Name() string {
|
||||
return m.mf.Path
|
||||
}
|
||||
|
||||
func (m *transcodedMediaStream) ModTime() time.Time {
|
||||
return m.mf.UpdatedAt
|
||||
}
|
||||
|
||||
func (m *transcodedMediaStream) Close() error {
|
||||
log.Trace(m.ctx, "Closing stream", "id", m.mf.ID, "path", m.mf.Path)
|
||||
err := m.pipe.Close()
|
||||
m.pipe = nil
|
||||
m.pos = 0
|
||||
return err
|
||||
}
|
||||
|
||||
func newTranscode(ctx context.Context, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
|
||||
cmdLine, args := createTranscodeCommand(path, maxBitRate, format)
|
||||
|
||||
log.Trace(ctx, "Executing ffmpeg command", "arg0", cmdLine, "args", args)
|
||||
cmd := exec.Command(cmdLine, args...)
|
||||
cmd.Stderr = os.Stderr
|
||||
if f, err = cmd.StdoutPipe(); err != nil {
|
||||
return f, err
|
||||
}
|
||||
return f, cmd.Start()
|
||||
}
|
||||
|
||||
func createTranscodeCommand(path string, maxBitRate int, format string) (string, []string) {
|
||||
cmd := conf.Server.DownsampleCommand
|
||||
|
||||
split := strings.Split(cmd, " ")
|
||||
for i, s := range split {
|
||||
s = strings.Replace(s, "%s", path, -1)
|
||||
s = strings.Replace(s, "%b", strconv.Itoa(maxBitRate), -1)
|
||||
split[i] = s
|
||||
}
|
||||
|
||||
return split[0], split[1:]
|
||||
}
|
||||
75
engine/media_streamer_test.go
Normal file
75
engine/media_streamer_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("MediaStreamer", func() {
|
||||
|
||||
var streamer MediaStreamer
|
||||
var ds model.DataStore
|
||||
ctx := log.NewContext(nil)
|
||||
|
||||
BeforeEach(func() {
|
||||
conf.Server.EnableDownsampling = true
|
||||
ds = &persistence.MockDataStore{}
|
||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "bitRate": 128}]`, 1)
|
||||
streamer = NewMediaStreamer(ds)
|
||||
})
|
||||
|
||||
Context("NewStream", func() {
|
||||
It("returns a rawMediaStream if format is 'raw'", func() {
|
||||
Expect(streamer.NewStream(ctx, "123", 0, "raw")).To(BeAssignableToTypeOf(&rawMediaStream{}))
|
||||
})
|
||||
It("returns a rawMediaStream if maxBitRate is 0", func() {
|
||||
Expect(streamer.NewStream(ctx, "123", 0, "mp3")).To(BeAssignableToTypeOf(&rawMediaStream{}))
|
||||
})
|
||||
It("returns a rawMediaStream if maxBitRate is higher than file bitRate", func() {
|
||||
Expect(streamer.NewStream(ctx, "123", 256, "mp3")).To(BeAssignableToTypeOf(&rawMediaStream{}))
|
||||
})
|
||||
It("returns a transcodedMediaStream if maxBitRate is lower than file bitRate", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", 64, "mp3")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s).To(BeAssignableToTypeOf(&transcodedMediaStream{}))
|
||||
Expect(s.(*transcodedMediaStream).bitRate).To(Equal(64))
|
||||
})
|
||||
})
|
||||
|
||||
Context("rawMediaStream", func() {
|
||||
var rawStream mediaStream
|
||||
var modTime time.Time
|
||||
|
||||
BeforeEach(func() {
|
||||
modTime = time.Now()
|
||||
mf := &model.MediaFile{ID: "123", Path: "test.mp3", UpdatedAt: modTime, Suffix: "mp3"}
|
||||
rawStream = &rawMediaStream{mf: mf, ctx: ctx}
|
||||
})
|
||||
|
||||
It("returns the ContentType", func() {
|
||||
Expect(rawStream.ContentType()).To(Equal("audio/mpeg"))
|
||||
})
|
||||
|
||||
It("returns the ModTime", func() {
|
||||
Expect(rawStream.ModTime()).To(Equal(modTime))
|
||||
})
|
||||
})
|
||||
|
||||
Context("createTranscodeCommand", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DownsampleCommand = "ffmpeg -i %s -b:a %bk mp3 -"
|
||||
})
|
||||
It("creates a valid command line", func() {
|
||||
cmd, args := createTranscodeCommand("/music library/file.mp3", 123, "")
|
||||
Expect(cmd).To(Equal("ffmpeg"))
|
||||
Expect(args).To(Equal([]string{"-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
@@ -1,59 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/log"
|
||||
)
|
||||
|
||||
// TODO Encapsulate as a io.Reader
|
||||
func Stream(ctx context.Context, path string, bitRate int, maxBitRate int, w io.Writer) error {
|
||||
var f io.Reader
|
||||
var err error
|
||||
enabled := !conf.Server.DisableDownsampling
|
||||
if enabled && maxBitRate > 0 && bitRate > maxBitRate {
|
||||
f, err = downsample(ctx, path, maxBitRate)
|
||||
} else {
|
||||
f, err = os.Open(path)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error opening file", "path", path, err)
|
||||
return err
|
||||
}
|
||||
if _, err = io.Copy(w, f); err != nil {
|
||||
log.Error(ctx, "Error copying file", "path", path, err)
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func downsample(ctx context.Context, path string, maxBitRate int) (f io.Reader, err error) {
|
||||
cmdLine, args := createDownsamplingCommand(path, maxBitRate)
|
||||
|
||||
log.Debug(ctx, "Executing command", "cmdLine", cmdLine, "args", args)
|
||||
cmd := exec.Command(cmdLine, args...)
|
||||
cmd.Stderr = os.Stderr
|
||||
if f, err = cmd.StdoutPipe(); err != nil {
|
||||
return f, err
|
||||
}
|
||||
return f, cmd.Start()
|
||||
}
|
||||
|
||||
func createDownsamplingCommand(path string, maxBitRate int) (string, []string) {
|
||||
cmd := conf.Server.DownsampleCommand
|
||||
|
||||
split := strings.Split(cmd, " ")
|
||||
for i, s := range split {
|
||||
s = strings.Replace(s, "%s", path, -1)
|
||||
s = strings.Replace(s, "%b", strconv.Itoa(maxBitRate), -1)
|
||||
split[i] = s
|
||||
}
|
||||
|
||||
return split[0], split[1:]
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/deluan/navidrome/tests"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestDownsampling(t *testing.T) {
|
||||
|
||||
Init(t, false)
|
||||
|
||||
Convey("Subject: createDownsamplingCommand", t, func() {
|
||||
|
||||
Convey("It should create a valid command line", func() {
|
||||
cmd, args := createDownsamplingCommand("/music library/file.mp3", 128)
|
||||
|
||||
So(cmd, ShouldEqual, "ffmpeg")
|
||||
So(args[0], ShouldEqual, "-i")
|
||||
So(args[1], ShouldEqual, "/music library/file.mp3")
|
||||
So(args[2], ShouldEqual, "-b:a")
|
||||
So(args[3], ShouldEqual, "128k")
|
||||
So(args[4], ShouldEqual, "mp3")
|
||||
So(args[5], ShouldEqual, "-")
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
@@ -12,4 +12,5 @@ var Set = wire.NewSet(
|
||||
NewSearch,
|
||||
NewNowPlayingRepository,
|
||||
NewUsers,
|
||||
NewMediaStreamer,
|
||||
)
|
||||
|
||||
2
go.mod
2
go.mod
@@ -14,7 +14,7 @@ require (
|
||||
github.com/fatih/structs v1.0.0 // indirect
|
||||
github.com/go-chi/chi v4.0.3+incompatible
|
||||
github.com/go-chi/cors v1.0.0
|
||||
github.com/go-chi/jwtauth v4.0.3+incompatible
|
||||
github.com/go-chi/jwtauth v4.0.4+incompatible
|
||||
github.com/go-sql-driver/mysql v1.5.0 // indirect
|
||||
github.com/golang/protobuf v1.3.1 // indirect
|
||||
github.com/google/uuid v1.1.1
|
||||
|
||||
4
go.sum
4
go.sum
@@ -41,8 +41,8 @@ github.com/go-chi/chi v4.0.3+incompatible h1:gakN3pDJnzZN5jqFV2TEdF66rTfKeITyR8q
|
||||
github.com/go-chi/chi v4.0.3+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||
github.com/go-chi/cors v1.0.0 h1:e6x8k7uWbUwYs+aXDoiUzeQFT6l0cygBYyNhD7/1Tg0=
|
||||
github.com/go-chi/cors v1.0.0/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
|
||||
github.com/go-chi/jwtauth v4.0.3+incompatible h1:hPhobLUgh7fMpA1qUDdId14u2Z93M22fCNPMVLNWeHU=
|
||||
github.com/go-chi/jwtauth v4.0.3+incompatible/go.mod h1:Q5EIArY/QnD6BdS+IyDw7B2m6iNbnPxtfd6/BcmtWbs=
|
||||
github.com/go-chi/jwtauth v4.0.4+incompatible h1:LGIxg6YfvSBzxU2BljXbrzVc1fMlgqSKBQgKOGAVtPY=
|
||||
github.com/go-chi/jwtauth v4.0.4+incompatible/go.mod h1:Q5EIArY/QnD6BdS+IyDw7B2m6iNbnPxtfd6/BcmtWbs=
|
||||
github.com/go-redis/redis v6.14.2+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
|
||||
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
|
||||
3
main.go
3
main.go
@@ -2,12 +2,13 @@ package main
|
||||
|
||||
import (
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/consts"
|
||||
"github.com/deluan/navidrome/db"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if !conf.Server.DevDisableBanner {
|
||||
ShowBanner()
|
||||
println(consts.Banner())
|
||||
}
|
||||
|
||||
conf.Load()
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/astaxie/beego/orm"
|
||||
@@ -30,6 +31,14 @@ func userId(ctx context.Context) string {
|
||||
return usr.ID
|
||||
}
|
||||
|
||||
func loggedUser(ctx context.Context) *model.User {
|
||||
user := ctx.Value("user")
|
||||
if user == nil {
|
||||
return &model.User{}
|
||||
}
|
||||
return user.(*model.User)
|
||||
}
|
||||
|
||||
func (r sqlRepository) newSelect(options ...model.QueryOptions) SelectBuilder {
|
||||
sq := Select().From(r.tableName)
|
||||
sq = r.applyOptions(sq, options...)
|
||||
@@ -68,9 +77,10 @@ func (r sqlRepository) executeSQL(sq Sqlizer) (int64, error) {
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
start := time.Now()
|
||||
res, err := r.ormer.Raw(query, args...).Exec()
|
||||
c, _ := res.RowsAffected()
|
||||
r.logSQL(query, args, err, c)
|
||||
r.logSQL(query, args, err, c, start)
|
||||
if err != nil {
|
||||
if err.Error() != "LastInsertId is not supported by this driver" {
|
||||
return 0, err
|
||||
@@ -84,12 +94,13 @@ func (r sqlRepository) queryOne(sq Sqlizer, response interface{}) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
start := time.Now()
|
||||
err = r.ormer.Raw(query, args...).QueryRow(response)
|
||||
if err == orm.ErrNoRows {
|
||||
r.logSQL(query, args, nil, 1)
|
||||
r.logSQL(query, args, nil, 1, start)
|
||||
return model.ErrNotFound
|
||||
}
|
||||
r.logSQL(query, args, err, 1)
|
||||
r.logSQL(query, args, err, 1, start)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -98,12 +109,13 @@ func (r sqlRepository) queryAll(sq Sqlizer, response interface{}) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
start := time.Now()
|
||||
c, err := r.ormer.Raw(query, args...).QueryRows(response)
|
||||
if err == orm.ErrNoRows {
|
||||
r.logSQL(query, args, nil, c)
|
||||
r.logSQL(query, args, nil, c, start)
|
||||
return model.ErrNotFound
|
||||
}
|
||||
r.logSQL(query, args, nil, c)
|
||||
r.logSQL(query, args, nil, c, start)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -154,7 +166,8 @@ func (r sqlRepository) delete(cond Sqlizer) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (r sqlRepository) logSQL(sql string, args []interface{}, err error, rowsAffected int64) {
|
||||
func (r sqlRepository) logSQL(sql string, args []interface{}, err error, rowsAffected int64, start time.Time) {
|
||||
lapsed := time.Since(start)
|
||||
var fmtArgs []string
|
||||
for i := range args {
|
||||
var f string
|
||||
@@ -167,9 +180,9 @@ func (r sqlRepository) logSQL(sql string, args []interface{}, err error, rowsAff
|
||||
fmtArgs = append(fmtArgs, f)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected, err)
|
||||
log.Error(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected, "lapsedTime", lapsed, err)
|
||||
} else {
|
||||
log.Trace(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected)
|
||||
log.Trace(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected, "lapsedTime", lapsed)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,10 +85,18 @@ func (r *userRepository) UpdateLastAccessAt(id string) error {
|
||||
}
|
||||
|
||||
func (r *userRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
usr := loggedUser(r.ctx)
|
||||
if !usr.IsAdmin {
|
||||
return 0, rest.ErrPermissionDenied
|
||||
}
|
||||
return r.CountAll(r.parseRestOptions(options...))
|
||||
}
|
||||
|
||||
func (r *userRepository) Read(id string) (interface{}, error) {
|
||||
usr := loggedUser(r.ctx)
|
||||
if !usr.IsAdmin && usr.ID != id {
|
||||
return nil, rest.ErrPermissionDenied
|
||||
}
|
||||
usr, err := r.Get(id)
|
||||
if err == model.ErrNotFound {
|
||||
return nil, rest.ErrNotFound
|
||||
@@ -97,6 +105,10 @@ func (r *userRepository) Read(id string) (interface{}, error) {
|
||||
}
|
||||
|
||||
func (r *userRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
usr := loggedUser(r.ctx)
|
||||
if !usr.IsAdmin {
|
||||
return nil, rest.ErrPermissionDenied
|
||||
}
|
||||
return r.GetAll(r.parseRestOptions(options...))
|
||||
}
|
||||
|
||||
@@ -109,17 +121,25 @@ func (r *userRepository) NewInstance() interface{} {
|
||||
}
|
||||
|
||||
func (r *userRepository) Save(entity interface{}) (string, error) {
|
||||
usr := entity.(*model.User)
|
||||
err := r.Put(usr)
|
||||
usr := loggedUser(r.ctx)
|
||||
if !usr.IsAdmin {
|
||||
return "", rest.ErrPermissionDenied
|
||||
}
|
||||
u := entity.(*model.User)
|
||||
err := r.Put(u)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return usr.ID, err
|
||||
return u.ID, err
|
||||
}
|
||||
|
||||
func (r *userRepository) Update(entity interface{}, cols ...string) error {
|
||||
usr := entity.(*model.User)
|
||||
err := r.Put(usr)
|
||||
u := entity.(*model.User)
|
||||
usr := loggedUser(r.ctx)
|
||||
if !usr.IsAdmin && usr.ID != u.ID {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
err := r.Put(u)
|
||||
if err == model.ErrNotFound {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
@@ -127,7 +147,11 @@ func (r *userRepository) Update(entity interface{}, cols ...string) error {
|
||||
}
|
||||
|
||||
func (r *userRepository) Delete(id string) error {
|
||||
err := r.Delete(id)
|
||||
usr := loggedUser(r.ctx)
|
||||
if !usr.IsAdmin && usr.ID != id {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
err := r.delete(Eq{"id": id})
|
||||
if err == model.ErrNotFound {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ func (m *Metadata) Artist() string { return m.tags["artist"] }
|
||||
func (m *Metadata) AlbumArtist() string { return m.tags["album_artist"] }
|
||||
func (m *Metadata) Composer() string { return m.tags["composer"] }
|
||||
func (m *Metadata) Genre() string { return m.tags["genre"] }
|
||||
func (m *Metadata) Year() int { return m.parseInt("year") }
|
||||
func (m *Metadata) Year() int { return m.parseYear("year") }
|
||||
func (m *Metadata) TrackNumber() (int, int) { return m.parseTuple("trackNum", "trackTotal") }
|
||||
func (m *Metadata) DiscNumber() (int, int) { return m.parseTuple("discNum", "discTotal") }
|
||||
func (m *Metadata) HasPicture() bool { return m.tags["hasPicture"] == "Video" }
|
||||
@@ -74,7 +74,7 @@ func ExtractAllMetadata(dirPath string) (map[string]*Metadata, error) {
|
||||
func probe(inputs []string) (map[string]*Metadata, error) {
|
||||
cmdLine, args := createProbeCommand(inputs)
|
||||
|
||||
log.Trace("Executing command", "cmdLine", cmdLine, "args", args)
|
||||
log.Trace("Executing command", "arg0", cmdLine, "args", args)
|
||||
cmd := exec.Command(cmdLine, args...)
|
||||
output, _ := cmd.CombinedOutput()
|
||||
mds := map[string]*Metadata{}
|
||||
@@ -192,6 +192,33 @@ func (m *Metadata) parseInt(tagName string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
var tagYearFormats = []string{
|
||||
"2006",
|
||||
"2006.01",
|
||||
"2006.01.02",
|
||||
"2006-01",
|
||||
"2006-01-02",
|
||||
time.RFC3339,
|
||||
}
|
||||
|
||||
func (m *Metadata) parseYear(tagName string) int {
|
||||
if v, ok := m.tags[tagName]; ok {
|
||||
var y time.Time
|
||||
var err error
|
||||
for _, fmt := range tagYearFormats {
|
||||
if y, err = time.Parse(fmt, v); err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("Error parsing year from ffmpeg date field. Please report this issue", "file", m.filePath, "date", v)
|
||||
return 0
|
||||
}
|
||||
return y.Year()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m *Metadata) parseTuple(numTag string, totalTag string) (int, int) {
|
||||
if v, ok := m.tags[numTag]; ok {
|
||||
tuple := strings.Split(v, "/")
|
||||
@@ -233,10 +260,15 @@ func createProbeCommand(inputs []string) (string, []string) {
|
||||
|
||||
split := strings.Split(cmd, " ")
|
||||
args := make([]string, 0)
|
||||
first := true
|
||||
for _, s := range split {
|
||||
if s == "%s" {
|
||||
for _, inp := range inputs {
|
||||
args = append(args, "-i", inp)
|
||||
if !first {
|
||||
args = append(args, "-i")
|
||||
}
|
||||
args = append(args, inp)
|
||||
first = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -61,21 +61,8 @@ var _ = Describe("Metadata", func() {
|
||||
const outputWithOverlappingTitleTag = `
|
||||
Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3':
|
||||
Metadata:
|
||||
iTunSMPB : 00000000 000002D6 00000216 0000000000CB9F94 02000003 0049D539 00000000 00000000 00000000 00000000 00000000 00000000
|
||||
iTunNORM : 000002FF 0000027E 00000FEF 00000C17 0002E647 00044605 00007F02 00007A92 0000273E 0000273E
|
||||
title : Pablo's Blues
|
||||
artist : Gare Du Nord
|
||||
album : Putumayo Presents Blues Lounge
|
||||
TT1 : Putumayo
|
||||
track : 9/10
|
||||
compilation : 1
|
||||
genre : Blues
|
||||
date : 2004
|
||||
Duration: 00:05:02.63, start: 0.000000, bitrate: 140 kb/s
|
||||
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 128 kb/s
|
||||
Stream #0:1: Video: png, rgb24(pc), 500x478 [SAR 2835:2835 DAR 250:239], 90k tbr, 90k tbn, 90k tbc
|
||||
Metadata:
|
||||
comment : Other`
|
||||
Duration: 00:05:02.63, start: 0.000000, bitrate: 140 kb/s`
|
||||
md, _ := extractMetadata("tests/fixtures/test.mp3", outputWithOverlappingTitleTag)
|
||||
Expect(md.Compilation()).To(BeTrue())
|
||||
})
|
||||
@@ -85,22 +72,9 @@ Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/
|
||||
Input #0, mp3, from 'groovin.mp3':
|
||||
Metadata:
|
||||
title : Groovin' (feat. Daniel Sneijers, Susanne Alt)
|
||||
artist : Bone 40
|
||||
track : 1
|
||||
album : Groovin'
|
||||
album_artist : Bone 40
|
||||
comment : Visit http://bone40.bandcamp.com
|
||||
date : 2016
|
||||
Duration: 00:03:34.28, start: 0.025056, bitrate: 323 kb/s
|
||||
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 320 kb/s
|
||||
Metadata:
|
||||
encoder : LAME3.99r
|
||||
Side data:
|
||||
replaygain: track gain - -6.000000, track peak - unknown, album gain - unknown, album peak - unknown,
|
||||
Stream #0:1: Video: mjpeg, yuvj444p(pc, bt470bg/unknown/unknown), 700x700 [SAR 72:72 DAR 1:1], 90k tbr, 90k tbn, 90k tbc
|
||||
Metadata:
|
||||
title : cover
|
||||
comment : Cover (front)
|
||||
At least one output file must be specified`
|
||||
md, _ := extractMetadata("tests/fixtures/test.mp3", outputWithOverlappingTitleTag)
|
||||
Expect(md.Title()).To(Equal("Groovin' (feat. Daniel Sneijers, Susanne Alt)"))
|
||||
@@ -111,24 +85,14 @@ At least one output file must be specified`
|
||||
Input #0, flac, from '/Users/deluan/Downloads/06. Back In Black.flac':
|
||||
Metadata:
|
||||
ALBUM : Back In Black
|
||||
album_artist : AC/DC
|
||||
ARTIST : AC/DC
|
||||
COMPOSER : Angus Young;Malcolm Young;Brian Johnson
|
||||
DATE : 1980.07.25
|
||||
disc : 1
|
||||
GENRE : Hard Rock
|
||||
LANGUAGE : EN
|
||||
RATING : 2
|
||||
TITLE : Back In Black
|
||||
DISCTOTAL : 1
|
||||
TRACKTOTAL : 10
|
||||
track : 6
|
||||
REPLAYGAIN_TRACK_GAIN: -8.51 dB
|
||||
REPLAYGAIN_TRACK_PEAK: 0.998322
|
||||
Duration: 00:04:16.00, start: 0.000000, bitrate: 995 kb/s
|
||||
Stream #0:0: Audio: flac, 44100 Hz, stereo, s16
|
||||
Side data:
|
||||
replaygain: track gain - -8.510000, track peak - 0.000023, album gain - unknown, album peak - unknown,`
|
||||
Duration: 00:04:16.00, start: 0.000000, bitrate: 995 kb/s`
|
||||
md, _ := extractMetadata("tests/fixtures/test.mp3", outputWithOverlappingTitleTag)
|
||||
Expect(md.Title()).To(Equal("Back In Black"))
|
||||
Expect(md.Album()).To(Equal("Back In Black"))
|
||||
@@ -139,7 +103,7 @@ Input #0, flac, from '/Users/deluan/Downloads/06. Back In Black.flac':
|
||||
n, t = md.DiscNumber()
|
||||
Expect(n).To(Equal(1))
|
||||
Expect(t).To(Equal(1))
|
||||
|
||||
Expect(md.Year()).To(Equal(1980))
|
||||
})
|
||||
|
||||
// TODO Handle multiline tags
|
||||
@@ -147,14 +111,6 @@ Input #0, flac, from '/Users/deluan/Downloads/06. Back In Black.flac':
|
||||
const outputWithMultilineComment = `
|
||||
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'modulo.m4a':
|
||||
Metadata:
|
||||
major_brand : mp42
|
||||
minor_version : 0
|
||||
compatible_brands: M4A mp42isom
|
||||
creation_time : 2014-05-10T21:11:57.000000Z
|
||||
iTunSMPB : 00000000 00000920 000000E0 00000000021CA200 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
|
||||
encoder : Nero AAC codec / 1.5.4.0
|
||||
title : Módulo Especial
|
||||
artist : Saara Saara
|
||||
comment : https://www.mixcloud.com/codigorock/30-minutos-com-saara-saara/
|
||||
:
|
||||
: Tracklist:
|
||||
@@ -167,18 +123,7 @@ Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'modulo.m4a':
|
||||
: 06. Doktor Fritz
|
||||
: 07. Wunderbar
|
||||
: 08. Quarta Dimensão
|
||||
album : Módulo Especial
|
||||
genre : Electronic
|
||||
track : 1
|
||||
Duration: 00:26:46.96, start: 0.052971, bitrate: 69 kb/s
|
||||
Chapter #0:0: start 0.105941, end 1607.013149
|
||||
Metadata:
|
||||
title :
|
||||
Stream #0:0(und): Audio: aac (HE-AAC) (mp4a / 0x6134706D), 44100 Hz, stereo, fltp, 69 kb/s (default)
|
||||
Metadata:
|
||||
creation_time : 2014-05-10T21:11:57.000000Z
|
||||
handler_name : Sound Media Handler
|
||||
At least one output file must be specified`
|
||||
Duration: 00:26:46.96, start: 0.052971, bitrate: 69 kb/s`
|
||||
const expectedComment = `https://www.mixcloud.com/codigorock/30-minutos-com-saara-saara/
|
||||
|
||||
Tracklist:
|
||||
@@ -196,4 +141,24 @@ Tracklist:
|
||||
Expect(md.Comment()).To(Equal(expectedComment))
|
||||
})
|
||||
})
|
||||
|
||||
Context("parseYear", func() {
|
||||
It("parses the year correctly", func() {
|
||||
var examples = map[string]int{
|
||||
"1985": 1985,
|
||||
"2002-01": 2002,
|
||||
"1969.06": 1969,
|
||||
"1980.07.25": 1980,
|
||||
}
|
||||
for tag, expected := range examples {
|
||||
md := &Metadata{tags: map[string]string{"year": tag}}
|
||||
Expect(md.Year()).To(Equal(expected))
|
||||
}
|
||||
})
|
||||
|
||||
It("returns 0 if year is invalid", func() {
|
||||
md := &Metadata{tags: map[string]string{"year": "invalid"}}
|
||||
Expect(md.Year()).To(Equal(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,12 +13,6 @@ import (
|
||||
"github.com/go-chi/jwtauth"
|
||||
)
|
||||
|
||||
var initialUser = model.User{
|
||||
UserName: "admin",
|
||||
Name: "Admin",
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
ds model.DataStore
|
||||
mux http.Handler
|
||||
|
||||
@@ -63,6 +63,8 @@ func handleLogin(ds model.DataStore, username string, password string, w http.Re
|
||||
"token": tokenString,
|
||||
"name": user.Name,
|
||||
"username": username,
|
||||
"isAdmin": user.IsAdmin,
|
||||
"version": consts.Version(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -148,10 +150,6 @@ func validateLogin(userRepo model.UserRepository, userName, password string) (*m
|
||||
if u.Password != password {
|
||||
return nil, nil
|
||||
}
|
||||
if !u.IsAdmin {
|
||||
log.Warn("Non-admin user tried to login", "user", userName)
|
||||
return nil, nil
|
||||
}
|
||||
err = userRepo.UpdateLastLoginAt(u.ID)
|
||||
if err != nil {
|
||||
log.Error("Could not update LastLoginAt", "user", userName)
|
||||
@@ -164,6 +162,7 @@ func createToken(u *model.User) (string, error) {
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
claims["iss"] = consts.JWTIssuer
|
||||
claims["sub"] = u.UserName
|
||||
claims["adm"] = u.IsAdmin
|
||||
|
||||
return touchToken(token)
|
||||
}
|
||||
@@ -176,11 +175,10 @@ func touchToken(token *jwt.Token) (string, error) {
|
||||
return token.SignedString(jwtSecret)
|
||||
}
|
||||
|
||||
func userFrom(claims jwt.MapClaims) *model.User {
|
||||
user := &model.User{
|
||||
UserName: claims["sub"].(string),
|
||||
}
|
||||
return user
|
||||
func contextWithUser(ctx context.Context, ds model.DataStore, claims jwt.MapClaims) context.Context {
|
||||
userName := claims["sub"].(string)
|
||||
user, _ := ds.User(ctx).FindByUsername(userName)
|
||||
return context.WithValue(ctx, "user", user)
|
||||
}
|
||||
|
||||
func getToken(ds model.DataStore, ctx context.Context) (*jwt.Token, error) {
|
||||
@@ -217,7 +215,7 @@ func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
|
||||
newCtx := context.WithValue(r.Context(), "loggedUser", userFrom(claims))
|
||||
newCtx := contextWithUser(r.Context(), ds, claims)
|
||||
newTokenString, err := touchToken(token)
|
||||
if err != nil {
|
||||
log.Error(r, "signing new token", err)
|
||||
|
||||
@@ -23,7 +23,6 @@ type Server struct {
|
||||
|
||||
func New(scanner *scanner.Scanner, ds model.DataStore) *Server {
|
||||
a := &Server{Scanner: scanner, ds: ds}
|
||||
initMimeTypes()
|
||||
initialSetup(ds)
|
||||
a.initRoutes()
|
||||
a.initScanner()
|
||||
|
||||
@@ -25,15 +25,17 @@ type Router struct {
|
||||
Scrobbler engine.Scrobbler
|
||||
Search engine.Search
|
||||
Users engine.Users
|
||||
Streamer engine.MediaStreamer
|
||||
|
||||
mux http.Handler
|
||||
}
|
||||
|
||||
func New(browser engine.Browser, cover engine.Cover, listGenerator engine.ListGenerator, users engine.Users,
|
||||
playlists engine.Playlists, ratings engine.Ratings, scrobbler engine.Scrobbler, search engine.Search) *Router {
|
||||
playlists engine.Playlists, ratings engine.Ratings, scrobbler engine.Scrobbler, search engine.Search,
|
||||
streamer engine.MediaStreamer) *Router {
|
||||
|
||||
r := &Router{Browser: browser, Cover: cover, ListGenerator: listGenerator, Playlists: playlists,
|
||||
Ratings: ratings, Scrobbler: scrobbler, Search: search, Users: users}
|
||||
Ratings: ratings, Scrobbler: scrobbler, Search: search, Users: users, Streamer: streamer}
|
||||
r.mux = r.routes()
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package subsonic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -200,6 +201,9 @@ func ToChild(entry engine.Entry) responses.Child {
|
||||
child.Type = entry.Type
|
||||
child.UserRating = entry.UserRating
|
||||
child.SongCount = entry.SongCount
|
||||
// TODO Must be dynamic, based on player/transcoding config
|
||||
child.TranscodedSuffix = "mp3"
|
||||
child.TranscodedContentType = mime.TypeByExtension(".mp3")
|
||||
return child
|
||||
}
|
||||
|
||||
|
||||
@@ -2,92 +2,51 @@ package subsonic
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/deluan/navidrome/engine"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/deluan/navidrome/server/subsonic/responses"
|
||||
"github.com/deluan/navidrome/utils"
|
||||
)
|
||||
|
||||
type StreamController struct {
|
||||
browser engine.Browser
|
||||
streamer engine.MediaStreamer
|
||||
}
|
||||
|
||||
func NewStreamController(browser engine.Browser) *StreamController {
|
||||
return &StreamController{browser: browser}
|
||||
func NewStreamController(streamer engine.MediaStreamer) *StreamController {
|
||||
return &StreamController{streamer: streamer}
|
||||
}
|
||||
|
||||
func (c *StreamController) getMediaFile(r *http.Request) (mf *engine.Entry, err error) {
|
||||
func (c *StreamController) Stream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
id, err := RequiredParamString(r, "id", "id parameter required")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
maxBitRate := ParamInt(r, "maxBitRate", 0)
|
||||
format := ParamString(r, "format")
|
||||
|
||||
ms, err := c.streamer.NewStream(r.Context(), id, maxBitRate, format)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Override Content-Type detected by http.FileServer
|
||||
w.Header().Set("Content-Type", ms.ContentType())
|
||||
http.ServeContent(w, r, ms.Name(), ms.ModTime(), ms)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (c *StreamController) Download(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
id, err := RequiredParamString(r, "id", "id parameter required")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mf, err = c.browser.GetSong(r.Context(), id)
|
||||
switch {
|
||||
case err == model.ErrNotFound:
|
||||
log.Error(r, "Mediafile not found", "id", id)
|
||||
return nil, NewError(responses.ErrorDataNotFound)
|
||||
case err != nil:
|
||||
log.Error(r, "Error reading mediafile from DB", "id", id, err)
|
||||
return nil, NewError(responses.ErrorGeneric, "Internal error")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// TODO Still getting the "Conn.Write wrote more than the declared Content-Length" error.
|
||||
// Don't know if this causes any issues
|
||||
func (c *StreamController) Stream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
mf, err := c.getMediaFile(r)
|
||||
ms, err := c.streamer.NewStream(r.Context(), id, 0, "raw")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
maxBitRate := ParamInt(r, "maxBitRate", 0)
|
||||
maxBitRate = utils.MinInt(mf.BitRate, maxBitRate)
|
||||
|
||||
log.Debug(r, "Streaming file", "id", mf.Id, "path", mf.AbsolutePath, "bitrate", mf.BitRate, "maxBitRate", maxBitRate)
|
||||
|
||||
// TODO Send proper estimated content-length
|
||||
//contentLength := mf.Size
|
||||
//if maxBitRate > 0 {
|
||||
// contentLength = strconv.Itoa((mf.Duration + 1) * maxBitRate * 1000 / 8)
|
||||
//}
|
||||
h := w.Header()
|
||||
h.Set("Content-Length", strconv.Itoa(mf.Size))
|
||||
h.Set("Content-Type", "audio/mpeg")
|
||||
h.Set("Expires", "0")
|
||||
h.Set("Cache-Control", "must-revalidate")
|
||||
h.Set("Pragma", "public")
|
||||
|
||||
if r.Method == "HEAD" {
|
||||
log.Debug(r, "Just a HEAD. Not streaming", "path", mf.AbsolutePath)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
err = engine.Stream(r.Context(), mf.AbsolutePath, mf.BitRate, maxBitRate, w)
|
||||
if err != nil {
|
||||
log.Error(r, "Error streaming file", "id", mf.Id, err)
|
||||
}
|
||||
|
||||
log.Debug(r, "Finished streaming", "path", mf.AbsolutePath)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (c *StreamController) Download(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
mf, err := c.getMediaFile(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Debug(r, "Sending file", "path", mf.AbsolutePath)
|
||||
|
||||
err = engine.Stream(r.Context(), mf.AbsolutePath, 0, 0, w)
|
||||
if err != nil {
|
||||
log.Error(r, "Error downloading file", "path", mf.AbsolutePath, err)
|
||||
}
|
||||
|
||||
log.Debug(r, "Finished sending", "path", mf.AbsolutePath)
|
||||
|
||||
// Override Content-Type detected by http.FileServer
|
||||
w.Header().Set("Content-Type", ms.ContentType())
|
||||
http.ServeContent(w, r, ms.Name(), ms.ModTime(), ms)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -59,8 +59,8 @@ func initMediaRetrievalController(router *Router) *MediaRetrievalController {
|
||||
}
|
||||
|
||||
func initStreamController(router *Router) *StreamController {
|
||||
browser := router.Browser
|
||||
streamController := NewStreamController(browser)
|
||||
mediaStreamer := router.Streamer
|
||||
streamController := NewStreamController(mediaStreamer)
|
||||
return streamController
|
||||
}
|
||||
|
||||
@@ -75,5 +75,5 @@ var allProviders = wire.NewSet(
|
||||
NewSearchingController,
|
||||
NewUsersController,
|
||||
NewMediaRetrievalController,
|
||||
NewStreamController, wire.FieldsOf(new(*Router), "Browser", "Cover", "ListGenerator", "Playlists", "Ratings", "Scrobbler", "Search"),
|
||||
NewStreamController, wire.FieldsOf(new(*Router), "Browser", "Cover", "ListGenerator", "Playlists", "Ratings", "Scrobbler", "Search", "Streamer"),
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ var allProviders = wire.NewSet(
|
||||
NewUsersController,
|
||||
NewMediaRetrievalController,
|
||||
NewStreamController,
|
||||
wire.FieldsOf(new(*Router), "Browser", "Cover", "ListGenerator", "Playlists", "Ratings", "Scrobbler", "Search"),
|
||||
wire.FieldsOf(new(*Router), "Browser", "Cover", "ListGenerator", "Playlists", "Ratings", "Scrobbler", "Search", "Streamer"),
|
||||
)
|
||||
|
||||
func initSystemController(router *Router) *SystemController {
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
|
||||
"github.com/deluan/navidrome/server/subsonic/responses"
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func ShouldMatchXML(actual interface{}, expected ...interface{}) string {
|
||||
xml, err := xml.Marshal(actual)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Malformed XML: %v", err)
|
||||
}
|
||||
return convey.ShouldEqual(string(xml), expected[0].(string))
|
||||
|
||||
}
|
||||
|
||||
func ShouldMatchJSON(actual interface{}, expected ...interface{}) string {
|
||||
json, err := json.Marshal(actual)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Malformed JSON: %v", err)
|
||||
}
|
||||
s := UnindentJSON(json)
|
||||
return convey.ShouldEqual(s, expected[0].(string))
|
||||
}
|
||||
|
||||
func ShouldContainJSON(actual interface{}, expected ...interface{}) string {
|
||||
a := UnindentJSON(actual.(*bytes.Buffer).Bytes())
|
||||
|
||||
return convey.ShouldContainSubstring(a, expected[0].(string))
|
||||
}
|
||||
|
||||
func ShouldReceiveError(actual interface{}, expected ...interface{}) string {
|
||||
v := responses.Subsonic{}
|
||||
err := xml.Unmarshal(actual.(*bytes.Buffer).Bytes(), &v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Malformed XML: %v", err)
|
||||
}
|
||||
|
||||
return convey.ShouldEqual(v.Error.Code, expected[0].(int))
|
||||
}
|
||||
|
||||
func ShouldMatchMD5(actual interface{}, expected ...interface{}) string {
|
||||
a := fmt.Sprintf("%x", md5.Sum(actual.([]byte)))
|
||||
return convey.ShouldEqual(a, expected[0].(string))
|
||||
}
|
||||
|
||||
func ShouldBeAValid(actual interface{}, expected ...interface{}) string {
|
||||
v := responses.Subsonic{}
|
||||
err := json.Unmarshal(actual.(*bytes.Buffer).Bytes(), &v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Malformed response: %v", err)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func UnindentJSON(j []byte) string {
|
||||
var m = make(map[string]interface{})
|
||||
json.Unmarshal(j, &m)
|
||||
s, _ := json.Marshal(m)
|
||||
return string(s)
|
||||
}
|
||||
@@ -20,10 +20,12 @@ const App = () => (
|
||||
layout={Layout}
|
||||
loginPage={Login}
|
||||
>
|
||||
<Resource name="artist" {...artist} options={{ subMenu: 'library' }} />
|
||||
<Resource name="album" {...album} options={{ subMenu: 'library' }} />
|
||||
<Resource name="song" {...song} options={{ subMenu: 'library' }} />
|
||||
<Resource name="user" {...user} />
|
||||
{(permissions) => [
|
||||
<Resource name="artist" {...artist} options={{ subMenu: 'library' }} />,
|
||||
<Resource name="album" {...album} options={{ subMenu: 'library' }} />,
|
||||
<Resource name="song" {...song} options={{ subMenu: 'library' }} />,
|
||||
permissions === 'admin' ? <Resource name="user" {...user} /> : null
|
||||
]}
|
||||
</Admin>
|
||||
)
|
||||
export default App
|
||||
|
||||
@@ -23,8 +23,10 @@ const authProvider = {
|
||||
jwtDecode(response.token)
|
||||
localStorage.removeItem('initialAccountCreation')
|
||||
localStorage.setItem('token', response.token)
|
||||
localStorage.setItem('version', response.version)
|
||||
localStorage.setItem('name', response.name)
|
||||
localStorage.setItem('username', response.username)
|
||||
localStorage.setItem('role', response.isAdmin ? 'admin' : 'regular')
|
||||
return response
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -59,13 +61,18 @@ const authProvider = {
|
||||
return Promise.resolve()
|
||||
},
|
||||
|
||||
getPermissions: (params) => Promise.resolve()
|
||||
getPermissions: () => {
|
||||
const role = localStorage.getItem('role')
|
||||
return role ? Promise.resolve(role) : Promise.reject()
|
||||
}
|
||||
}
|
||||
|
||||
const removeItems = () => {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('name')
|
||||
localStorage.removeItem('username')
|
||||
localStorage.removeItem('role')
|
||||
localStorage.removeItem('version')
|
||||
}
|
||||
|
||||
export default authProvider
|
||||
|
||||
23
ui/src/layout/AppBar.js
Normal file
23
ui/src/layout/AppBar.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import { AppBar as RAAppBar, UserMenu, MenuItemLink } from 'react-admin'
|
||||
import InfoIcon from '@material-ui/icons/Info';
|
||||
|
||||
const ConfigurationMenu = forwardRef(({ onClick }, ref) => (
|
||||
<MenuItemLink
|
||||
ref={ref}
|
||||
to=""
|
||||
primaryText={"Version " + localStorage.getItem("version") }
|
||||
leftIcon={<InfoIcon />}
|
||||
onClick={onClick}
|
||||
/>
|
||||
))
|
||||
|
||||
const CustomUserMenu = (props) => (
|
||||
<UserMenu {...props}>
|
||||
<ConfigurationMenu />
|
||||
</UserMenu>
|
||||
)
|
||||
|
||||
const AppBar = (props) => <RAAppBar {...props} userMenu={<CustomUserMenu />} />
|
||||
|
||||
export default AppBar
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import { Layout } from 'react-admin'
|
||||
import Menu from './Menu'
|
||||
import AppBar from './AppBar'
|
||||
|
||||
export default (props) => <Layout {...props} menu={Menu} />
|
||||
export default (props) => <Layout {...props} menu={Menu} appBar={AppBar} />
|
||||
|
||||
@@ -41,7 +41,8 @@ func CreateSubsonicAPIRouter() *subsonic.Router {
|
||||
ratings := engine.NewRatings(dataStore)
|
||||
scrobbler := engine.NewScrobbler(dataStore, nowPlayingRepository)
|
||||
search := engine.NewSearch(dataStore)
|
||||
router := subsonic.New(browser, cover, listGenerator, users, playlists, ratings, scrobbler, search)
|
||||
mediaStreamer := engine.NewMediaStreamer(dataStore)
|
||||
router := subsonic.New(browser, cover, listGenerator, users, playlists, ratings, scrobbler, search, mediaStreamer)
|
||||
return router
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user