mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-06 05:48:09 -05:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
894536c8ec | ||
|
|
92f6e55821 | ||
|
|
c3bd181648 | ||
|
|
3b12c92ad5 | ||
|
|
272d897ec9 | ||
|
|
e6d717cbbc | ||
|
|
b7f1fc0374 | ||
|
|
de525edde0 | ||
|
|
7f94660183 | ||
|
|
b2d022b823 | ||
|
|
ba08f00c20 | ||
|
|
d9993c5877 | ||
|
|
edb839a41d | ||
|
|
9fa73e3b7b | ||
|
|
8ebb85b0af | ||
|
|
a37beac753 | ||
|
|
8a31e80b7a | ||
|
|
ce11a2f3be | ||
|
|
5a95feeedc |
@@ -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"]
|
||||
|
||||
@@ -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
2
go.mod
@@ -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
15
go.sum
@@ -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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -143,44 +143,57 @@ 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
|
||||
}
|
||||
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 {
|
||||
for _, ct := range currentTracks {
|
||||
numPurgedTracks++
|
||||
if err := s.ds.MediaFile(ctx).Delete(id); err != nil {
|
||||
updatedArtists[ct.ArtistID] = true
|
||||
updatedAlbums[ct.AlbumID] = true
|
||||
if err := s.ds.MediaFile(ctx).Delete(ct.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -206,11 +219,12 @@ func (s *TagScanner) processDeletedDir(ctx context.Context, dir string, updatedA
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"}]}}
|
||||
@@ -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 <a target='_blank' href="http://www.last.fm/tag/heavy%20metal" class="bbcode_tag" rel="tag">heavy metal</a> 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>
|
||||
@@ -0,0 +1 @@
|
||||
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","artistInfo":{}}
|
||||
@@ -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>
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ const App = () => {
|
||||
<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 />
|
||||
]}
|
||||
|
||||
64
ui/src/album/AlbumActions.js
Normal file
64
ui/src/album/AlbumActions.js
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
16
ui/src/album/AlbumSongBulkActions.js
Normal file
16
ui/src/album/AlbumSongBulkActions.js
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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.toString().padStart(2, '0') + ' ' + 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
47
ui/src/album/styles.js
Normal 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'
|
||||
}
|
||||
}))
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
10
ui/src/song/SongBulkActions.js
Normal file
10
ui/src/song/SongBulkActions.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import React, { Fragment } from 'react'
|
||||
import AddToQueueButton from './AddToQueueButton'
|
||||
|
||||
export const SongBulkActions = (props) => {
|
||||
return (
|
||||
<Fragment>
|
||||
<AddToQueueButton {...props} />
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user