Compare commits

...

71 Commits

Author SHA1 Message Date
Deluan
21b9f51b71 Rename migrations package, to match goose generated migration files 2020-08-01 16:49:01 -04:00
Deluan
ed726c2126 Better implementation of Bookmarks, using its own table 2020-08-01 12:17:15 -04:00
Deluan
23d69d26e0 Add Bookmarks to Subsonic API 2020-07-31 17:45:49 -04:00
Deluan
3d0e70e907 Add MediaFile to Bookmark 2020-07-31 17:45:49 -04:00
Deluan
34e843a4b3 Add updatedAt to Bookmarks 2020-07-31 17:45:49 -04:00
Deluan
924ada0dab Add bookmark API repsonse 2020-07-31 17:45:49 -04:00
Deluan
2d3ed85311 Add bookmark in persistence layer 2020-07-31 17:45:49 -04:00
Deluan
3d4f4b4e2b Fix lint errors 2020-07-31 17:45:49 -04:00
Deluan
338cbacb79 Return absolute paths in Subsonic API responses 2020-07-31 17:45:49 -04:00
Deluan
0cf574198e Use Last.FM "white star" URL for artist info 2020-07-31 17:45:49 -04:00
Deluan
3000238a3c Implements the get/save play queue Subsonic endpoints and bumps API version to 1.12.0 2020-07-31 17:45:49 -04:00
Deluan
16c38eb344 Add PlayQueue Subsonic response 2020-07-31 17:45:49 -04:00
Deluan
721a959735 Create playqueue table and repository 2020-07-31 17:45:49 -04:00
Deluan
3c2b14d362 Rename make target for creating a new migration 2020-07-31 11:38:56 -04:00
Deluan
2b59d4b87a Rename 'Cover' to the more generic term 'Artwork' 2020-07-31 11:38:56 -04:00
Deluan
cefdeee495 Update Danish translations 2020-07-30 14:26:32 -04:00
Deluan
3383327c51 Show year range over the album art when in "artist view" mode 2020-07-29 22:34:33 -04:00
dependabot-preview[bot]
38b341ebc5 [Security] Bump elliptic from 6.5.2 to 6.5.3 in /ui
Bumps [elliptic](https://github.com/indutny/elliptic) from 6.5.2 to 6.5.3. **This update includes a security fix.**
- [Release notes](https://github.com/indutny/elliptic/releases)
- [Commits](https://github.com/indutny/elliptic/compare/v6.5.2...v6.5.3)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-07-29 18:47:01 -04:00
Deluan
ef0e5b130d Add a xl breakpoint to the album grid 2020-07-29 15:42:03 -04:00
Deluan
3092f83a00 Add option to select default album view 2020-07-29 15:34:48 -04:00
Deluan
8daac43e99 Add list type to album list view title 2020-07-29 15:34:48 -04:00
Deluan
d5da23ae42 Redirect from plain /album path to a default album list 2020-07-29 15:34:48 -04:00
Deluan
eae46d15bf Fix pagination 2020-07-29 15:34:48 -04:00
Deluan
f6c518fd8b Add Portuguese translation for album lists 2020-07-29 15:34:48 -04:00
Deluan
db8a48bba6 Implement album lists 2020-07-29 15:34:48 -04:00
Deluan
d877928f11 Add UpdatedAt to transcoding cache key 2020-07-28 17:16:01 -04:00
Deluan
0403ec2a07 Use OS-independent path separators 2020-07-28 08:49:07 -04:00
Deluan
8d27c77c2c Highlight compilations in Features 2020-07-27 15:00:03 -04:00
Deluan
f992b5663f Remove old scanner 2020-07-27 12:34:44 -04:00
Deluan
4e4fcb2304 Small refactorings, better var/function names 2020-07-27 10:51:50 -04:00
Deluan
ddb30ceb11 Add a v prefix to the version in the description 2020-07-26 10:52:20 -04:00
Deluan
67da83c84d Use a RWMutex instead of an AtomicBool, to reduce contention 2020-07-26 00:45:33 -04:00
Deluan
f8f16d676d Fix Cached flag 2020-07-24 18:48:28 -04:00
Deluan
58b816c2ed Show cached in info log 2020-07-24 18:43:03 -04:00
Deluan
9b1d5c196f Load cache asynchronously 2020-07-24 16:54:04 -04:00
Deluan
a0bed9beeb Handle missing index.html template 2020-07-24 13:59:41 -04:00
Deluan
9f4f2f7381 Use new FileCache in cover service 2020-07-24 13:30:27 -04:00
Deluan
433e31acc8 Refactor FileCache, allow disabling Trasncoding cache 2020-07-24 12:42:11 -04:00
Deluan
b795ad55a3 Allow SeekStart in a merged dir 2020-07-23 22:00:59 -04:00
Deluan
72efc18158 Allow translations to be overridden in the data folder 2020-07-23 18:11:10 -04:00
Deluan
93626129b6 Also import .m3u8 playlists 2020-07-23 03:26:39 -04:00
Deluan
60178c264d Keep annotations if tracks were already in DB 2020-07-23 03:26:39 -04:00
Deluan Quintão
de6afa16ec Update da.json 2020-07-22 16:07:22 -04:00
Deluan Quintão
fd2df12263 Update cs.json (POEditor.com) 2020-07-22 15:39:50 -04:00
Deluan
37d66a7d41 Add Danish translation 2020-07-22 15:39:50 -04:00
Deluan
040c7f1e7d Add missing call to refresh artists 2020-07-22 15:37:24 -04:00
Deluan
d4a5508f6a Remove LogLevel from Dockerfile 2020-07-22 12:56:50 -04:00
Deluan
036f9d6730 Flush albums and artists after each folder added/updated/deleted 2020-07-22 12:56:50 -04:00
Deluan
1b7f628759 Add tests for paths with UTF8 chars 2020-07-22 11:48:09 -04:00
Deluan
5a891fda9e Handle utf8 chars in paths 2020-07-22 09:36:22 -04:00
Deluan
f96e2f6c4f Process deleted folders even if there are no changed folders 2020-07-22 01:29:44 -04:00
Deluan
7a5285ae47 When deleting folders, only flush artists/albums after deleting the mediaFiles 2020-07-22 01:00:16 -04:00
Deluan
ba347bc0b1 Detect moved folders 2020-07-22 00:42:12 -04:00
Deluan
1bee98af52 Increase streamer test timeout 2020-07-21 20:43:59 -04:00
Deluan
ff623a8dce Run pre-push linting in verbose more 2020-07-21 20:30:04 -04:00
Deluan
f28e8118dc Strip 'v' prefix from version, to make it consistent for release and snapshot 2020-07-21 20:22:23 -04:00
Deluan
167fca86d0 Fix pipeline 2020-07-21 18:12:59 -04:00
Deluan
b828650cc5 Reduce the availability of old pipeline binaries artifacts 2020-07-21 18:11:09 -04:00
Deluan
e6846de0fa Small change, to trigger the pipeline that is stuck! 2020-07-21 17:46:35 -04:00
Deluan
6c6254a3c3 Get all git history when building the binaries 2020-07-21 17:37:36 -04:00
Deluan
0a9ad4e73a Bump action/upload-artifact and action/download-artifact to v2 2020-07-21 16:59:33 -04:00
Deluan
9f6eb4174f Do not upload packaged binaries as artifacts 2020-07-21 16:07:36 -04:00
Deluan
25cc523006 Output git tag info in the pipeline 2020-07-21 15:38:23 -04:00
Deluan
4c0000a809 Use Contributor Covenant v2.0 2020-07-21 14:40:21 -04:00
Deluan Quintão
0f7193f85d Create CODE_OF_CONDUCT.md 2020-07-21 14:19:34 -04:00
Deluan
715855280e Bump react-admin to 3.7.1 2020-07-21 13:15:03 -04:00
Deluan
c322253fde Upgrade react-player to 4.16.3 2020-07-21 13:06:33 -04:00
Deluan
17cea91e10 Bump @testing-library versions 2020-07-21 10:37:11 -04:00
Deluan
6caa5ee81f Bump react-ga from 3.0.0 to 3.1.2 2020-07-21 10:31:16 -04:00
Deluan
d46a8cf89f Allows config file to be specified with env var ND_CONFIGFILE. Fixes #415 2020-07-20 18:36:12 -04:00
Deluan
7e81a3b895 Fix default background image for login 2020-07-20 14:34:02 -04:00
117 changed files with 3038 additions and 1526 deletions

View File

@@ -27,7 +27,6 @@ COPY --from=copy-binary /navidrome /app/
VOLUME ["/data", "/music"]
ENV ND_MUSICFOLDER /music
ENV ND_DATAFOLDER /data
ENV ND_LOGLEVEL info
ENV ND_PORT 4533
ENV GODEBUG "asyncpreemptoff=1"

View File

@@ -85,7 +85,7 @@ jobs:
cd ui
npm run build
- uses: actions/upload-artifact@v1
- uses: actions/upload-artifact@v2
with:
name: js-bundle
path: ui/build
@@ -97,15 +97,20 @@ jobs:
steps:
- name: Checkout Code
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Unshallow
run: git fetch --prune --unshallow
- uses: actions/download-artifact@v1
- uses: actions/download-artifact@v2
with:
name: js-bundle
path: ui/build
- name: Show Tags
run: git tag
- name: Show Version
run: git describe --tags
- name: Run GoReleaser - SNAPSHOT
if: startsWith(github.ref, 'refs/tags/') != true
uses: docker://deluan/ci-goreleaser:1.14.4-2
@@ -122,10 +127,13 @@ jobs:
with:
args: goreleaser release --rm-dist
- uses: actions/upload-artifact@v1
- uses: actions/upload-artifact@v2
with:
name: binaries
path: dist
path: |
dist
!dist/*.tar.gz
!dist/*.zip
docker:
name: Docker images
@@ -145,7 +153,7 @@ jobs:
- uses: actions/checkout@v1
if: env.DOCKER_IMAGE != ''
- uses: actions/download-artifact@v1
- uses: actions/download-artifact@v2
if: env.DOCKER_IMAGE != ''
with:
name: binaries

129
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,129 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
navidrome@navidrome.org.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

View File

@@ -37,10 +37,10 @@ update-snapshots: check_go_env
UPDATE_SNAPSHOTS=true ginkgo ./server/subsonic/...
.PHONY: update-snapshots
create-migration:
migration:
@if [ -z "${name}" ]; then echo "Usage: make create-migration name=name_of_migration_file"; exit 1; fi
goose -dir db/migration create ${name}
.PHONY: create-migration
.PHONY: migration
setup:
@which go-bindata || (echo "Installing BinData" && GO111MODULE=off go get -u github.com/go-bindata/go-bindata/...)

View File

@@ -6,6 +6,7 @@
[![Docker Pulls](https://img.shields.io/docker/pulls/deluan/navidrome?style=flat-square)](https://hub.docker.com/r/deluan/navidrome)
[![Dev Chat](https://img.shields.io/discord/671335427726114836?label=chat&style=flat-square)](https://discord.gg/xh7j7yF)
[![Subreddit](https://img.shields.io/reddit/subreddit-subscribers/navidrome?style=flat-square)](https://www.reddit.com/r/navidrome/)
[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v2.0-ff69b4.svg)](code_of_conduct.md)
Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your
music collection from any browser or mobile device. It's like your personal Spotify!
@@ -31,7 +32,7 @@ See instructions in the [project's website](https://www.navidrome.org/docs/insta
- Handles very **large music collections**
- Streams virtually **any audio format** available
- Reads and uses all your beautifully curated **metadata**
- Great support for **Box Sets** (multi-disc albums)
- Great support for **compilations** (Various Artists albums) and **box sets** (multi-disc albums)
- **Multi-user**, each user has their own play counts, playlists, favourites, etc...
- Very **low resource usage**
- **Multi-platform**, runs on macOS, Linux and Windows. **Docker** images are also provided

View File

@@ -41,11 +41,8 @@ func CreateAppRouter() *app.Router {
func CreateSubsonicAPIRouter() (*subsonic.Router, error) {
dataStore := persistence.New()
browser := engine.NewBrowser(dataStore)
imageCache, err := core.NewImageCache()
if err != nil {
return nil, err
}
cover := core.NewCover(dataStore, imageCache)
artworkCache := core.NewImageCache()
artwork := core.NewArtwork(dataStore, artworkCache)
nowPlayingRepository := engine.NewNowPlayingRepository()
listGenerator := engine.NewListGenerator(dataStore, nowPlayingRepository)
users := engine.NewUsers(dataStore)
@@ -54,13 +51,10 @@ func CreateSubsonicAPIRouter() (*subsonic.Router, error) {
scrobbler := engine.NewScrobbler(dataStore, nowPlayingRepository)
search := engine.NewSearch(dataStore)
transcoderTranscoder := transcoder.New()
transcodingCache, err := core.NewTranscodingCache()
if err != nil {
return nil, err
}
transcodingCache := core.NewTranscodingCache()
mediaStreamer := core.NewMediaStreamer(dataStore, transcoderTranscoder, transcodingCache)
players := engine.NewPlayers(dataStore)
router := subsonic.New(browser, cover, listGenerator, users, playlists, ratings, scrobbler, search, mediaStreamer, players)
router := subsonic.New(browser, artwork, listGenerator, users, playlists, ratings, scrobbler, search, mediaStreamer, players, dataStore)
return router, nil
}

View File

@@ -40,7 +40,6 @@ type configOptions struct {
// DevFlags. These are used to enable/disable debugging and incomplete features
DevLogSourceLine bool
DevAutoCreateAdminPassword string
DevOldScanner bool
}
var Server = &configOptions{}
@@ -72,15 +71,15 @@ func Load() {
}
func init() {
viper.SetDefault("musicfolder", "./music")
viper.SetDefault("datafolder", "./")
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
viper.SetDefault("datafolder", ".")
viper.SetDefault("loglevel", "info")
viper.SetDefault("address", "0.0.0.0")
viper.SetDefault("port", 4533)
viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout)
viper.SetDefault("scaninterval", time.Minute)
viper.SetDefault("baseurl", "")
viper.SetDefault("uiloginbackgroundurl", "")
viper.SetDefault("uiloginbackgroundurl", "https://source.unsplash.com/random/1600x900?music")
viper.SetDefault("enabletranscodingconfig", false)
viper.SetDefault("transcodingcachesize", "100MB")
viper.SetDefault("imagecachesize", "100MB")
@@ -103,6 +102,7 @@ func init() {
}
func InitConfig(cfgFile string) {
cfgFile = getConfigFile(cfgFile)
if cfgFile != "" {
// Use config file from the flag.
viper.SetConfigFile(cfgFile)
@@ -122,3 +122,10 @@ func InitConfig(cfgFile string) {
os.Exit(1)
}
}
func getConfigFile(cfgFile string) string {
if cfgFile != "" {
return cfgFile
}
return os.Getenv("ND_CONFIGFILE")
}

View File

@@ -10,7 +10,6 @@ import (
const (
AppName = "navidrome"
LocalConfigFile = "./navidrome.toml"
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on"
InitialSetupFlagKey = "InitialSetup"

View File

@@ -1,6 +1,9 @@
package consts
import "fmt"
import (
"fmt"
"strings"
)
var (
// This will be set in build time. If not, version will be set to "dev"
@@ -11,10 +14,12 @@ var (
// Formats:
// dev
// v0.2.0 (5b84188)
// v0.3.2-SNAPSHOT (715f552)
// master (9ed35cb)
func Version() string {
if gitSha == "" {
return "dev"
}
gitTag = strings.TrimPrefix(gitTag, "v")
return fmt.Sprintf("%s (%s)", gitTag, gitSha)
}

View File

@@ -22,71 +22,58 @@ import (
"github.com/deluan/navidrome/utils"
"github.com/dhowden/tag"
"github.com/disintegration/imaging"
"github.com/djherbis/fscache"
)
type Cover interface {
type Artwork interface {
Get(ctx context.Context, id string, size int, out io.Writer) error
}
type ImageCache fscache.Cache
type ArtworkCache FileCache
func NewCover(ds model.DataStore, cache ImageCache) Cover {
return &cover{ds: ds, cache: cache}
func NewArtwork(ds model.DataStore, cache ArtworkCache) Artwork {
return &artwork{ds: ds, cache: cache}
}
type cover struct {
type artwork struct {
ds model.DataStore
cache fscache.Cache
cache FileCache
}
func (c *cover) Get(ctx context.Context, id string, size int, out io.Writer) error {
path, lastUpdate, err := c.getCoverPath(ctx, id)
type imageInfo struct {
c *artwork
path string
size int
lastUpdate time.Time
}
func (ci *imageInfo) String() string {
return fmt.Sprintf("%s.%d.%s.%d", ci.path, ci.size, ci.lastUpdate.Format(time.RFC3339Nano), conf.Server.CoverJpegQuality)
}
func (c *artwork) Get(ctx context.Context, id string, size int, out io.Writer) error {
path, lastUpdate, err := c.getImagePath(ctx, id)
if err != nil && err != model.ErrNotFound {
return err
}
// If cache is disabled, just read the coverart directly from file
if c.cache == nil {
log.Trace(ctx, "Retrieving cover art from file", "path", path, "size", size, err)
reader, err := c.getCover(ctx, path, size)
if err != nil {
log.Error(ctx, "Error loading cover art", "path", path, "size", size, err)
} else {
_, err = io.Copy(out, reader)
}
return err
info := &imageInfo{
c: c,
path: path,
size: size,
lastUpdate: lastUpdate,
}
cacheKey := imageCacheKey(path, size, lastUpdate)
r, w, err := c.cache.Get(cacheKey)
r, err := c.cache.Get(ctx, info)
if err != nil {
log.Error(ctx, "Error reading from image cache", "path", path, "size", size, err)
log.Error(ctx, "Error accessing image cache", "path", path, "size", size, err)
return err
}
defer r.Close()
if w != nil {
log.Trace(ctx, "Image cache miss", "path", path, "size", size, "lastUpdate", lastUpdate)
go func() {
defer w.Close()
reader, err := c.getCover(ctx, path, size)
if err != nil {
log.Error(ctx, "Error loading cover art", "path", path, "size", size, err)
return
}
if _, err := io.Copy(w, reader); err != nil {
log.Error(ctx, "Error saving covert art to cache", "path", path, "size", size, err)
}
}()
} else {
log.Trace(ctx, "Loading image from cache", "path", path, "size", size, "lastUpdate", lastUpdate)
}
_, err = io.Copy(out, r)
return err
}
func (c *cover) getCoverPath(ctx context.Context, id string) (path string, lastUpdated time.Time, err error) {
func (c *artwork) getImagePath(ctx context.Context, id string) (path string, lastUpdated time.Time, err error) {
// If id is an album cover ID
if strings.HasPrefix(id, "al-") {
log.Trace(ctx, "Looking for album art", "id", id)
@@ -115,14 +102,10 @@ func (c *cover) getCoverPath(ctx context.Context, id string) (path string, lastU
// if the mediafile does not have a coverArt, fallback to the album cover
log.Trace(ctx, "Media file does not contain art. Falling back to album art", "id", id, "albumId", "al-"+mf.AlbumID)
return c.getCoverPath(ctx, "al-"+mf.AlbumID)
return c.getImagePath(ctx, "al-"+mf.AlbumID)
}
func imageCacheKey(path string, size int, lastUpdate time.Time) string {
return fmt.Sprintf("%s.%d.%s.%d", path, size, lastUpdate.Format(time.RFC3339Nano), conf.Server.CoverJpegQuality)
}
func (c *cover) getCover(ctx context.Context, path string, size int) (reader io.Reader, err error) {
func (c *artwork) getArtwork(ctx context.Context, path string, size int) (reader io.Reader, err error) {
defer func() {
if err != nil {
log.Warn(ctx, "Error extracting image", "path", path, "size", size, err)
@@ -131,7 +114,7 @@ func (c *cover) getCover(ctx context.Context, path string, size int) (reader io.
}()
if path == "" {
return nil, errors.New("empty path given for cover")
return nil, errors.New("empty path given for artwork")
}
var data []byte
@@ -201,6 +184,15 @@ func readFromFile(path string) ([]byte, error) {
return buf.Bytes(), nil
}
func NewImageCache() (ImageCache, error) {
return newFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems)
func NewImageCache() ArtworkCache {
return NewFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems,
func(ctx context.Context, arg fmt.Stringer) (io.Reader, error) {
info := arg.(*imageInfo)
reader, err := info.c.getArtwork(ctx, info.path, info.size)
if err != nil {
log.Error(ctx, "Error loading artwork art", "path", info.path, "size", info.size, err)
return nil, err
}
return reader, nil
})
}

View File

@@ -4,7 +4,10 @@ import (
"bytes"
"context"
"image"
"io/ioutil"
"os"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/persistence"
@@ -12,8 +15,8 @@ import (
. "github.com/onsi/gomega"
)
var _ = Describe("Cover", func() {
var cover Cover
var _ = Describe("Artwork", func() {
var artwork Artwork
var ds model.DataStore
ctx := log.NewContext(context.TODO())
@@ -25,53 +28,61 @@ var _ = Describe("Cover", func() {
Context("Cache is configured", func() {
BeforeEach(func() {
cover = NewCover(ds, testCache)
conf.Server.DataFolder, _ = ioutil.TempDir("", "file_caches")
conf.Server.ImageCacheSize = "100MB"
cache := NewImageCache()
Eventually(func() bool { return cache.Ready() }).Should(BeTrue())
artwork = NewArtwork(ds, cache)
})
It("retrieves the external cover art for an album", func() {
AfterEach(func() {
os.RemoveAll(conf.Server.DataFolder)
})
It("retrieves the external artwork art for an album", func() {
buf := new(bytes.Buffer)
Expect(cover.Get(ctx, "al-444", 0, buf)).To(BeNil())
Expect(artwork.Get(ctx, "al-444", 0, buf)).To(BeNil())
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
Expect(err).To(BeNil())
Expect(format).To(Equal("jpeg"))
})
It("retrieves the embedded cover art for an album", func() {
It("retrieves the embedded artwork art for an album", func() {
buf := new(bytes.Buffer)
Expect(cover.Get(ctx, "al-222", 0, buf)).To(BeNil())
Expect(artwork.Get(ctx, "al-222", 0, buf)).To(BeNil())
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
Expect(err).To(BeNil())
Expect(format).To(Equal("jpeg"))
})
It("returns the default cover if album does not have cover", func() {
It("returns the default artwork if album does not have artwork", func() {
buf := new(bytes.Buffer)
Expect(cover.Get(ctx, "al-333", 0, buf)).To(BeNil())
Expect(artwork.Get(ctx, "al-333", 0, buf)).To(BeNil())
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
Expect(err).To(BeNil())
Expect(format).To(Equal("png"))
})
It("returns the default cover if album is not found", func() {
It("returns the default artwork if album is not found", func() {
buf := new(bytes.Buffer)
Expect(cover.Get(ctx, "al-0101", 0, buf)).To(BeNil())
Expect(artwork.Get(ctx, "al-0101", 0, buf)).To(BeNil())
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
Expect(err).To(BeNil())
Expect(format).To(Equal("png"))
})
It("retrieves the original cover art from a media_file", func() {
It("retrieves the original artwork art from a media_file", func() {
buf := new(bytes.Buffer)
Expect(cover.Get(ctx, "123", 0, buf)).To(BeNil())
Expect(artwork.Get(ctx, "123", 0, buf)).To(BeNil())
img, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
Expect(err).To(BeNil())
@@ -80,20 +91,20 @@ var _ = Describe("Cover", func() {
Expect(img.Bounds().Size().Y).To(Equal(600))
})
It("retrieves the album cover art if media_file does not have one", func() {
It("retrieves the album artwork art if media_file does not have one", func() {
buf := new(bytes.Buffer)
Expect(cover.Get(ctx, "456", 0, buf)).To(BeNil())
Expect(artwork.Get(ctx, "456", 0, buf)).To(BeNil())
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
Expect(err).To(BeNil())
Expect(format).To(Equal("jpeg"))
})
It("resized cover art as requested", func() {
It("resized artwork art as requested", func() {
buf := new(bytes.Buffer)
Expect(cover.Get(ctx, "123", 200, buf)).To(BeNil())
Expect(artwork.Get(ctx, "123", 200, buf)).To(BeNil())
img, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
Expect(err).To(BeNil())
@@ -107,30 +118,15 @@ var _ = Describe("Cover", func() {
ds.Album(ctx).(*persistence.MockAlbum).SetError(true)
buf := new(bytes.Buffer)
Expect(cover.Get(ctx, "al-222", 0, buf)).To(MatchError("Error!"))
Expect(artwork.Get(ctx, "al-222", 0, buf)).To(MatchError("Error!"))
})
It("returns err if gets error from media_file table", func() {
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetError(true)
buf := new(bytes.Buffer)
Expect(cover.Get(ctx, "123", 0, buf)).To(MatchError("Error!"))
Expect(artwork.Get(ctx, "123", 0, buf)).To(MatchError("Error!"))
})
})
})
Context("Cache is NOT configured", func() {
BeforeEach(func() {
cover = NewCover(ds, nil)
})
It("retrieves the original cover art from an album", func() {
buf := new(bytes.Buffer)
Expect(cover.Get(ctx, "al-222", 0, buf)).To(BeNil())
_, format, err := image.Decode(bytes.NewReader(buf.Bytes()))
Expect(err).To(BeNil())
Expect(format).To(Equal("jpeg"))
})
})
})

View File

@@ -1,13 +1,10 @@
package core
import (
"io/ioutil"
"os"
"testing"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/tests"
"github.com/djherbis/fscache"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
@@ -18,18 +15,3 @@ func TestEngine(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Core Suite")
}
var testCache fscache.Cache
var testCacheDir string
var _ = Describe("Core Suite Setup", func() {
BeforeSuite(func() {
testCacheDir, _ = ioutil.TempDir("", "core_test_cache")
fs, _ := fscache.NewFs(testCacheDir, 0755)
testCache, _ = fscache.NewCache(fs, nil)
})
AfterSuite(func() {
os.RemoveAll(testCacheDir)
})
})

View File

@@ -1,8 +1,12 @@
package core
import (
"context"
"fmt"
"io"
"path/filepath"
"sync"
"time"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/consts"
@@ -11,22 +15,181 @@ import (
"github.com/dustin/go-humanize"
)
func newFileCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cache, error) {
if cacheSize == "0" {
log.Warn(fmt.Sprintf("%s cache disabled", name))
return nil, nil
type ReadFunc func(ctx context.Context, arg fmt.Stringer) (io.Reader, error)
type FileCache interface {
Get(ctx context.Context, arg fmt.Stringer) (*CachedStream, error)
Ready() bool
}
func NewFileCache(name, cacheSize, cacheFolder string, maxItems int, getReader ReadFunc) *fileCache {
fc := &fileCache{
name: name,
cacheSize: cacheSize,
cacheFolder: filepath.FromSlash(cacheFolder),
maxItems: maxItems,
getReader: getReader,
mutex: &sync.RWMutex{},
}
size, err := humanize.ParseBytes(cacheSize)
if err != nil {
size = consts.DefaultCacheSize
go func() {
cache, err := newFSCache(fc.name, fc.cacheSize, fc.cacheFolder, fc.maxItems)
fc.mutex.Lock()
defer fc.mutex.Unlock()
if err == nil {
fc.cache = cache
fc.disabled = cache == nil
}
fc.ready = true
if fc.disabled {
log.Debug("Cache disabled", "cache", fc.name, "size", fc.cacheSize)
}
}()
return fc
}
type fileCache struct {
name string
cacheSize string
cacheFolder string
maxItems int
cache fscache.Cache
getReader ReadFunc
disabled bool
ready bool
mutex *sync.RWMutex
}
func (fc *fileCache) Ready() bool {
fc.mutex.RLock()
defer fc.mutex.RUnlock()
return fc.ready
}
func (fc *fileCache) available(ctx context.Context) bool {
fc.mutex.RLock()
defer fc.mutex.RUnlock()
if !fc.ready {
log.Debug(ctx, "Cache not initialized yet", "cache", fc.name)
}
lru := fscache.NewLRUHaunter(maxItems, int64(size), consts.DefaultCacheCleanUpInterval)
h := fscache.NewLRUHaunterStrategy(lru)
cacheFolder = filepath.Join(conf.Server.DataFolder, cacheFolder)
log.Info(fmt.Sprintf("Creating %s cache", name), "path", cacheFolder, "maxSize", humanize.Bytes(size))
fs, err := fscache.NewFs(cacheFolder, 0755)
return fc.ready && !fc.disabled
}
func (fc *fileCache) Get(ctx context.Context, arg fmt.Stringer) (*CachedStream, error) {
if !fc.available(ctx) {
reader, err := fc.getReader(ctx, arg)
if err != nil {
return nil, err
}
return &CachedStream{Reader: reader}, nil
}
key := arg.String()
r, w, err := fc.cache.Get(key)
if err != nil {
return nil, err
}
cached := w == nil
if !cached {
log.Trace(ctx, "Cache MISS", "cache", fc.name, "key", key)
reader, err := fc.getReader(ctx, arg)
if err != nil {
return nil, err
}
go copyAndClose(ctx, w, reader)
}
// If it is in the cache, check if the stream is done being written. If so, return a ReaderSeeker
if cached {
size := getFinalCachedSize(r)
if size >= 0 {
log.Trace(ctx, "Cache HIT", "cache", fc.name, "key", key, "size", size)
sr := io.NewSectionReader(r, 0, size)
return &CachedStream{
Reader: sr,
Seeker: sr,
Cached: true,
}, nil
} else {
log.Trace(ctx, "Cache HIT", "cache", fc.name, "key", key)
}
}
// All other cases, just return a Reader, without Seek capabilities
return &CachedStream{Reader: r, Cached: cached}, nil
}
type CachedStream struct {
io.Reader
io.Seeker
Cached bool
}
func (s *CachedStream) Seekable() bool { return s.Seeker != nil }
func (s *CachedStream) Close() error {
if c, ok := s.Reader.(io.Closer); ok {
return c.Close()
}
return nil
}
func getFinalCachedSize(r fscache.ReadAtCloser) int64 {
cr, ok := r.(*fscache.CacheReader)
if ok {
size, final, err := cr.Size()
if final && err == nil {
return size
}
}
return -1
}
func copyAndClose(ctx context.Context, w io.WriteCloser, r io.Reader) {
_, err := io.Copy(w, r)
if err != nil {
log.Error(ctx, "Error copying data to cache", err)
}
if c, ok := r.(io.Closer); ok {
err = c.Close()
if err != nil {
log.Error(ctx, "Error closing source stream", err)
}
}
err = w.Close()
if err != nil {
log.Error(ctx, "Error closing cache writer", err)
}
}
func newFSCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cache, error) {
size, err := humanize.ParseBytes(cacheSize)
if err != nil {
log.Error("Invalid cache size. Using default size", "cache", name, "size", cacheSize,
"defaultSize", humanize.Bytes(consts.DefaultCacheSize))
size = consts.DefaultCacheSize
}
if size == 0 {
log.Warn(fmt.Sprintf("%s cache disabled", name))
return nil, nil
}
start := time.Now()
lru := fscache.NewLRUHaunter(maxItems, int64(size), consts.DefaultCacheCleanUpInterval)
h := fscache.NewLRUHaunterStrategy(lru)
cacheFolder = filepath.Join(conf.Server.DataFolder, cacheFolder)
log.Info(fmt.Sprintf("Creating %s cache", name), "path", cacheFolder, "maxSize", humanize.Bytes(size))
fs, err := fscache.NewFs(cacheFolder, 0755)
if err != nil {
log.Error(fmt.Sprintf("Error initializing %s cache", name), err, "elapsedTime", time.Since(start))
return nil, err
}
log.Debug(fmt.Sprintf("%s cache initialized", name), "elapsedTime", time.Since(start))
return fscache.NewCacheWithHaunter(fs, h)
}

View File

@@ -1,15 +1,26 @@
package core
import (
"context"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"strings"
"github.com/deluan/navidrome/conf"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
// Call NewFileCache and wait for it to be ready
func callNewFileCache(name, cacheSize, cacheFolder string, maxItems int, getReader ReadFunc) *fileCache {
fc := NewFileCache(name, cacheSize, cacheFolder, maxItems, getReader)
Eventually(func() bool { return fc.Ready() }).Should(BeTrue())
return fc
}
var _ = Describe("File Caches", func() {
BeforeEach(func() {
conf.Server.DataFolder, _ = ioutil.TempDir("", "file_caches")
@@ -18,20 +29,72 @@ var _ = Describe("File Caches", func() {
os.RemoveAll(conf.Server.DataFolder)
})
Describe("newFileCache", func() {
Describe("NewFileCache", func() {
It("creates the cache folder", func() {
Expect(newFileCache("test", "1k", "test", 10)).ToNot(BeNil())
Expect(callNewFileCache("test", "1k", "test", 0, nil)).ToNot(BeNil())
_, err := os.Stat(filepath.Join(conf.Server.DataFolder, "test"))
Expect(os.IsNotExist(err)).To(BeFalse())
})
It("creates the cache folder with invalid size", func() {
Expect(newFileCache("test", "abc", "test", 10)).ToNot(BeNil())
fc := callNewFileCache("test", "abc", "test", 0, nil)
Expect(fc.cache).ToNot(BeNil())
Expect(fc.disabled).To(BeFalse())
})
It("returns empty if cache size is '0'", func() {
Expect(newFileCache("test", "0", "test", 10)).To(BeNil())
fc := callNewFileCache("test", "0", "test", 0, nil)
Expect(fc.cache).To(BeNil())
Expect(fc.disabled).To(BeTrue())
})
})
Describe("FileCache", func() {
It("caches data if cache is enabled", func() {
called := false
fc := callNewFileCache("test", "1KB", "test", 0, func(ctx context.Context, arg fmt.Stringer) (io.Reader, error) {
called = true
return strings.NewReader(arg.String()), nil
})
// First call is a MISS
s, err := fc.Get(context.TODO(), &testArg{"test"})
Expect(err).To(BeNil())
Expect(s.Cached).To(BeFalse())
Expect(ioutil.ReadAll(s)).To(Equal([]byte("test")))
// Second call is a HIT
called = false
s, err = fc.Get(context.TODO(), &testArg{"test"})
Expect(err).To(BeNil())
Expect(ioutil.ReadAll(s)).To(Equal([]byte("test")))
Expect(s.Cached).To(BeTrue())
Expect(called).To(BeFalse())
})
It("does not cache data if cache is disabled", func() {
called := false
fc := callNewFileCache("test", "0", "test", 0, func(ctx context.Context, arg fmt.Stringer) (io.Reader, error) {
called = true
return strings.NewReader(arg.String()), nil
})
// First call is a MISS
s, err := fc.Get(context.TODO(), &testArg{"test"})
Expect(err).To(BeNil())
Expect(s.Cached).To(BeFalse())
Expect(ioutil.ReadAll(s)).To(Equal([]byte("test")))
// Second call is also a MISS
called = false
s, err = fc.Get(context.TODO(), &testArg{"test"})
Expect(err).To(BeNil())
Expect(ioutil.ReadAll(s)).To(Equal([]byte("test")))
Expect(s.Cached).To(BeFalse())
Expect(called).To(BeTrue())
})
})
})
type testArg struct{ s string }
func (t *testArg) String() string { return t.s }

View File

@@ -14,14 +14,13 @@ import (
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/model/request"
"github.com/djherbis/fscache"
)
type MediaStreamer interface {
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error)
}
type TranscodingCache fscache.Cache
type TranscodingCache FileCache
func NewMediaStreamer(ds model.DataStore, ffm transcoder.Transcoder, cache TranscodingCache) MediaStreamer {
return &mediaStreamer{ds: ds, ffm: ffm, cache: cache}
@@ -30,7 +29,18 @@ func NewMediaStreamer(ds model.DataStore, ffm transcoder.Transcoder, cache Trans
type mediaStreamer struct {
ds model.DataStore
ffm transcoder.Transcoder
cache fscache.Cache
cache FileCache
}
type streamJob struct {
ms *mediaStreamer
mf *model.MediaFile
format string
bitRate int
}
func (j *streamJob) String() string {
return fmt.Sprintf("%s.%s.%d.%s", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format)
}
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error) {
@@ -49,92 +59,47 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat str
}()
format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate)
log.Trace(ctx, "Selected transcoding options",
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
"selectedBitrate", bitRate, "selectedFormat", format,
)
s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate}
if format == "raw" {
log.Debug(ctx, "Streaming raw file", "id", mf.ID, "path", mf.Path,
log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", mf.Path,
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
"selectedBitrate", bitRate, "selectedFormat", format)
f, err := os.Open(mf.Path)
if err != nil {
return nil, err
}
s.Reader = f
s.Closer = f
s.ReadCloser = f
s.Seeker = f
s.format = mf.Suffix
return s, nil
}
key := cacheKey(id, bitRate, format)
r, w, err := ms.cache.Get(key)
job := &streamJob{
ms: ms,
mf: mf,
format: format,
bitRate: bitRate,
}
r, err := ms.cache.Get(ctx, job)
if err != nil {
log.Error(ctx, "Error creating stream caching buffer", "id", mf.ID, err)
log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err)
return nil, err
}
cached = r.Cached
cached = w == nil
// If this is a brand new transcoding request, not in the cache, start transcoding
if !cached {
log.Trace(ctx, "Cache miss. Starting new transcoding session", "id", mf.ID)
t, err := ms.ds.Transcoding(ctx).FindByFormat(format)
if err != nil {
log.Error(ctx, "Error loading transcoding command", "format", format, err)
return nil, os.ErrInvalid
}
out, err := ms.ffm.Start(ctx, t.Command, mf.Path, bitRate)
if err != nil {
log.Error(ctx, "Error starting transcoder", "id", mf.ID, err)
return nil, os.ErrInvalid
}
go copyAndClose(ctx, w, out)
}
// If it is in the cache, check if the stream is done being written. If so, return a ReaderSeeker
if cached {
size := getFinalCachedSize(r)
if size > 0 {
log.Debug(ctx, "Streaming cached file", "id", mf.ID, "path", mf.Path,
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix, "size", size)
sr := io.NewSectionReader(r, 0, size)
s.Reader = sr
s.Closer = r
s.Seeker = sr
s.format = format
return s, nil
}
}
log.Debug(ctx, "Streaming transcoded file", "id", mf.ID, "path", mf.Path,
log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", mf.Path,
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
// All other cases, just return a ReadCloser, without Seek capabilities
s.Reader = r
s.Closer = r
s.format = format
return s, nil
}
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
"selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable())
func copyAndClose(ctx context.Context, w io.WriteCloser, r io.ReadCloser) {
_, err := io.Copy(w, r)
if err != nil {
log.Error(ctx, "Error copying data to cache", err)
}
err = r.Close()
if err != nil {
log.Error(ctx, "Error closing transcode output", err)
}
err = w.Close()
if err != nil {
log.Error(ctx, "Error closing cache", err)
s.ReadCloser = r
if r.Seekable() {
s.Seeker = r
}
return s, nil
}
type Stream struct {
@@ -142,8 +107,7 @@ type Stream struct {
mf *model.MediaFile
bitRate int
format string
io.Reader
io.Closer
io.ReadCloser
io.Seeker
}
@@ -202,21 +166,21 @@ func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model
return
}
func cacheKey(id string, bitRate int, format string) string {
return fmt.Sprintf("%s.%d.%s", id, bitRate, format)
}
func getFinalCachedSize(r fscache.ReadAtCloser) int64 {
cr, ok := r.(*fscache.CacheReader)
if ok {
size, final, err := cr.Size()
if final && err == nil {
return size
}
}
return -1
}
func NewTranscodingCache() (TranscodingCache, error) {
return newFileCache("Transcoding", conf.Server.TranscodingCacheSize, consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems)
func NewTranscodingCache() TranscodingCache {
return NewFileCache("Transcoding", conf.Server.TranscodingCacheSize,
consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems,
func(ctx context.Context, arg fmt.Stringer) (io.Reader, error) {
job := arg.(*streamJob)
t, err := job.ms.ds.Transcoding(ctx).FindByFormat(job.format)
if err != nil {
log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
return nil, os.ErrInvalid
}
out, err := job.ms.ffm.Start(ctx, t.Command, job.mf.Path, job.bitRate)
if err != nil {
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
return nil, os.ErrInvalid
}
return out, nil
})
}

View File

@@ -3,8 +3,11 @@ package core
import (
"context"
"io"
"io/ioutil"
"os"
"strings"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/model/request"
@@ -20,11 +23,19 @@ var _ = Describe("MediaStreamer", func() {
ctx := log.NewContext(context.TODO())
BeforeEach(func() {
conf.Server.DataFolder, _ = ioutil.TempDir("", "file_caches")
conf.Server.TranscodingCacheSize = "100MB"
ds = &persistence.MockDataStore{MockedTranscoding: &mockTranscodingRepository{}}
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "suffix": "mp3", "bitRate": 128, "duration": 257.0}]`)
testCache := NewTranscodingCache()
Eventually(func() bool { return testCache.Ready() }).Should(BeTrue())
streamer = NewMediaStreamer(ds, ffmpeg, testCache)
})
AfterEach(func() {
os.RemoveAll(conf.Server.DataFolder)
})
Context("NewStream", func() {
It("returns a seekable stream if format is 'raw'", func() {
s, err := streamer.NewStream(ctx, "123", "raw", 0)
@@ -48,8 +59,13 @@ var _ = Describe("MediaStreamer", func() {
Expect(s.Duration()).To(Equal(float32(257.0)))
})
It("returns a seekable stream if the file is complete in the cache", func() {
Eventually(func() bool { return ffmpeg.closed }).Should(BeTrue())
s, err := streamer.NewStream(ctx, "123", "mp3", 64)
s, err := streamer.NewStream(ctx, "123", "mp3", 32)
Expect(err).To(BeNil())
_, _ = ioutil.ReadAll(s)
_ = s.Close()
Eventually(func() bool { return ffmpeg.closed }, "3s").Should(BeTrue())
s, err = streamer.NewStream(ctx, "123", "mp3", 32)
Expect(err).To(BeNil())
Expect(s.Seekable()).To(BeTrue())
})

View File

@@ -6,7 +6,7 @@ import (
)
var Set = wire.NewSet(
NewCover,
NewArtwork,
NewMediaStreamer,
NewTranscodingCache,
NewImageCache,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,36 @@
package migrations
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(upCreatePlayQueuesTable, downCreatePlayQueuesTable)
}
func upCreatePlayQueuesTable(tx *sql.Tx) error {
_, err := tx.Exec(`
create table playqueue
(
id varchar(255) not null primary key,
user_id varchar(255) not null
references user (id)
on update cascade on delete cascade,
comment varchar(255),
current varchar(255) not null,
position integer,
changed_by varchar(255),
items varchar(255),
created_at datetime,
updated_at datetime
);
`)
return err
}
func downCreatePlayQueuesTable(tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,53 @@
package migrations
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(upCreateBookmarkTable, downCreateBookmarkTable)
}
func upCreateBookmarkTable(tx *sql.Tx) error {
_, err := tx.Exec(`
create table bookmark
(
user_id varchar(255) not null
references user
on update cascade on delete cascade,
item_id varchar(255) not null,
item_type varchar(255) not null,
comment varchar(255),
position integer,
changed_by varchar(255),
created_at datetime,
updated_at datetime,
constraint bookmark_pk
unique (user_id, item_id, item_type)
);
create table playqueue_dg_tmp
(
id varchar(255) not null,
user_id varchar(255) not null
references user
on update cascade on delete cascade,
current varchar(255),
position real,
changed_by varchar(255),
items varchar(255),
created_at datetime,
updated_at datetime
);
drop table playqueue;
alter table playqueue_dg_tmp rename to playqueue;
`)
return err
}
func downCreateBookmarkTable(tx *sql.Tx) error {
return nil
}

View File

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

View File

@@ -2,48 +2,44 @@ package engine
import (
"context"
"fmt"
"time"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/model/request"
)
type Entry struct {
Id string
Title string
IsDir bool
Parent string
Album string
Year int
Artist string
Genre string
CoverArt string
Starred time.Time
Track int
Duration int
Size int64
Suffix string
BitRate int
ContentType string
Path string
PlayCount int32
DiscNumber int
Created time.Time
AlbumId string
ArtistId string
Type string
UserRating int
SongCount int
UserName string
MinutesAgo int
PlayerId int
PlayerName string
AlbumCount int
AbsolutePath string
Id string
Title string
IsDir bool
Parent string
Album string
Year int
Artist string
Genre string
CoverArt string
Starred time.Time
Track int
Duration int
Size int64
Suffix string
BitRate int
ContentType string
Path string
PlayCount int32
DiscNumber int
Created time.Time
AlbumId string
ArtistId string
Type string
UserRating int
SongCount int
UserName string
MinutesAgo int
PlayerId int
PlayerName string
AlbumCount int
BookmarkPosition int64
}
type Entries []Entry
@@ -105,11 +101,7 @@ func FromMediaFile(mf *model.MediaFile) Entry {
e.CoverArt = "al-" + mf.AlbumID
}
e.ContentType = mf.ContentType()
e.AbsolutePath = mf.Path
// Creates a "pseudo" Path, to avoid sending absolute paths to the client
if mf.Path != "" {
e.Path = fmt.Sprintf("%s/%s/%s.%s", realArtistName(mf), mf.Album, mf.Title, mf.Suffix)
}
e.Path = mf.Path
e.DiscNumber = mf.DiscNumber
e.Created = mf.CreatedAt
e.AlbumId = mf.AlbumID
@@ -120,20 +112,10 @@ func FromMediaFile(mf *model.MediaFile) Entry {
e.Starred = mf.StarredAt
}
e.UserRating = mf.Rating
e.BookmarkPosition = mf.BookmarkPosition
return e
}
func realArtistName(mf *model.MediaFile) string {
switch {
case mf.Compilation:
return consts.VariousArtists
case mf.AlbumArtist != "":
return mf.AlbumArtist
}
return mf.Artist
}
func FromAlbums(albums model.Albums) Entries {
entries := make(Entries, len(albums))
for i := range albums {

View File

@@ -1,5 +1,9 @@
#!/bin/sh
set -e
golangci-lint run
echo "#### Linting"
golangci-lint run -v
echo
echo "#### Running tests"
make test

28
model/bookmark.go Normal file
View File

@@ -0,0 +1,28 @@
package model
import "time"
type Bookmarkable struct {
BookmarkPosition int64 `json:"bookmarkPosition"`
}
type BookmarkableRepository interface {
AddBookmark(id, comment string, position int64) error
DeleteBookmark(id string) error
GetBookmarks() (Bookmarks, error)
}
type Bookmark struct {
Item MediaFile `json:"item"`
Comment string `json:"comment"`
Position int64 `json:"position"`
ChangedBy string `json:"changed_by"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type Bookmarks []Bookmark
// While I can't find a better way to make these fields optional in the models, I keep this list here
// to be used in other packages
var BookmarkFields = []string{"bookmarkPosition"}

View File

@@ -26,6 +26,7 @@ type DataStore interface {
MediaFolder(ctx context.Context) MediaFolderRepository
Genre(ctx context.Context) GenreRepository
Playlist(ctx context.Context) PlaylistRepository
PlayQueue(ctx context.Context) PlayQueueRepository
Property(ctx context.Context) PropertyRepository
User(ctx context.Context) UserRepository
Transcoding(ctx context.Context) TranscodingRepository

View File

@@ -7,6 +7,7 @@ import (
type MediaFile struct {
Annotations
Bookmarkable
ID string `json:"id" orm:"pk;column(id)"`
Path string `json:"path"`
@@ -63,6 +64,7 @@ type MediaFileRepository interface {
DeleteByPath(path string) (int64, error)
AnnotatedRepository
BookmarkableRepository
}
func (mf MediaFile) GetAnnotations() Annotations {

23
model/playqueue.go Normal file
View File

@@ -0,0 +1,23 @@
package model
import (
"time"
)
type PlayQueue struct {
ID string `json:"id" orm:"column(id)"`
UserID string `json:"userId" orm:"column(user_id)"`
Current string `json:"current"`
Position int64 `json:"position"`
ChangedBy string `json:"changedBy"`
Items MediaFiles `json:"items,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type PlayQueues []PlayQueue
type PlayQueueRepository interface {
Store(queue *PlayQueue) error
Retrieve(userId string) (*PlayQueue, error)
}

View File

@@ -36,15 +36,20 @@ func NewAlbumRepository(ctx context.Context, o orm.Ormer) model.AlbumRepository
"max_year": "max_year asc, name, order_album_name asc",
}
r.filterMappings = map[string]filterFunc{
"name": fullTextFilter,
"compilation": booleanFilter,
"artist_id": artistFilter,
"year": yearFilter,
"name": fullTextFilter,
"compilation": booleanFilter,
"artist_id": artistFilter,
"year": yearFilter,
"recently_played": recentlyPlayedFilter,
}
return r
}
func recentlyPlayedFilter(field string, value interface{}) Sqlizer {
return Gt{"play_count": 0}
}
func yearFilter(field string, value interface{}) Sqlizer {
return Or{
And{
@@ -67,7 +72,7 @@ func artistFilter(field string, value interface{}) Sqlizer {
}
func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) {
return r.count(Select(), options...)
return r.count(r.selectAlbum(), options...)
}
func (r *albumRepository) Exists(id string) (bool, error) {

View File

@@ -23,7 +23,9 @@ func toSqlArgs(rec interface{}) (map[string]interface{}, error) {
err = json.Unmarshal(b, &m)
r := make(map[string]interface{}, len(m))
for f, v := range m {
if !utils.StringInSlice(f, model.AnnotationFields) && v != nil {
isAnnotationField := utils.StringInSlice(f, model.AnnotationFields)
isBookmarkField := utils.StringInSlice(f, model.BookmarkFields)
if !isAnnotationField && !isBookmarkField && v != nil {
r[toSnakeCase(f)] = v
}
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"unicode/utf8"
. "github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
@@ -51,7 +52,8 @@ func (r mediaFileRepository) Put(m *model.MediaFile) error {
}
func (r mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder {
return r.newSelectWithAnnotation("media_file.id", options...).Columns("media_file.*")
sql := r.newSelectWithAnnotation("media_file.id", options...).Columns("media_file.*")
return r.withBookmark(sql, "media_file.id")
}
func (r mediaFileRepository) Get(id string) (*model.MediaFile, error) {
@@ -95,7 +97,8 @@ func (r mediaFileRepository) FindByPath(path string) (*model.MediaFile, error) {
// FindAllByPath only return mediafiles that are direct children of requested path
func (r mediaFileRepository) FindAllByPath(path string) (model.MediaFiles, error) {
// Query by path based on https://stackoverflow.com/a/13911906/653632
sel0 := r.selectMediaFile().Columns(fmt.Sprintf("substr(path, %d) AS item", len(path)+2)).
pathLen := utf8.RuneCountInString(path)
sel0 := r.selectMediaFile().Columns(fmt.Sprintf("substr(path, %d) AS item", pathLen+2)).
Where(pathStartsWith(path))
sel := r.newSelect().Columns("*", "item NOT GLOB '*"+string(os.PathSeparator)+"*' AS isLast").
Where(Eq{"isLast": 1}).FromSelect(sel0, "sel0")
@@ -107,7 +110,7 @@ func (r mediaFileRepository) FindAllByPath(path string) (model.MediaFiles, error
func pathStartsWith(path string) Sqlizer {
cleanPath := filepath.Clean(path)
substr := fmt.Sprintf("substr(path, 1, %d)", len(cleanPath))
substr := fmt.Sprintf("substr(path, 1, %d)", utf8.RuneCountInString(cleanPath))
return Eq{substr: cleanPath}
}
@@ -144,9 +147,10 @@ func (r mediaFileRepository) Delete(id string) error {
// DeleteByPath delete from the DB all mediafiles that are direct children of path
func (r mediaFileRepository) DeleteByPath(path string) (int64, error) {
path = filepath.Clean(path)
pathLen := utf8.RuneCountInString(path)
del := Delete(r.tableName).
Where(And{pathStartsWith(path),
Eq{fmt.Sprintf("substr(path, %d) glob '*%s*'", len(path)+2, string(os.PathSeparator)): 0}})
Eq{fmt.Sprintf("substr(path, %d) glob '*%s*'", pathLen+2, string(os.PathSeparator)): 0}})
log.Debug(r.ctx, "Deleting mediafiles by path", "path", path)
return r.executeSQL(del)
}

View File

@@ -61,6 +61,15 @@ var _ = Describe("MediaRepository", func() {
Expect(found[0].ID).To(Equal("7001"))
})
It("finds tracks by path when using UTF8 chars", func() {
Expect(mr.Put(&model.MediaFile{ID: "7010", Path: P("/Пётр Ильич Чайковский/123.mp3")})).To(BeNil())
Expect(mr.Put(&model.MediaFile{ID: "7011", Path: P("/Пётр Ильич Чайковский/222.mp3")})).To(BeNil())
found, err := mr.FindAllByPath(P("/Пётр Ильич Чайковский/"))
Expect(err).To(BeNil())
Expect(found).To(HaveLen(2))
})
It("finds tracks by path case sensitively", func() {
Expect(mr.Put(&model.MediaFile{ID: "7003", Path: P("/Casesensitive/file1.mp3")})).To(BeNil())
Expect(mr.Put(&model.MediaFile{ID: "7004", Path: P("/casesensitive/file2.mp3")})).To(BeNil())
@@ -94,15 +103,15 @@ var _ = Describe("MediaRepository", func() {
})
It("delete tracks by path", func() {
id1 := "1111"
id1 := "6001"
Expect(mr.Put(&model.MediaFile{ID: id1, Path: P("/abc/123/" + id1 + ".mp3")})).To(BeNil())
id2 := "2222"
id2 := "6002"
Expect(mr.Put(&model.MediaFile{ID: id2, Path: P("/abc/123/" + id2 + ".mp3")})).To(BeNil())
id3 := "3333"
id3 := "6003"
Expect(mr.Put(&model.MediaFile{ID: id3, Path: P("/ab_/" + id3 + ".mp3")})).To(BeNil())
id4 := "4444"
id4 := "6004"
Expect(mr.Put(&model.MediaFile{ID: id4, Path: P("/abc/" + id4 + ".mp3")})).To(BeNil())
id5 := "5555"
id5 := "6005"
Expect(mr.Put(&model.MediaFile{ID: id5, Path: P("/Ab_/" + id5 + ".mp3")})).To(BeNil())
Expect(mr.DeleteByPath(P("/ab_"))).To(Equal(int64(1)))
@@ -115,6 +124,19 @@ var _ = Describe("MediaRepository", func() {
Expect(err).To(MatchError(model.ErrNotFound))
})
It("delete tracks by path containing UTF8 chars", func() {
id1 := "6011"
Expect(mr.Put(&model.MediaFile{ID: id1, Path: P("/Legião Urbana/" + id1 + ".mp3")})).To(BeNil())
id2 := "6012"
Expect(mr.Put(&model.MediaFile{ID: id2, Path: P("/Legião Urbana/" + id2 + ".mp3")})).To(BeNil())
id3 := "6003"
Expect(mr.Put(&model.MediaFile{ID: id3, Path: P("/Legião Urbana/" + id3 + ".mp3")})).To(BeNil())
Expect(mr.FindAllByPath(P("/Legião Urbana"))).To(HaveLen(3))
Expect(mr.DeleteByPath(P("/Legião Urbana"))).To(Equal(int64(3)))
Expect(mr.FindAllByPath(P("/Legião Urbana"))).To(HaveLen(0))
})
Context("Annotations", func() {
It("increments play count when the tracks does not have annotations", func() {
id := "incplay.firsttime"

View File

@@ -52,6 +52,10 @@ func (db *MockDataStore) Playlist(context.Context) model.PlaylistRepository {
return struct{ model.PlaylistRepository }{}
}
func (db *MockDataStore) PlayQueue(context.Context) model.PlayQueueRepository {
return struct{ model.PlayQueueRepository }{}
}
func (db *MockDataStore) Property(context.Context) model.PropertyRepository {
return struct{ model.PropertyRepository }{}
}

View File

@@ -38,6 +38,10 @@ func (s *SQLStore) Genre(ctx context.Context) model.GenreRepository {
return NewGenreRepository(ctx, s.getOrmer())
}
func (s *SQLStore) PlayQueue(ctx context.Context) model.PlayQueueRepository {
return NewPlayQueueRepository(ctx, s.getOrmer())
}
func (s *SQLStore) Playlist(ctx context.Context) model.PlaylistRepository {
return NewPlaylistRepository(ctx, s.getOrmer())
}
@@ -133,6 +137,11 @@ func (s *SQLStore) GC(ctx context.Context) error {
log.Error(ctx, "Error removing orphan artist annotations", err)
return err
}
err = s.MediaFile(ctx).(*mediaFileRepository).cleanBookmarks()
if err != nil {
log.Error(ctx, "Error removing orphan bookmarks", err)
return err
}
err = s.Playlist(ctx).(*playlistRepository).removeOrphans()
if err != nil {
log.Error(ctx, "Error tidying up playlists", err)

View File

@@ -0,0 +1,153 @@
package persistence
import (
"context"
"strings"
"time"
. "github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
)
type playQueueRepository struct {
sqlRepository
}
func NewPlayQueueRepository(ctx context.Context, o orm.Ormer) model.PlayQueueRepository {
r := &playQueueRepository{}
r.ctx = ctx
r.ormer = o
r.tableName = "playqueue"
return r
}
type playQueue struct {
ID string `orm:"column(id)"`
UserID string `orm:"column(user_id)"`
Current string
Position int64
ChangedBy string
Items string
CreatedAt time.Time
UpdatedAt time.Time
}
func (r *playQueueRepository) Store(q *model.PlayQueue) error {
u := loggedUser(r.ctx)
err := r.clearPlayQueue(q.UserID)
if err != nil {
log.Error(r.ctx, "Error deleting previous playqueue", "user", u.UserName, err)
return err
}
pq := r.fromModel(q)
if pq.ID == "" {
pq.CreatedAt = time.Now()
}
pq.UpdatedAt = time.Now()
_, err = r.put(pq.ID, pq)
if err != nil {
log.Error(r.ctx, "Error saving playqueue", "user", u.UserName, err)
return err
}
return nil
}
func (r *playQueueRepository) Retrieve(userId string) (*model.PlayQueue, error) {
sel := r.newSelect().Columns("*").Where(Eq{"user_id": userId})
var res playQueue
err := r.queryOne(sel, &res)
pls := r.toModel(&res)
return &pls, err
}
func (r *playQueueRepository) fromModel(q *model.PlayQueue) playQueue {
pq := playQueue{
ID: q.ID,
UserID: q.UserID,
Current: q.Current,
Position: q.Position,
ChangedBy: q.ChangedBy,
CreatedAt: q.CreatedAt,
UpdatedAt: q.UpdatedAt,
}
var itemIDs []string
for _, t := range q.Items {
itemIDs = append(itemIDs, t.ID)
}
pq.Items = strings.Join(itemIDs, ",")
return pq
}
func (r *playQueueRepository) toModel(pq *playQueue) model.PlayQueue {
q := model.PlayQueue{
ID: pq.ID,
UserID: pq.UserID,
Current: pq.Current,
Position: pq.Position,
ChangedBy: pq.ChangedBy,
CreatedAt: pq.CreatedAt,
UpdatedAt: pq.UpdatedAt,
}
if strings.TrimSpace(pq.Items) != "" {
tracks := strings.Split(pq.Items, ",")
for _, t := range tracks {
q.Items = append(q.Items, model.MediaFile{ID: t})
}
}
q.Items = r.loadTracks(q.Items)
return q
}
func (r *playQueueRepository) loadTracks(tracks model.MediaFiles) model.MediaFiles {
if len(tracks) == 0 {
return nil
}
// Collect all ids
ids := make([]string, len(tracks))
for i, t := range tracks {
ids[i] = t.ID
}
// Break the list in chunks, up to 50 items, to avoid hitting SQLITE_MAX_FUNCTION_ARG limit
const chunkSize = 50
var chunks [][]string
for i := 0; i < len(ids); i += chunkSize {
end := i + chunkSize
if end > len(ids) {
end = len(ids)
}
chunks = append(chunks, ids[i:end])
}
// Query each chunk of media_file ids and store results in a map
mfRepo := NewMediaFileRepository(r.ctx, r.ormer)
trackMap := map[string]model.MediaFile{}
for i := range chunks {
idsFilter := Eq{"id": chunks[i]}
tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: idsFilter})
if err != nil {
u := loggedUser(r.ctx)
log.Error(r.ctx, "Could not load playqueue/bookmark's tracks", "user", u.UserName, err)
}
for _, t := range tracks {
trackMap[t.ID] = t
}
}
// Create a new list of tracks with the same order as the original
newTracks := make(model.MediaFiles, len(tracks))
for i, t := range tracks {
newTracks[i] = trackMap[t.ID]
}
return newTracks
}
func (r *playQueueRepository) clearPlayQueue(userId string) error {
return r.delete(Eq{"user_id": userId})
}
var _ model.PlayQueueRepository = (*playQueueRepository)(nil)

View File

@@ -0,0 +1,92 @@
package persistence
import (
"context"
"time"
"github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/model/request"
"github.com/google/uuid"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("PlayQueueRepository", func() {
var repo model.PlayQueueRepository
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "user1", UserName: "user1", IsAdmin: true})
repo = NewPlayQueueRepository(ctx, orm.NewOrm())
})
Describe("PlayQueues", func() {
It("returns notfound error if there's no playqueue for the user", func() {
_, err := repo.Retrieve("user999")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("stores and retrieves the playqueue for the user", func() {
By("Storing a playqueue for the user")
expected := aPlayQueue("user1", songDayInALife.ID, 123, songComeTogether, songDayInALife)
Expect(repo.Store(expected)).To(BeNil())
actual, err := repo.Retrieve("user1")
Expect(err).To(BeNil())
AssertPlayQueue(expected, actual)
By("Storing a new playqueue for the same user")
new := aPlayQueue("user1", songRadioactivity.ID, 321, songAntenna, songRadioactivity)
Expect(repo.Store(new)).To(BeNil())
actual, err = repo.Retrieve("user1")
Expect(err).To(BeNil())
AssertPlayQueue(new, actual)
Expect(countPlayQueues(repo, "user1")).To(Equal(1))
})
})
})
func countPlayQueues(repo model.PlayQueueRepository, userId string) int {
r := repo.(*playQueueRepository)
c, err := r.count(squirrel.Select().Where(squirrel.Eq{"user_id": userId}))
if err != nil {
panic(err)
}
return int(c)
}
func AssertPlayQueue(expected, actual *model.PlayQueue) {
Expect(actual.ID).To(Equal(expected.ID))
Expect(actual.UserID).To(Equal(expected.UserID))
Expect(actual.Current).To(Equal(expected.Current))
Expect(actual.Position).To(Equal(expected.Position))
Expect(actual.ChangedBy).To(Equal(expected.ChangedBy))
Expect(actual.Items).To(HaveLen(len(expected.Items)))
for i, item := range actual.Items {
Expect(item.Title).To(Equal(expected.Items[i].Title))
}
}
func aPlayQueue(userId, current string, position int64, items ...model.MediaFile) *model.PlayQueue {
createdAt := time.Now()
updatedAt := createdAt.Add(time.Minute)
id, _ := uuid.NewRandom()
return &model.PlayQueue{
ID: id.String(),
UserID: userId,
Current: current,
Position: position,
ChangedBy: "test",
Items: items,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
}

View File

@@ -23,9 +23,9 @@ func (r sqlRepository) newSelectWithAnnotation(idField string, options ...model.
func (r sqlRepository) annId(itemID ...string) And {
return And{
Eq{"user_id": userId(r.ctx)},
Eq{"item_type": r.tableName},
Eq{"item_id": itemID},
Eq{annotationTable + ".user_id": userId(r.ctx)},
Eq{annotationTable + ".item_type": r.tableName},
Eq{annotationTable + ".item_id": itemID},
}
}

View File

@@ -112,7 +112,7 @@ func (r sqlRepository) executeSQL(sq Sqlizer) (int64, error) {
return res.RowsAffected()
}
// Note: Due to a bug in the QueryRow, this method does not map any embedded structs (ex: annotations)
// Note: Due to a bug in the QueryRow method, this function does not map any embedded structs (ex: annotations)
// In this case, use the queryAll method and get the first item of the returned list
func (r sqlRepository) queryOne(sq Sqlizer, response interface{}) error {
query, args, err := sq.ToSql()

View File

@@ -0,0 +1,151 @@
package persistence
import (
"time"
. "github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/model/request"
)
const bookmarkTable = "bookmark"
func (r sqlRepository) withBookmark(sql SelectBuilder, idField string) SelectBuilder {
return sql.
LeftJoin("bookmark on (" +
"bookmark.item_id = " + idField +
" AND bookmark.item_type = '" + r.tableName + "'" +
" AND bookmark.user_id = '" + userId(r.ctx) + "')").
Columns("position as bookmark_position")
}
func (r sqlRepository) bmkID(itemID ...string) And {
return And{
Eq{bookmarkTable + ".user_id": userId(r.ctx)},
Eq{bookmarkTable + ".item_type": r.tableName},
Eq{bookmarkTable + ".item_id": itemID},
}
}
func (r sqlRepository) bmkUpsert(itemID, comment string, position int64) error {
client, _ := request.ClientFrom(r.ctx)
user, _ := request.UserFrom(r.ctx)
values := map[string]interface{}{
"comment": comment,
"position": position,
"updated_at": time.Now(),
"changed_by": client,
}
upd := Update(bookmarkTable).Where(r.bmkID(itemID)).SetMap(values)
c, err := r.executeSQL(upd)
if err == nil {
log.Debug(r.ctx, "Updated bookmark", "id", itemID, "user", user.UserName, "position", position, "comment", comment)
}
if c == 0 || err == orm.ErrNoRows {
values["user_id"] = user.ID
values["item_type"] = r.tableName
values["item_id"] = itemID
values["created_at"] = time.Now()
values["updated_at"] = time.Now()
ins := Insert(bookmarkTable).SetMap(values)
_, err = r.executeSQL(ins)
if err != nil {
return err
}
log.Debug(r.ctx, "Added bookmark", "id", itemID, "user", user.UserName, "position", position, "comment", comment)
}
return err
}
func (r sqlRepository) AddBookmark(id, comment string, position int64) error {
user, _ := request.UserFrom(r.ctx)
err := r.bmkUpsert(id, comment, position)
if err != nil {
log.Error(r.ctx, "Error adding bookmark", "id", id, "user", user.UserName, "position", position, "comment", comment)
}
return err
}
func (r sqlRepository) DeleteBookmark(id string) error {
user, _ := request.UserFrom(r.ctx)
del := Delete(bookmarkTable).Where(r.bmkID(id))
_, err := r.executeSQL(del)
if err != nil {
log.Error(r.ctx, "Error removing bookmark", "id", id, "user", user.UserName)
}
return err
}
type bookmark struct {
UserID string `json:"user_id" orm:"column(user_id)"`
ItemID string `json:"item_id" orm:"column(item_id)"`
ItemType string `json:"item_type"`
Comment string `json:"comment"`
Position int64 `json:"position"`
ChangedBy string `json:"changed_by"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (r sqlRepository) GetBookmarks() (model.Bookmarks, error) {
user, _ := request.UserFrom(r.ctx)
idField := r.tableName + ".id"
sql := r.newSelectWithAnnotation(idField).Columns("*")
sql = r.withBookmark(sql, idField).Where(NotEq{bookmarkTable + ".item_id": nil})
var mfs model.MediaFiles
err := r.queryAll(sql, &mfs)
if err != nil {
log.Error(r.ctx, "Error getting mediafiles with bookmarks", "user", user.UserName, err)
return nil, err
}
ids := make([]string, len(mfs))
mfMap := make(map[string]int)
for i, mf := range mfs {
ids[i] = mf.ID
mfMap[mf.ID] = i
}
sql = Select("*").From(bookmarkTable).Where(r.bmkID(ids...))
var bmks []bookmark
err = r.queryAll(sql, &bmks)
if err != nil {
log.Error(r.ctx, "Error getting bookmarks", "user", user.UserName, "ids", ids, err)
return nil, err
}
resp := make(model.Bookmarks, len(bmks))
for i, bmk := range bmks {
if itemIdx, ok := mfMap[bmk.ItemID]; !ok {
log.Debug(r.ctx, "Invalid bookmark", "id", bmk.ItemID, "user", user.UserName)
continue
} else {
resp[i] = model.Bookmark{
Comment: bmk.Comment,
Position: bmk.Position,
CreatedAt: bmk.CreatedAt,
UpdatedAt: bmk.UpdatedAt,
ChangedBy: bmk.ChangedBy,
Item: mfs[itemIdx],
}
}
}
return resp, nil
}
func (r sqlRepository) cleanBookmarks() error {
del := Delete(bookmarkTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")")
c, err := r.executeSQL(del)
if err != nil {
return err
}
if c > 0 {
log.Debug(r.ctx, "Clean-up bookmarks", "totalDeleted", c)
}
return nil
}

View File

@@ -0,0 +1,72 @@
package persistence
import (
"context"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/model/request"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("sqlBookmarks", func() {
var mr model.MediaFileRepository
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "user1"})
mr = NewMediaFileRepository(ctx, orm.NewOrm())
})
Describe("Bookmarks", func() {
It("returns an empty collection if there are no bookmarks", func() {
Expect(mr.GetBookmarks()).To(BeEmpty())
})
It("saves and overrides bookmarks", func() {
By("Saving the bookmark")
Expect(mr.AddBookmark(songAntenna.ID, "this is a comment", 123)).To(BeNil())
bms, err := mr.GetBookmarks()
Expect(err).To(BeNil())
Expect(bms).To(HaveLen(1))
Expect(bms[0].Item.ID).To(Equal(songAntenna.ID))
Expect(bms[0].Item.Title).To(Equal(songAntenna.Title))
Expect(bms[0].Comment).To(Equal("this is a comment"))
Expect(bms[0].Position).To(Equal(int64(123)))
created := bms[0].CreatedAt
updated := bms[0].UpdatedAt
Expect(created.IsZero()).To(BeFalse())
Expect(updated).To(BeTemporally(">=", created))
By("Overriding the bookmark")
Expect(mr.AddBookmark(songAntenna.ID, "another comment", 333)).To(BeNil())
bms, err = mr.GetBookmarks()
Expect(err).To(BeNil())
Expect(bms[0].Item.ID).To(Equal(songAntenna.ID))
Expect(bms[0].Comment).To(Equal("another comment"))
Expect(bms[0].Position).To(Equal(int64(333)))
Expect(bms[0].CreatedAt).To(Equal(created))
Expect(bms[0].UpdatedAt).To(BeTemporally(">=", updated))
By("Saving another bookmark")
Expect(mr.AddBookmark(songComeTogether.ID, "one more comment", 444)).To(BeNil())
bms, err = mr.GetBookmarks()
Expect(err).To(BeNil())
Expect(bms).To(HaveLen(2))
By("Delete bookmark")
Expect(mr.DeleteBookmark(songAntenna.ID))
bms, err = mr.GetBookmarks()
Expect(err).To(BeNil())
Expect(bms).To(HaveLen(1))
Expect(bms[0].Item.ID).To(Equal(songComeTogether.ID))
Expect(bms[0].Item.Title).To(Equal(songComeTogether.Title))
})
})
})

View File

@@ -174,7 +174,7 @@
"loading": "Načítání",
"not_found": "Nenalezeno",
"show": "%{name} #%{id}",
"empty": "Zatím žádné %{name}",
"empty": "Zatím žádný %{name}",
"invite": "Chcete jeden přidat?"
},
"input": {
@@ -240,7 +240,7 @@
"note": "POZNÁMKA",
"transcodingDisabled": "Měnění nastavení překódování je ve webovém prostředí vypnuto kvůli bezpečnosti. Pokud by jste chtěli změnit (upravit nebo přidat) možnosti překódování, restartujte server s možností %{config}.",
"transcodingEnabled": "Navidrome právě běží s možností %{config}, umožňující spouštění systémových příkazů z nastavení překódování pomocí webového rozhraní. Doporučujeme ji vypnout kvůli bezpečnosti a použít ji pouze pokud upravujete nastavení překódování.",
"songsAddedToPlaylist": "1 skladba přidána na seznam skladeb ||| %{smart_count} skladeb přidáno na seznam skladeb",
"songsAddedToPlaylist": "1 skladba přidána na seznam skladeb |||| %{smart_count} skladeb přidáno na seznam skladeb",
"noPlaylistsAvailable": "Žádné nejsou dostupné",
"delete_user_title": "Odstranit uživatele '%{name}'",
"delete_user_content": "Jste si jisti že chcete odstranit tohoto uživatele a všechny jejich data (zahrujicí seznamy skladeb a nastavení)?"

297
resources/i18n/da.json Normal file
View File

@@ -0,0 +1,297 @@
{
"languageName": "Dansk",
"resources": {
"song": {
"name": "Sang |||| Sange",
"fields": {
"albumArtist": "Album kunstner",
"duration": "Varighed",
"trackNumber": "#",
"playCount": "Afspilninger",
"title": "Titel",
"artist": "Kunstner",
"album": "Album navn",
"path": "Fil placering",
"genre": "Genre",
"compilation": "Samling",
"year": "År",
"size": "Fil størrelse",
"updatedAt": "Opdateret den",
"bitRate": "Bitrate",
"discSubtitle": "Plade undernavn",
"starred": "Stjernemarkeret"
},
"actions": {
"addToQueue": "Afspil senere",
"playNow": "Afspil nu",
"addToPlaylist": "Tilføj til afspilningsliste"
}
},
"album": {
"name": "Album |||| Albums",
"fields": {
"albumArtist": "Album kunstner",
"artist": "Kunstner",
"duration": "Varighed",
"songCount": "Sange",
"playCount": "Afspilninger",
"name": "Navn",
"genre": "Genre",
"compilation": "Samling",
"year": "År",
"updatedAt": "Opdateret den"
},
"actions": {
"playAll": "Afspil",
"playNext": "Afspil næste",
"addToQueue": "Afspil senere",
"shuffle": "Bland"
},
"lists": {
"all": "Alle",
"random": "Tilfældig",
"recentlyAdded": "Nyligt tilføjet",
"recentlyPlayed": "Nyligt Afspillet",
"mostPlayed": "Mest Afspillet"
}
},
"artist": {
"name": "Kunstner |||| Kunstnere",
"fields": {
"name": "Navn",
"albumCount": "Antal album",
"songCount": "Antal sange"
}
},
"user": {
"name": "Bruger |||| Brugere",
"fields": {
"userName": "Brugernavn",
"isAdmin": "Er administrator",
"lastLoginAt": "Sidste login",
"updatedAt": "Opdateret den",
"name": "Navn",
"password": "Kodeord",
"createdAt": "Oprettet den"
}
},
"player": {
"name": "Afspiller |||| Afspillere",
"fields": {
"name": "Navn",
"transcodingId": "Omkodning",
"maxBitRate": "Maks. bitrate",
"client": "Klient",
"userName": "Brugernavn",
"lastSeen": "Sidst set"
}
},
"transcoding": {
"name": "Omkodning |||| Omkodninger",
"fields": {
"name": "Navn",
"targetFormat": "Målformat",
"defaultBitRate": "Standard bitrate",
"command": "Kommando"
}
},
"playlist": {
"name": "Afspilningsliste |||| Afspilningslister",
"fields": {
"name": "Navn",
"duration": "Varighed",
"owner": "Ejer",
"public": "Offentlig",
"updatedAt": "Opdateret den",
"createdAt": "Oprettet den",
"songCount": "Sange",
"comment": "Kommentar",
"sync": "Auto-importér",
"path": "Importér fra"
},
"actions": {
"selectPlaylist": "Vælg en afspilningsliste:",
"addNewPlaylist": "Opret \"%{name}\""
}
}
},
"ra": {
"auth": {
"welcome1": "Tak fordi du installerede Navidrome!",
"welcome2": "Opret administrator for at begynde",
"confirmPassword": "Bekræft kodeord",
"buttonCreateAdmin": "Opret administrator",
"auth_check_error": "Venligst login for at fortsætte",
"user_menu": "Profil",
"username": "Brugernavn",
"password": "Password",
"sign_in": "Log ind",
"sign_in_error": "Dit log ind fejlede, prøv igen",
"logout": "Log ud"
},
"validation": {
"invalidChars": "Vær venlig kun at benytte bogstaver og tal",
"passwordDoesNotMatch": "Kodeord er ikke ens",
"required": "Obligatorisk",
"minLength": "Skal være mindst %{min} tegn",
"maxLength": "Skal være max %{max} tegn",
"minValue": "Skal være mindst %{min}",
"maxValue": "Skal være max %{max}",
"number": "Skal være et nummer",
"email": "Skal være en gyldig e-mail-adresse",
"oneOf": "Skal være en af: %{options}",
"regex": "Skal matche et bestemt format (regexp): %{pattern}"
},
"action": {
"add_filter": "Tilføj filter",
"add": "Tilføj",
"back": "Tilbage",
"bulk_actions": "%{smart_count} valgt",
"cancel": "Annuller",
"clear_input_value": "Ryd",
"clone": "Klon",
"confirm": "Bekræft",
"create": "Opret",
"delete": "Slet",
"edit": "Rediger",
"export": "Eksporter",
"list": "Liste",
"refresh": "Opdater",
"remove_filter": "Slet filter",
"remove": "Fjern",
"save": "Gem",
"search": "Søg",
"show": "Vis",
"sort": "Sortér",
"undo": "Fortryd",
"expand": "Udvid",
"close": "Luk",
"open_menu": "Åben menu",
"close_menu": "Luk menu",
"unselect": "Fravælg"
},
"boolean": {
"true": "Ja",
"false": "Nej"
},
"page": {
"create": "Opret %{name}",
"dashboard": "Dashboard",
"edit": "%{name} #%{id}",
"error": "Noget gik galt",
"list": "%{name} liste",
"loading": "Henter",
"not_found": "Ikke fundet",
"show": "%{name} #%{id}",
"empty": "Ingen %{name} endnu",
"invite": "Vil du tilføje en?"
},
"input": {
"file": {
"upload_several": "Træk og slip filer for at uploade, eller klik for at vælge filer.",
"upload_single": "Træk og slip en fil for at uploade, eller klik for at vælge en fil."
},
"image": {
"upload_several": "Træk og slip filer for at uploade, eller klik for at vælge filer.",
"upload_single": "Træk og slip et billede for at uploade, eller klik for at vælge en fil."
},
"references": {
"all_missing": "Kan ikke finde nogle referencedata.",
"many_missing": "Mindst en af de tilknyttede referencer synes ikke længere at være tilgængelig.",
"single_missing": "Tilknyttede referencer synes ikke længere at være tilgængelige."
},
"password": {
"toggle_visible": "Skjul kodeord",
"toggle_hidden": "Vis kodeord"
}
},
"message": {
"about": "Om",
"are_you_sure": "Er du sikker?",
"bulk_delete_content": "Er du sikker på du vil slette %{name}? |||| Er du sikker på du ville slette %{smart_count} poster?",
"bulk_delete_title": "Slet %{name} |||| Sletter %{smart_count} %{name} poster",
"delete_content": "Er du sikker på du ville slette denne post?",
"delete_title": "Slet %{name} #%{id}",
"details": "Detaljer",
"error": "Der opstod en klientfejl, og din forespørgsel kunne ikke udføres.",
"invalid_form": "Formularen er ikke gyldig. Kontroller for fejl",
"loading": "Siden indlæses, Vent et øjeblik",
"no": "Nej",
"not_found": "Enten har du skrevet en forkert URL eller du har fulgt et invalidt link.",
"yes": "Ja",
"unsaved_changes": "Du har lavet ændringer der ikke er gemt. Er du sikker på at du vil ignorere dem?"
},
"navigation": {
"no_results": "Ingen resultater fundet",
"no_more_results": "Sidenummeret %{page} eksistere ikke. Gå tilbage til forrige side.",
"page_out_of_boundaries": "Sidenummeret %{page} eksistere ikke",
"page_out_from_end": "Der findes ikke flere sider",
"page_out_from_begin": "Der er ingen side før end side 1",
"page_range_info": "%{offsetBegin}-%{offsetEnd} af %{total}",
"page_rows_per_page": "Rækker pr. side:",
"next": "Næste",
"prev": "Forrige"
},
"notification": {
"updated": "Objekt opdateret |||| %{smart_count} objekter opdateret",
"created": "Objekt oprettet",
"deleted": "Objekt slettet |||| %{smart_count} objekter slettet",
"bad_item": "Incorrect element",
"item_doesnt_exist": "Objektet findes ikke",
"http_error": "Kommunikationsfejl med serveren",
"data_provider_error": "dataProvider fejl. Check din console for detaljer.",
"i18n_error": "Kan ikke indlæse oversættelse af det ønskede sprog",
"canceled": "Handling blev annulleret",
"logged_out": "Din session er udløbet, venligst tilslut igen"
}
},
"message": {
"note": "NOTE",
"transcodingDisabled": "Skift af indstillinger for omkodning gennem web platformen er frakoblet af sikkerhedsgrunde. Genstart serveren med %{config} indstilling tilvalgt.",
"transcodingEnabled": "Navidrome kører i øjeblikket med %{config}, hvilket gør det muligt at køre system kommandoer fra web platformen. Vi anbefaler at slå det fra af sikkerhedsgrunde og kun slå det til ved indstilling af omkodning.",
"songsAddedToPlaylist": "Tilføjede 1 sang til afspilningsliste |||| Tilføjede %{smart_count} sange til afspilningsliste",
"noPlaylistsAvailable": "Ingen tilgængelige",
"delete_user_title": "Slet bruger '%{name}'",
"delete_user_content": "Er du sikker på at du vil slette denne bruger og tilhørende data (inklusive afspilningslister og indstillinger)?"
},
"menu": {
"library": "Bibliotek",
"settings": "Indstillinger",
"version": "Version %{version}",
"theme": "Tema",
"personal": {
"name": "Personligt",
"options": {
"theme": "Tema",
"language": "Sprog",
"defaultView": "Standardopsætning"
}
},
"albumList": "Albums"
},
"player": {
"playListsText": "Afspilnings kø",
"openText": "Åben",
"closeText": "Luk",
"notContentText": "Ingen musik",
"clickToPlayText": "Tryk for at afspille",
"clickToPauseText": "Tryk for at pause",
"nextTrackText": "Næste nummer",
"previousTrackText": "Forrige nummer",
"reloadText": "Genindlæs",
"volumeText": "Lydstyrke",
"toggleLyricText": "Skift sangtekst",
"toggleMiniModeText": "Minimer",
"destroyText": "Fjern",
"downloadText": "Hent",
"removeAudioListsText": "Slet afspillingsliste",
"clickToDeleteText": "Tryk for at slette %{name}",
"emptyLyricText": "Ingen sangtekst",
"playModeText": {
"order": "I rækkefølge",
"orderLoop": "Gentag",
"singleLoop": "Gentag enkelt",
"shufflePlay": "Bland"
}
}
}

View File

@@ -45,6 +45,13 @@
"playNext": "Tocar em seguida",
"addToQueue": "Adicionar à fila",
"shuffle": "Aleatório"
},
"lists": {
"all": "Todos",
"random": "Aleatório",
"recentlyAdded": "Recém-adicionados",
"recentlyPlayed": "Recém-tocados",
"mostPlayed": "Mais tocados"
}
},
"artist": {
@@ -247,6 +254,7 @@
"delete_user_content": "Você tem certeza que deseja excluir o usuário e todos os seus dados (incluindo suas playlists e preferências)?"
},
"menu": {
"albumList": "Álbuns",
"library": "Biblioteca",
"settings": "Configurações",
"version": "Versão %{version}",
@@ -255,7 +263,8 @@
"name": "Pessoal",
"options": {
"theme": "Tema",
"language": "Língua"
"language": "Língua",
"defaultView": "Tela inicial"
}
}
},

View File

@@ -1,128 +0,0 @@
package scanner
import (
"context"
"io/ioutil"
"os"
"path/filepath"
"time"
"github.com/deluan/navidrome/log"
)
type dirInfo struct {
mdate time.Time
maybe bool
}
type dirInfoMap map[string]dirInfo
type changeDetector struct {
rootFolder string
dirMap dirInfoMap
}
func newChangeDetector(rootFolder string) *changeDetector {
return &changeDetector{
rootFolder: rootFolder,
dirMap: dirInfoMap{},
}
}
func (s *changeDetector) Scan(ctx context.Context, lastModifiedSince time.Time) (changed []string, deleted []string, err error) {
start := time.Now()
newMap := make(dirInfoMap)
err = s.loadMap(ctx, newMap, s.rootFolder, lastModifiedSince, false)
if err != nil {
return
}
changed, deleted, err = s.checkForUpdates(lastModifiedSince, newMap)
if err != nil {
return
}
elapsed := time.Since(start)
log.Trace(ctx, "Folder analysis complete", "total", len(newMap), "changed", len(changed), "deleted", len(deleted), "elapsed", elapsed)
s.dirMap = newMap
return
}
func (s *changeDetector) loadDir(ctx context.Context, dirPath string) (children []string, lastUpdated time.Time, err error) {
dirInfo, err := os.Stat(dirPath)
if err != nil {
log.Error(ctx, "Error stating dir", "path", dirPath, err)
return
}
lastUpdated = dirInfo.ModTime()
files, err := ioutil.ReadDir(dirPath)
if err != nil {
log.Error(ctx, "Error reading dir", "path", dirPath, err)
return
}
for _, f := range files {
isDir, err := isDirOrSymlinkToDir(dirPath, f)
// Skip invalid symlinks
if err != nil {
continue
}
if isDir && !isDirIgnored(dirPath, f) && isDirReadable(dirPath, f) {
children = append(children, filepath.Join(dirPath, f.Name()))
} else {
if f.ModTime().After(lastUpdated) {
lastUpdated = f.ModTime()
}
}
}
return
}
func (s *changeDetector) loadMap(ctx context.Context, dirMap dirInfoMap, path string, since time.Time, maybe bool) error {
children, lastUpdated, err := s.loadDir(ctx, path)
if err != nil {
return err
}
maybe = maybe || lastUpdated.After(since)
for _, c := range children {
err := s.loadMap(ctx, dirMap, c, since, maybe)
if err != nil {
return err
}
}
dir := s.getRelativePath(path)
dirMap[dir] = dirInfo{mdate: lastUpdated, maybe: maybe}
return nil
}
func (s *changeDetector) getRelativePath(subFolder string) string {
dir, _ := filepath.Rel(s.rootFolder, subFolder)
if dir == "" {
dir = "."
}
return dir
}
func (s *changeDetector) checkForUpdates(lastModifiedSince time.Time, newMap dirInfoMap) (changed []string, deleted []string, err error) {
for dir, newEntry := range newMap {
lastUpdated := newEntry.mdate
oldLastUpdated := lastModifiedSince
if oldEntry, ok := s.dirMap[dir]; ok {
oldLastUpdated = oldEntry.mdate
} else {
if newEntry.maybe {
oldLastUpdated = time.Time{}
}
}
if lastUpdated.After(oldLastUpdated) {
changed = append(changed, dir)
}
}
for dir := range s.dirMap {
if _, ok := newMap[dir]; !ok {
deleted = append(deleted, dir)
}
}
return
}

View File

@@ -1,152 +0,0 @@
package scanner
import (
"context"
"io/ioutil"
"os"
"path/filepath"
"time"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("changeDetector", func() {
var testFolder string
var scanner *changeDetector
lastModifiedSince := time.Time{}
BeforeEach(func() {
testFolder, _ = ioutil.TempDir("", "navidrome_tests")
err := os.MkdirAll(testFolder, 0777)
if err != nil {
panic(err)
}
scanner = newChangeDetector(testFolder)
})
It("detects changes recursively", func() {
// Scan empty folder
changed, deleted, err := scanner.Scan(context.TODO(), lastModifiedSince)
Expect(err).To(BeNil())
Expect(deleted).To(BeEmpty())
Expect(changed).To(ConsistOf("."))
// Add one subfolder
lastModifiedSince = nowWithDelay()
err = os.MkdirAll(filepath.Join(testFolder, "a"), 0777)
if err != nil {
panic(err)
}
changed, deleted, err = scanner.Scan(context.TODO(), lastModifiedSince)
Expect(err).To(BeNil())
Expect(deleted).To(BeEmpty())
Expect(changed).To(ConsistOf(".", P("a")))
// Add more subfolders
lastModifiedSince = nowWithDelay()
err = os.MkdirAll(filepath.Join(testFolder, "a", "b", "c"), 0777)
if err != nil {
panic(err)
}
changed, deleted, err = scanner.Scan(context.TODO(), lastModifiedSince)
Expect(err).To(BeNil())
Expect(deleted).To(BeEmpty())
Expect(changed).To(ConsistOf(P("a"), P("a/b"), P("a/b/c")))
// Scan with no changes
lastModifiedSince = nowWithDelay()
changed, deleted, err = scanner.Scan(context.TODO(), lastModifiedSince)
Expect(err).To(BeNil())
Expect(deleted).To(BeEmpty())
Expect(changed).To(BeEmpty())
// New file in subfolder
lastModifiedSince = nowWithDelay()
_, err = os.Create(filepath.Join(testFolder, "a", "b", "empty.txt"))
if err != nil {
panic(err)
}
changed, deleted, err = scanner.Scan(context.TODO(), lastModifiedSince)
Expect(err).To(BeNil())
Expect(deleted).To(BeEmpty())
Expect(changed).To(ConsistOf(P("a/b")))
// Delete file in subfolder
lastModifiedSince = nowWithDelay()
err = os.Remove(filepath.Join(testFolder, "a", "b", "empty.txt"))
if err != nil {
panic(err)
}
changed, deleted, err = scanner.Scan(context.TODO(), lastModifiedSince)
Expect(err).To(BeNil())
Expect(deleted).To(BeEmpty())
Expect(changed).To(ConsistOf(P("a/b")))
// Delete subfolder
lastModifiedSince = nowWithDelay()
err = os.Remove(filepath.Join(testFolder, "a", "b", "c"))
if err != nil {
panic(err)
}
changed, deleted, err = scanner.Scan(context.TODO(), lastModifiedSince)
Expect(err).To(BeNil())
Expect(deleted).To(ConsistOf(P("a/b/c")))
Expect(changed).To(ConsistOf(P("a/b")))
// Only returns changes after lastModifiedSince
lastModifiedSince = nowWithDelay()
newScanner := newChangeDetector(testFolder)
changed, deleted, err = newScanner.Scan(context.TODO(), lastModifiedSince)
Expect(err).To(BeNil())
Expect(deleted).To(BeEmpty())
Expect(changed).To(BeEmpty())
Expect(changed).To(BeEmpty())
f, _ := os.Create(filepath.Join(testFolder, "a", "b", "new.txt"))
_ = f.Close()
changed, deleted, err = newScanner.Scan(context.TODO(), lastModifiedSince)
Expect(err).To(BeNil())
Expect(deleted).To(BeEmpty())
Expect(changed).To(ConsistOf(P("a/b")))
})
Describe("isDirOrSymlinkToDir", func() {
It("returns true for normal dirs", func() {
dir, _ := os.Stat("tests/fixtures")
Expect(isDirOrSymlinkToDir("tests", dir)).To(BeTrue())
})
It("returns true for symlinks to dirs", func() {
dir, _ := os.Stat("tests/fixtures/symlink2dir")
Expect(isDirOrSymlinkToDir("tests/fixtures", dir)).To(BeTrue())
})
It("returns false for files", func() {
dir, _ := os.Stat("tests/fixtures/test.mp3")
Expect(isDirOrSymlinkToDir("tests/fixtures", dir)).To(BeFalse())
})
It("returns false for symlinks to files", func() {
dir, _ := os.Stat("tests/fixtures/symlink")
Expect(isDirOrSymlinkToDir("tests/fixtures", dir)).To(BeFalse())
})
})
Describe("isDirIgnored", func() {
baseDir := filepath.Join("tests", "fixtures")
It("returns false for normal dirs", func() {
dir, _ := os.Stat(filepath.Join(baseDir, "empty_folder"))
Expect(isDirIgnored(baseDir, dir)).To(BeFalse())
})
It("returns true when folder contains .ndignore file", func() {
dir, _ := os.Stat(filepath.Join(baseDir, "ignored_folder"))
Expect(isDirIgnored(baseDir, dir)).To(BeTrue())
})
})
})
// I hate time-based tests....
func nowWithDelay() time.Time {
now := time.Now()
time.Sleep(50 * time.Millisecond)
return now
}

View File

@@ -1,58 +0,0 @@
package scanner
import (
"context"
"fmt"
"github.com/deluan/navidrome/log"
)
const (
// batchSize used for albums/artists updates
batchSize = 5
)
type refreshCallbackFunc = func(ids ...string) error
type flushableMap struct {
ctx context.Context
flushFunc refreshCallbackFunc
entity string
m map[string]struct{}
}
func newFlushableMap(ctx context.Context, entity string, flushFunc refreshCallbackFunc) *flushableMap {
return &flushableMap{
ctx: ctx,
flushFunc: flushFunc,
entity: entity,
m: map[string]struct{}{},
}
}
func (f *flushableMap) update(id string) error {
f.m[id] = struct{}{}
if len(f.m) >= batchSize {
err := f.flush()
if err != nil {
return err
}
}
return nil
}
func (f *flushableMap) flush() error {
if len(f.m) == 0 {
return nil
}
var ids []string
for id := range f.m {
ids = append(ids, id)
delete(f.m, id)
}
if err := f.flushFunc(ids...); err != nil {
log.Error(f.ctx, fmt.Sprintf("Error writing %ss to the DB", f.entity), err)
return err
}
return nil
}

View File

@@ -14,8 +14,10 @@ import (
type (
dirMapValue struct {
modTime time.Time
hasPlaylist bool
modTime time.Time
hasImages bool
hasPlaylist bool
hasAudioFiles bool
}
dirMap = map[string]dirMapValue
)
@@ -72,7 +74,9 @@ func loadDir(ctx context.Context, dirPath string) (children []string, info dirMa
if f.ModTime().After(info.modTime) {
info.modTime = f.ModTime()
}
info.hasImages = info.hasImages || utils.IsImageFile(f.Name())
info.hasPlaylist = info.hasPlaylist || utils.IsPlaylist(f.Name())
info.hasAudioFiles = info.hasAudioFiles || utils.IsAudioFile(f.Name())
}
}
return

42
scanner/load_tree_test.go Normal file
View File

@@ -0,0 +1,42 @@
package scanner
import (
"os"
"path/filepath"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("load_tree", func() {
Describe("isDirOrSymlinkToDir", func() {
It("returns true for normal dirs", func() {
dir, _ := os.Stat("tests/fixtures")
Expect(isDirOrSymlinkToDir("tests", dir)).To(BeTrue())
})
It("returns true for symlinks to dirs", func() {
dir, _ := os.Stat("tests/fixtures/symlink2dir")
Expect(isDirOrSymlinkToDir("tests/fixtures", dir)).To(BeTrue())
})
It("returns false for files", func() {
dir, _ := os.Stat("tests/fixtures/test.mp3")
Expect(isDirOrSymlinkToDir("tests/fixtures", dir)).To(BeFalse())
})
It("returns false for symlinks to files", func() {
dir, _ := os.Stat("tests/fixtures/symlink")
Expect(isDirOrSymlinkToDir("tests/fixtures", dir)).To(BeFalse())
})
})
Describe("isDirIgnored", func() {
baseDir := filepath.Join("tests", "fixtures")
It("returns false for normal dirs", func() {
dir, _ := os.Stat(filepath.Join(baseDir, "empty_folder"))
Expect(isDirIgnored(baseDir, dir)).To(BeFalse())
})
It("returns true when folder contains .ndignore file", func() {
dir, _ := os.Stat(filepath.Join(baseDir, "ignored_folder"))
Expect(isDirIgnored(baseDir, dir)).To(BeTrue())
})
})
})

21
scanner/mapping_test.go Normal file
View File

@@ -0,0 +1,21 @@
package scanner
import (
"github.com/deluan/navidrome/conf"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("mapping", func() {
Describe("sanitizeFieldForSorting", func() {
BeforeEach(func() {
conf.Server.IgnoredArticles = "The"
})
It("sanitize accents", func() {
Expect(sanitizeFieldForSorting("Céu")).To(Equal("Ceu"))
})
It("removes articles", func() {
Expect(sanitizeFieldForSorting("The Beatles")).To(Equal("Beatles"))
})
})
})

60
scanner/refresh_buffer.go Normal file
View File

@@ -0,0 +1,60 @@
package scanner
import (
"context"
"fmt"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
)
type refreshBuffer struct {
ctx context.Context
ds model.DataStore
album map[string]struct{}
artist map[string]struct{}
}
func newRefreshBuffer(ctx context.Context, ds model.DataStore) *refreshBuffer {
return &refreshBuffer{
ctx: ctx,
ds: ds,
album: map[string]struct{}{},
artist: map[string]struct{}{},
}
}
func (f *refreshBuffer) accumulate(mf model.MediaFile) {
f.album[mf.AlbumID] = struct{}{}
f.artist[mf.AlbumArtistID] = struct{}{}
}
type refreshCallbackFunc = func(ids ...string) error
func (f *refreshBuffer) flushMap(m map[string]struct{}, entity string, refresh refreshCallbackFunc) error {
if len(m) == 0 {
return nil
}
var ids []string
for id := range m {
ids = append(ids, id)
delete(m, id)
}
if err := refresh(ids...); err != nil {
log.Error(f.ctx, fmt.Sprintf("Error writing %ss to the DB", entity), err)
return err
}
return nil
}
func (f *refreshBuffer) flush() error {
err := f.flushMap(f.album, "album", f.ds.Album(f.ctx).Refresh)
if err != nil {
return err
}
err = f.flushMap(f.artist, "artist", f.ds.Artist(f.ctx).Refresh)
if err != nil {
return err
}
return nil
}

View File

@@ -7,7 +7,6 @@ import (
"strconv"
"time"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
)
@@ -87,10 +86,7 @@ func (s *Scanner) loadFolders() {
}
func (s *Scanner) newScanner(f model.MediaFolder) FolderScanner {
if conf.Server.DevOldScanner {
return NewTagScanner(f.Path, s.ds)
}
return NewTagScanner2(f.Path, s.ds)
return NewTagScanner(f.Path, s.ds)
}
type Status int

View File

@@ -1,7 +1,6 @@
package scanner
import (
"path/filepath"
"testing"
"github.com/deluan/navidrome/log"
@@ -16,7 +15,3 @@ func TestScanner(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Scanner Suite")
}
func P(path string) string {
return filepath.FromSlash(path)
}

View File

@@ -6,36 +6,32 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/model/request"
"github.com/deluan/navidrome/utils"
)
type TagScanner struct {
rootFolder string
ds model.DataStore
detector *changeDetector
mapper *mediaFileMapper
firstRun sync.Once
plsSync *playlistSync
cnt *counters
}
func NewTagScanner(rootFolder string, ds model.DataStore) *TagScanner {
return &TagScanner{
rootFolder: rootFolder,
ds: ds,
detector: newChangeDetector(rootFolder),
mapper: newMediaFileMapper(rootFolder),
firstRun: sync.Once{},
plsSync: newPlaylistSync(ds),
ds: ds,
}
}
type (
artistMap map[string]struct{}
albumMap map[string]struct{}
counters struct {
added int64
updated int64
@@ -44,113 +40,181 @@ type (
)
const (
// filesBatchSize used for extract file metadata
// filesBatchSize used for batching file metadata extraction
filesBatchSize = 100
)
// Scan algorithm overview:
// For each changed folder: Get all files from DB that starts with the folder, scan each file:
// Scanner algorithm overview:
// Load all directories under the music folder, with their ModTime (self or any non-dir children, whichever is newer)
// Load all directories from the DB
// Compare both collections to find changed folders (based on lastModifiedSince) and deleted folders
// For each deleted folder: delete all files from DB whose path starts with the delete folder path (non-recursively)
// For each changed folder: get all files from DB whose path starts with the changed folder (non-recursively), check each file:
// if file in folder is newer, update the one in DB
// if file in folder does not exists in DB, add
// for each file in the DB that is not found in the folder, delete from DB
// For each deleted folder: delete all files from DB that starts with the folder path
// Only on first run, check if any folder under each changed folder is missing.
// if it is, delete everything under it
// if file in folder does not exists in DB, add it
// for each file in the DB that is not found in the folder, delete it from DB
// Create new albums/artists, update counters:
// collect all albumIDs and artistIDs from previous steps
// refresh the collected albums and artists with the metadata from the mediafiles
// Delete all empty albums, delete all empty Artists
// For each changed folder, process playlists:
// If the playlist is not in the DB, import it, setting sync = true
// If the playlist is in the DB and sync == true, import it, or else skip it
// Delete all empty albums, delete all empty artists, clean-up playlists
func (s *TagScanner) Scan(ctx context.Context, lastModifiedSince time.Time) error {
start := time.Now()
log.Trace(ctx, "Looking for changes in music folder", "folder", s.rootFolder)
ctx = s.withAdminUser(ctx)
changed, deleted, err := s.detector.Scan(ctx, lastModifiedSince)
start := time.Now()
allFSDirs, err := s.getDirTree(ctx)
if err != nil {
return err
}
if len(changed)+len(deleted) == 0 {
allDBDirs, err := s.getDBDirTree(ctx)
if err != nil {
return err
}
changedDirs := s.getChangedDirs(ctx, allFSDirs, allDBDirs, lastModifiedSince)
deletedDirs := s.getDeletedDirs(ctx, allFSDirs, allDBDirs)
if len(changedDirs)+len(deletedDirs) == 0 {
log.Debug(ctx, "No changes found in Music Folder", "folder", s.rootFolder)
return nil
}
if log.CurrentLevel() >= log.LevelTrace {
log.Info(ctx, "Folder changes found", "numChanged", len(changed), "numDeleted", len(deleted),
"changed", strings.Join(changed, ";"), "deleted", strings.Join(deleted, ";"))
log.Info(ctx, "Folder changes detected", "changedFolders", len(changedDirs), "deletedFolders", len(deletedDirs),
"changed", strings.Join(changedDirs, ";"), "deleted", strings.Join(deletedDirs, ";"))
} else {
log.Info(ctx, "Folder changes found", "numChanged", len(changed), "numDeleted", len(deleted))
log.Info(ctx, "Folder changes detected", "changedFolders", len(changedDirs), "deletedFolders", len(deletedDirs))
}
sort.Strings(changed)
sort.Strings(deleted)
s.cnt = &counters{}
updatedArtists := artistMap{}
updatedAlbums := albumMap{}
cnt := &counters{}
for _, c := range changed {
err := s.processChangedDir(ctx, c, updatedArtists, updatedAlbums, cnt)
for _, dir := range deletedDirs {
err := s.processDeletedDir(ctx, dir)
if err != nil {
return err
log.Error("Error removing deleted folder from DB", "path", dir, err)
}
// TODO Search for playlists and import (with `sync` on)
}
for _, c := range deleted {
err := s.processDeletedDir(ctx, c, updatedArtists, updatedAlbums, cnt)
for _, dir := range changedDirs {
err := s.processChangedDir(ctx, dir)
if err != nil {
return err
log.Error("Error updating folder in the DB", "path", dir, err)
}
// TODO "Un-sync" all playlists synched from a deleted folder
}
err = s.flushAlbums(ctx, updatedAlbums)
if err != nil {
return err
// Now that all mediafiles are imported/updated, search for and import playlists
u, _ := request.UserFrom(ctx)
plsCount := 0
for _, dir := range changedDirs {
info := allFSDirs[dir]
if info.hasPlaylist {
if !u.IsAdmin {
log.Warn("Playlists will not be imported, as there are no admin users yet, "+
"Please create an admin user first, and then update the playlists for them to be imported", "dir", dir)
} else {
plsCount = s.plsSync.processPlaylists(ctx, dir)
}
}
}
err = s.flushArtists(ctx, updatedArtists)
if err != nil {
return err
}
s.firstRun.Do(func() {
s.removeDeletedFolders(context.TODO(), changed, cnt)
})
err = s.ds.GC(log.NewContext(context.TODO()))
log.Info("Finished Music Folder", "folder", s.rootFolder, "elapsed", time.Since(start),
"added", cnt.added, "updated", cnt.updated, "deleted", cnt.deleted)
err = s.ds.GC(log.NewContext(ctx))
log.Info("Finished processing Music Folder", "folder", s.rootFolder, "elapsed", time.Since(start),
"added", s.cnt.added, "updated", s.cnt.updated, "deleted", s.cnt.deleted, "playlistsImported", plsCount)
return err
}
func (s *TagScanner) flushAlbums(ctx context.Context, updatedAlbums albumMap) error {
if len(updatedAlbums) == 0 {
return nil
}
var ids []string
for id := range updatedAlbums {
ids = append(ids, id)
delete(updatedAlbums, id)
}
return s.ds.Album(ctx).Refresh(ids...)
}
func (s *TagScanner) flushArtists(ctx context.Context, updatedArtists artistMap) error {
if len(updatedArtists) == 0 {
return nil
}
var ids []string
for id := range updatedArtists {
ids = append(ids, id)
delete(updatedArtists, id)
}
return s.ds.Artist(ctx).Refresh(ids...)
}
func (s *TagScanner) processChangedDir(ctx context.Context, dir string, updatedArtists artistMap, updatedAlbums albumMap, cnt *counters) error {
dir = filepath.Join(s.rootFolder, dir)
func (s *TagScanner) getDirTree(ctx context.Context) (dirMap, error) {
start := time.Now()
log.Trace(ctx, "Loading directory tree from music folder", "folder", s.rootFolder)
dirs, err := loadDirTree(ctx, s.rootFolder)
if err != nil {
return nil, err
}
log.Debug("Directory tree loaded from music folder", "total", len(dirs), "elapsed", time.Since(start))
return dirs, nil
}
func (s *TagScanner) getDBDirTree(ctx context.Context) (map[string]struct{}, error) {
start := time.Now()
log.Trace(ctx, "Loading directory tree from database", "folder", s.rootFolder)
repo := s.ds.MediaFile(ctx)
dirs, err := repo.FindPathsRecursively(s.rootFolder)
if err != nil {
return nil, err
}
resp := map[string]struct{}{}
for _, d := range dirs {
resp[filepath.Clean(d)] = struct{}{}
}
log.Debug("Directory tree loaded from DB", "total", len(resp), "elapsed", time.Since(start))
return resp, nil
}
func (s *TagScanner) getChangedDirs(ctx context.Context, fsDirs dirMap, dbDirs map[string]struct{}, lastModified time.Time) []string {
start := time.Now()
log.Trace(ctx, "Checking for changed folders")
var changed []string
for d, info := range fsDirs {
_, inDB := dbDirs[d]
if (!inDB && (info.hasAudioFiles)) || info.modTime.After(lastModified) {
changed = append(changed, d)
}
}
sort.Strings(changed)
log.Debug(ctx, "Finished changed folders check", "total", len(changed), "elapsed", time.Since(start))
return changed
}
func (s *TagScanner) getDeletedDirs(ctx context.Context, fsDirs dirMap, dbDirs map[string]struct{}) []string {
start := time.Now()
log.Trace(ctx, "Checking for deleted folders")
var deleted []string
for d := range dbDirs {
if _, ok := fsDirs[d]; !ok {
deleted = append(deleted, d)
}
}
sort.Strings(deleted)
log.Debug(ctx, "Finished deleted folders check", "total", len(deleted), "elapsed", time.Since(start))
return deleted
}
func (s *TagScanner) processDeletedDir(ctx context.Context, dir string) error {
start := time.Now()
buffer := newRefreshBuffer(ctx, s.ds)
mfs, err := s.ds.MediaFile(ctx).FindAllByPath(dir)
if err != nil {
return err
}
c, err := s.ds.MediaFile(ctx).DeleteByPath(dir)
if err != nil {
return err
}
s.cnt.deleted += c
for _, t := range mfs {
buffer.accumulate(t)
}
err = buffer.flush()
log.Info(ctx, "Finished processing deleted folder", "path", dir, "purged", len(mfs), "elapsed", time.Since(start))
return err
}
func (s *TagScanner) processChangedDir(ctx context.Context, dir string) error {
start := time.Now()
buffer := newRefreshBuffer(ctx, s.ds)
// Load folder's current tracks from DB into a map
currentTracks := map[string]model.MediaFile{}
@@ -163,7 +227,7 @@ func (s *TagScanner) processChangedDir(ctx context.Context, dir string, updatedA
}
// Load tracks FileInfo from the folder
files, err := LoadAllAudioFiles(dir)
files, err := loadAllAudioFiles(dir)
if err != nil {
return err
}
@@ -173,159 +237,102 @@ func (s *TagScanner) processChangedDir(ctx context.Context, dir string, updatedA
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))
orphanTracks := map[string]model.MediaFile{}
for k, v := range currentTracks {
orphanTracks[k] = v
}
// If track from folder is newer than the one in DB, select for update/insert in DB
log.Trace(ctx, "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 {
filesToUpdate = append(filesToUpdate, filePath)
cnt.added++
s.cnt.added++
}
if ok && info.ModTime().After(c.UpdatedAt) {
filesToUpdate = append(filesToUpdate, filePath)
cnt.updated++
s.cnt.updated++
}
delete(currentTracks, filePath)
// Force a refresh of the album and artist, to cater for cover art files. Ideally we would only do this
// if there are any image file in the folder (TODO)
err = s.updateAlbum(ctx, c.AlbumID, updatedAlbums)
if err != nil {
return err
}
err = s.updateArtist(ctx, c.AlbumArtistID, updatedArtists)
if err != nil {
return err
}
// Force a refresh of the album and artist, to cater for cover art files
buffer.accumulate(c)
// Only leaves in orphanTracks the ones not found in the folder. After this loop any remaining orphanTracks
// are considered gone from the music folder and will be deleted from DB
delete(orphanTracks, filePath)
}
numUpdatedTracks := 0
numPurgedTracks := 0
if len(filesToUpdate) > 0 {
// Break the file list in chunks to avoid calling ffmpeg with too many parameters
chunks := utils.BreakUpStringSlice(filesToUpdate, filesBatchSize)
for _, chunk := range chunks {
// Load tracks Metadata from the folder
newTracks, err := s.loadTracks(chunk)
if err != nil {
return err
}
// If track from folder is newer than the one in DB, update/insert in DB
log.Trace("Updating mediaFiles in DB", "dir", dir, "files", chunk, "numFiles", len(chunk))
for i := range newTracks {
n := newTracks[i]
err := s.ds.MediaFile(ctx).Put(&n)
if err != nil {
return err
}
err = s.updateAlbum(ctx, n.AlbumID, updatedAlbums)
if err != nil {
return err
}
err = s.updateArtist(ctx, n.AlbumArtistID, updatedArtists)
if err != nil {
return err
}
numUpdatedTracks++
}
}
}
if len(currentTracks) > 0 {
log.Trace("Deleting dangling tracks from DB", "dir", dir, "numTracks", len(currentTracks))
// Remaining tracks from DB that are not in the folder are deleted
for _, ct := range currentTracks {
numPurgedTracks++
err = s.updateAlbum(ctx, ct.AlbumID, updatedAlbums)
if err != nil {
return err
}
err = s.updateArtist(ctx, ct.AlbumArtistID, updatedArtists)
if err != nil {
return err
}
if err := s.ds.MediaFile(ctx).Delete(ct.ID); err != nil {
return err
}
cnt.deleted++
}
}
log.Info("Finished processing changed folder", "dir", dir, "updated", numUpdatedTracks, "purged", numPurgedTracks, "elapsed", time.Since(start))
return nil
}
func (s *TagScanner) updateAlbum(ctx context.Context, albumId string, updatedAlbums albumMap) error {
updatedAlbums[albumId] = struct{}{}
if len(updatedAlbums) >= batchSize {
err := s.flushAlbums(ctx, updatedAlbums)
if err != nil {
return err
}
}
return nil
}
func (s *TagScanner) updateArtist(ctx context.Context, artistId string, updatedArtists artistMap) error {
updatedArtists[artistId] = struct{}{}
if len(updatedArtists) >= batchSize {
err := s.flushArtists(ctx, updatedArtists)
if err != nil {
return err
}
}
return nil
}
func (s *TagScanner) processDeletedDir(ctx context.Context, dir string, updatedArtists artistMap, updatedAlbums albumMap, cnt *counters) error {
dir = filepath.Join(s.rootFolder, dir)
start := time.Now()
mfs, err := s.ds.MediaFile(ctx).FindAllByPath(dir)
if err != nil {
return err
}
for _, t := range mfs {
err = s.updateAlbum(ctx, t.AlbumID, updatedAlbums)
if err != nil {
return err
}
err = s.updateArtist(ctx, t.AlbumArtistID, updatedArtists)
numUpdatedTracks, err = s.addOrUpdateTracksInDB(ctx, dir, currentTracks, filesToUpdate, buffer)
if err != nil {
return err
}
}
log.Info("Finished processing deleted folder", "dir", dir, "purged", len(mfs), "elapsed", time.Since(start))
c, err := s.ds.MediaFile(ctx).DeleteByPath(dir)
cnt.deleted += c
if len(orphanTracks) > 0 {
numPurgedTracks, err = s.deleteOrphanSongs(ctx, dir, orphanTracks, buffer)
if err != nil {
return err
}
}
err = buffer.flush()
log.Info(ctx, "Finished processing changed folder", "dir", dir, "updated", numUpdatedTracks,
"purged", numPurgedTracks, "elapsed", time.Since(start))
return err
}
func (s *TagScanner) removeDeletedFolders(ctx context.Context, changed []string, cnt *counters) {
for _, dir := range changed {
fullPath := filepath.Join(s.rootFolder, dir)
paths, err := s.ds.MediaFile(ctx).FindPathsRecursively(fullPath)
func (s *TagScanner) deleteOrphanSongs(ctx context.Context, dir string, tracksToDelete map[string]model.MediaFile, buffer *refreshBuffer) (int, error) {
numPurgedTracks := 0
log.Debug(ctx, "Deleting orphan tracks from DB", "dir", dir, "numTracks", len(tracksToDelete))
// Remaining tracks from DB that are not in the folder are deleted
for _, ct := range tracksToDelete {
numPurgedTracks++
buffer.accumulate(ct)
if err := s.ds.MediaFile(ctx).Delete(ct.ID); err != nil {
return 0, err
}
s.cnt.deleted++
}
return numPurgedTracks, nil
}
func (s *TagScanner) addOrUpdateTracksInDB(ctx context.Context, dir string, currentTracks map[string]model.MediaFile, filesToUpdate []string, buffer *refreshBuffer) (int, error) {
numUpdatedTracks := 0
log.Trace(ctx, "Updating mediaFiles in DB", "dir", dir, "numFiles", len(filesToUpdate))
// Break the file list in chunks to avoid calling ffmpeg with too many parameters
chunks := utils.BreakUpStringSlice(filesToUpdate, filesBatchSize)
for _, chunk := range chunks {
// Load tracks Metadata from the folder
newTracks, err := s.loadTracks(chunk)
if err != nil {
log.Error(ctx, "Error reading paths from DB", "path", dir, err)
return
return 0, err
}
// If a path is unreadable, remove from the DB
for _, path := range paths {
if readable, err := utils.IsDirReadable(path); !readable {
log.Info(ctx, "Path unavailable. Removing tracks from DB", "path", path, err)
c, err := s.ds.MediaFile(ctx).DeleteByPath(path)
if err != nil {
log.Error(ctx, "Error removing MediaFiles from DB", "path", path, err)
}
cnt.deleted += c
// If track from folder is newer than the one in DB, update/insert in DB
log.Trace(ctx, "Updating mediaFiles in DB", "dir", dir, "files", chunk, "numFiles", len(chunk))
for i := range newTracks {
n := newTracks[i]
// Keep current annotations if the track is in the DB
if t, ok := currentTracks[n.Path]; ok {
n.Annotations = t.Annotations
}
err := s.ds.MediaFile(ctx).Put(&n)
if err != nil {
return 0, err
}
buffer.accumulate(n)
numUpdatedTracks++
}
}
return numUpdatedTracks, nil
}
func (s *TagScanner) loadTracks(filePaths []string) (model.MediaFiles, error) {
@@ -342,7 +349,18 @@ func (s *TagScanner) loadTracks(filePaths []string) (model.MediaFiles, error) {
return mfs, nil
}
func LoadAllAudioFiles(dirPath string) (map[string]os.FileInfo, error) {
func (s *TagScanner) withAdminUser(ctx context.Context) context.Context {
u, err := s.ds.User(ctx).FindFirstAdmin()
if err != nil {
log.Warn(ctx, "No admin user found!", err)
u = &model.User{}
}
ctx = request.WithUsername(ctx, u.UserName)
return request.WithUser(ctx, *u)
}
func loadAllAudioFiles(dirPath string) (map[string]os.FileInfo, error) {
dir, err := os.Open(dirPath)
if err != nil {
return nil, err

View File

@@ -1,354 +0,0 @@
package scanner
import (
"context"
"path/filepath"
"sort"
"strings"
"time"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/model/request"
"github.com/deluan/navidrome/utils"
)
type TagScanner2 struct {
rootFolder string
ds model.DataStore
mapper *mediaFileMapper
plsSync *playlistSync
albumMap *flushableMap
artistMap *flushableMap
cnt *counters
}
func NewTagScanner2(rootFolder string, ds model.DataStore) *TagScanner2 {
return &TagScanner2{
rootFolder: rootFolder,
mapper: newMediaFileMapper(rootFolder),
plsSync: newPlaylistSync(ds),
ds: ds,
}
}
// Scan algorithm overview:
// Load all directories under the music folder, with their ModTime (self or any non-dir children, whichever is newer)
// Find changed folders (based on lastModifiedSince) and deleted folders (comparing to the DB)
// For each deleted folder: delete all files from DB whose path starts with the delete folder path (non-recursively)
// For each changed folder: get all files from DB whose path starts with the changed folder (non-recursively), check each file:
// if file in folder is newer, update the one in DB
// if file in folder does not exists in DB, add it
// for each file in the DB that is not found in the folder, delete it from DB
// Create new albums/artists, update counters:
// collect all albumIDs and artistIDs from previous steps
// refresh the collected albums and artists with the metadata from the mediafiles
// For each changed folder, process playlists:
// If the playlist is not in the DB, import it, setting sync = true
// If the playlist is in the DB and sync == true, import it, or else skip it
// Delete all empty albums, delete all empty artists, clean-up playlists
func (s *TagScanner2) Scan(ctx context.Context, lastModifiedSince time.Time) error {
ctx = s.withAdminUser(ctx)
start := time.Now()
allDirs, err := s.getDirTree(ctx)
if err != nil {
return err
}
changedDirs := s.getChangedDirs(ctx, allDirs, lastModifiedSince)
if len(changedDirs) == 0 {
log.Debug(ctx, "No changes found in Music Folder", "folder", s.rootFolder)
return nil
}
deletedDirs, _ := s.getDeletedDirs(ctx, allDirs, changedDirs)
if log.CurrentLevel() >= log.LevelTrace {
log.Info(ctx, "Folder changes detected", "changedFolders", len(changedDirs), "deletedFolders", len(deletedDirs),
"changed", strings.Join(changedDirs, ";"), "deleted", strings.Join(deletedDirs, ";"))
} else {
log.Info(ctx, "Folder changes detected", "changedFolders", len(changedDirs), "deletedFolders", len(deletedDirs))
}
s.albumMap = newFlushableMap(ctx, "album", s.ds.Album(ctx).Refresh)
s.artistMap = newFlushableMap(ctx, "artist", s.ds.Artist(ctx).Refresh)
s.cnt = &counters{}
for _, dir := range deletedDirs {
err := s.processDeletedDir(ctx, dir)
if err != nil {
log.Error("Error removing deleted folder from DB", "path", dir, err)
}
}
for _, dir := range changedDirs {
err := s.processChangedDir(ctx, dir)
if err != nil {
log.Error("Error updating folder in the DB", "path", dir, err)
}
}
_ = s.albumMap.flush()
_ = s.artistMap.flush()
// Now that all mediafiles are imported/updated, search for and import playlists
u, _ := request.UserFrom(ctx)
plsCount := 0
for _, dir := range changedDirs {
info := allDirs[dir]
if info.hasPlaylist {
if !u.IsAdmin {
log.Warn("Playlists will not be imported, as there are no admin users yet, "+
"Please create an admin user first, and then update the playlists for them to be imported", "dir", dir)
} else {
plsCount = s.plsSync.processPlaylists(ctx, dir)
}
}
}
err = s.ds.GC(log.NewContext(ctx))
log.Info("Finished processing Music Folder", "folder", s.rootFolder, "elapsed", time.Since(start),
"added", s.cnt.added, "updated", s.cnt.updated, "deleted", s.cnt.deleted, "playlistsImported", plsCount)
return err
}
func (s *TagScanner2) getDirTree(ctx context.Context) (dirMap, error) {
start := time.Now()
log.Trace(ctx, "Loading directory tree from music folder", "folder", s.rootFolder)
dirs, err := loadDirTree(ctx, s.rootFolder)
if err != nil {
return nil, err
}
log.Debug("Directory tree loaded", "total", len(dirs), "elapsed", time.Since(start))
return dirs, nil
}
func (s *TagScanner2) getChangedDirs(ctx context.Context, dirs dirMap, lastModified time.Time) []string {
start := time.Now()
log.Trace(ctx, "Checking for changed folders")
var changed []string
for d, info := range dirs {
if info.modTime.After(lastModified) {
changed = append(changed, d)
}
}
sort.Strings(changed)
log.Debug(ctx, "Finished changed folders check", "total", len(changed), "elapsed", time.Since(start))
return changed
}
func (s *TagScanner2) getDeletedDirs(ctx context.Context, allDirs dirMap, changedDirs []string) ([]string, error) {
start := time.Now()
log.Trace(ctx, "Checking for deleted folders")
var deleted []string
repo := s.ds.MediaFile(ctx)
// If rootFolder is in the list of changedDirs, optimize and only do one query to the DB
var foldersToCheck []string
if utils.StringInSlice(s.rootFolder, changedDirs) {
foldersToCheck = []string{s.rootFolder}
} else {
foldersToCheck = changedDirs
}
for _, changedDir := range foldersToCheck {
dirs, err := repo.FindPathsRecursively(changedDir)
if err != nil {
log.Error("Error getting subfolders from DB", "path", changedDir, err)
continue
}
for _, d := range dirs {
d := filepath.Clean(d)
if _, ok := allDirs[d]; !ok {
deleted = append(deleted, d)
}
}
}
sort.Strings(deleted)
log.Debug(ctx, "Finished deleted folders check", "total", len(deleted), "elapsed", time.Since(start))
return deleted, nil
}
func (s *TagScanner2) processDeletedDir(ctx context.Context, dir string) error {
start := time.Now()
mfs, err := s.ds.MediaFile(ctx).FindAllByPath(dir)
if err != nil {
return err
}
for _, t := range mfs {
err = s.albumMap.update(t.AlbumID)
if err != nil {
return err
}
err = s.artistMap.update(t.AlbumArtistID)
if err != nil {
return err
}
}
log.Info(ctx, "Finished processing deleted folder", "path", dir, "purged", len(mfs), "elapsed", time.Since(start))
c, err := s.ds.MediaFile(ctx).DeleteByPath(dir)
s.cnt.deleted += c
return err
}
func (s *TagScanner2) processChangedDir(ctx context.Context, dir string) error {
start := time.Now()
// Load folder's current tracks from DB into a map
currentTracks := map[string]model.MediaFile{}
ct, err := s.ds.MediaFile(ctx).FindAllByPath(dir)
if err != nil {
return err
}
for _, t := range ct {
currentTracks[t.Path] = t
}
// Load tracks FileInfo from the folder
files, err := LoadAllAudioFiles(dir)
if err != nil {
return err
}
// 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
log.Trace(ctx, "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 {
filesToUpdate = append(filesToUpdate, filePath)
s.cnt.added++
}
if ok && info.ModTime().After(c.UpdatedAt) {
filesToUpdate = append(filesToUpdate, filePath)
s.cnt.updated++
}
// Force a refresh of the album and artist, to cater for cover art files
err = s.albumMap.update(c.AlbumID)
if err != nil {
return err
}
err = s.artistMap.update(c.AlbumArtistID)
if err != nil {
return err
}
// Remove it from currentTracks (the ones found in DB). After this loop any currentTracks remaining
// are considered gone from the music folder and will be deleted from DB
delete(currentTracks, filePath)
}
numUpdatedTracks := 0
numPurgedTracks := 0
if len(filesToUpdate) > 0 {
numUpdatedTracks, err = s.addOrUpdateTracksInDB(ctx, dir, filesToUpdate)
if err != nil {
return err
}
}
if len(currentTracks) > 0 {
numPurgedTracks, err = s.deleteOrphanSongs(ctx, dir, currentTracks)
if err != nil {
return err
}
}
log.Info(ctx, "Finished processing changed folder", "dir", dir, "updated", numUpdatedTracks,
"purged", numPurgedTracks, "elapsed", time.Since(start))
return nil
}
func (s *TagScanner2) deleteOrphanSongs(ctx context.Context, dir string, tracksToDelete map[string]model.MediaFile) (int, error) {
numPurgedTracks := 0
log.Debug(ctx, "Deleting orphan tracks from DB", "dir", dir, "numTracks", len(tracksToDelete))
// Remaining tracks from DB that are not in the folder are deleted
for _, ct := range tracksToDelete {
numPurgedTracks++
err := s.albumMap.update(ct.AlbumID)
if err != nil {
return 0, err
}
err = s.artistMap.update(ct.AlbumArtistID)
if err != nil {
return 0, err
}
if err := s.ds.MediaFile(ctx).Delete(ct.ID); err != nil {
return 0, err
}
s.cnt.deleted++
}
return numPurgedTracks, nil
}
func (s *TagScanner2) addOrUpdateTracksInDB(ctx context.Context, dir string, filesToUpdate []string) (int, error) {
numUpdatedTracks := 0
log.Trace(ctx, "Updating mediaFiles in DB", "dir", dir, "numFiles", len(filesToUpdate))
// Break the file list in chunks to avoid calling ffmpeg with too many parameters
chunks := utils.BreakUpStringSlice(filesToUpdate, filesBatchSize)
for _, chunk := range chunks {
// Load tracks Metadata from the folder
newTracks, err := s.loadTracks(chunk)
if err != nil {
return 0, err
}
// If track from folder is newer than the one in DB, update/insert in DB
log.Trace(ctx, "Updating mediaFiles in DB", "dir", dir, "files", chunk, "numFiles", len(chunk))
for i := range newTracks {
n := newTracks[i]
err := s.ds.MediaFile(ctx).Put(&n)
if err != nil {
return 0, err
}
err = s.albumMap.update(n.AlbumID)
if err != nil {
return 0, err
}
err = s.artistMap.update(n.AlbumArtistID)
if err != nil {
return 0, err
}
numUpdatedTracks++
}
}
return numUpdatedTracks, nil
}
func (s *TagScanner2) 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.mapper.toMediaFile(md)
mfs = append(mfs, mf)
}
return mfs, nil
}
func (s *TagScanner2) withAdminUser(ctx context.Context) context.Context {
u, err := s.ds.User(ctx).FindFirstAdmin()
if err != nil {
log.Warn(ctx, "No admin user found!", err)
u = &model.User{}
}
ctx = request.WithUsername(ctx, u.UserName)
return request.WithUser(ctx, *u)
}

View File

@@ -1,27 +1,14 @@
package scanner
import (
"github.com/deluan/navidrome/conf"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("TagScanner", func() {
Describe("sanitizeFieldForSorting", func() {
BeforeEach(func() {
conf.Server.IgnoredArticles = "The"
})
It("sanitize accents", func() {
Expect(sanitizeFieldForSorting("Céu")).To(Equal("Ceu"))
})
It("removes articles", func() {
Expect(sanitizeFieldForSorting("The Beatles")).To(Equal("Beatles"))
})
})
Describe("LoadAllAudioFiles", func() {
Describe("loadAllAudioFiles", func() {
It("return all audio files from the folder", func() {
files, err := LoadAllAudioFiles("tests/fixtures")
files, err := loadAllAudioFiles("tests/fixtures")
Expect(err).ToNot(HaveOccurred())
Expect(files).To(HaveLen(3))
Expect(files).To(HaveKey("tests/fixtures/test.ogg"))
@@ -30,12 +17,12 @@ var _ = Describe("TagScanner", func() {
Expect(files).ToNot(HaveKey("tests/fixtures/playlist.m3u"))
})
It("returns error if path does not exist", func() {
_, err := LoadAllAudioFiles("./INVALID/PATH")
_, err := loadAllAudioFiles("./INVALID/PATH")
Expect(err).To(HaveOccurred())
})
It("returns empty map if there are no audio files in path", func() {
Expect(LoadAllAudioFiles("tests/fixtures/empty_folder")).To(BeEmpty())
Expect(loadAllAudioFiles("tests/fixtures/empty_folder")).To(BeEmpty())
})
})
})

View File

@@ -21,10 +21,10 @@ func ServeIndex(ds model.DataStore, fs http.FileSystem) http.HandlerFunc {
c, err := ds.User(r.Context()).CountAll()
firstTime := c == 0 && err == nil
t := getIndexTemplate(r, fs)
t, err := getIndexTemplate(r, fs)
if err != nil {
log.Error("Error loading default English translation file", err)
http.NotFound(w, r)
return
}
appConfig := map[string]interface{}{
"version": consts.Version(),
@@ -43,9 +43,13 @@ func ServeIndex(ds model.DataStore, fs http.FileSystem) http.HandlerFunc {
}
log.Debug("UI configuration", "appConfig", appConfig)
version := consts.Version()
if version != "dev" {
version = "v" + version
}
data := map[string]interface{}{
"AppConfig": string(j),
"Version": consts.Version(),
"Version": version,
}
err = t.Execute(w, data)
if err != nil {
@@ -54,19 +58,22 @@ func ServeIndex(ds model.DataStore, fs http.FileSystem) http.HandlerFunc {
}
}
func getIndexTemplate(r *http.Request, fs http.FileSystem) *template.Template {
func getIndexTemplate(r *http.Request, fs http.FileSystem) (*template.Template, error) {
t := template.New("initial state")
indexHtml, err := fs.Open("index.html")
if err != nil {
log.Error(r, "Could not find `index.html` template", err)
return nil, err
}
indexStr, err := ioutil.ReadAll(indexHtml)
if err != nil {
log.Error(r, "Could not read from `index.html`", err)
return nil, err
}
t, err = t.Parse(string(indexStr))
if err != nil {
log.Error(r, "Error parsing `index.html`", err)
return nil, err
}
return t
return t, nil
}

View File

@@ -10,9 +10,11 @@ import (
"strings"
"sync"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/resources"
"github.com/deluan/navidrome/utils"
"github.com/deluan/rest"
)
@@ -28,7 +30,10 @@ var (
)
func newTranslationRepository(context.Context) rest.Repository {
dir := resources.AssetFile()
dir := utils.NewMergeFS(
resources.AssetFile(),
http.Dir(filepath.Join(conf.Server.DataFolder, "resources")),
)
if err := loadTranslations(dir); err != nil {
log.Error("Error loading translation files", err)
}

View File

@@ -11,19 +11,20 @@ import (
"github.com/deluan/navidrome/core"
"github.com/deluan/navidrome/engine"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/server/subsonic/responses"
"github.com/deluan/navidrome/utils"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
)
const Version = "1.10.2"
const Version = "1.12.0"
type Handler = func(http.ResponseWriter, *http.Request) (*responses.Subsonic, error)
type Router struct {
Browser engine.Browser
Cover core.Cover
Artwork core.Artwork
ListGenerator engine.ListGenerator
Playlists engine.Playlists
Ratings engine.Ratings
@@ -32,15 +33,17 @@ type Router struct {
Users engine.Users
Streamer core.MediaStreamer
Players engine.Players
DataStore model.DataStore
mux http.Handler
}
func New(browser engine.Browser, cover core.Cover, listGenerator engine.ListGenerator, users engine.Users,
func New(browser engine.Browser, artwork core.Artwork, listGenerator engine.ListGenerator, users engine.Users,
playlists engine.Playlists, ratings engine.Ratings, scrobbler engine.Scrobbler, search engine.Search,
streamer core.MediaStreamer, players engine.Players) *Router {
r := &Router{Browser: browser, Cover: cover, ListGenerator: listGenerator, Playlists: playlists,
Ratings: ratings, Scrobbler: scrobbler, Search: search, Users: users, Streamer: streamer, Players: players}
streamer core.MediaStreamer, players engine.Players, ds model.DataStore) *Router {
r := &Router{Browser: browser, Artwork: artwork, ListGenerator: listGenerator, Playlists: playlists,
Ratings: ratings, Scrobbler: scrobbler, Search: search, Users: users, Streamer: streamer, Players: players,
DataStore: ds}
r.mux = r.routes()
return r
}
@@ -107,6 +110,15 @@ func (api *Router) routes() http.Handler {
H(withPlayer, "deletePlaylist", c.DeletePlaylist)
H(withPlayer, "updatePlaylist", c.UpdatePlaylist)
})
r.Group(func(r chi.Router) {
c := initBookmarksController(api)
withPlayer := r.With(getPlayer(api.Players))
H(withPlayer, "getBookmarks", c.GetBookmarks)
H(withPlayer, "createBookmark", c.CreateBookmark)
H(withPlayer, "deleteBookmark", c.DeleteBookmark)
H(withPlayer, "getPlayQueue", c.GetPlayQueue)
H(withPlayer, "savePlayQueue", c.SavePlayQueue)
})
r.Group(func(r chi.Router) {
c := initSearchingController(api)
withPlayer := r.With(getPlayer(api.Players))

View File

@@ -0,0 +1,131 @@
package subsonic
import (
"net/http"
"time"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/model/request"
"github.com/deluan/navidrome/server/subsonic/responses"
"github.com/deluan/navidrome/utils"
)
type BookmarksController struct {
ds model.DataStore
}
func NewBookmarksController(ds model.DataStore) *BookmarksController {
return &BookmarksController{ds: ds}
}
func (c *BookmarksController) GetBookmarks(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
user, _ := request.UserFrom(r.Context())
repo := c.ds.MediaFile(r.Context())
bmks, err := repo.GetBookmarks()
if err != nil {
return nil, NewError(responses.ErrorGeneric, "Internal Error")
}
response := NewResponse()
response.Bookmarks = &responses.Bookmarks{}
for _, bmk := range bmks {
b := responses.Bookmark{
Entry: []responses.Child{ChildFromMediaFile(r.Context(), bmk.Item)},
Position: bmk.Position,
Username: user.UserName,
Comment: bmk.Comment,
Created: bmk.CreatedAt,
Changed: bmk.UpdatedAt,
}
response.Bookmarks.Bookmark = append(response.Bookmarks.Bookmark, b)
}
return response, nil
}
func (c *BookmarksController) CreateBookmark(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
id, err := RequiredParamString(r, "id", "id parameter required")
if err != nil {
return nil, err
}
comment := utils.ParamString(r, "comment")
position := utils.ParamInt64(r, "position", 0)
repo := c.ds.MediaFile(r.Context())
err = repo.AddBookmark(id, comment, position)
if err != nil {
return nil, NewError(responses.ErrorGeneric, "Internal Error")
}
return NewResponse(), nil
}
func (c *BookmarksController) DeleteBookmark(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
id, err := RequiredParamString(r, "id", "id parameter required")
if err != nil {
return nil, err
}
repo := c.ds.MediaFile(r.Context())
err = repo.DeleteBookmark(id)
if err != nil {
return nil, NewError(responses.ErrorGeneric, "Internal Error")
}
return NewResponse(), nil
}
func (c *BookmarksController) GetPlayQueue(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
user, _ := request.UserFrom(r.Context())
repo := c.ds.PlayQueue(r.Context())
pq, err := repo.Retrieve(user.ID)
if err != nil {
return nil, NewError(responses.ErrorGeneric, "Internal Error")
}
response := NewResponse()
response.PlayQueue = &responses.PlayQueue{
Entry: ChildrenFromMediaFiles(r.Context(), pq.Items),
Current: pq.Current,
Position: pq.Position,
Username: user.UserName,
Changed: &pq.UpdatedAt,
ChangedBy: pq.ChangedBy,
}
return response, nil
}
func (c *BookmarksController) SavePlayQueue(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
ids, err := RequiredParamStrings(r, "id", "id parameter required")
if err != nil {
return nil, err
}
current := utils.ParamString(r, "current")
position := utils.ParamInt64(r, "position", 0)
user, _ := request.UserFrom(r.Context())
client, _ := request.ClientFrom(r.Context())
var items model.MediaFiles
for _, id := range ids {
items = append(items, model.MediaFile{ID: id})
}
pq := &model.PlayQueue{
UserID: user.ID,
Current: current,
Position: position,
ChangedBy: client,
Items: items,
CreatedAt: time.Time{},
UpdatedAt: time.Time{},
}
repo := c.ds.PlayQueue(r.Context())
err = repo.Store(pq)
if err != nil {
return nil, NewError(responses.ErrorGeneric, "Internal Error")
}
return NewResponse(), nil
}

View File

@@ -165,16 +165,18 @@ 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"
const placeholderArtistImageSmallUrl = "https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png"
const placeholderArtistImageMediumUrl = "https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png"
const placeholderArtistImageLargeUrl = "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.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
response.ArtistInfo.SmallImageUrl = placeholderArtistImageSmallUrl
response.ArtistInfo.MediumImageUrl = placeholderArtistImageMediumUrl
response.ArtistInfo.LargeImageUrl = placeholderArtistImageLargeUrl
return response, nil
}
@@ -183,9 +185,9 @@ func (c *BrowsingController) GetArtistInfo2(w http.ResponseWriter, r *http.Reque
response := NewResponse()
response.ArtistInfo2 = &responses.ArtistInfo2{}
response.ArtistInfo2.Biography = "Biography not available"
response.ArtistInfo2.SmallImageUrl = noImageAvailableUrl
response.ArtistInfo2.MediumImageUrl = noImageAvailableUrl
response.ArtistInfo2.LargeImageUrl = noImageAvailableUrl
response.ArtistInfo2.SmallImageUrl = placeholderArtistImageSmallUrl
response.ArtistInfo2.MediumImageUrl = placeholderArtistImageSmallUrl
response.ArtistInfo2.LargeImageUrl = placeholderArtistImageSmallUrl
return response, nil
}

View File

@@ -142,6 +142,7 @@ func ToChild(ctx context.Context, entry engine.Entry) responses.Child {
child.TranscodedSuffix = format
child.TranscodedContentType = mime.TypeByExtension("." + format)
}
child.BookmarkPosition = entry.BookmarkPosition
return child
}
@@ -162,3 +163,55 @@ func getTranscoding(ctx context.Context) (format string, bitRate int) {
}
return
}
// This seems to be duplicated, but it is an initial step into merging `engine` and the `subsonic` packages,
// In the future there won't be any conversion to/from `engine. Entry` anymore
func ChildFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child {
child := responses.Child{}
child.Id = mf.ID
child.Title = mf.Title
child.IsDir = false
child.Parent = mf.AlbumID
child.Album = mf.Album
child.Year = mf.Year
child.Artist = mf.Artist
child.Genre = mf.Genre
child.Track = mf.TrackNumber
child.Duration = int(mf.Duration)
child.Size = mf.Size
child.Suffix = mf.Suffix
child.BitRate = mf.BitRate
if mf.HasCoverArt {
child.CoverArt = mf.ID
} else {
child.CoverArt = "al-" + mf.AlbumID
}
child.ContentType = mf.ContentType()
child.Path = mf.Path
child.DiscNumber = mf.DiscNumber
child.Created = &mf.CreatedAt
child.AlbumId = mf.AlbumID
child.ArtistId = mf.ArtistID
child.Type = "music"
child.PlayCount = mf.PlayCount
if mf.Starred {
child.Starred = &mf.StarredAt
}
child.UserRating = mf.Rating
format, _ := getTranscoding(ctx)
if mf.Suffix != "" && format != "" && mf.Suffix != format {
child.TranscodedSuffix = format
child.TranscodedContentType = mime.TypeByExtension("." + format)
}
child.BookmarkPosition = mf.BookmarkPosition
return child
}
func ChildrenFromMediaFiles(ctx context.Context, mfs model.MediaFiles) []responses.Child {
children := make([]responses.Child, len(mfs))
for i, mf := range mfs {
children[i] = ChildFromMediaFile(ctx, mf)
}
return children
}

View File

@@ -59,7 +59,7 @@ func (c *MediaAnnotationController) Star(w http.ResponseWriter, r *http.Request)
ids = append(ids, albumIds...)
ids = append(ids, artistIds...)
err := c.star(r.Context(), true, ids...)
err := c.setStar(r.Context(), true, ids...)
if err != nil {
return nil, err
}
@@ -67,7 +67,7 @@ func (c *MediaAnnotationController) Star(w http.ResponseWriter, r *http.Request)
return NewResponse(), nil
}
func (c *MediaAnnotationController) star(ctx context.Context, starred bool, ids ...string) error {
func (c *MediaAnnotationController) setStar(ctx context.Context, starred bool, ids ...string) error {
if len(ids) == 0 {
return nil
}
@@ -94,7 +94,7 @@ func (c *MediaAnnotationController) Unstar(w http.ResponseWriter, r *http.Reques
ids = append(ids, albumIds...)
ids = append(ids, artistIds...)
err := c.star(r.Context(), false, ids...)
err := c.setStar(r.Context(), false, ids...)
if err != nil {
return nil, err
}

View File

@@ -14,11 +14,11 @@ import (
)
type MediaRetrievalController struct {
cover core.Cover
artwork core.Artwork
}
func NewMediaRetrievalController(cover core.Cover) *MediaRetrievalController {
return &MediaRetrievalController{cover: cover}
func NewMediaRetrievalController(artwork core.Artwork) *MediaRetrievalController {
return &MediaRetrievalController{artwork: artwork}
}
func (c *MediaRetrievalController) GetAvatar(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
@@ -41,12 +41,12 @@ func (c *MediaRetrievalController) GetCoverArt(w http.ResponseWriter, r *http.Re
size := utils.ParamInt(r, "size", 0)
w.Header().Set("cache-control", "public, max-age=315360000")
err = c.cover.Get(r.Context(), id, size, w)
err = c.artwork.Get(r.Context(), id, size, w)
switch {
case err == model.ErrNotFound:
log.Error(r, "Couldn't find coverArt", "id", id, err)
return nil, NewError(responses.ErrorDataNotFound, "Cover not found")
return nil, NewError(responses.ErrorDataNotFound, "Artwork not found")
case err != nil:
log.Error(r, "Error retrieving coverArt", "id", id, err)
return nil, NewError(responses.ErrorGeneric, "Internal Error")

View File

@@ -11,44 +11,27 @@ import (
. "github.com/onsi/gomega"
)
type fakeCover struct {
data string
err error
recvId string
recvSize int
}
func (c *fakeCover) Get(ctx context.Context, id string, size int, out io.Writer) error {
if c.err != nil {
return c.err
}
c.recvId = id
c.recvSize = size
_, err := out.Write([]byte(c.data))
return err
}
var _ = Describe("MediaRetrievalController", func() {
var controller *MediaRetrievalController
var cover *fakeCover
var artwork *fakeArtwork
var w *httptest.ResponseRecorder
BeforeEach(func() {
cover = &fakeCover{}
controller = NewMediaRetrievalController(cover)
artwork = &fakeArtwork{}
controller = NewMediaRetrievalController(artwork)
w = httptest.NewRecorder()
})
Describe("GetCoverArt", func() {
It("should return data for that id", func() {
cover.data = "image data"
artwork.data = "image data"
r := newGetRequest("id=34", "size=128")
_, err := controller.GetCoverArt(w, r)
Expect(err).To(BeNil())
Expect(cover.recvId).To(Equal("34"))
Expect(cover.recvSize).To(Equal(128))
Expect(w.Body.String()).To(Equal(cover.data))
Expect(artwork.recvId).To(Equal("34"))
Expect(artwork.recvSize).To(Equal(128))
Expect(w.Body.String()).To(Equal(artwork.data))
})
It("should fail if missing id parameter", func() {
@@ -59,15 +42,15 @@ var _ = Describe("MediaRetrievalController", func() {
})
It("should fail when the file is not found", func() {
cover.err = model.ErrNotFound
artwork.err = model.ErrNotFound
r := newGetRequest("id=34", "size=128")
_, err := controller.GetCoverArt(w, r)
Expect(err).To(MatchError("Cover not found"))
Expect(err).To(MatchError("Artwork not found"))
})
It("should fail when there is an unknown error", func() {
cover.err = errors.New("weird error")
artwork.err = errors.New("weird error")
r := newGetRequest("id=34", "size=128")
_, err := controller.GetCoverArt(w, r)
@@ -75,3 +58,20 @@ var _ = Describe("MediaRetrievalController", func() {
})
})
})
type fakeArtwork struct {
data string
err error
recvId string
recvSize int
}
func (c *fakeArtwork) Get(ctx context.Context, id string, size int, out io.Writer) error {
if c.err != nil {
return c.err
}
c.recvId = id
c.recvSize = size
_, err := out.Write([]byte(c.data))
return err
}

View File

@@ -0,0 +1 @@
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","bookmarks":{"bookmark":[{"entry":[{"id":"1","isDir":false,"title":"title","isVideo":false}],"position":123,"username":"user2","comment":"a comment","created":"0001-01-01T00:00:00Z","changed":"0001-01-01T00:00:00Z"}]}}

View File

@@ -0,0 +1 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><bookmarks><bookmark position="123" username="user2" comment="a comment" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"><entry id="1" isDir="false" title="title" isVideo="false"></entry></bookmark></bookmarks></subsonic-response>

View File

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

View File

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

View File

@@ -0,0 +1 @@
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","playQueue":{"entry":[{"id":"1","isDir":false,"title":"title","isVideo":false}],"current":"111","position":243,"username":"user1","changed":"0001-01-01T00:00:00Z","changedBy":"a_client"}}

View File

@@ -0,0 +1 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><playQueue current="111" position="243" username="user1" changed="0001-01-01T00:00:00Z" changedBy="a_client"><entry id="1" isDir="false" title="title" isVideo="false"></entry></playQueue></subsonic-response>

View File

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

View File

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

View File

@@ -36,8 +36,11 @@ type Subsonic struct {
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"`
ArtistInfo *ArtistInfo `xml:"artistInfo,omitempty" json:"artistInfo,omitempty"`
ArtistInfo2 *ArtistInfo2 `xml:"artistInfo2,omitempty" json:"artistInfo2,omitempty"`
PlayQueue *PlayQueue `xml:"playQueue,omitempty" json:"playQueue,omitempty"`
Bookmarks *Bookmarks `xml:"bookmarks,omitempty" json:"bookmarks,omitempty"`
}
type JsonWrapper struct {
@@ -114,9 +117,9 @@ type Child struct {
UserRating int `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
SongCount int `xml:"songCount,attr,omitempty" json:"songCount,omitempty"`
IsVideo bool `xml:"isVideo,attr" json:"isVideo"`
BookmarkPosition int64 `xml:"bookmarkPosition,attr,omitempty" json:"bookmarkPosition,omitempty"`
/*
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.6.0 -->
<xs:attribute name="bookmarkPosition" type="xs:long" use="optional"/> <!-- In millis. Added in 1.10.1 -->
<xs:attribute name="originalWidth" type="xs:int" use="optional"/> <!-- Added in 1.13.0 -->
<xs:attribute name="originalHeight" type="xs:int" use="optional"/> <!-- Added in 1.13.0 -->
*/
@@ -293,3 +296,25 @@ type ArtistInfo2 struct {
ArtistInfoBase
SimilarArtist []ArtistID3 `xml:"similarArtist,omitempty" json:"similarArtist,omitempty"`
}
type PlayQueue struct {
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
Current string `xml:"current,attr,omitempty" json:"current,omitempty"`
Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
Username string `xml:"username,attr" json:"username"`
Changed *time.Time `xml:"changed,attr,omitempty" json:"changed,omitempty"`
ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
}
type Bookmark struct {
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
Username string `xml:"username,attr" json:"username"`
Comment string `xml:"comment,attr" json:"comment"`
Created time.Time `xml:"created,attr" json:"created"`
Changed time.Time `xml:"changed,attr" json:"changed"`
}
type Bookmarks struct {
Bookmark []Bookmark `xml:"bookmark,omitempty" json:"bookmark,omitempty"`
}

View File

@@ -331,4 +331,73 @@ var _ = Describe("Responses", func() {
})
})
Describe("PlayQueue", func() {
BeforeEach(func() {
response.PlayQueue = &PlayQueue{}
})
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.PlayQueue.Username = "user1"
response.PlayQueue.Current = "111"
response.PlayQueue.Position = 243
response.PlayQueue.Changed = &time.Time{}
response.PlayQueue.ChangedBy = "a_client"
child := make([]Child, 1)
child[0] = Child{Id: "1", Title: "title", IsDir: false}
response.PlayQueue.Entry = child
})
It("should match .XML", func() {
Expect(xml.Marshal(response)).To(MatchSnapshot())
})
It("should match .JSON", func() {
Expect(json.Marshal(response)).To(MatchSnapshot())
})
})
})
Describe("Bookmarks", func() {
BeforeEach(func() {
response.Bookmarks = &Bookmarks{}
})
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() {
bmk := Bookmark{
Position: 123,
Username: "user2",
Comment: "a comment",
Created: time.Time{},
Changed: time.Time{},
}
bmk.Entry = []Child{{Id: "1", Title: "title", IsDir: false}}
response.Bookmarks.Bookmark = []Bookmark{bmk}
})
It("should match .XML", func() {
Expect(xml.Marshal(response)).To(MatchSnapshot())
})
It("should match .JSON", func() {
Expect(json.Marshal(response)).To(MatchSnapshot())
})
})
})
})

View File

@@ -32,8 +32,10 @@ func (c *StreamController) Stream(w http.ResponseWriter, r *http.Request) (*resp
if err != nil {
return nil, err
}
// Make sure the stream will be closed at the end, to avoid leakage
defer func() {
if err := stream.Close(); err != nil {
if err := stream.Close(); err != nil && log.CurrentLevel() >= log.LevelDebug {
log.Error("Error closing stream", "id", id, "file", stream.Name(), err)
}
}()

View File

@@ -53,8 +53,8 @@ func initUsersController(router *Router) *UsersController {
}
func initMediaRetrievalController(router *Router) *MediaRetrievalController {
cover := router.Cover
mediaRetrievalController := NewMediaRetrievalController(cover)
artwork := router.Artwork
mediaRetrievalController := NewMediaRetrievalController(artwork)
return mediaRetrievalController
}
@@ -64,6 +64,12 @@ func initStreamController(router *Router) *StreamController {
return streamController
}
func initBookmarksController(router *Router) *BookmarksController {
dataStore := router.DataStore
bookmarksController := NewBookmarksController(dataStore)
return bookmarksController
}
// wire_injectors.go:
var allProviders = wire.NewSet(
@@ -75,5 +81,6 @@ var allProviders = wire.NewSet(
NewSearchingController,
NewUsersController,
NewMediaRetrievalController,
NewStreamController, wire.FieldsOf(new(*Router), "Browser", "Cover", "ListGenerator", "Playlists", "Ratings", "Scrobbler", "Search", "Streamer"),
NewStreamController,
NewBookmarksController, wire.FieldsOf(new(*Router), "Browser", "Artwork", "ListGenerator", "Playlists", "Ratings", "Scrobbler", "Search", "Streamer", "DataStore"),
)

View File

@@ -16,7 +16,8 @@ var allProviders = wire.NewSet(
NewUsersController,
NewMediaRetrievalController,
NewStreamController,
wire.FieldsOf(new(*Router), "Browser", "Cover", "ListGenerator", "Playlists", "Ratings", "Scrobbler", "Search", "Streamer"),
NewBookmarksController,
wire.FieldsOf(new(*Router), "Browser", "Artwork", "ListGenerator", "Playlists", "Ratings", "Scrobbler", "Search", "Streamer", "DataStore"),
)
func initSystemController(router *Router) *SystemController {
@@ -54,3 +55,7 @@ func initMediaRetrievalController(router *Router) *MediaRetrievalController {
func initStreamController(router *Router) *StreamController {
panic(wire.Build(allProviders))
}
func initBookmarksController(router *Router) *BookmarksController {
panic(wire.Build(allProviders))
}

429
ui/package-lock.json generated
View File

@@ -1657,9 +1657,9 @@
}
},
"@material-ui/core": {
"version": "4.10.2",
"resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.10.2.tgz",
"integrity": "sha512-Uf4iDLi9sW6HKbVQDyDZDr1nMR4RUAE7w/RIIJZGNVZResC0xwmpLRZMtaUdSO43N0R0yJehfxTi4Z461Cd49A==",
"version": "4.11.0",
"resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.11.0.tgz",
"integrity": "sha512-bYo9uIub8wGhZySHqLQ833zi4ZML+XCBE1XwJ8EuUVSpTWWG57Pm+YugQToJNFsEyiKFhPh8DPD0bgupz8n01g==",
"requires": {
"@babel/runtime": "^7.4.4",
"@material-ui/styles": "^4.10.0",
@@ -1675,16 +1675,6 @@
"react-transition-group": "^4.4.0"
},
"dependencies": {
"@material-ui/utils": {
"version": "4.10.2",
"resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-4.10.2.tgz",
"integrity": "sha512-eg29v74P7W5r6a4tWWDAAfZldXIzfyO1am2fIsC39hdUUHm/33k6pGOKPbgDjg/U/4ifmgAePy/1OjkKN6rFRw==",
"requires": {
"@babel/runtime": "^7.4.4",
"prop-types": "^15.7.2",
"react-is": "^16.8.0"
}
},
"hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
@@ -1777,9 +1767,9 @@
"integrity": "sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A=="
},
"@material-ui/utils": {
"version": "4.9.12",
"resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-4.9.12.tgz",
"integrity": "sha512-/0rgZPEOcZq5CFA4+4n6Q6zk7fi8skHhH2Bcra8R3epoJEYy5PL55LuMazPtPH1oKeRausDV/Omz4BbgFsn1HQ==",
"version": "4.10.2",
"resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-4.10.2.tgz",
"integrity": "sha512-eg29v74P7W5r6a4tWWDAAfZldXIzfyO1am2fIsC39hdUUHm/33k6pGOKPbgDjg/U/4ifmgAePy/1OjkKN6rFRw==",
"requires": {
"@babel/runtime": "^7.4.4",
"prop-types": "^15.7.2",
@@ -1847,11 +1837,6 @@
"resolved": "https://registry.npmjs.org/@redux-saga/types/-/types-1.1.0.tgz",
"integrity": "sha512-afmTuJrylUU/0OtqzaRkbyYFFNgCF73Bvel/sw90pvGrWIZ+vyoIJqA6eMSoA6+nb443kTmulmBtC9NerXboNg=="
},
"@scarf/scarf": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.0.6.tgz",
"integrity": "sha512-y4+DuXrAd1W5UIY3zTcsosi/1GyYT8k5jGnZ/wG7UUHVrU+MHlH4Mp87KK2/lvMW4+H7HVcdB+aJhqywgXksjA=="
},
"@sheerun/mutationobserver-shim": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@sheerun/mutationobserver-shim/-/mutationobserver-shim-0.3.3.tgz",
@@ -1967,30 +1952,31 @@
}
},
"@testing-library/dom": {
"version": "7.16.2",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.16.2.tgz",
"integrity": "sha512-4fT5l5L+5gfNhUZVCg0wnSszbRJ7A1ZHEz32v7OzH3mcY5lUsK++brI3IB2L9F5zO4kSDc2TRGEVa8v2hgl9vA==",
"version": "7.21.4",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.21.4.tgz",
"integrity": "sha512-IXjKRTAH31nQ+mx6q3IPw85RTLul8VlWBm1rxURoxDt7JI0HPlAAfbtrKTdeq83XYCYO7HSHogyV+OsD+6FX0Q==",
"dev": true,
"requires": {
"@babel/runtime": "^7.10.2",
"aria-query": "^4.0.2",
"dom-accessibility-api": "^0.4.5",
"@babel/runtime": "^7.10.3",
"@types/aria-query": "^4.2.0",
"aria-query": "^4.2.2",
"dom-accessibility-api": "^0.4.6",
"pretty-format": "^25.5.0"
},
"dependencies": {
"@babel/runtime": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.3.tgz",
"integrity": "sha512-RzGO0RLSdokm9Ipe/YD+7ww8X2Ro79qiXZF3HU9ljrM+qnJmH1Vqth+hbiQZy761LnMJTMitHDuKVYTk3k4dLw==",
"version": "7.10.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.5.tgz",
"integrity": "sha512-otddXKhdNn7d0ptoFRHtMLa8LqDxLYwTjB4nYgM1yy5N6gU/MUf8zqyyLltCH3yAVitBzmwK4us+DD0l/MauAg==",
"dev": true,
"requires": {
"regenerator-runtime": "^0.13.4"
}
},
"@babel/runtime-corejs3": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.10.3.tgz",
"integrity": "sha512-HA7RPj5xvJxQl429r5Cxr2trJwOfPjKiqhCXcdQPSqO2G0RHPZpXu4fkYmBaTKCp2c/jRaMK9GB/lN+7zvvFPw==",
"version": "7.10.5",
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.10.5.tgz",
"integrity": "sha512-RMafpmrNB5E/bwdSphLr8a8++9TosnyJp98RZzI6VOx2R2CCMpsXXXRvmI700O9oEKpXdZat6oEK68/F0zjd4A==",
"dev": true,
"requires": {
"core-js-pure": "^3.0.0",
@@ -2016,15 +2002,16 @@
}
},
"@testing-library/jest-dom": {
"version": "5.10.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.10.1.tgz",
"integrity": "sha512-uv9lLAnEFRzwUTN/y9lVVXVXlEzazDkelJtM5u92PsGkEasmdI+sfzhZHxSDzlhZVTrlLfuMh2safMr8YmzXLg==",
"version": "5.11.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.11.1.tgz",
"integrity": "sha512-NHOHjDwyBoqM7mXjNLieSp/6vJ17DILzhNTw7+RarluaBkyWRzWgFj+d6xnd1adMBlwfQSeR2FWGTxHXCxeMSA==",
"dev": true,
"requires": {
"@babel/runtime": "^7.9.2",
"@types/testing-library__jest-dom": "^5.9.1",
"aria-query": "^4.2.2",
"chalk": "^3.0.0",
"css": "^2.2.4",
"css": "^3.0.0",
"css.escape": "^1.5.1",
"jest-diff": "^25.1.0",
"jest-matcher-utils": "^25.1.0",
@@ -2033,36 +2020,77 @@
},
"dependencies": {
"@babel/runtime": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.3.tgz",
"integrity": "sha512-RzGO0RLSdokm9Ipe/YD+7ww8X2Ro79qiXZF3HU9ljrM+qnJmH1Vqth+hbiQZy761LnMJTMitHDuKVYTk3k4dLw==",
"version": "7.10.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.5.tgz",
"integrity": "sha512-otddXKhdNn7d0ptoFRHtMLa8LqDxLYwTjB4nYgM1yy5N6gU/MUf8zqyyLltCH3yAVitBzmwK4us+DD0l/MauAg==",
"dev": true,
"requires": {
"regenerator-runtime": "^0.13.4"
}
},
"@babel/runtime-corejs3": {
"version": "7.10.5",
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.10.5.tgz",
"integrity": "sha512-RMafpmrNB5E/bwdSphLr8a8++9TosnyJp98RZzI6VOx2R2CCMpsXXXRvmI700O9oEKpXdZat6oEK68/F0zjd4A==",
"dev": true,
"requires": {
"core-js-pure": "^3.0.0",
"regenerator-runtime": "^0.13.4"
}
},
"aria-query": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-4.2.2.tgz",
"integrity": "sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==",
"dev": true,
"requires": {
"@babel/runtime": "^7.10.2",
"@babel/runtime-corejs3": "^7.10.2"
}
},
"css": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/css/-/css-3.0.0.tgz",
"integrity": "sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ==",
"dev": true,
"requires": {
"inherits": "^2.0.4",
"source-map": "^0.6.1",
"source-map-resolve": "^0.6.0"
}
},
"regenerator-runtime": {
"version": "0.13.5",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz",
"integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==",
"dev": true
},
"source-map-resolve": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.6.0.tgz",
"integrity": "sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w==",
"dev": true,
"requires": {
"atob": "^2.1.2",
"decode-uri-component": "^0.2.0"
}
}
}
},
"@testing-library/react": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-10.3.0.tgz",
"integrity": "sha512-Rhn5uJK6lYHWzlGVbK6uAvheAW8AUoFYxTLGdDxgsJDaK/PYy5drWfW/6YpMMOKMw+u6jHHl4MNHlt5qLHnm0Q==",
"version": "10.4.7",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-10.4.7.tgz",
"integrity": "sha512-hUYbum3X2f1ZKusKfPaooKNYqE/GtPiQ+D2HJaJ4pkxeNJQFVUEvAvEh9+3QuLdBeTWkDMNY5NSijc5+pGdM4Q==",
"dev": true,
"requires": {
"@babel/runtime": "^7.10.2",
"@testing-library/dom": "^7.14.2"
"@babel/runtime": "^7.10.3",
"@testing-library/dom": "^7.17.1"
},
"dependencies": {
"@babel/runtime": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.3.tgz",
"integrity": "sha512-RzGO0RLSdokm9Ipe/YD+7ww8X2Ro79qiXZF3HU9ljrM+qnJmH1Vqth+hbiQZy761LnMJTMitHDuKVYTk3k4dLw==",
"version": "7.10.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.5.tgz",
"integrity": "sha512-otddXKhdNn7d0ptoFRHtMLa8LqDxLYwTjB4nYgM1yy5N6gU/MUf8zqyyLltCH3yAVitBzmwK4us+DD0l/MauAg==",
"dev": true,
"requires": {
"regenerator-runtime": "^0.13.4"
@@ -2077,18 +2105,18 @@
}
},
"@testing-library/user-event": {
"version": "12.0.6",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-12.0.6.tgz",
"integrity": "sha512-rPAlp3dCdn2kfc8qxDOdi00/4pcbfeQigCMiew0SnwbfpgbVLKwhVicjEt9Lt8eR4klbhToTZ3AVi7r10qAbNg==",
"version": "12.0.11",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-12.0.11.tgz",
"integrity": "sha512-r7QNfktLE2n8IODEl32orup/HNOMueJpoXRDeTMlvWR4nZIHJwx59+8SkLf6nqV4Ot5Xo6qNeaWrvC1KO4eOng==",
"dev": true,
"requires": {
"@babel/runtime": "^7.10.2"
},
"dependencies": {
"@babel/runtime": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.3.tgz",
"integrity": "sha512-RzGO0RLSdokm9Ipe/YD+7ww8X2Ro79qiXZF3HU9ljrM+qnJmH1Vqth+hbiQZy761LnMJTMitHDuKVYTk3k4dLw==",
"version": "7.10.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.5.tgz",
"integrity": "sha512-otddXKhdNn7d0ptoFRHtMLa8LqDxLYwTjB4nYgM1yy5N6gU/MUf8zqyyLltCH3yAVitBzmwK4us+DD0l/MauAg==",
"dev": true,
"requires": {
"regenerator-runtime": "^0.13.4"
@@ -2102,6 +2130,12 @@
}
}
},
"@types/aria-query": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.0.tgz",
"integrity": "sha512-iIgQNzCm0v7QMhhe4Jjn9uRh+I6GoPmt03CbEtwx3ao8/EfoQcmgtqH4vQ5Db/lxiIGaWDv6nwvunuh0RyX0+A==",
"dev": true
},
"@types/babel__core": {
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.6.tgz",
@@ -2187,9 +2221,9 @@
}
},
"@types/jest": {
"version": "26.0.0",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.0.tgz",
"integrity": "sha512-/yeMsH9HQ1RLORlXAwoLXe8S98xxvhNtUz3yrgrwbaxYjT+6SFPZZRksmRKRA6L5vsUtSHeN71viDOTTyYAD+g==",
"version": "26.0.5",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.5.tgz",
"integrity": "sha512-heU+7w8snfwfjtcj2H458aTx3m5unIToOJhx75ebHilBiiQ39OIdA18WkG4LP08YKeAoWAGvWg8s+22w/PeJ6w==",
"dev": true,
"requires": {
"jest-diff": "^25.2.1",
@@ -2227,9 +2261,9 @@
"integrity": "sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw=="
},
"@types/react": {
"version": "16.9.38",
"resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.38.tgz",
"integrity": "sha512-pHAeZbjjNRa/hxyNuLrvbxhhnKyKNiLC6I5fRF2Zr/t/S6zS41MiyzH4+c+1I9vVfvuRt1VS2Lodjr4ZWnxrdA==",
"version": "16.9.43",
"resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.43.tgz",
"integrity": "sha512-PxshAFcnJqIWYpJbLPriClH53Z2WlJcVZE+NP2etUtWQs2s7yIMj3/LDKZT/5CHJ/F62iyjVCDu2H3jHEXIxSg==",
"requires": {
"@types/prop-types": "*",
"csstype": "^2.2.0"
@@ -2531,14 +2565,6 @@
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-6.2.0.tgz",
"integrity": "sha512-7evsyfH1cLOCdAzZAd43Cic04yKydNx0cF+7tiA19p1XnLLPU4dpCQOqpjqwokFe//vS0QqfqqjCS2JkiIs0cA=="
},
"add-dom-event-listener": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/add-dom-event-listener/-/add-dom-event-listener-1.1.0.tgz",
"integrity": "sha512-WCxx1ixHT0GQU9hb0KI/mhgRQhnU+U3GvwY6ZvVjYq8rsihIGoaIOUbY0yMPBxLH5MDtr0kz3fisWGNcbWW7Jw==",
"requires": {
"object-assign": "4.x"
}
},
"address": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/address/-/address-1.1.2.tgz",
@@ -4653,9 +4679,9 @@
}
},
"csstype": {
"version": "2.6.10",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.10.tgz",
"integrity": "sha512-D34BqZU4cIlMCY93rZHbrq9pjTAQJ3U8S8rfBqjwHxkGPThWFjzZDQpgMJY0QViLxth6ZKYiwFBo14RdN44U/w=="
"version": "2.6.11",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.11.tgz",
"integrity": "sha512-l8YyEC9NBkSm783PFTvh0FmJy7s5pFKrDp49ZL7zBGX3fWkO+N4EEyan1qqp8cwPLDcD0OSdyY6hAMoxp34JFw=="
},
"cyclist": {
"version": "1.0.1",
@@ -4975,15 +5001,15 @@
}
},
"dom-accessibility-api": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.4.5.tgz",
"integrity": "sha512-HcPDilI95nKztbVikaN2vzwvmv0sE8Y2ZJFODy/m15n7mGXLeOKGiys9qWVbFbh+aq/KYj2lqMLybBOkYAEXqg==",
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.4.6.tgz",
"integrity": "sha512-qxFVFR/ymtfamEQT/AsYLe048sitxFCoCHiM+vuOdR3fE94i3so2SCFJxyz/RxV69PZ+9FgToYWOd7eqJqcbYw==",
"dev": true
},
"dom-align": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.11.1.tgz",
"integrity": "sha512-hN42DmUgtweBx0iBjDLO4WtKOMcK8yBmPx/fgdsgQadLuzPu/8co3oLdK5yMmeM/vnUd3yDyV6qV8/NzxBexQg=="
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.0.tgz",
"integrity": "sha512-YkoezQuhp3SLFGdOlr5xkqZ640iXrnHAwVYcDg8ZKRUtO7mSzSC2BA5V0VuyAwPSJA4CLIc6EDDJh4bEsD2+zA=="
},
"dom-converter": {
"version": "0.2.0",
@@ -5003,9 +5029,9 @@
},
"dependencies": {
"@babel/runtime": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.3.tgz",
"integrity": "sha512-RzGO0RLSdokm9Ipe/YD+7ww8X2Ro79qiXZF3HU9ljrM+qnJmH1Vqth+hbiQZy761LnMJTMitHDuKVYTk3k4dLw==",
"version": "7.10.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.5.tgz",
"integrity": "sha512-otddXKhdNn7d0ptoFRHtMLa8LqDxLYwTjB4nYgM1yy5N6gU/MUf8zqyyLltCH3yAVitBzmwK4us+DD0l/MauAg==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
@@ -5176,9 +5202,9 @@
"integrity": "sha512-2jhQxJKcjcSpVOQm0NAfuLq8o+130blrcawoumdXT6411xG/xIAOyZodO/y7WTaYlz/NHe3sCCAe/cJLnDsqTw=="
},
"elliptic": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz",
"integrity": "sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw==",
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.3.tgz",
"integrity": "sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==",
"requires": {
"bn.js": "^4.4.0",
"brorand": "^1.0.1",
@@ -5205,11 +5231,21 @@
"integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k="
},
"encoding": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz",
"integrity": "sha1-U4tm8+5izRq1HsMjgp0flIDHS+s=",
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"requires": {
"iconv-lite": "~0.4.13"
"iconv-lite": "^0.6.2"
},
"dependencies": {
"iconv-lite": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.2.tgz",
"integrity": "sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ==",
"requires": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
}
}
}
},
"end-of-stream": {
@@ -6440,18 +6476,17 @@
}
},
"final-form": {
"version": "4.20.0",
"resolved": "https://registry.npmjs.org/final-form/-/final-form-4.20.0.tgz",
"integrity": "sha512-kdPGNlR/23M2p7ccVwE/vCBQH9TH1NAhhMVkETHbaQXkTWIJdEii3ZdHrOgYvFY7O87myEhcqzx3zjMERtoNJg==",
"version": "4.20.1",
"resolved": "https://registry.npmjs.org/final-form/-/final-form-4.20.1.tgz",
"integrity": "sha512-IIsOK3JRxJrN72OBj7vFWZxtGt3xc1bYwJVPchjVWmDol9DlzMSAOPB+vwe75TUYsw1JaH0fTQnIgwSQZQ9Acg==",
"requires": {
"@babel/runtime": "^7.10.0",
"@scarf/scarf": "^1.0.5"
"@babel/runtime": "^7.10.0"
},
"dependencies": {
"@babel/runtime": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.3.tgz",
"integrity": "sha512-RzGO0RLSdokm9Ipe/YD+7ww8X2Ro79qiXZF3HU9ljrM+qnJmH1Vqth+hbiQZy761LnMJTMitHDuKVYTk3k4dLw==",
"version": "7.10.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.5.tgz",
"integrity": "sha512-otddXKhdNn7d0ptoFRHtMLa8LqDxLYwTjB4nYgM1yy5N6gU/MUf8zqyyLltCH3yAVitBzmwK4us+DD0l/MauAg==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
@@ -7335,9 +7370,9 @@
"integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM="
},
"hyphenate-style-name": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.3.tgz",
"integrity": "sha512-EcuixamT82oplpoJ2XU4pDtKGWQ7b00CD9f1ug9IaQ3p1bkHMiKCZ9ut9QDI6qsa6cpUuB+A/I+zLtdNK4n2DQ=="
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz",
"integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ=="
},
"iconv-lite": {
"version": "0.4.24",
@@ -7674,9 +7709,9 @@
"integrity": "sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU="
},
"is-mobile": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-2.2.1.tgz",
"integrity": "sha512-6zELsfVFr326eq2CI53yvqq6YBanOxKBybwDT+MbMS2laBnK6Ez8m5XHSuTQQbnKRfpDzCod1CMWW5q3wZYMvA=="
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-2.2.2.tgz",
"integrity": "sha512-wW/SXnYJkTjs++tVK5b6kVITZpAZPtUrt9SF80vvxGiF/Oywal+COk1jlRkiVq15RFNEQKQY31TkV24/1T5cVg=="
},
"is-number": {
"version": "3.0.0",
@@ -12979,9 +13014,9 @@
"integrity": "sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA=="
},
"ra-core": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/ra-core/-/ra-core-3.6.1.tgz",
"integrity": "sha512-9JikbyJwdyFksi3Pd4joFPWNTgCTZv/8easN0NEMejgANeOXyez8dXvpYFUlm8QLr/NFRmUfm8qUtZEHKzosxA==",
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/ra-core/-/ra-core-3.7.1.tgz",
"integrity": "sha512-T6gYppeTMoG4qbpD4cHJ76EQTuf0b6DedMSL8ekFuZdh+MDtpGywUlCzkcSQK9J4Ck31prtVvONXGm9uwEyEyg==",
"requires": {
"@testing-library/react": "^8.0.7",
"classnames": "~2.2.5",
@@ -13074,35 +13109,35 @@
}
},
"ra-data-json-server": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/ra-data-json-server/-/ra-data-json-server-3.6.1.tgz",
"integrity": "sha512-d+weR7CU49iRTYip2+81rPzd/x0hfuT10DIw339FtbY+85QyUF1WqgZ032B1ST0DhlssxJmb6fBnnppZIGm1Wg==",
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/ra-data-json-server/-/ra-data-json-server-3.7.1.tgz",
"integrity": "sha512-MUVSgFFTQ0+cLwHwZeljcME/bXFq8xVG172VWbp6vRghGFWCuHwlDHFAUVFz4JcMMa1/6tCHdWmWfFjV/3zoWQ==",
"requires": {
"query-string": "^5.1.1",
"ra-core": "^3.6.1"
"ra-core": "^3.7.1"
}
},
"ra-i18n-polyglot": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/ra-i18n-polyglot/-/ra-i18n-polyglot-3.6.1.tgz",
"integrity": "sha512-HY8Hq0GFHwq4Th7yHXjsHw9Ff3OAz/xT5wWNN+ckZhGlKfep3/125V2u5VUTMluDhjYs+ThBIZO3vRgky7bXDg==",
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/ra-i18n-polyglot/-/ra-i18n-polyglot-3.7.1.tgz",
"integrity": "sha512-BWYJKGp0nZP8ATsYqfWkvQpyaVx4KrYw/XT6Taf8HfhfPqZaiOQUhfw4a9co3Nm/0Gu3mFdHq5CVMDsEZL1r+w==",
"requires": {
"node-polyglot": "^2.2.2",
"ra-core": "^3.6.1"
"ra-core": "^3.7.1"
}
},
"ra-language-english": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/ra-language-english/-/ra-language-english-3.6.1.tgz",
"integrity": "sha512-eT7ZqnxP6X6QC7yL8cZhdr50LAzmZNk5xMOO5gBXHoQiW9uCOp/U2ipAvCvdWkEn4SjfDKgpjUx0T6X7q5sVqw==",
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/ra-language-english/-/ra-language-english-3.7.1.tgz",
"integrity": "sha512-85jgvpEdRgfQ1SVVVudUh7AOlmhbiLhT/iLn5tXO9N7HCudK7Fno37y+txjrPywki6Vs/XGkRLG/eNsrYmyMFQ==",
"requires": {
"ra-core": "^3.6.1"
"ra-core": "^3.7.1"
}
},
"ra-ui-materialui": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/ra-ui-materialui/-/ra-ui-materialui-3.6.1.tgz",
"integrity": "sha512-/cCjqGvX5znNe7AHRQQVZVqxsqK6aavGVnT/r95hYoeQsJb6sy6IL0erkCecPY7D9uknbHC6OKbyNDPNOmbrLw==",
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/ra-ui-materialui/-/ra-ui-materialui-3.7.1.tgz",
"integrity": "sha512-EA5z/2fnqv1HTJuzXCoxZoCIgohHY+C+zVjDjEBQ/ABa/PosZT+epOsKK9Gn4gLuHbLHJCwdjcOZXJHH6F1jiQ==",
"requires": {
"autosuggest-highlight": "^3.1.1",
"classnames": "~2.2.5",
@@ -13168,38 +13203,68 @@
}
},
"rc-align": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/rc-align/-/rc-align-3.0.0.tgz",
"integrity": "sha512-/T/4LOlKJLFe8EwsORuc3pFWOJ8caUpj2vtKIHWea4PhakoleM7KDQsx0n1WDQENIeSfrP9P1FowVxAdvhjsvw==",
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/rc-align/-/rc-align-4.0.1.tgz",
"integrity": "sha512-RQ5Fhxl0LW+zsxbY8dxAcpXdaHkHH2jzRSSpvBTS7G9LMK3T+WRcn4ovjg/eqAESM6TdTx0hfqWF2S1pO75jxQ==",
"requires": {
"@babel/runtime": "^7.10.1",
"classnames": "2.x",
"dom-align": "^1.7.0",
"rc-util": "^4.12.0",
"rc-util": "^5.0.1",
"resize-observer-polyfill": "^1.5.1"
},
"dependencies": {
"@babel/runtime": {
"version": "7.10.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.5.tgz",
"integrity": "sha512-otddXKhdNn7d0ptoFRHtMLa8LqDxLYwTjB4nYgM1yy5N6gU/MUf8zqyyLltCH3yAVitBzmwK4us+DD0l/MauAg==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
},
"regenerator-runtime": {
"version": "0.13.5",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz",
"integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA=="
}
}
},
"rc-animate": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/rc-animate/-/rc-animate-3.0.0.tgz",
"integrity": "sha512-+ANeyCei4lWSJHWTcocywdYAy6lpRdBva/7Fs3nBBiAngW/W+Gmx+gQEcsmcgQBqziWUYnR91Bk12ltR3GBHPA==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/rc-animate/-/rc-animate-3.1.0.tgz",
"integrity": "sha512-8FsM+3B1H+0AyTyGggY6JyVldHTs1CyYT8CfTmG/nGHHXlecvSLeICJhcKgRLjUiQlctNnRtB1rwz79cvBVmrw==",
"requires": {
"@ant-design/css-animation": "^1.7.2",
"classnames": "^2.2.6",
"raf": "^3.4.0",
"rc-util": "^4.15.3"
"rc-util": "^5.0.1"
}
},
"rc-slider": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-9.2.4.tgz",
"integrity": "sha512-wSr7vz+WtzzGqsGU2rTQ4mmLz9fkuIDMPYMYm8ygYFvxQ2Rh4uRhOWHYI0R8krNK5k1bGycckYxmQqUIvLAh3w==",
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-9.3.1.tgz",
"integrity": "sha512-c52PWPyrfJWh28K6dixAm0906L3/4MUIxqrNQA4TLnC/Z+cBNycWJUZoJerpwSOE1HdM3XDwixCsmtFc/7aWlQ==",
"requires": {
"babel-runtime": "6.x",
"@babel/runtime": "^7.10.1",
"classnames": "^2.2.5",
"rc-tooltip": "^4.0.0",
"rc-util": "^4.0.4",
"shallowequal": "^1.1.0",
"warning": "^4.0.3"
"rc-util": "^5.0.0",
"shallowequal": "^1.1.0"
},
"dependencies": {
"@babel/runtime": {
"version": "7.10.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.5.tgz",
"integrity": "sha512-otddXKhdNn7d0ptoFRHtMLa8LqDxLYwTjB4nYgM1yy5N6gU/MUf8zqyyLltCH3yAVitBzmwK4us+DD0l/MauAg==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
},
"regenerator-runtime": {
"version": "0.13.5",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz",
"integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA=="
}
}
},
"rc-switch": {
@@ -13213,34 +13278,47 @@
}
},
"rc-tooltip": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-4.0.3.tgz",
"integrity": "sha512-HNyBh9/fPdds0DXja8JQX0XTIHmZapB3lLzbdn74aNSxXG1KUkt+GK4X1aOTRY5X9mqm4uUKdeFrn7j273H8gw==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-4.2.1.tgz",
"integrity": "sha512-oykuaGsHg7RFvPUaxUpxo7ScEqtH61C66x4JUmjlFlSS8gSx2L8JFtfwM1D68SLBxUqGqJObtxj4TED75gQTiA==",
"requires": {
"rc-trigger": "^4.0.0"
"rc-trigger": "^4.2.1"
}
},
"rc-trigger": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/rc-trigger/-/rc-trigger-4.2.1.tgz",
"integrity": "sha512-iFQ+/FbzDvYDrTS3jXbdk4MgVNU0R/A8UAAQkspXSr4Q6jTcR6p+lfNhSS0JJgJuXtfjoInC0+8jXK8HUShQ0g==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/rc-trigger/-/rc-trigger-4.3.0.tgz",
"integrity": "sha512-jnGNzosXmDdivMBjPCYe/AfOXTpJU2/xQ9XukgoXDQEoZq/9lcI1r7eUIfq70WlWpLxlUEqQktiV3hwyy6Nw9g==",
"requires": {
"@babel/runtime": "^7.10.1",
"classnames": "^2.2.6",
"raf": "^3.4.1",
"rc-align": "^3.0.0",
"rc-align": "^4.0.0",
"rc-animate": "^3.0.0",
"rc-util": "^4.20.0"
"rc-util": "^5.0.1"
},
"dependencies": {
"@babel/runtime": {
"version": "7.10.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.5.tgz",
"integrity": "sha512-otddXKhdNn7d0ptoFRHtMLa8LqDxLYwTjB4nYgM1yy5N6gU/MUf8zqyyLltCH3yAVitBzmwK4us+DD0l/MauAg==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
},
"regenerator-runtime": {
"version": "0.13.5",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz",
"integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA=="
}
}
},
"rc-util": {
"version": "4.20.5",
"resolved": "https://registry.npmjs.org/rc-util/-/rc-util-4.20.5.tgz",
"integrity": "sha512-f67s4Dt1quBYhrVPq5QMKmK3eS2hN1NNIAyhaiG0HmvqiGYAXMQ7SP2AlGqv750vnzhJs38JklbkWT1/wjhFPg==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.0.5.tgz",
"integrity": "sha512-zLIdNm6qz+hQbB5T1fmzHFFgPuRl3uB2eS2iLR/mewUWvgC3l7NzRYRVlHoCEEFVUkKEEsHuJXG1J52FInl5lA==",
"requires": {
"add-dom-event-listener": "^1.1.0",
"prop-types": "^15.5.10",
"react-is": "^16.12.0",
"react-lifecycles-compat": "^3.0.4",
"shallowequal": "^1.1.0"
}
},
@@ -13255,9 +13333,9 @@
}
},
"react-admin": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/react-admin/-/react-admin-3.6.1.tgz",
"integrity": "sha512-co/4A858JZVWdYMaYXaOx+i7TueUTH3HU5W4CqUH9GQQp5CojaPt3V5S+Z8LiMos6UAdvnMzOcZr9nqDmV/qWQ==",
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/react-admin/-/react-admin-3.7.1.tgz",
"integrity": "sha512-eVOdvh7HIc83BmoFjZNTcRU5l9XNhk6rHn5vBu1zcl5i//91jPw0NTS1uxG18STjiERckh/DVphELC5tFRIGwA==",
"requires": {
"@material-ui/core": "^4.3.3",
"@material-ui/icons": "^4.2.1",
@@ -13265,10 +13343,10 @@
"connected-react-router": "^6.5.2",
"final-form": "^4.18.5",
"final-form-arrays": "^3.0.1",
"ra-core": "^3.6.1",
"ra-i18n-polyglot": "^3.6.1",
"ra-language-english": "^3.6.1",
"ra-ui-materialui": "^3.6.1",
"ra-core": "^3.7.1",
"ra-i18n-polyglot": "^3.7.1",
"ra-language-english": "^3.7.1",
"ra-ui-materialui": "^3.7.1",
"react-final-form": "^6.3.3",
"react-final-form-arrays": "^3.1.1",
"react-redux": "^7.1.0",
@@ -13565,9 +13643,9 @@
}
},
"react-draggable": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.2.tgz",
"integrity": "sha512-zLQs4R4bnBCGnCVTZiD8hPsHtkiJxgMpGDlRESM+EHQo8ysXhKJ2GKdJ8UxxLJdRVceX1j19jy+hQS2wHislPQ==",
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.3.tgz",
"integrity": "sha512-jV4TE59MBuWm7gb6Ns3Q1mxX8Azffb7oTtDtBgFkxRvhDp38YAARmRplrj0+XGkhOJB5XziArX+4HUUABtyZ0w==",
"requires": {
"classnames": "^2.2.5",
"prop-types": "^15.6.0"
@@ -13589,19 +13667,17 @@
"integrity": "sha512-TAv1KJFh3RhqxNvhzxj6LeT5NWklP6rDr2a0jaTfsZ5wSZWHOGeqQyejUp3xxLfPt2UpyJEcVQB/zyPcmonNFA=="
},
"react-final-form": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/react-final-form/-/react-final-form-6.5.0.tgz",
"integrity": "sha512-H97PLCtfMIN32NHqm85E738Pj+NOF1p0eQEG+h5DbdaofwtqDRp7taHu45+PlXOqg9ANbM6MyXkYxWpIiE6qbQ==",
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/react-final-form/-/react-final-form-6.5.1.tgz",
"integrity": "sha512-+Hzd9PqYY1Cv3MnWzw64QOl5BjC5BtSDakx+N7Re49r0FASdFhgpXLFFCJ31fvegq2euP6hz6Ow9K6XM9BSqCA==",
"requires": {
"@babel/runtime": "^7.10.0",
"@scarf/scarf": "^1.0.5",
"ts-essentials": "^6.0.5"
"@babel/runtime": "^7.10.0"
},
"dependencies": {
"@babel/runtime": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.3.tgz",
"integrity": "sha512-RzGO0RLSdokm9Ipe/YD+7ww8X2Ro79qiXZF3HU9ljrM+qnJmH1Vqth+hbiQZy761LnMJTMitHDuKVYTk3k4dLw==",
"version": "7.10.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.5.tgz",
"integrity": "sha512-otddXKhdNn7d0ptoFRHtMLa8LqDxLYwTjB4nYgM1yy5N6gU/MUf8zqyyLltCH3yAVitBzmwK4us+DD0l/MauAg==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
@@ -13622,9 +13698,9 @@
}
},
"react-ga": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/react-ga/-/react-ga-3.0.0.tgz",
"integrity": "sha512-IKqqCtSMe0IfSRNvbHAiwXwIXbuza4VvHvB/2N3hEiMFGSjv8fNI6obPH6bkDdIjDpwzbUqKs8895OxBckWJ2g=="
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/react-ga/-/react-ga-3.1.2.tgz",
"integrity": "sha512-OJrMqaHEHbodm+XsnjA6ISBEHTwvpFrxco65mctzl/v3CASMSLSyUkFqz9yYrPDKGBUfNQzKCjuMJwctjlWBbw=="
},
"react-icon-base": {
"version": "2.1.0",
@@ -13645,9 +13721,9 @@
"integrity": "sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q=="
},
"react-jinke-music-player": {
"version": "4.13.1",
"resolved": "https://registry.npmjs.org/react-jinke-music-player/-/react-jinke-music-player-4.13.1.tgz",
"integrity": "sha512-WTX0a5mSo2gAHSfAYWj97QazoIw6F9y3r7jaC5cSjwJAGW3Ud8Qh7rIrAKf/17bjo8RjwyFiJFhG9R2CiXdihQ==",
"version": "4.16.3",
"resolved": "https://registry.npmjs.org/react-jinke-music-player/-/react-jinke-music-player-4.16.3.tgz",
"integrity": "sha512-YgIvbMzTmkGss4WI9+q7/1yFRvMKW3eEPJiTM8hpkANr4m1Y+/2ZkWo2wi4c97fLd2W6gHZbP6ummdbiqyFcXw==",
"requires": {
"classnames": "^2.2.6",
"downloadjs": "^1.4.7",
@@ -15793,11 +15869,6 @@
"punycode": "^2.1.0"
}
},
"ts-essentials": {
"version": "6.0.7",
"resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-6.0.7.tgz",
"integrity": "sha512-2E4HIIj4tQJlIHuATRHayv0EfMGK3ris/GRk1E3CFnsZzeNV+hUmelbaTZHLtXaZppM5oLhHRtO04gINC4Jusw=="
},
"ts-pnp": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.1.6.tgz",

View File

@@ -11,21 +11,21 @@
"lodash.throttle": "^4.1.1",
"md5-hex": "^3.0.1",
"prop-types": "^15.7.2",
"ra-data-json-server": "^3.6.1",
"ra-data-json-server": "^3.7.1",
"react": "^16.13.1",
"react-admin": "^3.6.1",
"react-admin": "^3.7.1",
"react-dom": "^16.13.1",
"react-drag-listview": "^0.1.7",
"react-ga": "^3.0.0",
"react-jinke-music-player": "^4.13.1",
"react-ga": "^3.1.2",
"react-jinke-music-player": "^4.16.3",
"react-measure": "^2.3.0",
"react-redux": "^7.2.0",
"react-scripts": "^3.4.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.3.0",
"@testing-library/user-event": "^12.0.6",
"@testing-library/jest-dom": "^5.11.1",
"@testing-library/react": "^10.4.7",
"@testing-library/user-event": "^12.0.11",
"prettier": "^2.0.5"
},
"scripts": {

Some files were not shown because too many files have changed in this diff Show More