mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-23 10:27:51 -05:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
894536c8ec | ||
|
|
92f6e55821 | ||
|
|
c3bd181648 | ||
|
|
3b12c92ad5 | ||
|
|
272d897ec9 | ||
|
|
e6d717cbbc | ||
|
|
b7f1fc0374 | ||
|
|
de525edde0 | ||
|
|
7f94660183 | ||
|
|
b2d022b823 | ||
|
|
ba08f00c20 | ||
|
|
d9993c5877 | ||
|
|
edb839a41d | ||
|
|
9fa73e3b7b | ||
|
|
8ebb85b0af |
@@ -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"]
|
||||
|
||||
@@ -212,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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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