Compare commits

..

20 Commits

Author SHA1 Message Date
Deluan
0370f0a3ea refactor: rename ffmpeg to transcoder 2020-02-25 10:32:34 -05:00
Deluan
33ede13eef fix: check if album is starred before adding the starred date in the response. also return "starred" in search responses 2020-02-24 22:06:12 -05:00
Deluan
e032bfcf6b refactor: make parameters consistent 2020-02-24 19:04:54 -05:00
Deluan
f4014c475d refactor: make fakeFFmpeg more configurable, change test name 2020-02-24 14:17:32 -05:00
Deluan
f394de664a refactor: new transcoding engine. third (fourth?) time is a charm! 2020-02-24 13:56:09 -05:00
Deluan
d2eea64528 fix: typo 2020-02-23 21:41:10 -05:00
Deluan
d7b5e6a36c fix: add public attribute to playlists. Even though it is optional,
DSub requires it
2020-02-23 00:10:05 -05:00
Deluan
b49b9e3ca0 chore: remove unused script 2020-02-22 20:29:57 -05:00
Deluan
1322bb3bf3 refactor: move cache constructor 2020-02-21 09:36:29 -05:00
Deluan
13a046a679 fix: change stream cache eviction check period to every 10 minutes 2020-02-20 20:12:52 -05:00
Deluan
e6d2056438 fix: typo 2020-02-20 19:39:32 -05:00
Deluan
a6b0c57ce0 feat: add a proper caching system to the transcoding functionality 2020-02-20 19:25:39 -05:00
Deluan
fc14e346b9 feat: store duration as float, to cater for milliseconds 2020-02-20 17:02:06 -05:00
Deluan
5525145906 fix: audio stream's bitrate has precedence over container's bitrate 2020-02-20 13:56:45 -05:00
Deluan
74d87790b8 refactor: better ffmpeg output metadata parsing 2020-02-20 10:41:16 -05:00
Deluan
8ce796756f fix: error message 2020-02-19 15:34:05 -05:00
Deluan
a412989f7e refactor: more stable transcoder, based on http.FileSystem 2020-02-19 14:53:35 -05:00
Deluan
ae02dc203e chore: remove unused code 2020-02-19 09:08:05 -05:00
Deluan
fc7595a464 fix: cover art detection regex 2020-02-18 11:19:22 -05:00
Deluan
4ceaea7732 fix: extract stream level metadata 2020-02-18 10:00:05 -05:00
39 changed files with 649 additions and 454 deletions

View File

@@ -1,98 +0,0 @@
#!/bin/bash
# Script to transfort .itc files into images (JPG or PNG)
#
# .itc files are located in ~/Music/iTunes/Album Artwork
#
# This script uses (/!\ needs ) ImageMagick's convert, hexdump, printf and dd.
#
# This script might be a little slow, You might want to look at Simon Kennedy's work at http://www.sffjunkie.co.uk/python-itc.html
#
# ~/{Library Path}/Album Artwork/Cache/D989408F65D05F99/04/13/04/D989408F65D05F99-EB5B7A9086F4B4D4.itc
#
# The filenames are an amalgam of the library ID (D989408F65D05F99) and the track's ID (EB5B7A9086F4B4D4).
# The directory structure comes from the library ID and the last three digits of the track's ID converted to decimal,
# ie 4D4 becomes 04, 13, 04.
#
AlbumArtwork="${HOME}/Music/iTunes 1/Album Artwork"
DestinationDir="Artwork"
IFS=$'\n'
if [ ! -d "$DestinationDir" ]; then
mkdir "$DestinationDir"
echo "new Images dir"
fi
for file in `find "$AlbumArtwork" -name '*.itc'`; do
start=0x11C
exit=0;
i=1;
echo $file
while [ 1 ]; do
typeOffset=$(($start+0x30))
imageType=$(hexdump -n 4 -s $typeOffset -e '"0x"4/1 "%02x" "\n"' $file)
#If there is no next byte, jump to the next itc file.
if [[ -z $imageType ]]; then
break
fi
imageOffsetOffset=$(($start+8))
itemSize=$(hexdump -n 4 -s $start -e '"0x"4/1 "%02x" "\n"' $file)
imageOffset=$(hexdump -n 4 -s $imageOffsetOffset -e '"0x"4/1 "%02x" "\n"' $file)
imageStart=$(($start+$imageOffset))
imageSize=$(($itemSize-imageOffset))
imageWidth=$(hexdump -n 4 -s $(($start+56)) -e '"0x"4/1 "%02x" "\n"' $file)
imageWidth=$(printf "%d" $imageWidth)
imageHeight=$(hexdump -n 4 -s $(($start+60)) -e '"0x"4/1 "%02x" "\n"' $file)
imageHeight=$(printf "%d" $imageHeight)
dir=$(dirname "$file")
xbase=${file##*/} #file.etc
xpref=${xbase%.*} #file prefix
#echo $file
#echo itemsize $itemSize
#echo start $start
#echo imageOffset $imageOffset
#echo imageStart $imageStart
#echo imageSize $imageSize
#echo imageWidth $imageWidth
#echo imageHeight $imageHeight
if [[ $imageType -eq 0x504E4766 ]] || [[ $imageType -eq 0x0000000E ]] ; then
targetFile="$DestinationDir/$xpref-$i.png"
if [ ! -f "$targetFile" ]; then
echo PNG
dd skip=$imageStart count=$imageSize if="$file" of="$targetFile" bs=1 &> /dev/null
fi
elif [[ $imageType -eq 0x41524762 ]] ; then
targetFile="$DestinationDir/$xpref-$i.png"
if [ ! -f "$targetFile" ]; then
echo ARGB
dd skip=$imageStart count=$imageSize if="$file" of="$TMPDIR/test$i" bs=1 &> /dev/null
#Using a matrix to convert ARGB to RGBA since imagemagick does only support rgba input
convert -size $imageWidth"x"$imageHeight -depth 8 -color-matrix '0 1 0 0 0 0 1 0 0 0 0 1 1 0 0 0' rgba:"$TMPDIR/test$i" "$targetFile"
fi
elif [[ $imageType -eq 0x0000000D ]] ; then
targetFile="$DestinationDir/$xpref-$i.jpg"
if [ ! -f "$targetFile" ]; then
echo JPG
dd skip=$imageStart count=$imageSize if="$file" of="$targetFile" bs=1 &> /dev/null
fi
else
echo $imageType
exit=1
break;
fi
start=$(($start+$itemSize))
i=$(($i+1))
done
done

View File

@@ -13,20 +13,21 @@ import (
)
type nd struct {
Port string `default:"4533"`
MusicFolder string `default:"./music"`
DataFolder string `default:"./"`
DbPath string
LogLevel string `default:"info"`
Port string `default:"4533"`
MusicFolder string `default:"./music"`
DataFolder string `default:"./"`
ScanInterval string `default:"1m"`
DbPath string
LogLevel string `default:"info"`
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]([)"`
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"`
EnableDownsampling bool `default:"false"`
MaxBitRate int `default:"0"`
MaxTranscodingCacheSize int64 `default:"100000000"` // 100MB
DownsampleCommand string `default:"ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -"`
ProbeCommand string `default:"ffmpeg -i %s -f ffmetadata"`
// DevFlags. These are used to enable/disable debugging and incomplete features
DevDisableBanner bool `default:"false"`

View File

@@ -14,6 +14,8 @@ const (
UIAssetsLocalPath = "ui/build"
CacheDir = "cache"
DevInitialUserName = "admin"
DevInitialName = "Dev Admin"
)

View File

@@ -0,0 +1,129 @@
package migration
import (
"database/sql"
"github.com/deluan/navidrome/log"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(Up20200220143731, Down20200220143731)
}
func Up20200220143731(tx *sql.Tx) error {
log.Warn("This migration will force the next scan to be a full rescan!")
_, err := tx.Exec(`
create table media_file_dg_tmp
(
id varchar(255) not null
primary key,
path varchar(255) default '' not null,
title varchar(255) default '' not null,
album varchar(255) default '' not null,
artist varchar(255) default '' not null,
artist_id varchar(255) default '' not null,
album_artist varchar(255) default '' not null,
album_id varchar(255) default '' not null,
has_cover_art bool default FALSE not null,
track_number integer default 0 not null,
disc_number integer default 0 not null,
year integer default 0 not null,
size integer default 0 not null,
suffix varchar(255) default '' not null,
duration real default 0 not null,
bit_rate integer default 0 not null,
genre varchar(255) default '' not null,
compilation bool default FALSE not null,
created_at datetime,
updated_at datetime
);
insert into media_file_dg_tmp(id, path, title, album, artist, artist_id, album_artist, album_id, has_cover_art, track_number, disc_number, year, size, suffix, duration, bit_rate, genre, compilation, created_at, updated_at) select id, path, title, album, artist, artist_id, album_artist, album_id, has_cover_art, track_number, disc_number, year, size, suffix, duration, bit_rate, genre, compilation, created_at, updated_at from media_file;
drop table media_file;
alter table media_file_dg_tmp rename to media_file;
create index media_file_album_id
on media_file (album_id);
create index media_file_genre
on media_file (genre);
create index media_file_path
on media_file (path);
create index media_file_title
on media_file (title);
create table album_dg_tmp
(
id varchar(255) not null
primary key,
name varchar(255) default '' not null,
artist_id varchar(255) default '' not null,
cover_art_path varchar(255) default '' not null,
cover_art_id varchar(255) default '' not null,
artist varchar(255) default '' not null,
album_artist varchar(255) default '' not null,
year integer default 0 not null,
compilation bool default FALSE not null,
song_count integer default 0 not null,
duration real default 0 not null,
genre varchar(255) default '' not null,
created_at datetime,
updated_at datetime
);
insert into album_dg_tmp(id, name, artist_id, cover_art_path, cover_art_id, artist, album_artist, year, compilation, song_count, duration, genre, created_at, updated_at) select id, name, artist_id, cover_art_path, cover_art_id, artist, album_artist, year, compilation, song_count, duration, genre, created_at, updated_at from album;
drop table album;
alter table album_dg_tmp rename to album;
create index album_artist
on album (artist);
create index album_artist_id
on album (artist_id);
create index album_genre
on album (genre);
create index album_name
on album (name);
create index album_year
on album (year);
create table playlist_dg_tmp
(
id varchar(255) not null
primary key,
name varchar(255) default '' not null,
comment varchar(255) default '' not null,
duration real default 0 not null,
owner varchar(255) default '' not null,
public bool default FALSE not null,
tracks text not null
);
insert into playlist_dg_tmp(id, name, comment, duration, owner, public, tracks) select id, name, comment, duration, owner, public, tracks from playlist;
drop table playlist;
alter table playlist_dg_tmp rename to playlist;
create index playlist_name
on playlist (name);
-- Force a full rescan
delete from property where id like 'LastScan%';
update media_file set updated_at = '0001-01-01';
`)
return err
}
func Down20200220143731(tx *sql.Tx) error {
return nil
}

View File

@@ -159,16 +159,19 @@ func (b *browser) buildAlbumDir(al *model.Album, tracks model.MediaFiles) *Direc
Artist: al.Artist,
ArtistId: al.ArtistID,
SongCount: al.SongCount,
Duration: al.Duration,
Duration: int(al.Duration),
Created: al.CreatedAt,
Year: al.Year,
Genre: al.Genre,
CoverArt: al.CoverArtId,
PlayCount: int32(al.PlayCount),
Starred: al.StarredAt,
UserRating: al.Rating,
}
if al.Starred {
dir.Starred = al.StarredAt
}
dir.Entries = FromMediaFiles(tracks)
return dir
}

View File

@@ -14,7 +14,7 @@ var _ = Describe("Browser", func() {
var repo *mockGenreRepository
var b Browser
BeforeSuite(func() {
BeforeEach(func() {
repo = &mockGenreRepository{data: model.Genres{
{Name: "Rock", SongCount: 1000, AlbumCount: 100},
{Name: "", SongCount: 13, AlbumCount: 13},

View File

@@ -51,7 +51,9 @@ func FromArtist(ar *model.Artist) Entry {
e.Title = ar.Name
e.AlbumCount = ar.AlbumCount
e.IsDir = true
e.Starred = ar.StarredAt
if ar.Starred {
e.Starred = ar.StarredAt
}
return e
}
@@ -69,9 +71,11 @@ func FromAlbum(al *model.Album) Entry {
e.Created = al.CreatedAt
e.AlbumId = al.ID
e.ArtistId = al.ArtistID
e.Duration = al.Duration
e.Duration = int(al.Duration)
e.SongCount = al.SongCount
e.Starred = al.StarredAt
if al.Starred {
e.Starred = al.StarredAt
}
e.PlayCount = int32(al.PlayCount)
e.UserRating = al.Rating
return e
@@ -88,7 +92,7 @@ func FromMediaFile(mf *model.MediaFile) Entry {
e.Artist = mf.Artist
e.Genre = mf.Genre
e.Track = mf.TrackNumber
e.Duration = mf.Duration
e.Duration = int(mf.Duration)
e.Size = mf.Size
e.Suffix = mf.Suffix
e.BitRate = mf.BitRate
@@ -105,9 +109,11 @@ func FromMediaFile(mf *model.MediaFile) Entry {
e.Created = mf.CreatedAt
e.AlbumId = mf.AlbumID
e.ArtistId = mf.ArtistID
e.Type = "music" // TODO Hardcoded for now
e.Type = "music"
e.PlayCount = int32(mf.PlayCount)
e.Starred = mf.StarredAt
if mf.Starred {
e.Starred = mf.StarredAt
}
e.UserRating = mf.Rating
return e
}

View File

@@ -2,232 +2,182 @@ package engine
import (
"context"
"fmt"
"io"
"io/ioutil"
"mime"
"os"
"os/exec"
"strconv"
"strings"
"path/filepath"
"time"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/engine/transcoder"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/utils"
"github.com/djherbis/fscache"
)
type MediaStreamer interface {
NewStream(ctx context.Context, id string, maxBitRate int, format string) (mediaStream, error)
NewStream(ctx context.Context, id string, maxBitRate int, format string) (*Stream, 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
Duration() int
func NewMediaStreamer(ds model.DataStore, ffm transcoder.Transcoder, cache fscache.Cache) MediaStreamer {
return &mediaStreamer{ds: ds, ffm: ffm, cache: cache}
}
type mediaStreamer struct {
ds model.DataStore
ds model.DataStore
ffm transcoder.Transcoder
cache fscache.Cache
}
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, maxBitRate int, format string) (mediaStream, error) {
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, maxBitRate int, reqFormat string) (*Stream, error) {
mf, err := ms.ds.MediaFile(ctx).Get(id)
if err != nil {
return nil, err
}
bitRate, format := selectTranscodingOptions(mf, maxBitRate, reqFormat)
s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate}
if format == "raw" {
log.Debug(ctx, "Streaming raw file", "id", mf.ID, "path", mf.Path,
"requestBitrate", maxBitRate, "requestFormat", reqFormat,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
f, err := os.Open(mf.Path)
if err != nil {
return nil, err
}
s.Reader = f
s.Closer = f
s.Seeker = f
s.format = mf.Suffix
return s, nil
}
key := cacheKey(id, bitRate, format)
r, w, err := ms.cache.Get(key)
if err != nil {
log.Error(ctx, "Error creating stream caching buffer", "id", mf.ID, err)
return nil, err
}
// If this is a brand new transcoding request, not in the cache, start transcoding
if w != nil {
log.Trace(ctx, "Cache miss. Starting new transcoding session", "id", mf.ID)
out, err := ms.ffm.Start(ctx, mf.Path, bitRate, format)
if err != nil {
log.Error(ctx, "Error starting transcoder", "id", mf.ID, err)
return nil, os.ErrInvalid
}
go copyAndClose(ctx, w, out)()
}
// If it is in the cache, check if the stream is done being written. If so, return a ReaderSeeker
if w == nil {
size := getFinalCachedSize(r)
if size > 0 {
log.Debug(ctx, "Streaming cached file", "id", mf.ID, "path", mf.Path,
"requestBitrate", maxBitRate, "requestFormat", reqFormat,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix, "size", size)
sr := io.NewSectionReader(r, 0, size)
s.Reader = sr
s.Closer = r
s.Seeker = sr
s.format = format
return s, nil
}
}
log.Debug(ctx, "Streaming transcoded file", "id", mf.ID, "path", mf.Path,
"requestBitrate", maxBitRate, "requestFormat", reqFormat,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
// All other cases, just return a ReadCloser, without Seek capabilities
s.Reader = r
s.Closer = r
s.format = format
return s, nil
}
func copyAndClose(ctx context.Context, w io.WriteCloser, r io.ReadCloser) func() {
return func() {
_, err := io.Copy(w, r)
if err != nil {
log.Error(ctx, "Error copying data to cache", err)
}
err = r.Close()
if err != nil {
log.Error(ctx, "Error closing transcode output", err)
}
err = w.Close()
if err != nil {
log.Error(ctx, "Error closing cache", err)
}
}
}
type Stream struct {
ctx context.Context
mf *model.MediaFile
bitRate int
format string
io.Reader
io.Closer
io.Seeker
}
func (s *Stream) Seekable() bool { return s.Seeker != nil }
func (s *Stream) Duration() float32 { return s.mf.Duration }
func (s *Stream) ContentType() string { return mime.TypeByExtension("." + s.format) }
func (s *Stream) Name() string { return s.mf.Path }
func (s *Stream) ModTime() time.Time { return s.mf.UpdatedAt }
func selectTranscodingOptions(mf *model.MediaFile, maxBitRate int, format string) (int, string) {
var bitRate int
if format == "raw" || !conf.Server.EnableDownsampling {
bitRate = mf.BitRate
format = mf.Suffix
return bitRate, "raw"
} else {
if maxBitRate == 0 {
bitRate = mf.BitRate
} else {
bitRate = utils.MinInt(mf.BitRate, maxBitRate)
}
format = mf.Suffix
format = "mp3" //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
if bitRate == mf.BitRate {
return bitRate, "raw"
}
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
return bitRate, format
}
type rawMediaStream struct {
file *os.File
ctx context.Context
mf *model.MediaFile
func cacheKey(id string, bitRate int, format string) string {
return fmt.Sprintf("%s.%d.%s", id, bitRate, format)
}
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) Duration() int {
return m.mf.Duration
}
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
}
func getFinalCachedSize(r fscache.ReadAtCloser) int64 {
cr, ok := r.(*fscache.CacheReader)
if ok {
size, final, err := cr.Size()
if final && err == nil {
return size
}
}
n, err = m.pipe.Read(p)
m.pos += int64(n)
if err == io.EOF {
m.Close()
return -1
}
func NewTranscodingCache() (fscache.Cache, error) {
lru := fscache.NewLRUHaunter(0, conf.Server.MaxTranscodingCacheSize, 10*time.Minute)
h := fscache.NewLRUHaunterStrategy(lru)
cacheFolder := filepath.Join(conf.Server.DataFolder, consts.CacheDir)
fs, err := fscache.NewFs(cacheFolder, 0755)
if err != nil {
return nil, err
}
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 transcoded stream", "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) Duration() int {
return m.mf.Duration
}
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
}
if err = cmd.Start(); err != nil {
return f, err
}
go cmd.Wait() // prevent zombies
return f, err
}
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:]
return fscache.NewCacheWithHaunter(fs, h)
}

View File

@@ -1,75 +1,93 @@
package engine
import (
"time"
"context"
"io"
"io/ioutil"
"os"
"strings"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/persistence"
"github.com/djherbis/fscache"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("MediaStreamer", func() {
var streamer MediaStreamer
var ds model.DataStore
var cache fscache.Cache
var tempDir string
ffmpeg := &fakeFFmpeg{Data: "fake data"}
ctx := log.NewContext(nil)
BeforeSuite(func() {
tempDir, _ = ioutil.TempDir("", "stream_tests")
fs, _ := fscache.NewFs(tempDir, 0755)
cache, _ = fscache.NewCache(fs, 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)
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "bitRate": 128, "duration": 257.0}]`, 1)
streamer = NewMediaStreamer(ds, ffmpeg, cache)
})
AfterSuite(func() {
os.RemoveAll(tempDir)
})
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 seekable stream if format is 'raw'", func() {
s, err := streamer.NewStream(ctx, "123", 0, "raw")
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a rawMediaStream if maxBitRate is 0", func() {
Expect(streamer.NewStream(ctx, "123", 0, "mp3")).To(BeAssignableToTypeOf(&rawMediaStream{}))
It("returns a seekable stream if maxBitRate is 0", func() {
s, err := streamer.NewStream(ctx, "123", 0, "mp3")
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
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 seekable stream if maxBitRate is higher than file bitRate", func() {
s, err := streamer.NewStream(ctx, "123", 320, "mp3")
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a transcodedMediaStream if maxBitRate is lower than file bitRate", func() {
It("returns a NON seekable stream if transcode is required", 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))
Expect(s.Seekable()).To(BeFalse())
Expect(s.Duration()).To(Equal(float32(257.0)))
})
})
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 a seekable stream if the file is complete in the cache", func() {
Eventually(func() bool { return ffmpeg.closed }).Should(BeTrue())
s, err := streamer.NewStream(ctx, "123", 64, "mp3")
Expect(err).To(BeNil())
Expect(s.Seekable()).To(BeTrue())
})
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", "-"}))
})
})
})
type fakeFFmpeg struct {
Data string
r io.Reader
closed bool
}
func (ff *fakeFFmpeg) Start(ctx context.Context, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
ff.r = strings.NewReader(ff.Data)
return ff, nil
}
func (ff *fakeFFmpeg) Read(p []byte) (n int, err error) {
return ff.r.Read(p)
}
func (ff *fakeFFmpeg) Close() error {
ff.closed = true
return nil
}

View File

@@ -102,7 +102,11 @@ func (p *playlists) Update(ctx context.Context, playlistId string, name *string,
}
func (p *playlists) GetAll(ctx context.Context) (model.Playlists, error) {
return p.ds.Playlist(ctx).GetAll(model.QueryOptions{})
all, err := p.ds.Playlist(ctx).GetAll(model.QueryOptions{})
for i := range all {
all[i].Public = true
}
return all, err
}
type PlaylistInfo struct {
@@ -127,7 +131,7 @@ func (p *playlists) Get(ctx context.Context, id string) (*PlaylistInfo, error) {
Id: pl.ID,
Name: pl.Name,
SongCount: len(pl.Tracks),
Duration: pl.Duration,
Duration: int(pl.Duration),
Public: pl.Public,
Owner: pl.Owner,
Comment: pl.Comment,

View File

@@ -0,0 +1,52 @@
package transcoder
import (
"context"
"io"
"os"
"os/exec"
"strconv"
"strings"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/log"
)
type Transcoder interface {
Start(ctx context.Context, path string, maxBitRate int, format string) (f io.ReadCloser, err error)
}
func New() Transcoder {
return &ffmpeg{}
}
type ffmpeg struct{}
func (ff *ffmpeg) Start(ctx context.Context, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
arg0, args := createTranscodeCommand(path, maxBitRate, format)
log.Trace(ctx, "Executing ffmpeg command", "cmd", arg0, "args", args)
cmd := exec.Command(arg0, args...)
cmd.Stderr = os.Stderr
if f, err = cmd.StdoutPipe(); err != nil {
return
}
if err = cmd.Start(); err != nil {
return
}
go cmd.Wait() // prevent zombies
return
}
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:]
}

View File

@@ -0,0 +1,29 @@
package transcoder
import (
"testing"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/tests"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
func TestTranscoder(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelCritical)
RegisterFailHandler(Fail)
RunSpecs(t, "Transcoder Suite")
}
var _ = Describe("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", "-"}))
})
})

View File

@@ -1,6 +1,9 @@
package engine
import "github.com/google/wire"
import (
"github.com/deluan/navidrome/engine/transcoder"
"github.com/google/wire"
)
var Set = wire.NewSet(
NewBrowser,
@@ -13,4 +16,6 @@ var Set = wire.NewSet(
NewNowPlayingRepository,
NewUsers,
NewMediaStreamer,
transcoder.New,
NewTranscodingCache,
)

3
go.mod
View File

@@ -10,6 +10,7 @@ require (
github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8
github.com/djherbis/fscache v0.10.0
github.com/fatih/camelcase v0.0.0-20160318181535-f6a740d52f96 // indirect
github.com/fatih/structs v1.0.0 // indirect
github.com/go-chi/chi v4.0.3+incompatible
@@ -38,5 +39,7 @@ require (
golang.org/x/text v0.3.2 // indirect
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
gopkg.in/djherbis/atime.v1 v1.0.0 // indirect
gopkg.in/djherbis/stream.v1 v1.2.0 // indirect
gopkg.in/yaml.v2 v2.2.8 // indirect
)

6
go.sum
View File

@@ -28,6 +28,8 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumC
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8 h1:nmFnmD8VZXkjDHIb1Gnfz50cgzUvGN72zLjPRXBW/hU=
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8/go.mod h1:SniNVYuaD1jmdEEvi+7ywb1QFR7agjeTdGKyFb0p7Rw=
github.com/djherbis/fscache v0.10.0 h1:+O3s3LwKL1Jfz7txRHpgKOz8/krh9w+NzfzxtFdbsQg=
github.com/djherbis/fscache v0.10.0/go.mod h1:yyPYtkNnnPXsW+81lAcQS6yab3G2CRfnPLotBvtbf0c=
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8=
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
@@ -172,6 +174,10 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/djherbis/atime.v1 v1.0.0 h1:eMRqB/JrLKocla2PBPKgQYg/p5UG4L6AUAs92aP7F60=
gopkg.in/djherbis/atime.v1 v1.0.0/go.mod h1:hQIUStKmJfvf7xdh/wtK84qe+DsTV5LnA9lzxxtPpJ8=
gopkg.in/djherbis/stream.v1 v1.2.0 h1:3tZuXO+RK8opjw8/BJr780h+eAPwOFfLHCKRKyYxk3s=
gopkg.in/djherbis/stream.v1 v1.2.0/go.mod h1:aEV8CBVRmSpLamVJfM903Npic1IKmb2qS30VAZ+sssg=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=

View File

@@ -1,6 +1,8 @@
package main
import (
"fmt"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/db"
@@ -14,8 +16,12 @@ func main() {
conf.Load()
db.EnsureLatestVersion()
subsonic, err := CreateSubsonicAPIRouter()
if err != nil {
panic(fmt.Sprintf("Could not create the Subsonic API router. Aborting! err=%v", err))
}
a := CreateServer(conf.Server.MusicFolder)
a.MountRouter("/rest", CreateSubsonicAPIRouter())
a.MountRouter("/rest", subsonic)
a.MountRouter("/app", CreateAppRouter("/app"))
a.Run(":" + conf.Server.Port)
}

View File

@@ -13,7 +13,7 @@ type Album struct {
Year int `json:"year"`
Compilation bool `json:"compilation"`
SongCount int `json:"songCount"`
Duration int `json:"duration"`
Duration float32 `json:"duration"`
Genre string `json:"genre"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`

View File

@@ -20,7 +20,7 @@ type MediaFile struct {
Year int `json:"year"`
Size int `json:"size"`
Suffix string `json:"suffix"`
Duration int `json:"duration"`
Duration float32 `json:"duration"`
BitRate int `json:"bitRate"`
Genre string `json:"genre"`
Compilation bool `json:"compilation"`

View File

@@ -4,7 +4,7 @@ type Playlist struct {
ID string
Name string
Comment string
Duration int
Duration float32
Owner string
Public bool
Tracks MediaFiles

View File

@@ -41,7 +41,7 @@ func (r *albumRepository) Put(a *model.Album) error {
}
func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder {
return r.newSelectWithAnnotation("id", options...).Columns("*")
return r.newSelectWithAnnotation("album.id", options...).Columns("*")
}
func (r *albumRepository) Get(id string) (*model.Album, error) {

View File

@@ -29,7 +29,7 @@ func NewArtistRepository(ctx context.Context, o orm.Ormer) model.ArtistRepositor
}
func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder {
return r.newSelectWithAnnotation("id", options...).Columns("*")
return r.newSelectWithAnnotation("artist.id", options...).Columns("*")
}
func (r *artistRepository) CountAll(options ...model.QueryOptions) (int64, error) {

View File

@@ -13,7 +13,7 @@ type playlist struct {
ID string `orm:"column(id)"`
Name string
Comment string
Duration int
Duration float32
Owner string
Public bool
Tracks string

View File

@@ -188,7 +188,7 @@ func (r sqlRepository) delete(cond Sqlizer) error {
}
func (r sqlRepository) logSQL(sql string, args []interface{}, err error, rowsAffected int64, start time.Time) {
lapsed := time.Since(start)
elapsed := time.Since(start)
var fmtArgs []string
for i := range args {
var f string
@@ -201,9 +201,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, "lapsedTime", lapsed, err)
log.Error(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected, "elapsedTime", elapsed, err)
} else {
log.Trace(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected, "lapsedTime", lapsed)
log.Trace(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected, "elapsedTime", elapsed)
}
}

View File

@@ -39,7 +39,7 @@ func (r sqlRepository) doSearch(q string, offset, size int, results interface{},
if len(q) < 2 {
return nil
}
sq := Select("*").From(r.tableName)
sq := r.newSelectWithAnnotation(r.tableName + ".id").Columns("*")
sq = sq.Limit(uint64(size)).Offset(uint64(offset))
if len(orderBys) > 0 {
sq = sq.OrderBy(orderBys...)

View File

@@ -24,19 +24,19 @@ type Metadata struct {
tags map[string]string
}
func (m *Metadata) Title() string { return m.tags["title"] }
func (m *Metadata) Album() string { return m.tags["album"] }
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.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" }
func (m *Metadata) Comment() string { return m.tags["comment"] }
func (m *Metadata) Title() string { return m.getTag("title", "sort_name") }
func (m *Metadata) Album() string { return m.getTag("album", "sort_album") }
func (m *Metadata) Artist() string { return m.getTag("artist", "sort_artist") }
func (m *Metadata) AlbumArtist() string { return m.getTag("album_artist") }
func (m *Metadata) Composer() string { return m.getTag("composer", "tcm", "sort_composer") }
func (m *Metadata) Genre() string { return m.getTag("genre") }
func (m *Metadata) Year() int { return m.parseYear("date") }
func (m *Metadata) TrackNumber() (int, int) { return m.parseTuple("track") }
func (m *Metadata) DiscNumber() (int, int) { return m.parseTuple("tpa", "disc") }
func (m *Metadata) HasPicture() bool { return m.getTag("has_picture") == "true" }
func (m *Metadata) Comment() string { return m.getTag("comment") }
func (m *Metadata) Compilation() bool { return m.parseBool("compilation") }
func (m *Metadata) Duration() int { return m.parseDuration("duration") }
func (m *Metadata) Duration() float32 { return m.parseDuration("duration") }
func (m *Metadata) BitRate() int { return m.parseInt("bitrate") }
func (m *Metadata) ModificationTime() time.Time { return m.fileInfo.ModTime() }
func (m *Metadata) FilePath() string { return m.filePath }
@@ -94,7 +94,22 @@ func ExtractAllMetadata(inputs []string) (map[string]*Metadata, error) {
return mds, nil
}
var inputRegex = regexp.MustCompile(`(?m)^Input #\d+,.*,\sfrom\s'(.*)'`)
var (
// Input #0, mp3, from 'groovin.mp3':
inputRegex = regexp.MustCompile(`(?m)^Input #\d+,.*,\sfrom\s'(.*)'`)
// TITLE : Back In Black
tagsRx = regexp.MustCompile(`(?i)^\s{4,6}(\w+)\s+:(.*)`)
// Duration: 00:04:16.00, start: 0.000000, bitrate: 995 kb/s`
durationRx = regexp.MustCompile(`^\s\sDuration: ([\d.:]+).*bitrate: (\d+)`)
// Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s
bitRateRx = regexp.MustCompile(`^\s{4}Stream #\d+:\d+: (Audio):.*, (\d+) kb/s`)
// Stream #0:1: Video: mjpeg, yuvj444p(pc, bt470bg/unknown/unknown), 600x600 [SAR 1:1 DAR 1:1], 90k tbr, 90k tbn, 90k tbc`
coverRx = regexp.MustCompile(`^\s{4}Stream #\d+:\d+: (Video):.*`)
)
func parseOutput(output string) map[string]string {
split := map[string]string{}
@@ -141,47 +156,44 @@ func isAudioFile(extension string) bool {
return strings.HasPrefix(typ, "audio/")
}
var (
tagsRx = map[*regexp.Regexp]string{
regexp.MustCompile(`(?i)^\s{4}compilation\s+:(.*)`): "compilation",
regexp.MustCompile(`(?i)^\s{4}genre\s+:\s(.*)`): "genre",
regexp.MustCompile(`(?i)^\s{4}title\s+:\s(.*)`): "title",
regexp.MustCompile(`(?i)^\s{4}comment\s+:\s(.*)`): "comment",
regexp.MustCompile(`(?i)^\s{4}artist\s+:\s(.*)`): "artist",
regexp.MustCompile(`(?i)^\s{4}album_artist\s+:\s(.*)`): "album_artist",
regexp.MustCompile(`(?i)^\s{4}TCM\s+:\s(.*)`): "composer",
regexp.MustCompile(`(?i)^\s{4}album\s+:\s(.*)`): "album",
regexp.MustCompile(`(?i)^\s{4}track\s+:\s(.*)`): "trackNum",
regexp.MustCompile(`(?i)^\s{4}tracktotal\s+:\s(.*)`): "trackTotal",
regexp.MustCompile(`(?i)^\s{4}disc\s+:\s(.*)`): "discNum",
regexp.MustCompile(`(?i)^\s{4}disctotal\s+:\s(.*)`): "discTotal",
regexp.MustCompile(`(?i)^\s{4}TPA\s+:\s(.*)`): "discNum",
regexp.MustCompile(`(?i)^\s{4}date\s+:\s(.*)`): "year",
regexp.MustCompile(`^\s{4}Stream #\d+:\d+: (.+):\s`): "hasPicture",
}
durationRx = regexp.MustCompile(`^\s\sDuration: ([\d.:]+).*bitrate: (\d+)`)
)
func (m *Metadata) parseInfo(info string) {
reader := strings.NewReader(info)
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
line := scanner.Text()
for rx, tag := range tagsRx {
match := rx.FindStringSubmatch(line)
if len(match) > 0 {
m.tags[tag] = match[1]
break
}
match = durationRx.FindStringSubmatch(line)
if len(match) == 0 {
continue
if len(line) == 0 {
continue
}
match := tagsRx.FindStringSubmatch(line)
if len(match) > 0 {
tagName := strings.ToLower(match[1])
tagValue := strings.TrimSpace(match[2])
// Skip when the tag was previously found
if _, ok := m.tags[tagName]; !ok {
m.tags[tagName] = tagValue
}
continue
}
match = coverRx.FindStringSubmatch(line)
if len(match) > 0 {
m.tags["has_picture"] = "true"
continue
}
match = durationRx.FindStringSubmatch(line)
if len(match) > 0 {
m.tags["duration"] = match[1]
if len(match) > 1 {
m.tags["bitrate"] = match[2]
}
continue
}
match = bitRateRx.FindStringSubmatch(line)
if len(match) > 0 {
m.tags["bitrate"] = match[2]
}
}
}
@@ -194,15 +206,6 @@ 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,
}
var dateRegex = regexp.MustCompile(`^([12]\d\d\d)`)
func (m *Metadata) parseYear(tagName string) int {
@@ -218,17 +221,28 @@ func (m *Metadata) parseYear(tagName string) int {
return 0
}
func (m *Metadata) parseTuple(numTag string, totalTag string) (int, int) {
if v, ok := m.tags[numTag]; ok {
tuple := strings.Split(v, "/")
t1, t2 := 0, 0
t1, _ = strconv.Atoi(tuple[0])
if len(tuple) > 1 {
t2, _ = strconv.Atoi(tuple[1])
} else {
t2, _ = strconv.Atoi(m.tags[totalTag])
func (m *Metadata) getTag(tags ...string) string {
for _, t := range tags {
if v, ok := m.tags[t]; ok {
return v
}
}
return ""
}
func (m *Metadata) parseTuple(tags ...string) (int, int) {
for _, tagName := range tags {
if v, ok := m.tags[tagName]; ok {
tuple := strings.Split(v, "/")
t1, t2 := 0, 0
t1, _ = strconv.Atoi(tuple[0])
if len(tuple) > 1 {
t2, _ = strconv.Atoi(tuple[1])
} else {
t2, _ = strconv.Atoi(m.tags[tagName+"total"])
}
return t1, t2
}
return t1, t2
}
return 0, 0
}
@@ -243,13 +257,13 @@ func (m *Metadata) parseBool(tagName string) bool {
var zeroTime = time.Date(0000, time.January, 1, 0, 0, 0, 0, time.UTC)
func (m *Metadata) parseDuration(tagName string) int {
func (m *Metadata) parseDuration(tagName string) float32 {
if v, ok := m.tags[tagName]; ok {
d, err := time.Parse("15:04:05", v)
if err != nil {
return 0
}
return int(d.Sub(zeroTime).Seconds())
return float32(d.Sub(zeroTime).Seconds())
}
return 0
}

View File

@@ -67,31 +67,72 @@ var _ = Describe("Metadata", func() {
})
Context("extractMetadata", func() {
It("detects embedded cover art correctly", func() {
const output = `
Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3':
Metadata:
compilation : 1
Duration: 00:00:01.02, start: 0.000000, bitrate: 477 kb/s
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s
Stream #0:1: Video: mjpeg, yuvj444p(pc, bt470bg/unknown/unknown), 600x600 [SAR 1:1 DAR 1:1], 90k tbr, 90k tbn, 90k tbc`
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
Expect(md.HasPicture()).To(BeTrue())
})
It("gets bitrate from the stream, if available", func() {
const output = `
Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3':
Duration: 00:00:01.02, start: 0.000000, bitrate: 477 kb/s
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s`
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
Expect(md.BitRate()).To(Equal(192))
})
It("parses correctly the compilation tag", func() {
const outputWithOverlappingTitleTag = `
const output = `
Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3':
Metadata:
compilation : 1
Duration: 00:05:02.63, start: 0.000000, bitrate: 140 kb/s`
md, _ := extractMetadata("tests/fixtures/test.mp3", outputWithOverlappingTitleTag)
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
Expect(md.Compilation()).To(BeTrue())
})
It("parses correct the title without overlapping with the stream tag", func() {
const outputWithOverlappingTitleTag = `
It("parses duration with milliseconds", func() {
const output = `
Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3':
Duration: 00:05:02.63, start: 0.000000, bitrate: 140 kb/s`
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
Expect(md.Duration()).To(BeNumerically("~", 302.63, 0.001))
})
It("parses stream level tags", func() {
const output = `
Input #0, ogg, from './01-02 Drive (Teku).opus':
Metadata:
ALBUM : Hot Wheels Acceleracers Soundtrack
Duration: 00:03:37.37, start: 0.007500, bitrate: 135 kb/s
Stream #0:0(eng): Audio: opus, 48000 Hz, stereo, fltp
Metadata:
TITLE : Drive (Teku)`
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
Expect(md.Title()).To(Equal("Drive (Teku)"))
})
It("does not overlap top level tags with the stream level tags", func() {
const output = `
Input #0, mp3, from 'groovin.mp3':
Metadata:
title : Groovin' (feat. Daniel Sneijers, Susanne Alt)
Duration: 00:03:34.28, start: 0.025056, bitrate: 323 kb/s
Metadata:
title : cover
At least one output file must be specified`
md, _ := extractMetadata("tests/fixtures/test.mp3", outputWithOverlappingTitleTag)
title : garbage`
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
Expect(md.Title()).To(Equal("Groovin' (feat. Daniel Sneijers, Susanne Alt)"))
})
It("ignores case in the tag name", func() {
const outputWithOverlappingTitleTag = `
const output = `
Input #0, flac, from '/Users/deluan/Downloads/06. Back In Black.flac':
Metadata:
ALBUM : Back In Black
@@ -103,7 +144,7 @@ Input #0, flac, from '/Users/deluan/Downloads/06. Back In Black.flac':
TRACKTOTAL : 10
track : 6
Duration: 00:04:16.00, start: 0.000000, bitrate: 995 kb/s`
md, _ := extractMetadata("tests/fixtures/test.mp3", outputWithOverlappingTitleTag)
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
Expect(md.Title()).To(Equal("Back In Black"))
Expect(md.Album()).To(Equal("Back In Black"))
Expect(md.Genre()).To(Equal("Hard Rock"))
@@ -164,13 +205,13 @@ Tracklist:
"May 12, 2016": 0,
}
for tag, expected := range examples {
md := &Metadata{tags: map[string]string{"year": tag}}
md := &Metadata{tags: map[string]string{"date": tag}}
Expect(md.Year()).To(Equal(expected))
}
})
It("returns 0 if year is invalid", func() {
md := &Metadata{tags: map[string]string{"year": "invalid"}}
md := &Metadata{tags: map[string]string{"date": "invalid"}}
Expect(md.Year()).To(Equal(0))
})
})

View File

@@ -26,7 +26,7 @@ func RequestLogger(next http.Handler) http.Handler {
r.Context(),
message,
"remoteAddr", r.RemoteAddr,
"lapsedTime", time.Since(start),
"elapsedTime", time.Since(start),
"httpStatus", ww.Status(),
"responseSize", ww.BytesWritten(),
}

View File

@@ -133,6 +133,7 @@ func ToChild(entry engine.Entry) responses.Child {
child.AlbumId = entry.AlbumId
child.ArtistId = entry.ArtistId
child.Type = entry.Type
child.IsVideo = false
child.UserRating = entry.UserRating
child.SongCount = entry.SongCount
// TODO Must be dynamic, based on player/transcoding config

View File

@@ -32,7 +32,7 @@ func (c *PlaylistsController) GetPlaylists(w http.ResponseWriter, r *http.Reques
playlists[i].Name = p.Name
playlists[i].Comment = p.Comment
playlists[i].SongCount = len(p.Tracks)
playlists[i].Duration = p.Duration
playlists[i].Duration = int(p.Duration)
playlists[i].Owner = p.Owner
playlists[i].Public = p.Public
}

View File

@@ -1 +1 @@
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","albumList":{"album":[{"id":"1","isDir":false,"title":"title"}]}}
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","albumList":{"album":[{"id":"1","isDir":false,"title":"title","isVideo":false}]}}

View File

@@ -1 +1 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><albumList><album id="1" isDir="false" title="title"></album></albumList></subsonic-response>
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><albumList><album id="1" isDir="false" title="title" isVideo="false"></album></albumList></subsonic-response>

View File

@@ -1 +1 @@
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","directory":{"child":[{"id":"1","isDir":true,"title":"title","album":"album","artist":"artist","track":1,"year":1985,"genre":"Rock","coverArt":"1","size":"8421341","contentType":"audio/flac","suffix":"flac","starred":"2016-03-02T20:30:00Z","transcodedContentType":"audio/mpeg","transcodedSuffix":"mp3","duration":146,"bitRate":320}],"id":"1","name":"N"}}
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","directory":{"child":[{"id":"1","isDir":true,"title":"title","album":"album","artist":"artist","track":1,"year":1985,"genre":"Rock","coverArt":"1","size":"8421341","contentType":"audio/flac","suffix":"flac","starred":"2016-03-02T20:30:00Z","transcodedContentType":"audio/mpeg","transcodedSuffix":"mp3","duration":146,"bitRate":320,"isVideo":false}],"id":"1","name":"N"}}

View File

@@ -1 +1 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><directory id="1" name="N"><child id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320"></child></directory></subsonic-response>
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><directory id="1" name="N"><child id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false"></child></directory></subsonic-response>

View File

@@ -1 +1 @@
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","directory":{"child":[{"id":"1","isDir":false,"title":"title"}],"id":"1","name":"N"}}
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","directory":{"child":[{"id":"1","isDir":false,"title":"title","isVideo":false}],"id":"1","name":"N"}}

View File

@@ -1 +1 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><directory id="1" name="N"><child id="1" isDir="false" title="title"></child></directory></subsonic-response>
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><directory id="1" name="N"><child id="1" isDir="false" title="title" isVideo="false"></child></directory></subsonic-response>

View File

@@ -112,8 +112,8 @@ type Child struct {
Type string `xml:"type,attr,omitempty" json:"type,omitempty"`
UserRating int `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
SongCount int `xml:"songCount,attr,omitempty" json:"songCount,omitempty"`
IsVideo bool `xml:"isVideo,attr" json:"isVideo"`
/*
<xs:attribute name="isVideo" type="xs:boolean" use="optional"/> <!-- Added in 1.4.1 -->
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="bookmarkPosition" type="xs:long" use="optional"/> <!-- In millis. Added in 1.10.1 -->
<xs:attribute name="originalWidth" type="xs:int" use="optional"/> <!-- Added in 1.13.0 -->

View File

@@ -1,10 +1,12 @@
package subsonic
import (
"io"
"net/http"
"strconv"
"github.com/deluan/navidrome/engine"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/server/subsonic/responses"
"github.com/deluan/navidrome/utils"
)
@@ -25,15 +27,32 @@ func (c *StreamController) Stream(w http.ResponseWriter, r *http.Request) (*resp
maxBitRate := utils.ParamInt(r, "maxBitRate", 0)
format := utils.ParamString(r, "format")
ms, err := c.streamer.NewStream(r.Context(), id, maxBitRate, format)
stream, err := c.streamer.NewStream(r.Context(), id, maxBitRate, format)
if err != nil {
return nil, err
}
defer func() {
if err := stream.Close(); err != nil {
log.Error("Error closing stream", "id", id, "file", stream.Name(), err)
}
}()
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32))
if stream.Seekable() {
http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream)
} else {
// If the stream doesn't provide a size (i.e. is not seekable), we can't support ranges/content-length
w.Header().Set("Accept-Ranges", "none")
w.Header().Set("Content-Type", stream.ContentType())
if c, err := io.Copy(w, stream); err != nil {
log.Error(r.Context(), "Error sending transcoded file", "id", id, err)
} else {
log.Trace(r.Context(), "Success sending transcode file", "id", id, "size", c)
}
}
// Override Content-Type detected by http.FileServer
w.Header().Set("Content-Type", ms.ContentType())
w.Header().Set("X-Content-Duration", strconv.Itoa(ms.Duration()))
http.ServeContent(w, r, ms.Name(), ms.ModTime(), ms)
return nil, nil
}
@@ -43,13 +62,11 @@ func (c *StreamController) Download(w http.ResponseWriter, r *http.Request) (*re
return nil, err
}
ms, err := c.streamer.NewStream(r.Context(), id, 0, "raw")
stream, err := c.streamer.NewStream(r.Context(), id, 0, "raw")
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)
http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream)
return nil, nil
}

View File

@@ -7,6 +7,7 @@ package main
import (
"github.com/deluan/navidrome/engine"
"github.com/deluan/navidrome/engine/transcoder"
"github.com/deluan/navidrome/persistence"
"github.com/deluan/navidrome/scanner"
"github.com/deluan/navidrome/server"
@@ -30,7 +31,7 @@ func CreateAppRouter(path string) *app.Router {
return router
}
func CreateSubsonicAPIRouter() *subsonic.Router {
func CreateSubsonicAPIRouter() (*subsonic.Router, error) {
dataStore := persistence.New()
browser := engine.NewBrowser(dataStore)
cover := engine.NewCover(dataStore)
@@ -41,9 +42,14 @@ func CreateSubsonicAPIRouter() *subsonic.Router {
ratings := engine.NewRatings(dataStore)
scrobbler := engine.NewScrobbler(dataStore, nowPlayingRepository)
search := engine.NewSearch(dataStore)
mediaStreamer := engine.NewMediaStreamer(dataStore)
transcoderTranscoder := transcoder.New()
cache, err := engine.NewTranscodingCache()
if err != nil {
return nil, err
}
mediaStreamer := engine.NewMediaStreamer(dataStore, transcoderTranscoder, cache)
router := subsonic.New(browser, cover, listGenerator, users, playlists, ratings, scrobbler, search, mediaStreamer)
return router
return router, nil
}
// wire_injectors.go:

View File

@@ -31,6 +31,6 @@ func CreateAppRouter(path string) *app.Router {
panic(wire.Build(allProviders))
}
func CreateSubsonicAPIRouter() *subsonic.Router {
func CreateSubsonicAPIRouter() (*subsonic.Router, error) {
panic(wire.Build(allProviders))
}