Compare commits

..

25 Commits

Author SHA1 Message Date
Deluan
894536c8ec Revert "fix: extract stream level metadata"
This reverts commit 92f6e55821.
2020-02-15 23:18:37 -05:00
Deluan
92f6e55821 fix: extract stream level metadata 2020-02-15 20:47:06 -05:00
Deluan
c3bd181648 feat: use tini to help in avoiding dangling processes 2020-02-15 18:34:47 -05:00
Deluan
3b12c92ad5 feat: add cache to the getCoverArt endpoint, avoid it being reloaded every single time in the UI 2020-02-15 14:32:11 -05:00
Deluan
272d897ec9 chore: go mod tidy 2020-02-15 11:37:27 -05:00
Deluan
e6d717cbbc fix: prevent zombies in transcoding 2020-02-15 11:05:03 -05:00
Deluan
b7f1fc0374 refactor: remove unused import 2020-02-14 09:16:59 -05:00
Deluan
de525edde0 feat: add song count and duration to AlbumDetails 2020-02-14 09:14:50 -05:00
Deluan
7f94660183 feat: use different resource for listing songs in albums 2020-02-14 09:02:32 -05:00
Deluan
b2d022b823 fix: ignore environment dependant test 2020-02-13 20:19:51 -05:00
Deluan
ba08f00c20 feat: make rescan faster, only loading metadata from changed files 2020-02-13 20:18:17 -05:00
Deluan
d9993c5877 refactor: separate metadata extraction from audio files scanning 2020-02-13 10:03:52 -05:00
Deluan
edb839a41d fix: only update artists and albums if there were any changes in files 2020-02-12 23:05:10 -05:00
Deluan
9fa73e3b7b feat: implement AlbumShow using a Datagrid. WIP: still need to make it responsive 2020-02-12 20:35:35 -05:00
dependabot-preview[bot]
8ebb85b0af build(deps): bump github.com/astaxie/beego from 1.12.0 to 1.12.1
Bumps [github.com/astaxie/beego](https://github.com/astaxie/beego) from 1.12.0 to 1.12.1.
- [Release notes](https://github.com/astaxie/beego/releases)
- [Commits](https://github.com/astaxie/beego/compare/v1.12.0...v1.12.1)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-02-10 08:57:04 -05:00
Deluan
a37beac753 feat: add X-Content-Duration header to the stream response 2020-02-09 22:09:18 -05:00
Deluan
8a31e80b7a fix: find songs and albums when sending an artist name search query 2020-02-09 19:52:06 -05:00
Deluan
ce11a2f3be feat: fake getArtistInfo/getArtistInfo2, just to enable artist browsing in MusicStash 2020-02-09 19:42:37 -05:00
Deluan
5a95feeedc fix: allow searches with 2 chars. closes #65 2020-02-09 12:20:34 -05:00
Deluan
400fa65326 feat: better scanner logging when level = info 2020-02-08 23:36:09 -05:00
Deluan
ab10719d27 fix: use a regex to match year in ffmpeg date field. close #63 2020-02-08 23:17:12 -05:00
Deluan
029290f304 fix: set default play_count to 0
IncPlayCount was not incrementing when the annotation already existed with play_count = null
2020-02-08 22:55:05 -05:00
Deluan
2c146ea1fe feat: add option to auto-create admin user on first start-up
Useful for development purposes
2020-02-08 14:50:33 -05:00
Deluan
10ead1f5f2 feat: better way to detect initial account creation 2020-02-08 14:32:55 -05:00
Deluan
730722cfe3 feat: better track number formatting 2020-02-08 11:50:11 -05:00
49 changed files with 723 additions and 313 deletions

View File

@@ -48,6 +48,11 @@ RUN GIT_TAG=$(git name-rev --name-only HEAD) && \
FROM alpine as release
MAINTAINER Deluan Quintao <navidrome@deluan.com>
# Download Tini
ENV TINI_VERSION v0.18.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-static /tini
RUN chmod +x /tini
COPY --from=gobuilder /src/navidrome /app/
COPY --from=gobuilder /tmp/ffmpeg*/ffmpeg /usr/bin/
@@ -64,4 +69,5 @@ ENV ND_PORT 4533
EXPOSE 4533
WORKDIR /app
ENTRYPOINT "/app/navidrome"
ENTRYPOINT ["/tini", "--"]
CMD ["/app/navidrome"]

View File

@@ -29,8 +29,9 @@ type nd struct {
ScanInterval string `default:"1m"`
// DevFlags. These are used to enable/disable debugging and incomplete features
DevDisableBanner bool `default:"false"`
DevLogSourceLine bool `default:"false"`
DevDisableBanner bool `default:"false"`
DevLogSourceLine bool `default:"false"`
DevAutoCreateAdminPassword string `default:""`
}
var Server = &nd{}

View File

@@ -13,4 +13,7 @@ const (
JWTTokenExpiration = 30 * time.Minute
UIAssetsLocalPath = "ui/build"
DevInitialUserName = "admin"
DevInitialName = "Dev Admin"
)

View File

@@ -6,7 +6,7 @@ import (
"sync"
"github.com/deluan/navidrome/conf"
_ "github.com/deluan/navidrome/db/migrations"
_ "github.com/deluan/navidrome/db/migration"
"github.com/deluan/navidrome/log"
_ "github.com/mattn/go-sqlite3"
"github.com/pressly/goose"

View File

@@ -1,4 +1,4 @@
package migrations
package migration
import (
"database/sql"

View File

@@ -1,4 +1,4 @@
package migrations
package migration
import (
"database/sql"

View File

@@ -0,0 +1,56 @@
package migration
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(Up20200208222418, Down20200208222418)
}
func Up20200208222418(tx *sql.Tx) error {
_, err := tx.Exec(`
update annotation set play_count = 0 where play_count is null;
update annotation set rating = 0 where rating is null;
create table annotation_dg_tmp
(
ann_id varchar(255) not null
primary key,
user_id varchar(255) default '' not null,
item_id varchar(255) default '' not null,
item_type varchar(255) default '' not null,
play_count integer default 0,
play_date datetime,
rating integer default 0,
starred bool default FALSE not null,
starred_at datetime,
unique (user_id, item_id, item_type)
);
insert into annotation_dg_tmp(ann_id, user_id, item_id, item_type, play_count, play_date, rating, starred, starred_at) select ann_id, user_id, item_id, item_type, play_count, play_date, rating, starred, starred_at from annotation;
drop table annotation;
alter table annotation_dg_tmp rename to annotation;
create index annotation_play_count
on annotation (play_count);
create index annotation_play_date
on annotation (play_date);
create index annotation_rating
on annotation (rating);
create index annotation_starred
on annotation (starred);
`)
return err
}
func Down20200208222418(tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

View File

@@ -31,6 +31,7 @@ type mediaStream interface {
Name() string
ModTime() time.Time
Close() error
Duration() int
}
type mediaStreamer struct {
@@ -108,6 +109,10 @@ 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()
@@ -186,6 +191,10 @@ 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()
@@ -203,7 +212,11 @@ func newTranscode(ctx context.Context, path string, maxBitRate int, format strin
if f, err = cmd.StdoutPipe(); err != nil {
return f, err
}
return f, cmd.Start()
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) {

2
go.mod
View File

@@ -5,7 +5,7 @@ go 1.13
require (
github.com/BurntSushi/toml v0.3.1 // indirect
github.com/Masterminds/squirrel v1.2.0
github.com/astaxie/beego v1.12.0
github.com/astaxie/beego v1.12.1
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible
github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4
github.com/dgrijalva/jwt-go v3.2.0+incompatible

15
go.sum
View File

@@ -4,8 +4,8 @@ github.com/Knetic/govaluate v3.0.0+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8L
github.com/Masterminds/squirrel v1.2.0 h1:K1NhbTO21BWG47IVR0OnIZuE0LZcXAYqywrC3Ko53KI=
github.com/Masterminds/squirrel v1.2.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
github.com/OwnLocal/goes v1.0.0/go.mod h1:8rIFjBGTue3lCU0wplczcUgt9Gxgrkkrw7etMIcn8TM=
github.com/astaxie/beego v1.12.0 h1:MRhVoeeye5N+Flul5PoVfD9CslfdoH+xqC/xvSQ5u2Y=
github.com/astaxie/beego v1.12.0/go.mod h1:fysx+LZNZKnvh4GED/xND7jWtjCR6HzydR2Hh2Im57o=
github.com/astaxie/beego v1.12.1 h1:dfpuoxpzLVgclveAXe4PyNKqkzgm5zF4tgF2B3kkM2I=
github.com/astaxie/beego v1.12.1/go.mod h1:kPBWpSANNbSdIqOc8SUL9h+1oyBMZhROeYsXQDbidWQ=
github.com/beego/goyaml2 v0.0.0-20130207012346-5545475820dd/go.mod h1:1b+Y/CofkYwXMUU0OhQqGvsY2Bvgr4j6jfT699wyZKQ=
github.com/beego/x2j v0.0.0-20131220205130-a0352aadc542/go.mod h1:kSeGC/p1AbBiEp5kat81+DSQrZenVBZXklMLaELspWU=
github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737/go.mod h1:PmM6Mmwb0LSuEubjR8N7PtNe1KxZLtOUHtbeikc5h60=
@@ -130,19 +130,24 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
github.com/syndtr/goleveldb v0.0.0-20181127023241-353a9fca669c h1:3eGShk3EQf5gJCYW+WzA0TEJQd37HLOmlYF7N0YJwv0=
github.com/syndtr/goleveldb v0.0.0-20181127023241-353a9fca669c/go.mod h1:Z4AUp2Km+PwemOoO/VB5AOx9XSsIItzFjoJlOSiYmn0=
github.com/wendal/errors v0.0.0-20130201093226-f66c77a7882b/go.mod h1:Q12BUT7DqIlHRmgv3RskH+UCM/4eqVMgI0EMmlSpAXc=
golang.org/x/crypto v0.0.0-20181127143415-eb0de9b17e85/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2 h1:CCH4IOTTfewWjGOlSp+zGcjutRKlBEZQ6wTn8ozI/nI=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -157,8 +162,10 @@ golang.org/x/tools v0.0.0-20190328211700-ab21143f2384 h1:TFlARGu6Czu1z7q93HTxcP1
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b h1:NVD8gBK33xpdqCaZVVtd6OFJp+3dxkXuz7+U7KaVN6s=
golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20200117065230-39095c1d176c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

View File

@@ -9,6 +9,7 @@ import (
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/rest"
"github.com/dhowden/tag/mbz"
)
type albumRepository struct {
@@ -36,7 +37,7 @@ func (r *albumRepository) Put(a *model.Album) error {
if err != nil {
return err
}
return r.index(a.ID, a.Name)
return r.index(a.ID, a.Name, a.Artist, mbz.AlbumArtist)
}
func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder {

View File

@@ -41,7 +41,7 @@ func (r mediaFileRepository) Put(m *model.MediaFile) error {
if err != nil {
return err
}
return r.index(m.ID, m.Title)
return r.index(m.ID, m.Title, m.Album, m.Artist, m.AlbumArtist)
}
func (r mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder {

View File

@@ -2,6 +2,7 @@ package persistence
import (
"context"
"time"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/log"
@@ -86,4 +87,33 @@ var _ = Describe("MediaRepository", func() {
_, err := mr.Get(id3)
Expect(err).To(MatchError(model.ErrNotFound))
})
Context("Annotations", func() {
It("increments play count when the tracks does not have annotations", func() {
id := "incplay.firsttime"
Expect(mr.Put(&model.MediaFile{ID: id})).To(BeNil())
playDate := time.Now()
Expect(mr.IncPlayCount(id, playDate)).To(BeNil())
mf, err := mr.Get(id)
Expect(err).To(BeNil())
Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix()))
Expect(mf.PlayCount).To(Equal(1))
})
It("increments play count on newly starred items", func() {
id := "star.incplay"
Expect(mr.Put(&model.MediaFile{ID: id})).To(BeNil())
Expect(mr.SetStar(true, id)).To(BeNil())
playDate := time.Now()
Expect(mr.IncPlayCount(id, playDate)).To(BeNil())
mf, err := mr.Get(id)
Expect(err).To(BeNil())
Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix()))
Expect(mf.PlayCount).To(Equal(1))
})
})
})

View File

@@ -33,6 +33,14 @@ func (r sqlRepository) newSelectWithAnnotation(idField string, options ...model.
Columns("starred", "starred_at", "play_count", "play_date", "rating")
}
func (r sqlRepository) annId(itemID ...string) And {
return And{
Eq{"user_id": userId(r.ctx)},
Eq{"item_type": r.tableName},
Eq{"item_id": itemID},
}
}
func (r sqlRepository) annUpsert(values map[string]interface{}, itemIDs ...string) error {
upd := Update(annotationTable).Where(r.annId(itemIDs...))
for f, v := range values {
@@ -56,12 +64,13 @@ func (r sqlRepository) annUpsert(values map[string]interface{}, itemIDs ...strin
return err
}
func (r sqlRepository) annId(itemID ...string) And {
return And{
Eq{"user_id": userId(r.ctx)},
Eq{"item_type": r.tableName},
Eq{"item_id": itemID},
}
func (r sqlRepository) SetStar(starred bool, ids ...string) error {
starredAt := time.Now()
return r.annUpsert(map[string]interface{}{"starred": starred, "starred_at": starredAt}, ids...)
}
func (r sqlRepository) SetRating(rating int, itemID string) error {
return r.annUpsert(map[string]interface{}{"rating": rating}, itemID)
}
func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
@@ -88,15 +97,6 @@ func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
return err
}
func (r sqlRepository) SetStar(starred bool, ids ...string) error {
starredAt := time.Now()
return r.annUpsert(map[string]interface{}{"starred": starred, "starred_at": starredAt}, ids...)
}
func (r sqlRepository) SetRating(rating int, itemID string) error {
return r.annUpsert(map[string]interface{}{"rating": rating}, itemID)
}
func (r sqlRepository) cleanAnnotations() error {
del := Delete(annotationTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")")
c, err := r.executeSQL(del)

View File

@@ -10,13 +10,16 @@ import (
const searchTable = "search"
func (r sqlRepository) index(id string, text string) error {
sanitizedText := strings.TrimSpace(sanitize.Accents(strings.ToLower(text)))
func (r sqlRepository) index(id string, text ...string) error {
sanitizedText := strings.Builder{}
for _, txt := range text {
sanitizedText.WriteString(strings.TrimSpace(sanitize.Accents(strings.ToLower(txt))) + " ")
}
values := map[string]interface{}{
"id": id,
"item_type": r.tableName,
"full_text": sanitizedText,
"full_text": strings.TrimSpace(sanitizedText.String()),
}
update := Update(searchTable).Where(Eq{"id": id}).SetMap(values)
count, err := r.executeSQL(update)
@@ -33,7 +36,7 @@ func (r sqlRepository) index(id string, text string) error {
func (r sqlRepository) doSearch(q string, offset, size int, results interface{}, orderBys ...string) error {
q = strings.TrimSpace(sanitize.Accents(strings.ToLower(strings.TrimSuffix(q, "*"))))
if len(q) <= 2 {
if len(q) < 2 {
return nil
}
sq := Select("*").From(r.tableName)

View File

@@ -43,7 +43,7 @@ func (m *Metadata) FilePath() string { return m.filePath }
func (m *Metadata) Suffix() string { return m.suffix }
func (m *Metadata) Size() int { return int(m.fileInfo.Size()) }
func ExtractAllMetadata(dirPath string) (map[string]*Metadata, error) {
func LoadAllAudioFiles(dirPath string) (map[string]os.FileInfo, error) {
dir, err := os.Open(dirPath)
if err != nil {
return nil, err
@@ -52,7 +52,7 @@ func ExtractAllMetadata(dirPath string) (map[string]*Metadata, error) {
if err != nil {
return nil, err
}
var audioFiles []string
audioFiles := make(map[string]os.FileInfo)
for _, f := range files {
if f.IsDir() {
continue
@@ -62,16 +62,18 @@ func ExtractAllMetadata(dirPath string) (map[string]*Metadata, error) {
if !isAudioFile(extension) {
continue
}
audioFiles = append(audioFiles, filePath)
fi, err := os.Stat(filePath)
if err != nil {
log.Error("Could not stat file", "filePath", filePath, err)
} else {
audioFiles[filePath] = fi
}
}
if len(audioFiles) == 0 {
return map[string]*Metadata{}, nil
}
return probe(audioFiles)
return audioFiles, nil
}
func probe(inputs []string) (map[string]*Metadata, error) {
func ExtractAllMetadata(inputs []string) (map[string]*Metadata, error) {
cmdLine, args := createProbeCommand(inputs)
log.Trace("Executing command", "arg0", cmdLine, "args", args)
@@ -201,20 +203,17 @@ var tagYearFormats = []string{
time.RFC3339,
}
var dateRegex = regexp.MustCompile(`^([12]\d\d\d)`)
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 {
match := dateRegex.FindStringSubmatch(v)
if len(match) == 0 {
log.Error("Error parsing year from ffmpeg date field. Please report this issue", "file", m.filePath, "date", v)
return 0
}
return y.Year()
year, _ := strconv.Atoi(match[1])
return year
}
return 0
}

View File

@@ -9,9 +9,9 @@ var _ = Describe("Metadata", func() {
// TODO Need to mock `ffmpeg`
XContext("ExtractAllMetadata", func() {
It("correctly parses metadata from all files in folder", func() {
mds, err := ExtractAllMetadata("tests/fixtures")
mds, err := ExtractAllMetadata([]string{"tests/fixtures/test.mp3", "tests/fixtures/test.ogg"})
Expect(err).NotTo(HaveOccurred())
Expect(mds).To(HaveLen(3))
Expect(mds).To(HaveLen(2))
m := mds["tests/fixtures/test.mp3"]
Expect(m.Title()).To(Equal("Song"))
@@ -45,14 +45,24 @@ var _ = Describe("Metadata", func() {
Expect(m.FilePath()).To(Equal("tests/fixtures/test.ogg"))
Expect(m.Size()).To(Equal(4408))
})
})
Context("LoadAllAudioFiles", func() {
It("return all audiofiles from the folder", func() {
files, err := LoadAllAudioFiles("tests/fixtures")
Expect(err).ToNot(HaveOccurred())
Expect(files).To(HaveLen(3))
Expect(files).To(HaveKey("tests/fixtures/test.ogg"))
Expect(files).To(HaveKey("tests/fixtures/test.mp3"))
Expect(files).To(HaveKey("tests/fixtures/01 Invisible (RED) Edit Version.mp3"))
})
It("returns error if path does not exist", func() {
_, err := ExtractAllMetadata("./INVALID/PATH")
_, err := LoadAllAudioFiles("./INVALID/PATH")
Expect(err).To(HaveOccurred())
})
It("returns empty map if there are no audio files in path", func() {
Expect(ExtractAllMetadata(".")).To(BeEmpty())
Expect(LoadAllAudioFiles(".")).To(BeEmpty())
})
})
@@ -145,10 +155,13 @@ Tracklist:
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,
"1985": 1985,
"2002-01": 2002,
"1969.06": 1969,
"1980.07.25": 1980,
"2004-00-00": 2004,
"2013-May-12": 2013,
"May 12, 2016": 0,
}
for tag, expected := range examples {
md := &Metadata{tags: map[string]string{"year": tag}}

View File

@@ -40,7 +40,6 @@ func (s *Scanner) Rescan(mediaFolder string, fullRescan bool) error {
}
s.updateLastModifiedSince(mediaFolder, start)
log.Debug("Finished scanning folder", "folder", mediaFolder, "elapsed", time.Since(start))
return err
}

View File

@@ -39,12 +39,14 @@ func NewTagScanner(rootFolder string, ds model.DataStore) *TagScanner {
// refresh the collected albums and artists with the metadata from the mediafiles
// Delete all empty albums, delete all empty Artists
func (s *TagScanner) Scan(ctx context.Context, lastModifiedSince time.Time) error {
start := time.Now()
changed, deleted, err := s.detector.Scan(lastModifiedSince)
if err != nil {
return err
}
if len(changed)+len(deleted) == 0 {
log.Debug(ctx, "No changes found in Music Folder", "folder", s.rootFolder)
return nil
}
@@ -108,11 +110,10 @@ func (s *TagScanner) Scan(ctx context.Context, lastModifiedSince time.Time) erro
return err
}
if len(changed)+len(deleted) == 0 {
return nil
}
err = s.ds.GC(log.NewContext(nil))
log.Info("Finished Music Folder", "folder", s.rootFolder, "elapsed", time.Since(start))
return s.ds.GC(log.NewContext(nil))
return err
}
func (s *TagScanner) refreshAlbums(ctx context.Context, updatedAlbums map[string]bool) error {
@@ -133,7 +134,6 @@ func (s *TagScanner) refreshArtists(ctx context.Context, updatedArtists map[stri
func (s *TagScanner) processChangedDir(ctx context.Context, dir string, updatedArtists map[string]bool, updatedAlbums map[string]bool) error {
dir = filepath.Join(s.rootFolder, dir)
start := time.Now()
// Load folder's current tracks from DB into a map
@@ -143,54 +143,68 @@ func (s *TagScanner) processChangedDir(ctx context.Context, dir string, updatedA
return err
}
for _, t := range ct {
currentTracks[t.ID] = t
updatedArtists[t.ArtistID] = true
updatedAlbums[t.AlbumID] = true
currentTracks[t.Path] = t
}
// Load tracks from the folder
newTracks, err := s.loadTracks(dir)
// Load tracks FileInfo from the folder
files, err := LoadAllAudioFiles(dir)
if err != nil {
return err
}
// If no tracks to process, return
if len(newTracks)+len(currentTracks) == 0 {
// If no files to process, return
if len(files)+len(currentTracks) == 0 {
return nil
}
// If track from folder is newer than the one in DB, select for update/insert in DB and delete from the current tracks
log.Trace("Processing changed folder", "dir", dir, "tracksInDB", len(currentTracks), "tracksInFolder", len(files))
var filesToUpdate []string
for filePath, info := range files {
c, ok := currentTracks[filePath]
if !ok || (ok && info.ModTime().After(c.UpdatedAt)) {
filesToUpdate = append(filesToUpdate, filePath)
}
delete(currentTracks, filePath)
}
// Load tracks Metadata from the folder
newTracks, err := s.loadTracks(filesToUpdate)
if err != nil {
return err
}
// If track from folder is newer than the one in DB, update/insert in DB and delete from the current tracks
log.Trace("Processing changed folder", "dir", dir, "tracksInDB", len(currentTracks), "tracksInFolder", len(newTracks))
log.Trace("Updating mediaFiles in DB", "dir", dir, "files", filesToUpdate, "numFiles", len(filesToUpdate))
numUpdatedTracks := 0
numPurgedTracks := 0
for _, n := range newTracks {
c, ok := currentTracks[n.ID]
if !ok || (ok && n.UpdatedAt.After(c.UpdatedAt)) {
err := s.ds.MediaFile(ctx).Put(&n)
updatedArtists[n.ArtistID] = true
updatedAlbums[n.AlbumID] = true
numUpdatedTracks++
if err != nil {
return err
}
}
delete(currentTracks, n.ID)
}
// Remaining tracks from DB that are not in the folder are deleted
for id := range currentTracks {
numPurgedTracks++
if err := s.ds.MediaFile(ctx).Delete(id); err != nil {
err := s.ds.MediaFile(ctx).Put(&n)
updatedArtists[n.ArtistID] = true
updatedAlbums[n.AlbumID] = true
numUpdatedTracks++
if err != nil {
return err
}
}
log.Debug("Finished processing changed folder", "dir", dir, "updated", numUpdatedTracks, "purged", numPurgedTracks, "elapsed", time.Since(start))
// Remaining tracks from DB that are not in the folder are deleted
for _, ct := range currentTracks {
numPurgedTracks++
updatedArtists[ct.ArtistID] = true
updatedAlbums[ct.AlbumID] = true
if err := s.ds.MediaFile(ctx).Delete(ct.ID); err != nil {
return err
}
}
log.Info("Finished processing changed folder", "dir", dir, "updated", numUpdatedTracks, "purged", numPurgedTracks, "elapsed", time.Since(start))
return nil
}
func (s *TagScanner) processDeletedDir(ctx context.Context, dir string, updatedArtists map[string]bool, updatedAlbums map[string]bool) error {
dir = filepath.Join(s.rootFolder, dir)
start := time.Now()
ct, err := s.ds.MediaFile(ctx).FindByPath(dir)
if err != nil {
@@ -201,14 +215,16 @@ func (s *TagScanner) processDeletedDir(ctx context.Context, dir string, updatedA
updatedAlbums[t.AlbumID] = true
}
log.Info("Finished processing deleted folder", "dir", dir, "deleted", len(ct), "elapsed", time.Since(start))
return s.ds.MediaFile(ctx).DeleteByPath(dir)
}
func (s *TagScanner) loadTracks(dirPath string) (model.MediaFiles, error) {
mds, err := ExtractAllMetadata(dirPath)
func (s *TagScanner) loadTracks(filePaths []string) (model.MediaFiles, error) {
mds, err := ExtractAllMetadata(filePaths)
if err != nil {
return nil, err
}
var mfs model.MediaFiles
for _, md := range mds {
mf := s.toMediaFile(md)

View File

@@ -49,6 +49,7 @@ func (app *Router) routes() http.Handler {
})
// Serve UI app assets
r.Handle("/", ServeIndex(app.ds))
r.Handle("/*", http.StripPrefix(app.path, http.FileServer(assets.AssetFile())))
return r

43
server/app/serve_index.go Normal file
View File

@@ -0,0 +1,43 @@
package app
import (
"encoding/json"
"html/template"
"io/ioutil"
"net/http"
"github.com/deluan/navidrome/assets"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
)
// Injects the `firstTime` config in the `index.html` template
func ServeIndex(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c, err := ds.User(r.Context()).CountAll()
firstTime := c == 0 && err == nil
t := template.New("initial state")
fs := assets.AssetFile()
indexHtml, err := fs.Open("index.html")
if err != nil {
log.Error(r, "Could not find `index.html` template", err)
}
indexStr, err := ioutil.ReadAll(indexHtml)
if err != nil {
log.Error(r, "Could not read from `index.html`", err)
}
t, _ = t.Parse(string(indexStr))
appConfig := map[string]interface{}{
"firstTime": firstTime,
}
j, _ := json.Marshal(appConfig)
data := map[string]interface{}{
"AppConfig": string(j),
}
err = t.Execute(w, data)
if err != nil {
log.Error(r, "Could not execute `index.html` template", err)
}
}
}

View File

@@ -1,8 +1,11 @@
package server
import (
"context"
"fmt"
"time"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
@@ -20,11 +23,47 @@ func initialSetup(ds model.DataStore) {
return err
}
if conf.Server.DevAutoCreateAdminPassword != "" {
if err = createInitialAdminUser(ds); err != nil {
return err
}
}
err = ds.Property(nil).Put(consts.InitialSetupFlagKey, time.Now().String())
return err
})
}
func createInitialAdminUser(ds model.DataStore) error {
ctx := context.Background()
c, err := ds.User(ctx).CountAll()
if err != nil {
panic(fmt.Sprintf("Could not access User table: %s", err))
}
if c == 0 {
id, _ := uuid.NewRandom()
random, _ := uuid.NewRandom()
initialPassword := random.String()
if conf.Server.DevAutoCreateAdminPassword != "" {
initialPassword = conf.Server.DevAutoCreateAdminPassword
}
log.Warn("Creating initial admin user. This should only be used for development purposes!!", "user", consts.DevInitialUserName, "password", initialPassword)
initialUser := model.User{
ID: id.String(),
UserName: consts.DevInitialUserName,
Name: consts.DevInitialName,
Email: "",
Password: initialPassword,
IsAdmin: true,
}
err := ds.User(ctx).Put(&initialUser)
if err != nil {
log.Error("Could not create initial admin user", "user", initialUser, err)
}
}
return err
}
func createJWTSecret(ds model.DataStore) error {
_, err := ds.Property(nil).Get(consts.JWTSecretKey)
if err == nil {

View File

@@ -73,6 +73,8 @@ func (api *Router) routes() http.Handler {
H(reqParams, "getArtist", c.GetArtist)
H(reqParams, "getAlbum", c.GetAlbum)
H(reqParams, "getSong", c.GetSong)
H(reqParams, "getArtistInfo", c.GetArtistInfo)
H(reqParams, "getArtistInfo2", c.GetArtistInfo2)
})
r.Group(func(r chi.Router) {
c := initAlbumListController(api)

View File

@@ -165,6 +165,30 @@ func (c *BrowsingController) GetGenres(w http.ResponseWriter, r *http.Request) (
return response, nil
}
const noImageAvailableUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ac/No_image_available.svg/1024px-No_image_available.svg.png"
// TODO Integrate with Last.FM
func (c *BrowsingController) GetArtistInfo(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
response := NewResponse()
response.ArtistInfo = &responses.ArtistInfo{}
response.ArtistInfo.Biography = "Biography not available"
response.ArtistInfo.SmallImageUrl = noImageAvailableUrl
response.ArtistInfo.MediumImageUrl = noImageAvailableUrl
response.ArtistInfo.LargeImageUrl = noImageAvailableUrl
return response, nil
}
// TODO Integrate with Last.FM
func (c *BrowsingController) GetArtistInfo2(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
response := NewResponse()
response.ArtistInfo2 = &responses.ArtistInfo2{}
response.ArtistInfo2.Biography = "Biography not available"
response.ArtistInfo2.SmallImageUrl = noImageAvailableUrl
response.ArtistInfo2.MediumImageUrl = noImageAvailableUrl
response.ArtistInfo2.LargeImageUrl = noImageAvailableUrl
return response, nil
}
func (c *BrowsingController) buildDirectory(d *engine.DirectoryInfo) *responses.Directory {
dir := &responses.Directory{
Id: d.Id,

View File

@@ -39,6 +39,7 @@ func (c *MediaRetrievalController) GetCoverArt(w http.ResponseWriter, r *http.Re
}
size := utils.ParamInt(r, "size", 0)
w.Header().Set("cache-control", "public, max-age=300")
err = c.cover.Get(r.Context(), id, size, w)
switch {

View File

@@ -0,0 +1 @@
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","artistInfo":{"biography":"Black Sabbath is an English \u003ca target='_blank' href=\"http://www.last.fm/tag/heavy%20metal\" class=\"bbcode_tag\" rel=\"tag\"\u003eheavy metal\u003c/a\u003e band","musicBrainzId":"5182c1d9-c7d2-4dad-afa0-ccfeada921a8","lastFmUrl":"http://www.last.fm/music/Black+Sabbath","smallImageUrl":"http://userserve-ak.last.fm/serve/64/27904353.jpg","mediumImageUrl":"http://userserve-ak.last.fm/serve/126/27904353.jpg","largeImageUrl":"http://userserve-ak.last.fm/serve/_/27904353/Black+Sabbath+sabbath+1970.jpg","similarArtist":[{"id":"22","name":"Accept"},{"id":"101","name":"Bruce Dickinson"},{"id":"26","name":"Aerosmith"}]}}

View File

@@ -0,0 +1 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><artistInfo><biography>Black Sabbath is an English &lt;a target=&#39;_blank&#39; href=&#34;http://www.last.fm/tag/heavy%20metal&#34; class=&#34;bbcode_tag&#34; rel=&#34;tag&#34;&gt;heavy metal&lt;/a&gt; band</biography><musicBrainzId>5182c1d9-c7d2-4dad-afa0-ccfeada921a8</musicBrainzId><lastFmUrl>http://www.last.fm/music/Black+Sabbath</lastFmUrl><smallImageUrl>http://userserve-ak.last.fm/serve/64/27904353.jpg</smallImageUrl><mediumImageUrl>http://userserve-ak.last.fm/serve/126/27904353.jpg</mediumImageUrl><largeImageUrl>http://userserve-ak.last.fm/serve/_/27904353/Black+Sabbath+sabbath+1970.jpg</largeImageUrl><similarArtist id="22" name="Accept"></similarArtist><similarArtist id="101" name="Bruce Dickinson"></similarArtist><similarArtist id="26" name="Aerosmith"></similarArtist></artistInfo></subsonic-response>

View File

@@ -0,0 +1 @@
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","artistInfo":{}}

View File

@@ -0,0 +1 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><artistInfo></artistInfo></subsonic-response>

View File

@@ -34,6 +34,9 @@ type Subsonic struct {
Artist *Indexes `xml:"artists,omitempty" json:"artists,omitempty"`
ArtistWithAlbumsID3 *ArtistWithAlbumsID3 `xml:"artist,omitempty" json:"artist,omitempty"`
AlbumWithSongsID3 *AlbumWithSongsID3 `xml:"album,omitempty" json:"album,omitempty"`
ArtistInfo *ArtistInfo `xml:"artistInfo,omitempty" json:"artistInfo,omitempty"`
ArtistInfo2 *ArtistInfo2 `xml:"artistInfo2,omitempty" json:"artistInfo2,omitempty"`
}
type JsonWrapper struct {
@@ -272,3 +275,22 @@ type Genre struct {
type Genres struct {
Genre []Genre `xml:"genre,omitempty" json:"genre,omitempty"`
}
type ArtistInfoBase struct {
Biography string `xml:"biography,omitempty" json:"biography,omitempty"`
MusicBrainzID string `xml:"musicBrainzId,omitempty" json:"musicBrainzId,omitempty"`
LastFmUrl string `xml:"lastFmUrl,omitempty" json:"lastFmUrl,omitempty"`
SmallImageUrl string `xml:"smallImageUrl,omitempty" json:"smallImageUrl,omitempty"`
MediumImageUrl string `xml:"mediumImageUrl,omitempty" json:"mediumImageUrl,omitempty"`
LargeImageUrl string `xml:"largeImageUrl,omitempty" json:"largeImageUrl,omitempty"`
}
type ArtistInfo struct {
ArtistInfoBase
SimilarArtist []Artist `xml:"similarArtist,omitempty" json:"similarArtist,omitempty"`
}
type ArtistInfo2 struct {
ArtistInfoBase
SimilarArtist []ArtistID3 `xml:"similarArtist,omitempty" json:"similarArtist,omitempty"`
}

View File

@@ -282,4 +282,42 @@ var _ = Describe("Responses", func() {
})
})
})
Describe("ArtistInfo", func() {
BeforeEach(func() {
response.ArtistInfo = &ArtistInfo{}
})
Context("without data", func() {
It("should match .XML", func() {
Expect(xml.Marshal(response)).To(MatchSnapshot())
})
It("should match .JSON", func() {
Expect(json.Marshal(response)).To(MatchSnapshot())
})
})
Context("with data", func() {
BeforeEach(func() {
response.ArtistInfo.Biography = `Black Sabbath is an English <a target='_blank' href="http://www.last.fm/tag/heavy%20metal" class="bbcode_tag" rel="tag">heavy metal</a> band`
response.ArtistInfo.MusicBrainzID = "5182c1d9-c7d2-4dad-afa0-ccfeada921a8"
response.ArtistInfo.LastFmUrl = "http://www.last.fm/music/Black+Sabbath"
response.ArtistInfo.SmallImageUrl = "http://userserve-ak.last.fm/serve/64/27904353.jpg"
response.ArtistInfo.MediumImageUrl = "http://userserve-ak.last.fm/serve/126/27904353.jpg"
response.ArtistInfo.LargeImageUrl = "http://userserve-ak.last.fm/serve/_/27904353/Black+Sabbath+sabbath+1970.jpg"
response.ArtistInfo.SimilarArtist = []Artist{
{Id: "22", Name: "Accept"},
{Id: "101", Name: "Bruce Dickinson"},
{Id: "26", Name: "Aerosmith"},
}
})
It("should match .XML", func() {
Expect(xml.Marshal(response)).To(MatchSnapshot())
})
It("should match .JSON", func() {
Expect(json.Marshal(response)).To(MatchSnapshot())
})
})
})
})

View File

@@ -2,6 +2,7 @@ package subsonic
import (
"net/http"
"strconv"
"github.com/deluan/navidrome/engine"
"github.com/deluan/navidrome/server/subsonic/responses"
@@ -31,6 +32,7 @@ func (c *StreamController) Stream(w http.ResponseWriter, r *http.Request) (*resp
// 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
}

View File

@@ -25,6 +25,9 @@
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Navidrome</title>
<script>
window.__APP_CONFIG__ = "{{.AppConfig}}"
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React from 'react'
import { Admin, resolveBrowserLocale, Resource } from 'react-admin'
import dataProvider from './dataProvider'
import authProvider from './authProvider'
@@ -19,48 +19,36 @@ const i18nProvider = polyglotI18nProvider(
resolveBrowserLocale()
)
const App = () => (
<Admin
theme={theme}
customReducers={{ queue: playQueueReducer }}
dataProvider={dataProvider}
authProvider={authProvider}
i18nProvider={i18nProvider}
layout={Layout}
loginPage={Login}
>
{(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,
<Player />
]}
</Admin>
)
const App = () => {
try {
const appConfig = JSON.parse(window.__APP_CONFIG__)
// TODO: This is a complicated way to force a first check for initial setup. A better way would be to send this info
// set in the `window` object in the index.html
const AppWrapper = () => {
const [checked, setChecked] = useState(false)
// This flags to the login process that it should create the first account instead
if (appConfig.firstTime) {
localStorage.setItem('initialAccountCreation', 'true')
}
} catch (e) {}
if (!checked) {
dataProvider
.getOne('keepalive', { id: new Date().getTime() })
.then(() => setChecked(true))
.catch((err) => {
authProvider
.checkError(err)
.then(() => {
setChecked(true)
})
.catch(() => {
setChecked(true)
})
})
return null
}
return <App />
return (
<Admin
theme={theme}
customReducers={{ queue: playQueueReducer }}
dataProvider={dataProvider}
authProvider={authProvider}
i18nProvider={i18nProvider}
layout={Layout}
loginPage={Login}
>
{(permissions) => [
<Resource name="artist" {...artist} options={{ subMenu: 'library' }} />,
<Resource name="album" {...album} options={{ subMenu: 'library' }} />,
<Resource name="song" {...song} options={{ subMenu: 'library' }} />,
<Resource name="albumSong" />,
permissions === 'admin' ? <Resource name="user" {...user} /> : null,
<Player />
]}
</Admin>
)
}
export default AppWrapper
export default App

View File

@@ -0,0 +1,64 @@
import {
Button,
sanitizeListRestProps,
TopToolbar,
useTranslate
} from 'react-admin'
import PlayArrowIcon from '@material-ui/icons/PlayArrow'
import ShuffleIcon from '@material-ui/icons/Shuffle'
import React from 'react'
import { useDispatch } from 'react-redux'
import { playAlbum } from '../player'
export const AlbumActions = ({
className,
ids,
data,
exporter,
permanentFilter,
...rest
}) => {
const dispatch = useDispatch()
const translate = useTranslate()
const shuffle = (data) => {
const ids = Object.keys(data)
for (let i = ids.length - 1; i > 0; i--) {
let j = Math.floor(Math.random() * (i + 1))
;[ids[i], ids[j]] = [ids[j], ids[i]]
}
const shuffled = {}
ids.forEach((id) => (shuffled[id] = data[id]))
return shuffled
}
return (
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
<Button
color={'secondary'}
onClick={() => {
dispatch(playAlbum(ids[0], data))
}}
label={translate('resources.album.actions.playAll')}
>
<PlayArrowIcon />
</Button>
<Button
color={'secondary'}
onClick={() => {
const shuffled = shuffle(data)
const firstId = Object.keys(shuffled)[0]
dispatch(playAlbum(firstId, shuffled))
}}
label={translate('resources.album.actions.shuffle')}
>
<ShuffleIcon />
</Button>
</TopToolbar>
)
}
AlbumActions.defaultProps = {
selectedIds: [],
onUnselectItems: () => null
}

View File

@@ -1,48 +1,43 @@
import React from 'react'
import { Loading, useGetOne } from 'react-admin'
import { Card, CardContent, CardMedia, Typography } from '@material-ui/core'
import { useTranslate } from 'react-admin'
import { subsonicUrl } from '../subsonic'
import { DurationField } from '../common'
const AlbumDetails = ({ id, classes }) => {
const { data, loading, error } = useGetOne('album', id)
if (loading) {
return <Loading />
}
if (error) {
return <p>ERROR: {error}</p>
}
const genreYear = (data) => {
const AlbumDetails = ({ classes, record }) => {
const translate = useTranslate()
const genreYear = (record) => {
let genreDateLine = []
if (data.genre) {
genreDateLine.push(data.genre)
if (record.genre) {
genreDateLine.push(record.genre)
}
if (data.year) {
genreDateLine.push(data.year)
if (record.year) {
genreDateLine.push(record.year)
}
return genreDateLine.join(' - ')
return genreDateLine.join(' · ')
}
return (
<Card className={classes.container}>
<CardMedia
image={subsonicUrl(
'getCoverArt',
data.coverArtId || 'not_found',
'size=500'
)}
image={subsonicUrl('getCoverArt', record.coverArtId || 'not_found', {
size: 500
})}
className={classes.albumCover}
/>
<CardContent className={classes.albumDetails}>
<Typography variant="h5" className={classes.albumTitle}>
{data.name}
{record.name}
</Typography>
<Typography component="h6">
{data.albumArtist || data.artist}
{record.albumArtist || record.artist}
</Typography>
<Typography component="p">{genreYear(record)}</Typography>
<Typography component="p">
{record.songCount}{' '}
{translate('resources.song.name', { smart_count: record.songCount })}{' '}
· <DurationField record={record} source={'duration'} />
</Typography>
<Typography component="p">{genreYear(data)}</Typography>
</CardContent>
</Card>
)

View File

@@ -1,68 +1,74 @@
import React from 'react'
import { Show } from 'react-admin'
import { Title } from '../common'
import { makeStyles } from '@material-ui/core/styles'
import AlbumSongList from './AlbumSongList'
import {
Datagrid,
FunctionField,
List,
Loading,
TextField,
useGetOne
} from 'react-admin'
import AlbumDetails from './AlbumDetails'
const AlbumTitle = ({ record }) => {
return <Title subTitle={record ? record.name : ''} />
}
const useStyles = makeStyles((theme) => ({
container: {
[theme.breakpoints.down('xs')]: {
padding: '0.7em',
minWidth: '24em'
},
[theme.breakpoints.up('sm')]: {
padding: '1em',
minWidth: '32em'
}
},
albumCover: {
display: 'inline-block',
[theme.breakpoints.down('xs')]: {
height: '8em',
width: '8em'
},
[theme.breakpoints.up('sm')]: {
height: '15em',
width: '15em'
},
[theme.breakpoints.up('lg')]: {
height: '20em',
width: '20em'
}
},
albumDetails: {
display: 'inline-block',
verticalAlign: 'top',
[theme.breakpoints.down('xs')]: {
width: '14em'
},
[theme.breakpoints.up('sm')]: {
width: '26em'
},
[theme.breakpoints.up('lg')]: {
width: '38em'
}
},
albumTitle: {
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}
}))
import { DurationField, Title } from '../common'
import { useStyles } from './styles'
import { AlbumActions } from './AlbumActions'
import { AlbumSongBulkActions } from './AlbumSongBulkActions'
import { useMediaQuery } from '@material-ui/core'
import { setTrack } from '../player'
import { useDispatch } from 'react-redux'
const AlbumShow = (props) => {
const dispatch = useDispatch()
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
const classes = useStyles()
const { data: record, loading, error } = useGetOne('album', props.id)
if (loading) {
return <Loading />
}
if (error) {
return <p>ERROR: {error}</p>
}
const trackName = (r) => {
const name = r.title
if (r.trackNumber) {
return r.trackNumber.toString().padStart(2, '0') + ' ' + name
}
return name
}
return (
<>
<AlbumDetails classes={classes} {...props} />
<Show title={<AlbumTitle />} {...props}>
<AlbumSongList {...props} />
</Show>
<AlbumDetails {...props} classes={classes} record={record} />
<List
{...props}
title={<Title subTitle={record.name} />}
actions={<AlbumActions />}
filter={{ album_id: props.id }}
resource={'albumSong'}
exporter={false}
perPage={1000}
pagination={null}
sort={{ field: 'discNumber asc, trackNumber asc', order: 'ASC' }}
bulkActionButtons={<AlbumSongBulkActions />}
>
<Datagrid
rowClick={(id, basePath, record) => dispatch(setTrack(record))}
>
{isDesktop && (
<TextField
source="trackNumber"
sortBy="discNumber asc, trackNumber asc"
label="#"
/>
)}
{isDesktop && <TextField source="title" />}
{!isDesktop && <FunctionField source="title" render={trackName} />}
{record.compilation && <TextField source="artist" />}
<DurationField source="duration" />
</Datagrid>
</List>
</>
)
}

View File

@@ -0,0 +1,16 @@
import React, { Fragment, useEffect } from 'react'
import { useUnselectAll } from 'react-admin'
import AddToQueueButton from '../song/AddToQueueButton'
export const AlbumSongBulkActions = (props) => {
const unselectAll = useUnselectAll()
useEffect(() => {
unselectAll('albumSong')
// eslint-disable-next-line
}, [])
return (
<Fragment>
<AddToQueueButton {...props} />
</Fragment>
)
}

View File

@@ -1,54 +0,0 @@
import React from 'react'
import { useGetList } from 'react-admin'
import { DurationField, PlayButton, SimpleList } from '../common'
import { addTrack } from '../player'
import AddIcon from '@material-ui/icons/Add'
import { useDispatch } from 'react-redux'
import { playAlbum } from '../player/queue'
const AlbumSongList = (props) => {
const dispatch = useDispatch()
const { record } = props
const { data, total, loading, error } = useGetList(
'song',
{ page: 0, perPage: 100 },
{ field: 'album', order: 'ASC' },
{ album_id: record.id }
)
if (error) {
return <p>ERROR: {error}</p>
}
const trackName = (r) => {
const name = r.title
if (r.trackNumber) {
return r.trackNumber + '. ' + name
}
return name
}
return (
<SimpleList
data={data}
ids={Object.keys(data)}
loading={loading}
total={total}
primaryText={(r) => (
<>
<PlayButton action={playAlbum(r.id, data)} />
<PlayButton action={addTrack(r)} icon={<AddIcon />} />
{trackName(r)}
</>
)}
secondaryText={(r) =>
r.albumArtist && r.artist !== r.albumArtist ? r.artist : ''
}
tertiaryText={(r) => <DurationField record={r} source={'duration'} />}
linkType={(id) => dispatch(playAlbum(id, data))}
/>
)
}
export default AlbumSongList

47
ui/src/album/styles.js Normal file
View File

@@ -0,0 +1,47 @@
import { makeStyles } from '@material-ui/core/styles'
export const useStyles = makeStyles((theme) => ({
container: {
[theme.breakpoints.down('xs')]: {
padding: '0.7em',
minWidth: '24em'
},
[theme.breakpoints.up('sm')]: {
padding: '1em',
minWidth: '32em'
}
},
albumCover: {
display: 'inline-block',
[theme.breakpoints.down('xs')]: {
height: '8em',
width: '8em'
},
[theme.breakpoints.up('sm')]: {
height: '10em',
width: '10em'
},
[theme.breakpoints.up('lg')]: {
height: '15em',
width: '15em'
}
},
albumDetails: {
display: 'inline-block',
verticalAlign: 'top',
[theme.breakpoints.down('xs')]: {
width: '14em'
},
[theme.breakpoints.up('sm')]: {
width: '26em'
},
[theme.breakpoints.up('lg')]: {
width: '38em'
}
},
albumTitle: {
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}
}))

View File

@@ -56,11 +56,7 @@ const authProvider = {
checkAuth: () =>
localStorage.getItem('token') ? Promise.resolve() : Promise.reject(),
checkError: (error) => {
const { status, message } = error
if (message === 'no users created') {
localStorage.setItem('initialAccountCreation', 'true')
}
checkError: ({ status }) => {
if (status === 401 || status === 403) {
removeItems()
return Promise.reject()

View File

@@ -1,7 +1,10 @@
import { fetchUtils } from 'react-admin'
import jsonServerProvider from 'ra-data-json-server'
const baseUrl = '/app/api'
const httpClient = (url, options = {}) => {
url = url.replace(baseUrl + '/albumSong', baseUrl + '/song')
if (!options.headers) {
options.headers = new Headers({ Accept: 'application/json' })
}
@@ -19,6 +22,6 @@ const httpClient = (url, options = {}) => {
})
}
const dataProvider = jsonServerProvider('/app/api', httpClient)
const dataProvider = jsonServerProvider(baseUrl, httpClient)
export default dataProvider

View File

@@ -4,6 +4,7 @@ import englishMessages from 'ra-language-english'
export default deepmerge(englishMessages, {
resources: {
song: {
name: 'Song |||| Songs',
fields: {
albumArtist: 'Album Artist',
duration: 'Time',
@@ -17,6 +18,12 @@ export default deepmerge(englishMessages, {
fields: {
albumArtist: 'Album Artist',
duration: 'Time'
},
actions: {
playAll: 'Play',
playNext: 'Play Next',
addToQueue: 'Play Later',
shuffle: 'Shuffle'
}
}
},

View File

@@ -1,4 +1,4 @@
import Player from './Player'
import { addTrack, setTrack, playQueueReducer } from './queue'
import { addTrack, setTrack, playQueueReducer, playAlbum } from './queue'
export { Player, addTrack, setTrack, playQueueReducer }
export { Player, addTrack, setTrack, playAlbum, playQueueReducer }

View File

@@ -10,9 +10,9 @@ const PLAYER_PLAY_ALBUM = 'PLAYER_PLAY_ALBUM'
const mapToAudioLists = (item) => ({
name: item.title,
singer: item.artist,
cover: subsonicUrl('getCoverArt', item.id, 'size=300'),
musicSrc: subsonicUrl('stream', item.id),
scrobble: (submit) => subsonicUrl('scrobble', item.id, `submission=${submit}`)
cover: subsonicUrl('getCoverArt', item.id, { size: 300 }),
musicSrc: subsonicUrl('stream', item.id, { ts: true }),
scrobble: (submit) => subsonicUrl('scrobble', item.id, { submission: submit })
})
const addTrack = (data) => ({

View File

@@ -2,15 +2,13 @@ import React from 'react'
import {
Button,
useDataProvider,
useUnselectAll,
useTranslate
useTranslate,
useUnselectAll
} from 'react-admin'
import { useDispatch } from 'react-redux'
import { addTrack } from '../player'
import AddToQueueIcon from '@material-ui/icons/AddToQueue'
import Tooltip from '@material-ui/core/Tooltip'
const AddToQueueButton = ({ selectedIds }) => {
const dispatch = useDispatch()
const translate = useTranslate()
@@ -26,13 +24,12 @@ const AddToQueueButton = ({ selectedIds }) => {
}
return (
<Button color="secondary" onClick={addToQueue}>
<Tooltip
title={translate('resources.song.bulk.addToQueue')}
placement="right"
>
<AddToQueueIcon />
</Tooltip>
<Button
color="secondary"
onClick={addToQueue}
label={translate('resources.song.bulk.addToQueue')}
>
<AddToQueueIcon />
</Button>
)
}

View File

@@ -0,0 +1,10 @@
import React, { Fragment } from 'react'
import AddToQueueButton from './AddToQueueButton'
export const SongBulkActions = (props) => {
return (
<Fragment>
<AddToQueueButton {...props} />
</Fragment>
)
}

View File

@@ -1,4 +1,4 @@
import React, { Fragment } from 'react'
import React from 'react'
import {
BooleanField,
Datagrid,
@@ -13,12 +13,18 @@ import {
TextInput
} from 'react-admin'
import { useMediaQuery } from '@material-ui/core'
import { BitrateField, DurationField, Pagination, Title } from '../common'
import AddToQueueButton from './AddToQueueButton'
import { PlayButton, SimpleList } from '../common'
import {
BitrateField,
DurationField,
Pagination,
PlayButton,
SimpleList,
Title
} from '../common'
import { useDispatch } from 'react-redux'
import { setTrack, addTrack } from '../player'
import { addTrack, setTrack } from '../player'
import AddIcon from '@material-ui/icons/Add'
import { SongBulkActions } from './SongBulkActions'
const SongFilter = (props) => (
<Filter {...props}>
@@ -28,12 +34,6 @@ const SongFilter = (props) => (
</Filter>
)
const SongBulkActionButtons = (props) => (
<Fragment>
<AddToQueueButton {...props} />
</Fragment>
)
const SongDetails = (props) => {
return (
<Show {...props} title=" ">
@@ -59,7 +59,7 @@ const SongList = (props) => {
title={<Title subTitle={'Songs'} />}
sort={{ field: 'title', order: 'ASC' }}
exporter={false}
bulkActionButtons={<SongBulkActionButtons />}
bulkActionButtons={<SongBulkActions />}
filters={<SongFilter />}
perPage={isXsmall ? 50 : 15}
pagination={<Pagination />}

View File

@@ -1,13 +1,22 @@
const subsonicUrl = (command, id, options) => {
const username = localStorage.getItem('username')
const token = localStorage.getItem('subsonic-token')
const salt = localStorage.getItem('subsonic-salt')
const timeStamp = new Date().getTime()
const url = `rest/${command}?u=${username}&f=json&v=1.8.0&c=NavidromeUI&t=${token}&s=${salt}&id=${id}&_=${timeStamp}`
const params = new URLSearchParams()
params.append('u', localStorage.getItem('username'))
params.append('t', localStorage.getItem('subsonic-token'))
params.append('s', localStorage.getItem('subsonic-salt'))
params.append('f', 'json')
params.append('v', '1.8.0')
params.append('c', 'NavidromeUI')
params.append('id', id)
if (options) {
return url + '&' + options
if (options.ts) {
options['_'] = new Date().getTime()
delete options.ts
}
Object.keys(options).forEach((k) => {
params.append(k, options[k])
})
}
return url
return `rest/${command}?${params.toString()}`
}
export { subsonicUrl }