mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-06 05:48:09 -05:00
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21b9f51b71 | ||
|
|
ed726c2126 | ||
|
|
23d69d26e0 | ||
|
|
3d0e70e907 | ||
|
|
34e843a4b3 | ||
|
|
924ada0dab | ||
|
|
2d3ed85311 | ||
|
|
3d4f4b4e2b | ||
|
|
338cbacb79 | ||
|
|
0cf574198e | ||
|
|
3000238a3c | ||
|
|
16c38eb344 | ||
|
|
721a959735 | ||
|
|
3c2b14d362 | ||
|
|
2b59d4b87a | ||
|
|
cefdeee495 | ||
|
|
3383327c51 | ||
|
|
38b341ebc5 | ||
|
|
ef0e5b130d | ||
|
|
3092f83a00 | ||
|
|
8daac43e99 | ||
|
|
d5da23ae42 | ||
|
|
eae46d15bf | ||
|
|
f6c518fd8b | ||
|
|
db8a48bba6 | ||
|
|
d877928f11 | ||
|
|
0403ec2a07 | ||
|
|
8d27c77c2c | ||
|
|
f992b5663f | ||
|
|
4e4fcb2304 | ||
|
|
ddb30ceb11 | ||
|
|
67da83c84d | ||
|
|
f8f16d676d | ||
|
|
58b816c2ed | ||
|
|
9b1d5c196f | ||
|
|
a0bed9beeb | ||
|
|
9f4f2f7381 | ||
|
|
433e31acc8 | ||
|
|
b795ad55a3 | ||
|
|
72efc18158 | ||
|
|
93626129b6 | ||
|
|
60178c264d | ||
|
|
de6afa16ec | ||
|
|
fd2df12263 | ||
|
|
37d66a7d41 | ||
|
|
040c7f1e7d | ||
|
|
d4a5508f6a | ||
|
|
036f9d6730 | ||
|
|
1b7f628759 | ||
|
|
5a891fda9e | ||
|
|
f96e2f6c4f | ||
|
|
7a5285ae47 | ||
|
|
ba347bc0b1 | ||
|
|
1bee98af52 | ||
|
|
ff623a8dce | ||
|
|
f28e8118dc | ||
|
|
167fca86d0 | ||
|
|
b828650cc5 | ||
|
|
e6846de0fa | ||
|
|
6c6254a3c3 | ||
|
|
0a9ad4e73a | ||
|
|
9f6eb4174f | ||
|
|
25cc523006 | ||
|
|
4c0000a809 | ||
|
|
0f7193f85d | ||
|
|
715855280e | ||
|
|
c322253fde | ||
|
|
17cea91e10 | ||
|
|
6caa5ee81f | ||
|
|
d46a8cf89f | ||
|
|
7e81a3b895 |
1
.github/workflows/pipeline.dockerfile
vendored
1
.github/workflows/pipeline.dockerfile
vendored
@@ -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"
|
||||
|
||||
|
||||
24
.github/workflows/pipeline.yml
vendored
24
.github/workflows/pipeline.yml
vendored
@@ -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
129
CODE_OF_CONDUCT.md
Normal 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.
|
||||
4
Makefile
4
Makefile
@@ -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/...)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
[](https://hub.docker.com/r/deluan/navidrome)
|
||||
[](https://discord.gg/xh7j7yF)
|
||||
[](https://www.reddit.com/r/navidrome/)
|
||||
[](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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
var Set = wire.NewSet(
|
||||
NewCover,
|
||||
NewArtwork,
|
||||
NewMediaStreamer,
|
||||
NewTranscodingCache,
|
||||
NewImageCache,
|
||||
|
||||
2
db/db.go
2
db/db.go
@@ -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"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
36
db/migrations/20200731095603_create_play_queues_table.go
Normal file
36
db/migrations/20200731095603_create_play_queues_table.go
Normal 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
|
||||
}
|
||||
53
db/migrations/20200801101355_create_bookmark_table.go
Normal file
53
db/migrations/20200801101355_create_bookmark_table.go
Normal 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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
28
model/bookmark.go
Normal 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"}
|
||||
@@ -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
|
||||
|
||||
@@ -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
23
model/playqueue.go
Normal 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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }{}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
153
persistence/playqueue_repository.go
Normal file
153
persistence/playqueue_repository.go
Normal 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)
|
||||
92
persistence/playqueue_repository_test.go
Normal file
92
persistence/playqueue_repository_test.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
151
persistence/sql_bookmarks.go
Normal file
151
persistence/sql_bookmarks.go
Normal 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
|
||||
}
|
||||
72
persistence/sql_bookmarks_test.go
Normal file
72
persistence/sql_bookmarks_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
297
resources/i18n/da.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
42
scanner/load_tree_test.go
Normal 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
21
scanner/mapping_test.go
Normal 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
60
scanner/refresh_buffer.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
131
server/subsonic/bookmarks.go
Normal file
131
server/subsonic/bookmarks.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"}]}}
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","bookmarks":{}}
|
||||
@@ -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>
|
||||
@@ -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"}}
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","playQueue":{"username":"","changedBy":""}}
|
||||
@@ -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>
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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
429
ui/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user