Compare commits

..

68 Commits

Author SHA1 Message Date
Deluan
9554c8f783 build: rename generated archives 2020-03-14 21:09:39 -04:00
Deluan
e36a42f356 build: generate binaries for Linux armv6, armv7 and arm68 (v8) (fixes #92) 2020-03-14 21:09:39 -04:00
dependabot-preview[bot]
9d1960232c build(deps): [security] bump acorn from 5.7.3 to 5.7.4 in /ui
Bumps [acorn](https://github.com/acornjs/acorn) from 5.7.3 to 5.7.4. **This update includes a security fix.**
- [Release notes](https://github.com/acornjs/acorn/releases)
- [Commits](https://github.com/acornjs/acorn/compare/5.7.3...5.7.4)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-03-13 18:01:31 -04:00
Deluan
d3547544bf feat: new WebUI icon 2020-03-11 20:18:22 -04:00
Deluan
9cb42606ba fix: force full rescan to enable search by album artist 2020-03-10 17:23:25 -04:00
dependabot-preview[bot]
7772afce1c build(deps): bump @testing-library/react from 9.4.1 to 9.5.0 in /ui
Bumps [@testing-library/react](https://github.com/testing-library/react-testing-library) from 9.4.1 to 9.5.0.
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/master/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v9.4.1...v9.5.0)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-03-10 17:11:46 -04:00
Deluan
10e76257c6 fix: increase contrast in WebUI's dark theme 2020-03-08 16:12:12 -04:00
Deluan
9235ab6414 fix: index albumArtist as part of the album searchable fields 2020-03-07 13:10:20 -05:00
Deluan
59356f0029 refactor: removed indirect call introduced by intellij's refactor 2020-03-06 16:28:20 -05:00
Deluan
9ae14015a1 build: get Go and Node versions from go.mod and .nvmrc respectively 2020-03-06 11:50:39 -05:00
Deluan
0b131e91c1 chore: upgrade to NodeJS 13.10 2020-03-06 10:57:00 -05:00
Deluan
77b12eafde chore: upgrade react-jinke-music-player 2020-03-05 08:57:40 -05:00
Deluan
050778460d fix: missing id in queue items was preventing scrobble to work properly 2020-03-04 10:30:58 -05:00
Deluan
28bc9c1d4f fix: AlbumShow was adding previous played tracks when trying to shuffle the album 2020-03-02 14:51:52 -05:00
Deluan
5e7aaa667b fix: missing id in queue items was preventing scrobble to work properly 2020-03-02 14:20:57 -05:00
Deluan
1afc495920 chore: upgrade react, react-dom and react-redux 2020-03-02 13:06:27 -05:00
Deluan
cf7d877714 chore: upgrade @testing-library/user-event 2020-03-02 12:04:58 -05:00
Deluan
81831da67a chore: upgrade react-admin 2020-03-02 11:58:23 -05:00
Deluan
fcd2fcae67 chore: upgrade @testing-library, react-scripts 2020-03-02 11:52:06 -05:00
Deluan
1c33b0aea8 docs: update API compatibility chart 2020-03-02 09:48:46 -05:00
Deluan
fc06163b5a refactor: remove superfluous (and untested) code 2020-03-02 09:37:47 -05:00
Deluan
72f0a6fb66 chore: removed unused (video) mime types 2020-03-02 00:16:15 -05:00
Deluan
6f5a322927 fix: login must be case-insensitive 2020-03-01 15:45:41 -05:00
Deluan
a7f8e4ee2b fix: only set created_at when adding data to DB 2020-02-28 18:43:22 -05:00
Deluan
0850872b0f fix: ormer.Driver() is not available when creating orms with NewOrmWithDB() 2020-02-28 16:09:27 -05:00
Deluan
1d886156d5 feat: better SQLite3 configuration, to avoid DB contention 2020-02-28 15:06:31 -05:00
Deluan
faa2a978c0 refactor: use only one DB instance for the whole application 2020-02-28 15:06:31 -05:00
Deluan
38faffa907 feat: notice function to notify (in logs) about important changes in migrations 2020-02-28 14:00:41 -05:00
Deluan
65a792be3a fix: handle nil pointer dereference 2020-02-28 11:02:38 -05:00
Deluan
876354e58e feat: MaxTranscodingCacheSize is now specified in MB 2020-02-26 14:08:14 -05:00
Deluan
14b33bc34d fix: there are no docker images available for node 13.9 2020-02-26 12:00:00 -05:00
Deluan
9044aa8740 chore: upgrade NodeJS to 13.9.0 2020-02-26 09:52:25 -05:00
Deluan
07ac14f810 chore: upgrade Go to 1.14 2020-02-26 09:37:48 -05:00
Deluan
0370f0a3ea refactor: rename ffmpeg to transcoder 2020-02-25 10:32:34 -05:00
Deluan
33ede13eef fix: check if album is starred before adding the starred date in the response. also return "starred" in search responses 2020-02-24 22:06:12 -05:00
Deluan
e032bfcf6b refactor: make parameters consistent 2020-02-24 19:04:54 -05:00
Deluan
f4014c475d refactor: make fakeFFmpeg more configurable, change test name 2020-02-24 14:17:32 -05:00
Deluan
f394de664a refactor: new transcoding engine. third (fourth?) time is a charm! 2020-02-24 13:56:09 -05:00
Deluan
d2eea64528 fix: typo 2020-02-23 21:41:10 -05:00
Deluan
d7b5e6a36c fix: add public attribute to playlists. Even though it is optional,
DSub requires it
2020-02-23 00:10:05 -05:00
Deluan
b49b9e3ca0 chore: remove unused script 2020-02-22 20:29:57 -05:00
Deluan
1322bb3bf3 refactor: move cache constructor 2020-02-21 09:36:29 -05:00
Deluan
13a046a679 fix: change stream cache eviction check period to every 10 minutes 2020-02-20 20:12:52 -05:00
Deluan
e6d2056438 fix: typo 2020-02-20 19:39:32 -05:00
Deluan
a6b0c57ce0 feat: add a proper caching system to the transcoding functionality 2020-02-20 19:25:39 -05:00
Deluan
fc14e346b9 feat: store duration as float, to cater for milliseconds 2020-02-20 17:02:06 -05:00
Deluan
5525145906 fix: audio stream's bitrate has precedence over container's bitrate 2020-02-20 13:56:45 -05:00
Deluan
74d87790b8 refactor: better ffmpeg output metadata parsing 2020-02-20 10:41:16 -05:00
Deluan
8ce796756f fix: error message 2020-02-19 15:34:05 -05:00
Deluan
a412989f7e refactor: more stable transcoder, based on http.FileSystem 2020-02-19 14:53:35 -05:00
Deluan
ae02dc203e chore: remove unused code 2020-02-19 09:08:05 -05:00
Deluan
fc7595a464 fix: cover art detection regex 2020-02-18 11:19:22 -05:00
Deluan
4ceaea7732 fix: extract stream level metadata 2020-02-18 10:00:05 -05:00
Deluan
894536c8ec Revert "fix: extract stream level metadata"
This reverts commit 92f6e55821.
2020-02-15 23:18:37 -05:00
Deluan
92f6e55821 fix: extract stream level metadata 2020-02-15 20:47:06 -05:00
Deluan
c3bd181648 feat: use tini to help in avoiding dangling processes 2020-02-15 18:34:47 -05:00
Deluan
3b12c92ad5 feat: add cache to the getCoverArt endpoint, avoid it being reloaded every single time in the UI 2020-02-15 14:32:11 -05:00
Deluan
272d897ec9 chore: go mod tidy 2020-02-15 11:37:27 -05:00
Deluan
e6d717cbbc fix: prevent zombies in transcoding 2020-02-15 11:05:03 -05:00
Deluan
b7f1fc0374 refactor: remove unused import 2020-02-14 09:16:59 -05:00
Deluan
de525edde0 feat: add song count and duration to AlbumDetails 2020-02-14 09:14:50 -05:00
Deluan
7f94660183 feat: use different resource for listing songs in albums 2020-02-14 09:02:32 -05:00
Deluan
b2d022b823 fix: ignore environment dependant test 2020-02-13 20:19:51 -05:00
Deluan
ba08f00c20 feat: make rescan faster, only loading metadata from changed files 2020-02-13 20:18:17 -05:00
Deluan
d9993c5877 refactor: separate metadata extraction from audio files scanning 2020-02-13 10:03:52 -05:00
Deluan
edb839a41d fix: only update artists and albums if there were any changes in files 2020-02-12 23:05:10 -05:00
Deluan
9fa73e3b7b feat: implement AlbumShow using a Datagrid. WIP: still need to make it responsive 2020-02-12 20:35:35 -05:00
dependabot-preview[bot]
8ebb85b0af build(deps): bump github.com/astaxie/beego from 1.12.0 to 1.12.1
Bumps [github.com/astaxie/beego](https://github.com/astaxie/beego) from 1.12.0 to 1.12.1.
- [Release notes](https://github.com/astaxie/beego/releases)
- [Commits](https://github.com/astaxie/beego/compare/v1.12.0...v1.12.1)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-02-10 08:57:04 -05:00
111 changed files with 2233 additions and 1569 deletions

View File

@@ -11,10 +11,10 @@ jobs:
os: [macOS-latest, ubuntu-latest]
steps:
- name: Set up Go 1.13
- name: Set up Go 1.14
uses: actions/setup-go@v1
with:
go-version: 1.13
go-version: 1.14
id: go
- name: Check out code into the Go module directory

View File

@@ -15,7 +15,7 @@ jobs:
fetch-depth: 0
- uses: actions/setup-node@v1
with:
node-version: 13
node-version: 13.10
- name: Build UI
run: |
cd ui
@@ -24,7 +24,7 @@ jobs:
- name: Fetch tags
run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
- name: Run GoReleaser
uses: docker://bepsays/ci-goreleaser:latest
uses: docker://bepsays/ci-goreleaser:1.14-1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -2,11 +2,58 @@
before:
hooks:
- apt-get update
- apt-get install -y gcc-arm-linux-gnueabi gcc-aarch64-linux-gnu
- go get -u github.com/go-bindata/go-bindata/...
- go-bindata -fs -prefix ui/build -tags embed -nocompress -pkg assets -o assets/embedded_gen.go ui/build/...
- git checkout .
builds:
- id: navidrome_linux_amd64
env:
- CGO_ENABLED=1
goos:
- linux
goarch:
- amd64
flags:
- -tags=embed
ldflags:
- "-extldflags '-static'"
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
- id: navidrome_linux_arm
env:
- CGO_ENABLED=1
- CC=arm-linux-gnueabi-gcc
goos:
- linux
goarch:
- arm
goarm:
- 6
- 7
flags:
- -tags=embed
ldflags:
- "-extldflags '-static'"
- "-extld=$CC"
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
- id: navidrome_linux_arm64
env:
- CGO_ENABLED=1
- CC=aarch64-linux-gnu-gcc
goos:
- linux
goarch:
- arm64
flags:
- -tags=embed
ldflags:
- "-extldflags '-static'"
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
- id: navidrome_darwin
env:
- CGO_ENABLED=1
@@ -21,19 +68,6 @@ builds:
ldflags:
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
- id: navidrome_linux
env:
- CGO_ENABLED=1
goos:
- linux
goarch:
- amd64
flags:
- -tags=embed
ldflags:
- "-extldflags '-static'"
- -X github.com/deluan/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/deluan/navidrome/consts.gitTag={{.Tag}}
- id: navidrome_windows_i686
env:
- CGO_ENABLED=1
@@ -69,8 +103,15 @@ archives:
format_overrides:
- goos: windows
format: zip
replacements:
darwin: macOS
linux: Linux
windows: Windows
386: i386
amd64: x86_64
checksum:
name_template: 'checksums.txt'
name_template: '{{ .ProjectName }}_checksums.txt'
snapshot:
name_template: "{{ .Tag }}-next"

2
.nvmrc
View File

@@ -1 +1 @@
v13.7.0
v13.10.1

View File

@@ -10,7 +10,7 @@ Navidrome and Subsonic:
* Right now, Navidrome only works with a single Music Library (Music Folder)
* Navidrome does not mark songs as played by calls to `stream`, only when
`scrobble` is called with `submission=true`
* Next features to be implemented: Playlists (WIP), MultiUser (WIP), Jukebox, Sharing, Podcasts, Bookmarks, Internet Radio.
* Next features to be implemented: Last.FM integration, Jukebox, Sharing, Bookmarks, Podcasts, Internet Radio.
Navidrome is actively being tested with:
[DSub](http://www.subsonic.org/pages/apps.jsp#dsub),
@@ -54,7 +54,7 @@ Navidrome is actively being tested with:
| `deletePlaylist` | |
| ||
| _MEDIA RETRIEVAL_ ||
| `stream` | No Transcoding/Downsampling support (for now)|
| `stream` | Experimental Transcoding/Downsampling support available |
| `download` | |
| `getCoverArt` | Only gets embedded artwork |
| `getAvatar` | Always returns the same image |
@@ -62,7 +62,7 @@ Navidrome is actively being tested with:
| _MEDIA ANNOTATION_ ||
| `star` | |
| `unstar` | |
| `setRating` | Doesn't work with artists |
| `setRating` | |
| `scrobble` | No Last.FM support yet. It is used to update play count and last played |
| ||
| _USER MANAGEMENT_ ||

View File

@@ -1,6 +1,6 @@
#####################################################
### Build UI bundles
FROM node:13.7-alpine AS jsbuilder
FROM node:13.10-alpine AS jsbuilder
WORKDIR /src
COPY ui/package.json ui/package-lock.json ./
RUN npm ci
@@ -10,7 +10,7 @@ RUN npm run build
#####################################################
### Build executable
FROM golang:1.13-alpine AS gobuilder
FROM golang:1.14-alpine AS gobuilder
# Download build tools
RUN mkdir -p /src/ui/build
@@ -48,6 +48,11 @@ RUN GIT_TAG=$(git name-rev --name-only HEAD) && \
FROM alpine as release
MAINTAINER Deluan Quintao <navidrome@deluan.com>
# Download Tini
ENV TINI_VERSION v0.18.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-static /tini
RUN chmod +x /tini
COPY --from=gobuilder /src/navidrome /app/
COPY --from=gobuilder /tmp/ffmpeg*/ffmpeg /usr/bin/
@@ -64,4 +69,5 @@ ENV ND_PORT 4533
EXPOSE 4533
WORKDIR /app
ENTRYPOINT "/app/navidrome"
ENTRYPOINT ["/tini", "--"]
CMD ["/app/navidrome"]

View File

@@ -1,5 +1,5 @@
GO_VERSION=1.13
NODE_VERSION=v13.7.0
GO_VERSION=$(shell grep -e "^go " go.mod | cut -f 2 -d ' ')
NODE_VERSION=$(shell cat .nvmrc)
GIT_SHA=$(shell git rev-parse --short HEAD)
@@ -51,7 +51,7 @@ check_env: check_go_env check_node_env
.PHONY: check_go_env
check_go_env:
@(hash go) || (echo "\nERROR: GO environment not setup properly!\n"; exit 1)
@go version | grep -q $(GO_VERSION) || (echo "\nERROR: Please upgrade your GO version\n"; exit 1)
@go version | grep -q $(GO_VERSION) || (echo "\nERROR: Please upgrade your GO version\nThis project requires version $(GO_VERSION)"; exit 1)
.PHONY: check_node_env
check_node_env:
@@ -79,4 +79,4 @@ release:
.PHONY: dist
dist:
docker run -it -v $(PWD):/github/workspace -w /github/workspace bepsays/ci-goreleaser:1.13-4 goreleaser release --rm-dist --skip-publish --snapshot
docker run -it -v $(PWD):/github/workspace -w /github/workspace bepsays/ci-goreleaser:1.14-1 goreleaser release --rm-dist --skip-publish --snapshot

View File

@@ -34,7 +34,6 @@ This project is being actively worked on. Expect a more polished experience and
on a frequent basis. Some upcoming features planned:
- Last.FM integration
- Pre-build binaries for Raspberry Pi
- Smart/dynamic playlists (similar to iTunes)
- Support for audiobooks (bookmarking)
- Jukebox mode
@@ -49,7 +48,8 @@ Various options are available:
### Pre-built executables
Just head to the [releases page](https://github.com/deluan/navidrome/releases) and download the latest version for you
platform. There are builds available for Linux, macOS and Windows (32 and 64 bits).
platform. There are builds available for Linux (amd64 and arm), macOS and Windows (32 and 64 bits).
For Raspberry Pi (tested with Raspbian Buster on Pi 4), use the Linux arm builds.
Remember to install [ffmpeg](https://ffmpeg.org/download.html) in your system, a requirement for Navidrome to work properly.
You may find the latest static build for your platform here: https://johnvansickle.com/ffmpeg/
@@ -86,7 +86,7 @@ To get the cutting-edge, latest version from master, use the image `deluan/navid
### Build from source
You will need to install [Go 1.13](https://golang.org/dl/) and [Node 13.7.0](http://nodejs.org).
You will need to install [Go 1.14](https://golang.org/dl/) and [Node 13.10.1](http://nodejs.org).
You'll also need [ffmpeg](https://ffmpeg.org) installed in your system. The setup is very strict, and
the steps bellow only work with these specific versions (enforced in the Makefile)

View File

@@ -1,98 +0,0 @@
#!/bin/bash
# Script to transfort .itc files into images (JPG or PNG)
#
# .itc files are located in ~/Music/iTunes/Album Artwork
#
# This script uses (/!\ needs ) ImageMagick's convert, hexdump, printf and dd.
#
# This script might be a little slow, You might want to look at Simon Kennedy's work at http://www.sffjunkie.co.uk/python-itc.html
#
# ~/{Library Path}/Album Artwork/Cache/D989408F65D05F99/04/13/04/D989408F65D05F99-EB5B7A9086F4B4D4.itc
#
# The filenames are an amalgam of the library ID (D989408F65D05F99) and the track's ID (EB5B7A9086F4B4D4).
# The directory structure comes from the library ID and the last three digits of the track's ID converted to decimal,
# ie 4D4 becomes 04, 13, 04.
#
AlbumArtwork="${HOME}/Music/iTunes 1/Album Artwork"
DestinationDir="Artwork"
IFS=$'\n'
if [ ! -d "$DestinationDir" ]; then
mkdir "$DestinationDir"
echo "new Images dir"
fi
for file in `find "$AlbumArtwork" -name '*.itc'`; do
start=0x11C
exit=0;
i=1;
echo $file
while [ 1 ]; do
typeOffset=$(($start+0x30))
imageType=$(hexdump -n 4 -s $typeOffset -e '"0x"4/1 "%02x" "\n"' $file)
#If there is no next byte, jump to the next itc file.
if [[ -z $imageType ]]; then
break
fi
imageOffsetOffset=$(($start+8))
itemSize=$(hexdump -n 4 -s $start -e '"0x"4/1 "%02x" "\n"' $file)
imageOffset=$(hexdump -n 4 -s $imageOffsetOffset -e '"0x"4/1 "%02x" "\n"' $file)
imageStart=$(($start+$imageOffset))
imageSize=$(($itemSize-imageOffset))
imageWidth=$(hexdump -n 4 -s $(($start+56)) -e '"0x"4/1 "%02x" "\n"' $file)
imageWidth=$(printf "%d" $imageWidth)
imageHeight=$(hexdump -n 4 -s $(($start+60)) -e '"0x"4/1 "%02x" "\n"' $file)
imageHeight=$(printf "%d" $imageHeight)
dir=$(dirname "$file")
xbase=${file##*/} #file.etc
xpref=${xbase%.*} #file prefix
#echo $file
#echo itemsize $itemSize
#echo start $start
#echo imageOffset $imageOffset
#echo imageStart $imageStart
#echo imageSize $imageSize
#echo imageWidth $imageWidth
#echo imageHeight $imageHeight
if [[ $imageType -eq 0x504E4766 ]] || [[ $imageType -eq 0x0000000E ]] ; then
targetFile="$DestinationDir/$xpref-$i.png"
if [ ! -f "$targetFile" ]; then
echo PNG
dd skip=$imageStart count=$imageSize if="$file" of="$targetFile" bs=1 &> /dev/null
fi
elif [[ $imageType -eq 0x41524762 ]] ; then
targetFile="$DestinationDir/$xpref-$i.png"
if [ ! -f "$targetFile" ]; then
echo ARGB
dd skip=$imageStart count=$imageSize if="$file" of="$TMPDIR/test$i" bs=1 &> /dev/null
#Using a matrix to convert ARGB to RGBA since imagemagick does only support rgba input
convert -size $imageWidth"x"$imageHeight -depth 8 -color-matrix '0 1 0 0 0 0 1 0 0 0 0 1 1 0 0 0' rgba:"$TMPDIR/test$i" "$targetFile"
fi
elif [[ $imageType -eq 0x0000000D ]] ; then
targetFile="$DestinationDir/$xpref-$i.jpg"
if [ ! -f "$targetFile" ]; then
echo JPG
dd skip=$imageStart count=$imageSize if="$file" of="$targetFile" bs=1 &> /dev/null
fi
else
echo $imageType
exit=1
break;
fi
start=$(($start+$itemSize))
i=$(($i+1))
done
done

View File

@@ -13,20 +13,21 @@ import (
)
type nd struct {
Port string `default:"4533"`
MusicFolder string `default:"./music"`
DataFolder string `default:"./"`
DbPath string
LogLevel string `default:"info"`
Port string `default:"4533"`
MusicFolder string `default:"./music"`
DataFolder string `default:"./"`
ScanInterval string `default:"1m"`
DbPath string
LogLevel string `default:"info"`
IgnoredArticles string `default:"The El La Los Las Le Les Os As O A"`
IndexGroups string `default:"A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)"`
EnableDownsampling bool `default:"false"`
MaxBitRate int `default:"0"`
DownsampleCommand string `default:"ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -"`
ProbeCommand string `default:"ffmpeg -i %s -f ffmetadata"`
ScanInterval string `default:"1m"`
EnableDownsampling bool `default:"false"`
MaxBitRate int `default:"0"`
MaxTranscodingCacheSize int64 `default:"100"` // in MB
DownsampleCommand string `default:"ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -"`
ProbeCommand string `default:"ffmpeg -i %s -f ffmetadata"`
// DevFlags. These are used to enable/disable debugging and incomplete features
DevDisableBanner bool `default:"false"`
@@ -82,7 +83,7 @@ func LoadFromFile(confFile string, skipFlags ...bool) {
os.Exit(2)
}
if Server.DbPath == "" {
Server.DbPath = filepath.Join(Server.DataFolder, "navidrome.db")
Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath)
}
if os.Getenv("PORT") != "" {
Server.Port = os.Getenv("PORT")

View File

@@ -6,6 +6,7 @@ const (
AppName = "navidrome"
LocalConfigFile = "./navidrome.toml"
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL"
InitialSetupFlagKey = "InitialSetup"
JWTSecretKey = "JWTSecret"
@@ -14,6 +15,8 @@ const (
UIAssetsLocalPath = "ui/build"
CacheDir = "cache"
DevInitialUserName = "admin"
DevInitialName = "Dev Admin"
)

View File

@@ -8,7 +8,6 @@ func init() {
".ogg": "audio/ogg",
".oga": "audio/ogg",
".opus": "audio/ogg",
".ogx": "application/ogg",
".aac": "audio/mp4",
".m4a": "audio/mp4",
".m4b": "audio/mp4",
@@ -18,20 +17,8 @@ func init() {
".ape": "audio/x-monkeys-audio",
".mpc": "audio/x-musepack",
".shn": "audio/x-shn",
".flv": "video/x-flv",
".avi": "video/avi",
".mpg": "video/mpeg",
".mpeg": "video/mpeg",
".mp4": "video/mp4",
".m4v": "video/x-m4v",
".mkv": "video/x-matroska",
".mov": "video/quicktime",
".wmv": "video/x-ms-wmv",
".ogv": "video/ogg",
".divx": "video/divx",
".m2ts": "video/MP2T",
".ts": "video/MP2T",
".webm": "video/webm",
".aif": "audio/x-aiff",
".aiff": "audio/x-aiff",
".gif": "image/gif",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",

View File

@@ -13,32 +13,36 @@ import (
)
var (
once sync.Once
Driver = "sqlite3"
Path string
)
func Init() {
var (
once sync.Once
db *sql.DB
)
func Db() *sql.DB {
once.Do(func() {
var err error
Path = conf.Server.DbPath
if Path == ":memory:" {
Path = "file::memory:?cache=shared"
conf.Server.DbPath = Path
}
log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver)
db, err = sql.Open(Driver, Path)
if err != nil {
panic(err)
}
})
return db
}
func EnsureLatestVersion() {
Init()
db, err := sql.Open(Driver, Path)
defer db.Close()
if err != nil {
log.Error("Failed to open DB", err)
os.Exit(1)
}
db := Db()
err = goose.SetDialect(Driver)
err := goose.SetDialect(Driver)
if err != nil {
log.Error("Invalid DB driver", "driver", Driver, err)
os.Exit(1)

View File

@@ -0,0 +1,129 @@
package migration
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(Up20200220143731, Down20200220143731)
}
func Up20200220143731(tx *sql.Tx) error {
notice(tx, "This migration will force the next scan to be a full rescan!")
_, err := tx.Exec(`
create table media_file_dg_tmp
(
id varchar(255) not null
primary key,
path varchar(255) default '' not null,
title varchar(255) default '' not null,
album varchar(255) default '' not null,
artist varchar(255) default '' not null,
artist_id varchar(255) default '' not null,
album_artist varchar(255) default '' not null,
album_id varchar(255) default '' not null,
has_cover_art bool default FALSE not null,
track_number integer default 0 not null,
disc_number integer default 0 not null,
year integer default 0 not null,
size integer default 0 not null,
suffix varchar(255) default '' not null,
duration real default 0 not null,
bit_rate integer default 0 not null,
genre varchar(255) default '' not null,
compilation bool default FALSE not null,
created_at datetime,
updated_at datetime
);
insert into media_file_dg_tmp(id, path, title, album, artist, artist_id, album_artist, album_id, has_cover_art, track_number, disc_number, year, size, suffix, duration, bit_rate, genre, compilation, created_at, updated_at) select id, path, title, album, artist, artist_id, album_artist, album_id, has_cover_art, track_number, disc_number, year, size, suffix, duration, bit_rate, genre, compilation, created_at, updated_at from media_file;
drop table media_file;
alter table media_file_dg_tmp rename to media_file;
create index media_file_album_id
on media_file (album_id);
create index media_file_genre
on media_file (genre);
create index media_file_path
on media_file (path);
create index media_file_title
on media_file (title);
create table album_dg_tmp
(
id varchar(255) not null
primary key,
name varchar(255) default '' not null,
artist_id varchar(255) default '' not null,
cover_art_path varchar(255) default '' not null,
cover_art_id varchar(255) default '' not null,
artist varchar(255) default '' not null,
album_artist varchar(255) default '' not null,
year integer default 0 not null,
compilation bool default FALSE not null,
song_count integer default 0 not null,
duration real default 0 not null,
genre varchar(255) default '' not null,
created_at datetime,
updated_at datetime
);
insert into album_dg_tmp(id, name, artist_id, cover_art_path, cover_art_id, artist, album_artist, year, compilation, song_count, duration, genre, created_at, updated_at) select id, name, artist_id, cover_art_path, cover_art_id, artist, album_artist, year, compilation, song_count, duration, genre, created_at, updated_at from album;
drop table album;
alter table album_dg_tmp rename to album;
create index album_artist
on album (artist);
create index album_artist_id
on album (artist_id);
create index album_genre
on album (genre);
create index album_name
on album (name);
create index album_year
on album (year);
create table playlist_dg_tmp
(
id varchar(255) not null
primary key,
name varchar(255) default '' not null,
comment varchar(255) default '' not null,
duration real default 0 not null,
owner varchar(255) default '' not null,
public bool default FALSE not null,
tracks text not null
);
insert into playlist_dg_tmp(id, name, comment, duration, owner, public, tracks) select id, name, comment, duration, owner, public, tracks from playlist;
drop table playlist;
alter table playlist_dg_tmp rename to playlist;
create index playlist_name
on playlist (name);
-- Force a full rescan
delete from property where id like 'LastScan%';
update media_file set updated_at = '0001-01-01';
`)
return err
}
func Down20200220143731(tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,21 @@
package migration
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(Up20200310171621, Down20200310171621)
}
func Up20200310171621(tx *sql.Tx) error {
notice(tx, "A full rescan will be performed to enable search by Album Artist!")
return forceFullRescan(tx)
}
func Down20200310171621(tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

55
db/migration/migration.go Normal file
View File

@@ -0,0 +1,55 @@
package migration
import (
"database/sql"
"fmt"
"sync"
"github.com/deluan/navidrome/consts"
)
// Use this in migrations that need to communicate something important (braking changes, forced reindexes, etc...)
func notice(tx *sql.Tx, msg string) {
if isDBInitialized(tx) {
fmt.Printf(`
*************************************************************************************
NOTICE: %s
*************************************************************************************
`, msg)
}
}
// Call this in migrations that requires a full rescan
func forceFullRescan(tx *sql.Tx) error {
_, err := tx.Exec(`
delete from property where id like 'LastScan%';
update media_file set updated_at = '0001-01-01';
`)
return err
}
var once sync.Once
func isDBInitialized(tx *sql.Tx) (initialized bool) {
once.Do(func() {
rows, err := tx.Query("select count(*) from property where id='" + consts.InitialSetupFlagKey + "'")
checkErr(err)
initialized = checkCount(rows) > 0
})
return initialized
}
func checkCount(rows *sql.Rows) (count int) {
for rows.Next() {
err := rows.Scan(&count)
checkErr(err)
}
return count
}
func checkErr(err error) {
if err != nil {
panic(err)
}
}

View File

@@ -159,16 +159,19 @@ func (b *browser) buildAlbumDir(al *model.Album, tracks model.MediaFiles) *Direc
Artist: al.Artist,
ArtistId: al.ArtistID,
SongCount: al.SongCount,
Duration: al.Duration,
Duration: int(al.Duration),
Created: al.CreatedAt,
Year: al.Year,
Genre: al.Genre,
CoverArt: al.CoverArtId,
PlayCount: int32(al.PlayCount),
Starred: al.StarredAt,
UserRating: al.Rating,
}
if al.Starred {
dir.Starred = al.StarredAt
}
dir.Entries = FromMediaFiles(tracks)
return dir
}

View File

@@ -14,7 +14,7 @@ var _ = Describe("Browser", func() {
var repo *mockGenreRepository
var b Browser
BeforeSuite(func() {
BeforeEach(func() {
repo = &mockGenreRepository{data: model.Genres{
{Name: "Rock", SongCount: 1000, AlbumCount: 100},
{Name: "", SongCount: 13, AlbumCount: 13},

View File

@@ -51,7 +51,9 @@ func FromArtist(ar *model.Artist) Entry {
e.Title = ar.Name
e.AlbumCount = ar.AlbumCount
e.IsDir = true
e.Starred = ar.StarredAt
if ar.Starred {
e.Starred = ar.StarredAt
}
return e
}
@@ -69,9 +71,11 @@ func FromAlbum(al *model.Album) Entry {
e.Created = al.CreatedAt
e.AlbumId = al.ID
e.ArtistId = al.ArtistID
e.Duration = al.Duration
e.Duration = int(al.Duration)
e.SongCount = al.SongCount
e.Starred = al.StarredAt
if al.Starred {
e.Starred = al.StarredAt
}
e.PlayCount = int32(al.PlayCount)
e.UserRating = al.Rating
return e
@@ -88,7 +92,7 @@ func FromMediaFile(mf *model.MediaFile) Entry {
e.Artist = mf.Artist
e.Genre = mf.Genre
e.Track = mf.TrackNumber
e.Duration = mf.Duration
e.Duration = int(mf.Duration)
e.Size = mf.Size
e.Suffix = mf.Suffix
e.BitRate = mf.BitRate
@@ -105,9 +109,11 @@ func FromMediaFile(mf *model.MediaFile) Entry {
e.Created = mf.CreatedAt
e.AlbumId = mf.AlbumID
e.ArtistId = mf.ArtistID
e.Type = "music" // TODO Hardcoded for now
e.Type = "music"
e.PlayCount = int32(mf.PlayCount)
e.Starred = mf.StarredAt
if mf.Starred {
e.Starred = mf.StarredAt
}
e.UserRating = mf.Rating
return e
}

View File

@@ -2,228 +2,180 @@ package engine
import (
"context"
"fmt"
"io"
"io/ioutil"
"mime"
"os"
"os/exec"
"strconv"
"strings"
"path/filepath"
"time"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/engine/transcoder"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/utils"
"github.com/djherbis/fscache"
)
type MediaStreamer interface {
NewStream(ctx context.Context, id string, maxBitRate int, format string) (mediaStream, error)
NewStream(ctx context.Context, id string, maxBitRate int, format string) (*Stream, error)
}
func NewMediaStreamer(ds model.DataStore) MediaStreamer {
return &mediaStreamer{ds: ds}
}
type mediaStream interface {
io.ReadSeeker
ContentType() string
Name() string
ModTime() time.Time
Close() error
Duration() int
func NewMediaStreamer(ds model.DataStore, ffm transcoder.Transcoder, cache fscache.Cache) MediaStreamer {
return &mediaStreamer{ds: ds, ffm: ffm, cache: cache}
}
type mediaStreamer struct {
ds model.DataStore
ds model.DataStore
ffm transcoder.Transcoder
cache fscache.Cache
}
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, maxBitRate int, format string) (mediaStream, error) {
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, maxBitRate int, reqFormat string) (*Stream, error) {
mf, err := ms.ds.MediaFile(ctx).Get(id)
if err != nil {
return nil, err
}
bitRate, format := selectTranscodingOptions(mf, maxBitRate, reqFormat)
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,
"requestBitrate", maxBitRate, "requestFormat", reqFormat,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
f, err := os.Open(mf.Path)
if err != nil {
return nil, err
}
s.Reader = f
s.Closer = f
s.Seeker = f
s.format = mf.Suffix
return s, nil
}
key := cacheKey(id, bitRate, format)
r, w, err := ms.cache.Get(key)
if err != nil {
log.Error(ctx, "Error creating stream caching buffer", "id", mf.ID, err)
return nil, err
}
// If this is a brand new transcoding request, not in the cache, start transcoding
if w != nil {
log.Trace(ctx, "Cache miss. Starting new transcoding session", "id", mf.ID)
out, err := ms.ffm.Start(ctx, mf.Path, bitRate, format)
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 w == nil {
size := getFinalCachedSize(r)
if size > 0 {
log.Debug(ctx, "Streaming cached file", "id", mf.ID, "path", mf.Path,
"requestBitrate", maxBitRate, "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,
"requestBitrate", maxBitRate, "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
}
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)
}
}
type Stream struct {
ctx context.Context
mf *model.MediaFile
bitRate int
format string
io.Reader
io.Closer
io.Seeker
}
func (s *Stream) Seekable() bool { return s.Seeker != nil }
func (s *Stream) Duration() float32 { return s.mf.Duration }
func (s *Stream) ContentType() string { return mime.TypeByExtension("." + s.format) }
func (s *Stream) Name() string { return s.mf.Path }
func (s *Stream) ModTime() time.Time { return s.mf.UpdatedAt }
func selectTranscodingOptions(mf *model.MediaFile, maxBitRate int, format string) (int, string) {
var bitRate int
if format == "raw" || !conf.Server.EnableDownsampling {
bitRate = mf.BitRate
format = mf.Suffix
return bitRate, "raw"
} else {
if maxBitRate == 0 {
bitRate = mf.BitRate
} else {
bitRate = utils.MinInt(mf.BitRate, maxBitRate)
}
format = mf.Suffix
format = "mp3" //mf.Suffix
}
if conf.Server.MaxBitRate != 0 {
bitRate = utils.MinInt(bitRate, conf.Server.MaxBitRate)
}
var stream mediaStream
if bitRate == mf.BitRate && mime.TypeByExtension("."+format) == mf.ContentType() {
log.Debug(ctx, "Streaming raw file", "id", mf.ID, "path", mf.Path,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
f, err := os.Open(mf.Path)
if err != nil {
return nil, err
}
stream = &rawMediaStream{ctx: ctx, mf: mf, file: f}
return stream, nil
if bitRate == mf.BitRate {
return bitRate, "raw"
}
log.Debug(ctx, "Streaming transcoded file", "id", mf.ID, "path", mf.Path,
"requestBitrate", bitRate, "requestFormat", format,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
f := &transcodedMediaStream{ctx: ctx, mf: mf, bitRate: bitRate, format: format}
return f, err
return bitRate, format
}
type rawMediaStream struct {
file *os.File
ctx context.Context
mf *model.MediaFile
func cacheKey(id string, bitRate int, format string) string {
return fmt.Sprintf("%s.%d.%s", id, bitRate, format)
}
func (m *rawMediaStream) Read(p []byte) (n int, err error) {
return m.file.Read(p)
}
func (m *rawMediaStream) Seek(offset int64, whence int) (int64, error) {
return m.file.Seek(offset, whence)
}
func (m *rawMediaStream) ContentType() string {
return m.mf.ContentType()
}
func (m *rawMediaStream) Name() string {
return m.mf.Path
}
func (m *rawMediaStream) ModTime() time.Time {
return m.mf.UpdatedAt
}
func (m *rawMediaStream) Duration() int {
return m.mf.Duration
}
func (m *rawMediaStream) Close() error {
log.Trace(m.ctx, "Closing file", "id", m.mf.ID, "path", m.mf.Path)
return m.file.Close()
}
type transcodedMediaStream struct {
ctx context.Context
mf *model.MediaFile
pipe io.ReadCloser
bitRate int
format string
skip int64
pos int64
}
func (m *transcodedMediaStream) Read(p []byte) (n int, err error) {
// Open the pipe and optionally skip a initial chunk of the stream (to simulate a Seek)
if m.pipe == nil {
m.pipe, err = newTranscode(m.ctx, m.mf.Path, m.bitRate, m.format)
if err != nil {
return 0, err
}
if m.skip > 0 {
_, err := io.CopyN(ioutil.Discard, m.pipe, m.skip)
m.pos = m.skip
if err != nil {
return 0, err
}
func getFinalCachedSize(r fscache.ReadAtCloser) int64 {
cr, ok := r.(*fscache.CacheReader)
if ok {
size, final, err := cr.Size()
if final && err == nil {
return size
}
}
n, err = m.pipe.Read(p)
m.pos += int64(n)
if err == io.EOF {
m.Close()
return -1
}
func NewTranscodingCache() (fscache.Cache, error) {
lru := fscache.NewLRUHaunter(0, conf.Server.MaxTranscodingCacheSize*1024*1024, 10*time.Minute)
h := fscache.NewLRUHaunterStrategy(lru)
cacheFolder := filepath.Join(conf.Server.DataFolder, consts.CacheDir)
fs, err := fscache.NewFs(cacheFolder, 0755)
if err != nil {
return nil, err
}
return
}
// This is an attempt to make a pipe seekable. It is very wasteful, restarting the stream every time
// a Seek happens. This is ok-ish for audio, but would kill the server for video.
func (m *transcodedMediaStream) Seek(offset int64, whence int) (int64, error) {
size := int64((m.mf.Duration)*m.bitRate*1000) / 8
log.Trace(m.ctx, "Seeking transcoded stream", "path", m.mf.Path, "offset", offset, "whence", whence, "size", size)
switch whence {
case io.SeekEnd:
m.skip = size - offset
offset = size
case io.SeekStart:
m.skip = offset
case io.SeekCurrent:
io.CopyN(ioutil.Discard, m.pipe, offset)
m.pos += offset
offset = m.pos
}
// If need to Seek to a previous position, close the pipe (will be restarted on next Read)
var err error
if whence != io.SeekCurrent {
if m.pipe != nil {
err = m.Close()
}
}
return offset, err
}
func (m *transcodedMediaStream) ContentType() string {
return mime.TypeByExtension(".mp3")
}
func (m *transcodedMediaStream) Name() string {
return m.mf.Path
}
func (m *transcodedMediaStream) ModTime() time.Time {
return m.mf.UpdatedAt
}
func (m *transcodedMediaStream) Duration() int {
return m.mf.Duration
}
func (m *transcodedMediaStream) Close() error {
log.Trace(m.ctx, "Closing stream", "id", m.mf.ID, "path", m.mf.Path)
err := m.pipe.Close()
m.pipe = nil
m.pos = 0
return err
}
func newTranscode(ctx context.Context, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
cmdLine, args := createTranscodeCommand(path, maxBitRate, format)
log.Trace(ctx, "Executing ffmpeg command", "arg0", cmdLine, "args", args)
cmd := exec.Command(cmdLine, args...)
cmd.Stderr = os.Stderr
if f, err = cmd.StdoutPipe(); err != nil {
return f, err
}
return f, cmd.Start()
}
func createTranscodeCommand(path string, maxBitRate int, format string) (string, []string) {
cmd := conf.Server.DownsampleCommand
split := strings.Split(cmd, " ")
for i, s := range split {
s = strings.Replace(s, "%s", path, -1)
s = strings.Replace(s, "%b", strconv.Itoa(maxBitRate), -1)
split[i] = s
}
return split[0], split[1:]
return fscache.NewCacheWithHaunter(fs, h)
}

View File

@@ -1,75 +1,93 @@
package engine
import (
"time"
"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/persistence"
"github.com/djherbis/fscache"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("MediaStreamer", func() {
var streamer MediaStreamer
var ds model.DataStore
var cache fscache.Cache
var tempDir string
ffmpeg := &fakeFFmpeg{Data: "fake data"}
ctx := log.NewContext(nil)
BeforeSuite(func() {
tempDir, _ = ioutil.TempDir("", "stream_tests")
fs, _ := fscache.NewFs(tempDir, 0755)
cache, _ = fscache.NewCache(fs, nil)
})
BeforeEach(func() {
conf.Server.EnableDownsampling = true
ds = &persistence.MockDataStore{}
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "bitRate": 128}]`, 1)
streamer = NewMediaStreamer(ds)
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "bitRate": 128, "duration": 257.0}]`, 1)
streamer = NewMediaStreamer(ds, ffmpeg, cache)
})
AfterSuite(func() {
os.RemoveAll(tempDir)
})
Context("NewStream", func() {
It("returns a rawMediaStream if format is 'raw'", func() {
Expect(streamer.NewStream(ctx, "123", 0, "raw")).To(BeAssignableToTypeOf(&rawMediaStream{}))
It("returns a seekable stream if format is 'raw'", func() {
s, err := streamer.NewStream(ctx, "123", 0, "raw")
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a rawMediaStream if maxBitRate is 0", func() {
Expect(streamer.NewStream(ctx, "123", 0, "mp3")).To(BeAssignableToTypeOf(&rawMediaStream{}))
It("returns a seekable stream if maxBitRate is 0", func() {
s, err := streamer.NewStream(ctx, "123", 0, "mp3")
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a rawMediaStream if maxBitRate is higher than file bitRate", func() {
Expect(streamer.NewStream(ctx, "123", 256, "mp3")).To(BeAssignableToTypeOf(&rawMediaStream{}))
It("returns a seekable stream if maxBitRate is higher than file bitRate", func() {
s, err := streamer.NewStream(ctx, "123", 320, "mp3")
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a transcodedMediaStream if maxBitRate is lower than file bitRate", func() {
It("returns a NON seekable stream if transcode is required", func() {
s, err := streamer.NewStream(ctx, "123", 64, "mp3")
Expect(err).To(BeNil())
Expect(s).To(BeAssignableToTypeOf(&transcodedMediaStream{}))
Expect(s.(*transcodedMediaStream).bitRate).To(Equal(64))
Expect(s.Seekable()).To(BeFalse())
Expect(s.Duration()).To(Equal(float32(257.0)))
})
})
Context("rawMediaStream", func() {
var rawStream mediaStream
var modTime time.Time
BeforeEach(func() {
modTime = time.Now()
mf := &model.MediaFile{ID: "123", Path: "test.mp3", UpdatedAt: modTime, Suffix: "mp3"}
rawStream = &rawMediaStream{mf: mf, ctx: ctx}
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", 64, "mp3")
Expect(err).To(BeNil())
Expect(s.Seekable()).To(BeTrue())
})
It("returns the ContentType", func() {
Expect(rawStream.ContentType()).To(Equal("audio/mpeg"))
})
It("returns the ModTime", func() {
Expect(rawStream.ModTime()).To(Equal(modTime))
})
})
Context("createTranscodeCommand", func() {
BeforeEach(func() {
conf.Server.DownsampleCommand = "ffmpeg -i %s -b:a %bk mp3 -"
})
It("creates a valid command line", func() {
cmd, args := createTranscodeCommand("/music library/file.mp3", 123, "")
Expect(cmd).To(Equal("ffmpeg"))
Expect(args).To(Equal([]string{"-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
})
})
})
type fakeFFmpeg struct {
Data string
r io.Reader
closed bool
}
func (ff *fakeFFmpeg) Start(ctx context.Context, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
ff.r = strings.NewReader(ff.Data)
return ff, nil
}
func (ff *fakeFFmpeg) Read(p []byte) (n int, err error) {
return ff.r.Read(p)
}
func (ff *fakeFFmpeg) Close() error {
ff.closed = true
return nil
}

View File

@@ -102,7 +102,11 @@ func (p *playlists) Update(ctx context.Context, playlistId string, name *string,
}
func (p *playlists) GetAll(ctx context.Context) (model.Playlists, error) {
return p.ds.Playlist(ctx).GetAll(model.QueryOptions{})
all, err := p.ds.Playlist(ctx).GetAll(model.QueryOptions{})
for i := range all {
all[i].Public = true
}
return all, err
}
type PlaylistInfo struct {
@@ -127,7 +131,7 @@ func (p *playlists) Get(ctx context.Context, id string) (*PlaylistInfo, error) {
Id: pl.ID,
Name: pl.Name,
SongCount: len(pl.Tracks),
Duration: pl.Duration,
Duration: int(pl.Duration),
Public: pl.Public,
Owner: pl.Owner,
Comment: pl.Comment,

View File

@@ -0,0 +1,52 @@
package transcoder
import (
"context"
"io"
"os"
"os/exec"
"strconv"
"strings"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/log"
)
type Transcoder interface {
Start(ctx context.Context, path string, maxBitRate int, format string) (f io.ReadCloser, err error)
}
func New() Transcoder {
return &ffmpeg{}
}
type ffmpeg struct{}
func (ff *ffmpeg) Start(ctx context.Context, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
arg0, args := createTranscodeCommand(path, maxBitRate, format)
log.Trace(ctx, "Executing ffmpeg command", "cmd", arg0, "args", args)
cmd := exec.Command(arg0, args...)
cmd.Stderr = os.Stderr
if f, err = cmd.StdoutPipe(); err != nil {
return
}
if err = cmd.Start(); err != nil {
return
}
go cmd.Wait() // prevent zombies
return
}
func createTranscodeCommand(path string, maxBitRate int, format string) (string, []string) {
cmd := conf.Server.DownsampleCommand
split := strings.Split(cmd, " ")
for i, s := range split {
s = strings.Replace(s, "%s", path, -1)
s = strings.Replace(s, "%b", strconv.Itoa(maxBitRate), -1)
split[i] = s
}
return split[0], split[1:]
}

View File

@@ -0,0 +1,29 @@
package transcoder
import (
"testing"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/tests"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
func TestTranscoder(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelCritical)
RegisterFailHandler(Fail)
RunSpecs(t, "Transcoder Suite")
}
var _ = Describe("createTranscodeCommand", func() {
BeforeEach(func() {
conf.Server.DownsampleCommand = "ffmpeg -i %s -b:a %bk mp3 -"
})
It("creates a valid command line", func() {
cmd, args := createTranscodeCommand("/music library/file.mp3", 123, "")
Expect(cmd).To(Equal("ffmpeg"))
Expect(args).To(Equal([]string{"-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
})
})

View File

@@ -1,6 +1,9 @@
package engine
import "github.com/google/wire"
import (
"github.com/deluan/navidrome/engine/transcoder"
"github.com/google/wire"
)
var Set = wire.NewSet(
NewBrowser,
@@ -13,4 +16,6 @@ var Set = wire.NewSet(
NewNowPlayingRepository,
NewUsers,
NewMediaStreamer,
transcoder.New,
NewTranscodingCache,
)

7
go.mod
View File

@@ -1,15 +1,16 @@
module github.com/deluan/navidrome
go 1.13
go 1.14
require (
github.com/BurntSushi/toml v0.3.1 // indirect
github.com/Masterminds/squirrel v1.2.0
github.com/astaxie/beego v1.12.0
github.com/astaxie/beego v1.12.1
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible
github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8
github.com/djherbis/fscache v0.10.0
github.com/fatih/camelcase v0.0.0-20160318181535-f6a740d52f96 // indirect
github.com/fatih/structs v1.0.0 // indirect
github.com/go-chi/chi v4.0.3+incompatible
@@ -38,5 +39,7 @@ require (
golang.org/x/text v0.3.2 // indirect
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
gopkg.in/djherbis/atime.v1 v1.0.0 // indirect
gopkg.in/djherbis/stream.v1 v1.2.0 // indirect
gopkg.in/yaml.v2 v2.2.8 // indirect
)

21
go.sum
View File

@@ -4,8 +4,8 @@ github.com/Knetic/govaluate v3.0.0+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8L
github.com/Masterminds/squirrel v1.2.0 h1:K1NhbTO21BWG47IVR0OnIZuE0LZcXAYqywrC3Ko53KI=
github.com/Masterminds/squirrel v1.2.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
github.com/OwnLocal/goes v1.0.0/go.mod h1:8rIFjBGTue3lCU0wplczcUgt9Gxgrkkrw7etMIcn8TM=
github.com/astaxie/beego v1.12.0 h1:MRhVoeeye5N+Flul5PoVfD9CslfdoH+xqC/xvSQ5u2Y=
github.com/astaxie/beego v1.12.0/go.mod h1:fysx+LZNZKnvh4GED/xND7jWtjCR6HzydR2Hh2Im57o=
github.com/astaxie/beego v1.12.1 h1:dfpuoxpzLVgclveAXe4PyNKqkzgm5zF4tgF2B3kkM2I=
github.com/astaxie/beego v1.12.1/go.mod h1:kPBWpSANNbSdIqOc8SUL9h+1oyBMZhROeYsXQDbidWQ=
github.com/beego/goyaml2 v0.0.0-20130207012346-5545475820dd/go.mod h1:1b+Y/CofkYwXMUU0OhQqGvsY2Bvgr4j6jfT699wyZKQ=
github.com/beego/x2j v0.0.0-20131220205130-a0352aadc542/go.mod h1:kSeGC/p1AbBiEp5kat81+DSQrZenVBZXklMLaELspWU=
github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737/go.mod h1:PmM6Mmwb0LSuEubjR8N7PtNe1KxZLtOUHtbeikc5h60=
@@ -28,6 +28,8 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumC
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8 h1:nmFnmD8VZXkjDHIb1Gnfz50cgzUvGN72zLjPRXBW/hU=
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8/go.mod h1:SniNVYuaD1jmdEEvi+7ywb1QFR7agjeTdGKyFb0p7Rw=
github.com/djherbis/fscache v0.10.0 h1:+O3s3LwKL1Jfz7txRHpgKOz8/krh9w+NzfzxtFdbsQg=
github.com/djherbis/fscache v0.10.0/go.mod h1:yyPYtkNnnPXsW+81lAcQS6yab3G2CRfnPLotBvtbf0c=
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8=
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
@@ -130,19 +132,24 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
github.com/syndtr/goleveldb v0.0.0-20181127023241-353a9fca669c h1:3eGShk3EQf5gJCYW+WzA0TEJQd37HLOmlYF7N0YJwv0=
github.com/syndtr/goleveldb v0.0.0-20181127023241-353a9fca669c/go.mod h1:Z4AUp2Km+PwemOoO/VB5AOx9XSsIItzFjoJlOSiYmn0=
github.com/wendal/errors v0.0.0-20130201093226-f66c77a7882b/go.mod h1:Q12BUT7DqIlHRmgv3RskH+UCM/4eqVMgI0EMmlSpAXc=
golang.org/x/crypto v0.0.0-20181127143415-eb0de9b17e85/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2 h1:CCH4IOTTfewWjGOlSp+zGcjutRKlBEZQ6wTn8ozI/nI=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -157,14 +164,20 @@ golang.org/x/tools v0.0.0-20190328211700-ab21143f2384 h1:TFlARGu6Czu1z7q93HTxcP1
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b h1:NVD8gBK33xpdqCaZVVtd6OFJp+3dxkXuz7+U7KaVN6s=
golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20200117065230-39095c1d176c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/djherbis/atime.v1 v1.0.0 h1:eMRqB/JrLKocla2PBPKgQYg/p5UG4L6AUAs92aP7F60=
gopkg.in/djherbis/atime.v1 v1.0.0/go.mod h1:hQIUStKmJfvf7xdh/wtK84qe+DsTV5LnA9lzxxtPpJ8=
gopkg.in/djherbis/stream.v1 v1.2.0 h1:3tZuXO+RK8opjw8/BJr780h+eAPwOFfLHCKRKyYxk3s=
gopkg.in/djherbis/stream.v1 v1.2.0/go.mod h1:aEV8CBVRmSpLamVJfM903Npic1IKmb2qS30VAZ+sssg=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=

View File

@@ -1,6 +1,8 @@
package main
import (
"fmt"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/db"
@@ -14,8 +16,12 @@ func main() {
conf.Load()
db.EnsureLatestVersion()
subsonic, err := CreateSubsonicAPIRouter()
if err != nil {
panic(fmt.Sprintf("Could not create the Subsonic API router. Aborting! err=%v", err))
}
a := CreateServer(conf.Server.MusicFolder)
a.MountRouter("/rest", CreateSubsonicAPIRouter())
a.MountRouter("/rest", subsonic)
a.MountRouter("/app", CreateAppRouter("/app"))
a.Run(":" + conf.Server.Port)
}

View File

@@ -13,7 +13,7 @@ type Album struct {
Year int `json:"year"`
Compilation bool `json:"compilation"`
SongCount int `json:"songCount"`
Duration int `json:"duration"`
Duration float32 `json:"duration"`
Genre string `json:"genre"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`

View File

@@ -20,7 +20,7 @@ type MediaFile struct {
Year int `json:"year"`
Size int `json:"size"`
Suffix string `json:"suffix"`
Duration int `json:"duration"`
Duration float32 `json:"duration"`
BitRate int `json:"bitRate"`
Genre string `json:"genre"`
Compilation bool `json:"compilation"`

View File

@@ -4,7 +4,7 @@ type Playlist struct {
ID string
Name string
Comment string
Duration int
Duration float32
Owner string
Public bool
Tracks MediaFiles

View File

@@ -22,6 +22,7 @@ type UserRepository interface {
CountAll(...QueryOptions) (int64, error)
Get(id string) (*User, error)
Put(*User) error
// FindByUsername must be case-insensitive
FindByUsername(username string) (*User, error)
UpdateLastLoginAt(id string) error
UpdateLastAccessAt(id string) error

View File

@@ -9,7 +9,6 @@ import (
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/rest"
"github.com/dhowden/tag/mbz"
)
type albumRepository struct {
@@ -37,11 +36,11 @@ func (r *albumRepository) Put(a *model.Album) error {
if err != nil {
return err
}
return r.index(a.ID, a.Name, a.Artist, mbz.AlbumArtist)
return r.index(a.ID, a.Name, a.Artist, a.AlbumArtist)
}
func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder {
return r.newSelectWithAnnotation("id", options...).Columns("*")
return r.newSelectWithAnnotation("album.id", options...).Columns("*")
}
func (r *albumRepository) Get(id string) (*model.Album, error) {
@@ -71,12 +70,7 @@ func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, e
// TODO Keep order when paginating
func (r *albumRepository) GetRandom(options ...model.QueryOptions) (model.Albums, error) {
sq := r.selectAlbum(options...)
switch r.ormer.Driver().Type() {
case orm.DRMySQL:
sq = sq.OrderBy("RAND()")
default:
sq = sq.OrderBy("RANDOM()")
}
sq = sq.OrderBy("RANDOM()")
results := model.Albums{}
err := r.queryAll(sq, &results)
return results, err

View File

@@ -29,7 +29,7 @@ func NewArtistRepository(ctx context.Context, o orm.Ormer) model.ArtistRepositor
}
func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder {
return r.newSelectWithAnnotation("id", options...).Columns("*")
return r.newSelectWithAnnotation("artist.id", options...).Columns("*")
}
func (r *artistRepository) CountAll(options ...model.QueryOptions) (int64, error) {

View File

@@ -100,12 +100,7 @@ func (r mediaFileRepository) GetStarred(options ...model.QueryOptions) (model.Me
// TODO Keep order when paginating
func (r mediaFileRepository) GetRandom(options ...model.QueryOptions) (model.MediaFiles, error) {
sq := r.selectMediaFile(options...)
switch r.ormer.Driver().Type() {
case orm.DRMySQL:
sq = sq.OrderBy("RAND()")
default:
sq = sq.OrderBy("RANDOM()")
}
sq = sq.OrderBy("RANDOM()")
results := model.MediaFiles{}
err := r.queryAll(sq, &results)
return results, err

View File

@@ -20,65 +20,62 @@ type SQLStore struct {
}
func New() model.DataStore {
once.Do(func() {
err := orm.RegisterDataBase("default", db.Driver, db.Path)
if err != nil {
panic(err)
}
})
return &SQLStore{}
}
func (db *SQLStore) Album(ctx context.Context) model.AlbumRepository {
return NewAlbumRepository(ctx, db.getOrmer())
func (s *SQLStore) Album(ctx context.Context) model.AlbumRepository {
return NewAlbumRepository(ctx, s.getOrmer())
}
func (db *SQLStore) Artist(ctx context.Context) model.ArtistRepository {
return NewArtistRepository(ctx, db.getOrmer())
func (s *SQLStore) Artist(ctx context.Context) model.ArtistRepository {
return NewArtistRepository(ctx, s.getOrmer())
}
func (db *SQLStore) MediaFile(ctx context.Context) model.MediaFileRepository {
return NewMediaFileRepository(ctx, db.getOrmer())
func (s *SQLStore) MediaFile(ctx context.Context) model.MediaFileRepository {
return NewMediaFileRepository(ctx, s.getOrmer())
}
func (db *SQLStore) MediaFolder(ctx context.Context) model.MediaFolderRepository {
return NewMediaFolderRepository(ctx, db.getOrmer())
func (s *SQLStore) MediaFolder(ctx context.Context) model.MediaFolderRepository {
return NewMediaFolderRepository(ctx, s.getOrmer())
}
func (db *SQLStore) Genre(ctx context.Context) model.GenreRepository {
return NewGenreRepository(ctx, db.getOrmer())
func (s *SQLStore) Genre(ctx context.Context) model.GenreRepository {
return NewGenreRepository(ctx, s.getOrmer())
}
func (db *SQLStore) Playlist(ctx context.Context) model.PlaylistRepository {
return NewPlaylistRepository(ctx, db.getOrmer())
func (s *SQLStore) Playlist(ctx context.Context) model.PlaylistRepository {
return NewPlaylistRepository(ctx, s.getOrmer())
}
func (db *SQLStore) Property(ctx context.Context) model.PropertyRepository {
return NewPropertyRepository(ctx, db.getOrmer())
func (s *SQLStore) Property(ctx context.Context) model.PropertyRepository {
return NewPropertyRepository(ctx, s.getOrmer())
}
func (db *SQLStore) User(ctx context.Context) model.UserRepository {
return NewUserRepository(ctx, db.getOrmer())
func (s *SQLStore) User(ctx context.Context) model.UserRepository {
return NewUserRepository(ctx, s.getOrmer())
}
func (db *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
switch m.(type) {
case model.User:
return db.User(ctx).(model.ResourceRepository)
return s.User(ctx).(model.ResourceRepository)
case model.Artist:
return db.Artist(ctx).(model.ResourceRepository)
return s.Artist(ctx).(model.ResourceRepository)
case model.Album:
return db.Album(ctx).(model.ResourceRepository)
return s.Album(ctx).(model.ResourceRepository)
case model.MediaFile:
return db.MediaFile(ctx).(model.ResourceRepository)
return s.MediaFile(ctx).(model.ResourceRepository)
}
log.Error("Resource no implemented", "model", reflect.TypeOf(m).Name())
return nil
}
func (db *SQLStore) WithTx(block func(tx model.DataStore) error) error {
o := orm.NewOrm()
err := o.Begin()
func (s *SQLStore) WithTx(block func(tx model.DataStore) error) error {
o, err := orm.NewOrmWithDB(db.Driver, "default", db.Db())
if err != nil {
return err
}
err = o.Begin()
if err != nil {
return err
}
@@ -101,41 +98,45 @@ func (db *SQLStore) WithTx(block func(tx model.DataStore) error) error {
return nil
}
func (db *SQLStore) GC(ctx context.Context) error {
err := db.Album(ctx).PurgeEmpty()
func (s *SQLStore) GC(ctx context.Context) error {
err := s.Album(ctx).PurgeEmpty()
if err != nil {
return err
}
err = db.Artist(ctx).PurgeEmpty()
err = s.Artist(ctx).PurgeEmpty()
if err != nil {
return err
}
err = db.MediaFile(ctx).(*mediaFileRepository).cleanSearchIndex()
err = s.MediaFile(ctx).(*mediaFileRepository).cleanSearchIndex()
if err != nil {
return err
}
err = db.Album(ctx).(*albumRepository).cleanSearchIndex()
err = s.Album(ctx).(*albumRepository).cleanSearchIndex()
if err != nil {
return err
}
err = db.Artist(ctx).(*artistRepository).cleanSearchIndex()
err = s.Artist(ctx).(*artistRepository).cleanSearchIndex()
if err != nil {
return err
}
err = db.MediaFile(ctx).(*mediaFileRepository).cleanAnnotations()
err = s.MediaFile(ctx).(*mediaFileRepository).cleanAnnotations()
if err != nil {
return err
}
err = db.Album(ctx).(*albumRepository).cleanAnnotations()
err = s.Album(ctx).(*albumRepository).cleanAnnotations()
if err != nil {
return err
}
return db.Artist(ctx).(*artistRepository).cleanAnnotations()
return s.Artist(ctx).(*artistRepository).cleanAnnotations()
}
func (db *SQLStore) getOrmer() orm.Ormer {
if db.orm == nil {
return orm.NewOrm()
func (s *SQLStore) getOrmer() orm.Ormer {
if s.orm == nil {
o, err := orm.NewOrmWithDB(db.Driver, "default", db.Db())
if err != nil {
log.Error("Error obtaining new orm instance", err)
}
return o
}
return db.orm
return s.orm
}

View File

@@ -21,8 +21,8 @@ func TestPersistence(t *testing.T) {
//os.Remove("./test-123.db")
//conf.Server.Path = "./test-123.db"
conf.Server.DbPath = ":memory:"
db.Init()
conf.Server.DbPath = "file::memory:?cache=shared"
orm.RegisterDataBase("default", db.Driver, conf.Server.DbPath)
New()
db.EnsureLatestVersion()
log.SetLevel(log.LevelCritical)

View File

@@ -13,7 +13,7 @@ type playlist struct {
ID string `orm:"column(id)"`
Name string
Comment string
Duration int
Duration float32
Owner string
Public bool
Tracks string

View File

@@ -99,8 +99,11 @@ func (r sqlRepository) executeSQL(sq Sqlizer) (int64, error) {
return 0, err
}
start := time.Now()
var c int64
res, err := r.ormer.Raw(query, args...).Exec()
c, _ := res.RowsAffected()
if res != nil {
c, _ = res.RowsAffected()
}
r.logSQL(query, args, err, c, start)
if err != nil {
if err.Error() != "LastInsertId is not supported by this driver" {
@@ -157,6 +160,8 @@ func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOpt
func (r sqlRepository) put(id string, m interface{}) (newId string, err error) {
values, _ := toSqlArgs(m)
createdAt := values["created_at"]
delete(values, "created_at")
if id != "" {
update := Update(r.tableName).Where(Eq{"id": id}).SetMap(values)
count, err := r.executeSQL(update)
@@ -173,6 +178,9 @@ func (r sqlRepository) put(id string, m interface{}) (newId string, err error) {
id = rand.String()
values["id"] = id
}
if createdAt != nil {
values["created_at"] = createdAt
}
insert := Insert(r.tableName).SetMap(values)
_, err = r.executeSQL(insert)
return id, err
@@ -188,7 +196,7 @@ func (r sqlRepository) delete(cond Sqlizer) error {
}
func (r sqlRepository) logSQL(sql string, args []interface{}, err error, rowsAffected int64, start time.Time) {
lapsed := time.Since(start)
elapsed := time.Since(start)
var fmtArgs []string
for i := range args {
var f string
@@ -201,9 +209,9 @@ func (r sqlRepository) logSQL(sql string, args []interface{}, err error, rowsAff
fmtArgs = append(fmtArgs, f)
}
if err != nil {
log.Error(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected, "lapsedTime", lapsed, err)
log.Error(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected, "elapsedTime", elapsed, err)
} else {
log.Trace(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected, "lapsedTime", lapsed)
log.Trace(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected, "elapsedTime", elapsed)
}
}

View File

@@ -39,7 +39,7 @@ func (r sqlRepository) doSearch(q string, offset, size int, results interface{},
if len(q) < 2 {
return nil
}
sq := Select("*").From(r.tableName)
sq := r.newSelectWithAnnotation(r.tableName + ".id").Columns("*")
sq = sq.Limit(uint64(size)).Offset(uint64(offset))
if len(orderBys) > 0 {
sq = sq.OrderBy(orderBys...)

View File

@@ -65,6 +65,7 @@ func (r *userRepository) Put(u *model.User) error {
}
func (r *userRepository) FindByUsername(username string) (*model.User, error) {
username = strings.ToLower(username)
sel := r.newSelect().Columns("*").Where(Eq{"user_name": username})
var usr model.User
err := r.queryOne(sel, &usr)

View File

@@ -0,0 +1,41 @@
package persistence
import (
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("UserRepository", func() {
var repo model.UserRepository
BeforeEach(func() {
repo = NewUserRepository(log.NewContext(nil), orm.NewOrm())
})
Describe("Put/Get/FindByUsername", func() {
usr := model.User{
ID: "123",
UserName: "AdMiN",
Name: "Admin",
Email: "admin@admin.com",
Password: "wordpass",
IsAdmin: true,
}
It("saves the user to the DB", func() {
Expect(repo.Put(&usr)).To(BeNil())
})
It("returns the newly created user", func() {
actual, err := repo.Get("123")
Expect(err).ToNot(HaveOccurred())
Expect(actual.Name).To(Equal("Admin"))
})
It("find the user by case-insensitive username", func() {
actual, err := repo.FindByUsername("aDmIn")
Expect(err).ToNot(HaveOccurred())
Expect(actual.Name).To(Equal("Admin"))
})
})
})

View File

@@ -24,26 +24,26 @@ type Metadata struct {
tags map[string]string
}
func (m *Metadata) Title() string { return m.tags["title"] }
func (m *Metadata) Album() string { return m.tags["album"] }
func (m *Metadata) Artist() string { return m.tags["artist"] }
func (m *Metadata) AlbumArtist() string { return m.tags["album_artist"] }
func (m *Metadata) Composer() string { return m.tags["composer"] }
func (m *Metadata) Genre() string { return m.tags["genre"] }
func (m *Metadata) Year() int { return m.parseYear("year") }
func (m *Metadata) TrackNumber() (int, int) { return m.parseTuple("trackNum", "trackTotal") }
func (m *Metadata) DiscNumber() (int, int) { return m.parseTuple("discNum", "discTotal") }
func (m *Metadata) HasPicture() bool { return m.tags["hasPicture"] == "Video" }
func (m *Metadata) Comment() string { return m.tags["comment"] }
func (m *Metadata) Title() string { return m.getTag("title", "sort_name") }
func (m *Metadata) Album() string { return m.getTag("album", "sort_album") }
func (m *Metadata) Artist() string { return m.getTag("artist", "sort_artist") }
func (m *Metadata) AlbumArtist() string { return m.getTag("album_artist") }
func (m *Metadata) Composer() string { return m.getTag("composer", "tcm", "sort_composer") }
func (m *Metadata) Genre() string { return m.getTag("genre") }
func (m *Metadata) Year() int { return m.parseYear("date") }
func (m *Metadata) TrackNumber() (int, int) { return m.parseTuple("track") }
func (m *Metadata) DiscNumber() (int, int) { return m.parseTuple("tpa", "disc") }
func (m *Metadata) HasPicture() bool { return m.getTag("has_picture") == "true" }
func (m *Metadata) Comment() string { return m.getTag("comment") }
func (m *Metadata) Compilation() bool { return m.parseBool("compilation") }
func (m *Metadata) Duration() int { return m.parseDuration("duration") }
func (m *Metadata) Duration() float32 { return m.parseDuration("duration") }
func (m *Metadata) BitRate() int { return m.parseInt("bitrate") }
func (m *Metadata) ModificationTime() time.Time { return m.fileInfo.ModTime() }
func (m *Metadata) FilePath() string { return m.filePath }
func (m *Metadata) Suffix() string { return m.suffix }
func (m *Metadata) Size() int { return int(m.fileInfo.Size()) }
func ExtractAllMetadata(dirPath string) (map[string]*Metadata, error) {
func LoadAllAudioFiles(dirPath string) (map[string]os.FileInfo, error) {
dir, err := os.Open(dirPath)
if err != nil {
return nil, err
@@ -52,7 +52,7 @@ func ExtractAllMetadata(dirPath string) (map[string]*Metadata, error) {
if err != nil {
return nil, err
}
var audioFiles []string
audioFiles := make(map[string]os.FileInfo)
for _, f := range files {
if f.IsDir() {
continue
@@ -62,16 +62,18 @@ func ExtractAllMetadata(dirPath string) (map[string]*Metadata, error) {
if !isAudioFile(extension) {
continue
}
audioFiles = append(audioFiles, filePath)
fi, err := os.Stat(filePath)
if err != nil {
log.Error("Could not stat file", "filePath", filePath, err)
} else {
audioFiles[filePath] = fi
}
}
if len(audioFiles) == 0 {
return map[string]*Metadata{}, nil
}
return probe(audioFiles)
return audioFiles, nil
}
func probe(inputs []string) (map[string]*Metadata, error) {
func ExtractAllMetadata(inputs []string) (map[string]*Metadata, error) {
cmdLine, args := createProbeCommand(inputs)
log.Trace("Executing command", "arg0", cmdLine, "args", args)
@@ -92,7 +94,22 @@ func probe(inputs []string) (map[string]*Metadata, error) {
return mds, nil
}
var inputRegex = regexp.MustCompile(`(?m)^Input #\d+,.*,\sfrom\s'(.*)'`)
var (
// Input #0, mp3, from 'groovin.mp3':
inputRegex = regexp.MustCompile(`(?m)^Input #\d+,.*,\sfrom\s'(.*)'`)
// TITLE : Back In Black
tagsRx = regexp.MustCompile(`(?i)^\s{4,6}(\w+)\s+:(.*)`)
// Duration: 00:04:16.00, start: 0.000000, bitrate: 995 kb/s`
durationRx = regexp.MustCompile(`^\s\sDuration: ([\d.:]+).*bitrate: (\d+)`)
// Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s
bitRateRx = regexp.MustCompile(`^\s{4}Stream #\d+:\d+: (Audio):.*, (\d+) kb/s`)
// Stream #0:1: Video: mjpeg, yuvj444p(pc, bt470bg/unknown/unknown), 600x600 [SAR 1:1 DAR 1:1], 90k tbr, 90k tbn, 90k tbc`
coverRx = regexp.MustCompile(`^\s{4}Stream #\d+:\d+: (Video):.*`)
)
func parseOutput(output string) map[string]string {
split := map[string]string{}
@@ -139,47 +156,44 @@ func isAudioFile(extension string) bool {
return strings.HasPrefix(typ, "audio/")
}
var (
tagsRx = map[*regexp.Regexp]string{
regexp.MustCompile(`(?i)^\s{4}compilation\s+:(.*)`): "compilation",
regexp.MustCompile(`(?i)^\s{4}genre\s+:\s(.*)`): "genre",
regexp.MustCompile(`(?i)^\s{4}title\s+:\s(.*)`): "title",
regexp.MustCompile(`(?i)^\s{4}comment\s+:\s(.*)`): "comment",
regexp.MustCompile(`(?i)^\s{4}artist\s+:\s(.*)`): "artist",
regexp.MustCompile(`(?i)^\s{4}album_artist\s+:\s(.*)`): "album_artist",
regexp.MustCompile(`(?i)^\s{4}TCM\s+:\s(.*)`): "composer",
regexp.MustCompile(`(?i)^\s{4}album\s+:\s(.*)`): "album",
regexp.MustCompile(`(?i)^\s{4}track\s+:\s(.*)`): "trackNum",
regexp.MustCompile(`(?i)^\s{4}tracktotal\s+:\s(.*)`): "trackTotal",
regexp.MustCompile(`(?i)^\s{4}disc\s+:\s(.*)`): "discNum",
regexp.MustCompile(`(?i)^\s{4}disctotal\s+:\s(.*)`): "discTotal",
regexp.MustCompile(`(?i)^\s{4}TPA\s+:\s(.*)`): "discNum",
regexp.MustCompile(`(?i)^\s{4}date\s+:\s(.*)`): "year",
regexp.MustCompile(`^\s{4}Stream #\d+:\d+: (.+):\s`): "hasPicture",
}
durationRx = regexp.MustCompile(`^\s\sDuration: ([\d.:]+).*bitrate: (\d+)`)
)
func (m *Metadata) parseInfo(info string) {
reader := strings.NewReader(info)
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
line := scanner.Text()
for rx, tag := range tagsRx {
match := rx.FindStringSubmatch(line)
if len(match) > 0 {
m.tags[tag] = match[1]
break
}
match = durationRx.FindStringSubmatch(line)
if len(match) == 0 {
continue
if len(line) == 0 {
continue
}
match := tagsRx.FindStringSubmatch(line)
if len(match) > 0 {
tagName := strings.ToLower(match[1])
tagValue := strings.TrimSpace(match[2])
// Skip when the tag was previously found
if _, ok := m.tags[tagName]; !ok {
m.tags[tagName] = tagValue
}
continue
}
match = coverRx.FindStringSubmatch(line)
if len(match) > 0 {
m.tags["has_picture"] = "true"
continue
}
match = durationRx.FindStringSubmatch(line)
if len(match) > 0 {
m.tags["duration"] = match[1]
if len(match) > 1 {
m.tags["bitrate"] = match[2]
}
continue
}
match = bitRateRx.FindStringSubmatch(line)
if len(match) > 0 {
m.tags["bitrate"] = match[2]
}
}
}
@@ -192,15 +206,6 @@ func (m *Metadata) parseInt(tagName string) int {
return 0
}
var tagYearFormats = []string{
"2006",
"2006.01",
"2006.01.02",
"2006-01",
"2006-01-02",
time.RFC3339,
}
var dateRegex = regexp.MustCompile(`^([12]\d\d\d)`)
func (m *Metadata) parseYear(tagName string) int {
@@ -216,17 +221,28 @@ func (m *Metadata) parseYear(tagName string) int {
return 0
}
func (m *Metadata) parseTuple(numTag string, totalTag string) (int, int) {
if v, ok := m.tags[numTag]; ok {
tuple := strings.Split(v, "/")
t1, t2 := 0, 0
t1, _ = strconv.Atoi(tuple[0])
if len(tuple) > 1 {
t2, _ = strconv.Atoi(tuple[1])
} else {
t2, _ = strconv.Atoi(m.tags[totalTag])
func (m *Metadata) getTag(tags ...string) string {
for _, t := range tags {
if v, ok := m.tags[t]; ok {
return v
}
}
return ""
}
func (m *Metadata) parseTuple(tags ...string) (int, int) {
for _, tagName := range tags {
if v, ok := m.tags[tagName]; ok {
tuple := strings.Split(v, "/")
t1, t2 := 0, 0
t1, _ = strconv.Atoi(tuple[0])
if len(tuple) > 1 {
t2, _ = strconv.Atoi(tuple[1])
} else {
t2, _ = strconv.Atoi(m.tags[tagName+"total"])
}
return t1, t2
}
return t1, t2
}
return 0, 0
}
@@ -241,13 +257,13 @@ func (m *Metadata) parseBool(tagName string) bool {
var zeroTime = time.Date(0000, time.January, 1, 0, 0, 0, 0, time.UTC)
func (m *Metadata) parseDuration(tagName string) int {
func (m *Metadata) parseDuration(tagName string) float32 {
if v, ok := m.tags[tagName]; ok {
d, err := time.Parse("15:04:05", v)
if err != nil {
return 0
}
return int(d.Sub(zeroTime).Seconds())
return float32(d.Sub(zeroTime).Seconds())
}
return 0
}

View File

@@ -9,9 +9,9 @@ var _ = Describe("Metadata", func() {
// TODO Need to mock `ffmpeg`
XContext("ExtractAllMetadata", func() {
It("correctly parses metadata from all files in folder", func() {
mds, err := ExtractAllMetadata("tests/fixtures")
mds, err := ExtractAllMetadata([]string{"tests/fixtures/test.mp3", "tests/fixtures/test.ogg"})
Expect(err).NotTo(HaveOccurred())
Expect(mds).To(HaveLen(3))
Expect(mds).To(HaveLen(2))
m := mds["tests/fixtures/test.mp3"]
Expect(m.Title()).To(Equal("Song"))
@@ -45,43 +45,94 @@ var _ = Describe("Metadata", func() {
Expect(m.FilePath()).To(Equal("tests/fixtures/test.ogg"))
Expect(m.Size()).To(Equal(4408))
})
})
Context("LoadAllAudioFiles", func() {
It("return all audiofiles from the folder", func() {
files, err := LoadAllAudioFiles("tests/fixtures")
Expect(err).ToNot(HaveOccurred())
Expect(files).To(HaveLen(3))
Expect(files).To(HaveKey("tests/fixtures/test.ogg"))
Expect(files).To(HaveKey("tests/fixtures/test.mp3"))
Expect(files).To(HaveKey("tests/fixtures/01 Invisible (RED) Edit Version.mp3"))
})
It("returns error if path does not exist", func() {
_, err := ExtractAllMetadata("./INVALID/PATH")
_, err := LoadAllAudioFiles("./INVALID/PATH")
Expect(err).To(HaveOccurred())
})
It("returns empty map if there are no audio files in path", func() {
Expect(ExtractAllMetadata(".")).To(BeEmpty())
Expect(LoadAllAudioFiles(".")).To(BeEmpty())
})
})
Context("extractMetadata", func() {
It("detects embedded cover art correctly", func() {
const output = `
Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3':
Metadata:
compilation : 1
Duration: 00:00:01.02, start: 0.000000, bitrate: 477 kb/s
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s
Stream #0:1: Video: mjpeg, yuvj444p(pc, bt470bg/unknown/unknown), 600x600 [SAR 1:1 DAR 1:1], 90k tbr, 90k tbn, 90k tbc`
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
Expect(md.HasPicture()).To(BeTrue())
})
It("gets bitrate from the stream, if available", func() {
const output = `
Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3':
Duration: 00:00:01.02, start: 0.000000, bitrate: 477 kb/s
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s`
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
Expect(md.BitRate()).To(Equal(192))
})
It("parses correctly the compilation tag", func() {
const outputWithOverlappingTitleTag = `
const output = `
Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3':
Metadata:
compilation : 1
Duration: 00:05:02.63, start: 0.000000, bitrate: 140 kb/s`
md, _ := extractMetadata("tests/fixtures/test.mp3", outputWithOverlappingTitleTag)
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
Expect(md.Compilation()).To(BeTrue())
})
It("parses correct the title without overlapping with the stream tag", func() {
const outputWithOverlappingTitleTag = `
It("parses duration with milliseconds", func() {
const output = `
Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3':
Duration: 00:05:02.63, start: 0.000000, bitrate: 140 kb/s`
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
Expect(md.Duration()).To(BeNumerically("~", 302.63, 0.001))
})
It("parses stream level tags", func() {
const output = `
Input #0, ogg, from './01-02 Drive (Teku).opus':
Metadata:
ALBUM : Hot Wheels Acceleracers Soundtrack
Duration: 00:03:37.37, start: 0.007500, bitrate: 135 kb/s
Stream #0:0(eng): Audio: opus, 48000 Hz, stereo, fltp
Metadata:
TITLE : Drive (Teku)`
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
Expect(md.Title()).To(Equal("Drive (Teku)"))
})
It("does not overlap top level tags with the stream level tags", func() {
const output = `
Input #0, mp3, from 'groovin.mp3':
Metadata:
title : Groovin' (feat. Daniel Sneijers, Susanne Alt)
Duration: 00:03:34.28, start: 0.025056, bitrate: 323 kb/s
Metadata:
title : cover
At least one output file must be specified`
md, _ := extractMetadata("tests/fixtures/test.mp3", outputWithOverlappingTitleTag)
title : garbage`
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
Expect(md.Title()).To(Equal("Groovin' (feat. Daniel Sneijers, Susanne Alt)"))
})
It("ignores case in the tag name", func() {
const outputWithOverlappingTitleTag = `
const output = `
Input #0, flac, from '/Users/deluan/Downloads/06. Back In Black.flac':
Metadata:
ALBUM : Back In Black
@@ -93,7 +144,7 @@ Input #0, flac, from '/Users/deluan/Downloads/06. Back In Black.flac':
TRACKTOTAL : 10
track : 6
Duration: 00:04:16.00, start: 0.000000, bitrate: 995 kb/s`
md, _ := extractMetadata("tests/fixtures/test.mp3", outputWithOverlappingTitleTag)
md, _ := extractMetadata("tests/fixtures/test.mp3", output)
Expect(md.Title()).To(Equal("Back In Black"))
Expect(md.Album()).To(Equal("Back In Black"))
Expect(md.Genre()).To(Equal("Hard Rock"))
@@ -154,13 +205,13 @@ Tracklist:
"May 12, 2016": 0,
}
for tag, expected := range examples {
md := &Metadata{tags: map[string]string{"year": tag}}
md := &Metadata{tags: map[string]string{"date": tag}}
Expect(md.Year()).To(Equal(expected))
}
})
It("returns 0 if year is invalid", func() {
md := &Metadata{tags: map[string]string{"year": "invalid"}}
md := &Metadata{tags: map[string]string{"date": "invalid"}}
Expect(md.Year()).To(Equal(0))
})
})

View File

@@ -143,44 +143,57 @@ func (s *TagScanner) processChangedDir(ctx context.Context, dir string, updatedA
return err
}
for _, t := range ct {
currentTracks[t.ID] = t
updatedArtists[t.ArtistID] = true
updatedAlbums[t.AlbumID] = true
currentTracks[t.Path] = t
}
// Load tracks from the folder
newTracks, err := s.loadTracks(dir)
// Load tracks FileInfo from the folder
files, err := LoadAllAudioFiles(dir)
if err != nil {
return err
}
// If no tracks to process, return
if len(newTracks)+len(currentTracks) == 0 {
// If no files to process, return
if len(files)+len(currentTracks) == 0 {
return nil
}
// If track from folder is newer than the one in DB, select for update/insert in DB and delete from the current tracks
log.Trace("Processing changed folder", "dir", dir, "tracksInDB", len(currentTracks), "tracksInFolder", len(files))
var filesToUpdate []string
for filePath, info := range files {
c, ok := currentTracks[filePath]
if !ok || (ok && info.ModTime().After(c.UpdatedAt)) {
filesToUpdate = append(filesToUpdate, filePath)
}
delete(currentTracks, filePath)
}
// Load tracks Metadata from the folder
newTracks, err := s.loadTracks(filesToUpdate)
if err != nil {
return err
}
// If track from folder is newer than the one in DB, update/insert in DB and delete from the current tracks
log.Trace("Processing changed folder", "dir", dir, "tracksInDB", len(currentTracks), "tracksInFolder", len(newTracks))
log.Trace("Updating mediaFiles in DB", "dir", dir, "files", filesToUpdate, "numFiles", len(filesToUpdate))
numUpdatedTracks := 0
numPurgedTracks := 0
for _, n := range newTracks {
c, ok := currentTracks[n.ID]
if !ok || (ok && n.UpdatedAt.After(c.UpdatedAt)) {
err := s.ds.MediaFile(ctx).Put(&n)
updatedArtists[n.ArtistID] = true
updatedAlbums[n.AlbumID] = true
numUpdatedTracks++
if err != nil {
return err
}
err := s.ds.MediaFile(ctx).Put(&n)
updatedArtists[n.ArtistID] = true
updatedAlbums[n.AlbumID] = true
numUpdatedTracks++
if err != nil {
return err
}
delete(currentTracks, n.ID)
}
// Remaining tracks from DB that are not in the folder are deleted
for id := range currentTracks {
for _, ct := range currentTracks {
numPurgedTracks++
if err := s.ds.MediaFile(ctx).Delete(id); err != nil {
updatedArtists[ct.ArtistID] = true
updatedAlbums[ct.AlbumID] = true
if err := s.ds.MediaFile(ctx).Delete(ct.ID); err != nil {
return err
}
}
@@ -206,11 +219,12 @@ func (s *TagScanner) processDeletedDir(ctx context.Context, dir string, updatedA
return s.ds.MediaFile(ctx).DeleteByPath(dir)
}
func (s *TagScanner) loadTracks(dirPath string) (model.MediaFiles, error) {
mds, err := ExtractAllMetadata(dirPath)
func (s *TagScanner) loadTracks(filePaths []string) (model.MediaFiles, error) {
mds, err := ExtractAllMetadata(filePaths)
if err != nil {
return nil, err
}
var mfs model.MediaFiles
for _, md := range mds {
mf := s.toMediaFile(md)

View File

@@ -26,7 +26,7 @@ func RequestLogger(next http.Handler) http.Handler {
r.Context(),
message,
"remoteAddr", r.RemoteAddr,
"lapsedTime", time.Since(start),
"elapsedTime", time.Since(start),
"httpStatus", ww.Status(),
"responseSize", ww.BytesWritten(),
}

View File

@@ -68,13 +68,12 @@ func (api *Router) routes() http.Handler {
H(r, "getIndexes", c.GetIndexes)
H(r, "getArtists", c.GetArtists)
H(r, "getGenres", c.GetGenres)
reqParams := r.With(requiredParams("id"))
H(reqParams, "getMusicDirectory", c.GetMusicDirectory)
H(reqParams, "getArtist", c.GetArtist)
H(reqParams, "getAlbum", c.GetAlbum)
H(reqParams, "getSong", c.GetSong)
H(reqParams, "getArtistInfo", c.GetArtistInfo)
H(reqParams, "getArtistInfo2", c.GetArtistInfo2)
H(r, "getMusicDirectory", c.GetMusicDirectory)
H(r, "getArtist", c.GetArtist)
H(r, "getAlbum", c.GetAlbum)
H(r, "getSong", c.GetSong)
H(r, "getArtistInfo", c.GetArtistInfo)
H(r, "getArtistInfo2", c.GetArtistInfo2)
})
r.Group(func(r chi.Router) {
c := initAlbumListController(api)

View File

@@ -133,6 +133,7 @@ func ToChild(entry engine.Entry) responses.Child {
child.AlbumId = entry.AlbumId
child.ArtistId = entry.ArtistId
child.Type = entry.Type
child.IsVideo = false
child.UserRating = entry.UserRating
child.SongCount = entry.SongCount
// TODO Must be dynamic, based on player/transcoding config

View File

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

View File

@@ -89,18 +89,3 @@ func authenticate(users engine.Users) func(next http.Handler) http.Handler {
})
}
}
func requiredParams(params ...string) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, p := range params {
_, err := RequiredParamString(r, p, fmt.Sprintf("%s parameter is required", p))
if err != nil {
SendError(w, r, err)
return
}
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -32,7 +32,7 @@ func (c *PlaylistsController) GetPlaylists(w http.ResponseWriter, r *http.Reques
playlists[i].Name = p.Name
playlists[i].Comment = p.Comment
playlists[i].SongCount = len(p.Tracks)
playlists[i].Duration = p.Duration
playlists[i].Duration = int(p.Duration)
playlists[i].Owner = p.Owner
playlists[i].Public = p.Public
}

View File

@@ -1 +1 @@
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","albumList":{"album":[{"id":"1","isDir":false,"title":"title"}]}}
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","albumList":{"album":[{"id":"1","isDir":false,"title":"title","isVideo":false}]}}

View File

@@ -1 +1 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><albumList><album id="1" isDir="false" title="title"></album></albumList></subsonic-response>
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><albumList><album id="1" isDir="false" title="title" isVideo="false"></album></albumList></subsonic-response>

View File

@@ -1 +1 @@
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","directory":{"child":[{"id":"1","isDir":true,"title":"title","album":"album","artist":"artist","track":1,"year":1985,"genre":"Rock","coverArt":"1","size":"8421341","contentType":"audio/flac","suffix":"flac","starred":"2016-03-02T20:30:00Z","transcodedContentType":"audio/mpeg","transcodedSuffix":"mp3","duration":146,"bitRate":320}],"id":"1","name":"N"}}
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","directory":{"child":[{"id":"1","isDir":true,"title":"title","album":"album","artist":"artist","track":1,"year":1985,"genre":"Rock","coverArt":"1","size":"8421341","contentType":"audio/flac","suffix":"flac","starred":"2016-03-02T20:30:00Z","transcodedContentType":"audio/mpeg","transcodedSuffix":"mp3","duration":146,"bitRate":320,"isVideo":false}],"id":"1","name":"N"}}

View File

@@ -1 +1 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><directory id="1" name="N"><child id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320"></child></directory></subsonic-response>
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><directory id="1" name="N"><child id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false"></child></directory></subsonic-response>

View File

@@ -1 +1 @@
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","directory":{"child":[{"id":"1","isDir":false,"title":"title"}],"id":"1","name":"N"}}
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","directory":{"child":[{"id":"1","isDir":false,"title":"title","isVideo":false}],"id":"1","name":"N"}}

View File

@@ -1 +1 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><directory id="1" name="N"><child id="1" isDir="false" title="title"></child></directory></subsonic-response>
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><directory id="1" name="N"><child id="1" isDir="false" title="title" isVideo="false"></child></directory></subsonic-response>

View File

@@ -112,8 +112,8 @@ type Child struct {
Type string `xml:"type,attr,omitempty" json:"type,omitempty"`
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"`
/*
<xs:attribute name="isVideo" type="xs:boolean" use="optional"/> <!-- Added in 1.4.1 -->
<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 -->

View File

@@ -1,10 +1,12 @@
package subsonic
import (
"io"
"net/http"
"strconv"
"github.com/deluan/navidrome/engine"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/server/subsonic/responses"
"github.com/deluan/navidrome/utils"
)
@@ -25,15 +27,32 @@ func (c *StreamController) Stream(w http.ResponseWriter, r *http.Request) (*resp
maxBitRate := utils.ParamInt(r, "maxBitRate", 0)
format := utils.ParamString(r, "format")
ms, err := c.streamer.NewStream(r.Context(), id, maxBitRate, format)
stream, err := c.streamer.NewStream(r.Context(), id, maxBitRate, format)
if err != nil {
return nil, err
}
defer func() {
if err := stream.Close(); err != nil {
log.Error("Error closing stream", "id", id, "file", stream.Name(), err)
}
}()
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32))
if stream.Seekable() {
http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream)
} else {
// If the stream doesn't provide a size (i.e. is not seekable), we can't support ranges/content-length
w.Header().Set("Accept-Ranges", "none")
w.Header().Set("Content-Type", stream.ContentType())
if c, err := io.Copy(w, stream); err != nil {
log.Error(r.Context(), "Error sending transcoded file", "id", id, err)
} else {
log.Trace(r.Context(), "Success sending transcode file", "id", id, "size", c)
}
}
// Override Content-Type detected by http.FileServer
w.Header().Set("Content-Type", ms.ContentType())
w.Header().Set("X-Content-Duration", strconv.Itoa(ms.Duration()))
http.ServeContent(w, r, ms.Name(), ms.ModTime(), ms)
return nil, nil
}
@@ -43,13 +62,11 @@ func (c *StreamController) Download(w http.ResponseWriter, r *http.Request) (*re
return nil, err
}
ms, err := c.streamer.NewStream(r.Context(), id, 0, "raw")
stream, err := c.streamer.NewStream(r.Context(), id, 0, "raw")
if err != nil {
return nil, err
}
// Override Content-Type detected by http.FileServer
w.Header().Set("Content-Type", ms.ContentType())
http.ServeContent(w, r, ms.Name(), ms.ModTime(), ms)
http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream)
return nil, nil
}

View File

@@ -1,6 +1,6 @@
DevDisableAuthentication = false
User = "deluan"
Password = "wordpass"
DbPath = ":memory:"
DbPath = "file::memory:?cache=shared"
MusicFolder = "./tests/itunes-library.xml"
DownsampleCommand = "ffmpeg -i %s -b:a %bk mp3 -"

1596
ui/package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -3,20 +3,20 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.0.2",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^8.0.4",
"@testing-library/jest-dom": "^5.1.1",
"@testing-library/react": "^9.5.0",
"@testing-library/user-event": "^10.0.0",
"deepmerge": "^4.2.2",
"jwt-decode": "^2.2.0",
"md5-hex": "^3.0.1",
"prop-types": "^15.7.2",
"ra-data-json-server": "^3.1.2",
"react": "^16.12.0",
"react-admin": "^3.1.2",
"react-dom": "^16.12.0",
"react-jinke-music-player": "^4.7.2",
"react-redux": "^7.1.0",
"react-scripts": "3.3.0"
"ra-data-json-server": "^3.2.3",
"react": "^16.13.0",
"react-admin": "^3.2.3",
"react-dom": "^16.13.0",
"react-jinke-music-player": "^4.10.1",
"react-redux": "^7.2.0",
"react-scripts": "^3.4.0"
},
"scripts": {
"start": "react-scripts start",

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
ui/public/apple-icon.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>

BIN
ui/public/favicon-16x16.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
ui/public/favicon-32x32.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
ui/public/favicon-96x96.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -2,14 +2,30 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="description"
content="Navidrome Music Server"
/>
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="apple-touch-icon" sizes="57x57" href="%PUBLIC_URL%/apple-icon-57x57.png">
<link rel="apple-touch-icon" sizes="60x60" href="%PUBLIC_URL%/apple-icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="%PUBLIC_URL%/apple-icon-72x72.png">
<link rel="apple-touch-icon" sizes="76x76" href="%PUBLIC_URL%/apple-icon-76x76.png">
<link rel="apple-touch-icon" sizes="114x114" href="%PUBLIC_URL%/apple-icon-114x114.png">
<link rel="apple-touch-icon" sizes="120x120" href="%PUBLIC_URL%/apple-icon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="%PUBLIC_URL%/apple-icon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="%PUBLIC_URL%/apple-icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-icon-180x180.png">
<link rel="icon" type="image/png" sizes="192x192" href="%PUBLIC_URL%/android-icon-192x192.png">
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="96x96" href="%PUBLIC_URL%/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png">
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-TileImage" content="%PUBLIC_URL%/ms-icon-144x144.png">
<meta name="theme-color" content="#ffffff">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Navidrome Music Server"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

View File

@@ -1,25 +1,41 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
"name": "App",
"icons": [
{
"src": "\/android-icon-36x36.png",
"sizes": "36x36",
"type": "image\/png",
"density": "0.75"
},
{
"src": "\/android-icon-48x48.png",
"sizes": "48x48",
"type": "image\/png",
"density": "1.0"
},
{
"src": "\/android-icon-72x72.png",
"sizes": "72x72",
"type": "image\/png",
"density": "1.5"
},
{
"src": "\/android-icon-96x96.png",
"sizes": "96x96",
"type": "image\/png",
"density": "2.0"
},
{
"src": "\/android-icon-144x144.png",
"sizes": "144x144",
"type": "image\/png",
"density": "3.0"
},
{
"src": "\/android-icon-192x192.png",
"sizes": "192x192",
"type": "image\/png",
"density": "4.0"
}
]
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
ui/public/ms-icon-70x70.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -43,6 +43,7 @@ const App = () => {
<Resource name="artist" {...artist} options={{ subMenu: 'library' }} />,
<Resource name="album" {...album} options={{ subMenu: 'library' }} />,
<Resource name="song" {...song} options={{ subMenu: 'library' }} />,
<Resource name="albumSong" />,
permissions === 'admin' ? <Resource name="user" {...user} /> : null,
<Player />
]}

View File

@@ -0,0 +1,71 @@
import {
Button,
sanitizeListRestProps,
TopToolbar,
useTranslate
} from 'react-admin'
import PlayArrowIcon from '@material-ui/icons/PlayArrow'
import ShuffleIcon from '@material-ui/icons/Shuffle'
import React from 'react'
import { useDispatch } from 'react-redux'
import { playAlbum } from '../player'
export const AlbumActions = ({
className,
ids,
data,
exporter,
permanentFilter,
...rest
}) => {
const dispatch = useDispatch()
const translate = useTranslate()
// TODO Not sure why data is accumulating tracks from previous plays... Needs investigation. For now, filter out
// the unwanted tracks
const filteredData = ids.reduce((acc, id) => {
acc[id] = data[id]
return acc
}, {})
const shuffle = (data) => {
const ids = Object.keys(data)
for (let i = ids.length - 1; i > 0; i--) {
let j = Math.floor(Math.random() * (i + 1))
;[ids[i], ids[j]] = [ids[j], ids[i]]
}
const shuffled = {}
ids.forEach((id) => (shuffled[id] = data[id]))
return shuffled
}
return (
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
<Button
color={'secondary'}
onClick={() => {
dispatch(playAlbum(ids[0], filteredData))
}}
label={translate('resources.album.actions.playAll')}
>
<PlayArrowIcon />
</Button>
<Button
color={'secondary'}
onClick={() => {
const shuffled = shuffle(filteredData)
const firstId = Object.keys(shuffled)[0]
dispatch(playAlbum(firstId, shuffled))
}}
label={translate('resources.album.actions.shuffle')}
>
<ShuffleIcon />
</Button>
</TopToolbar>
)
}
AlbumActions.defaultProps = {
selectedIds: [],
onUnselectItems: () => null
}

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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