Compare commits

..

96 Commits

Author SHA1 Message Date
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
Deluan
a37beac753 feat: add X-Content-Duration header to the stream response 2020-02-09 22:09:18 -05:00
Deluan
8a31e80b7a fix: find songs and albums when sending an artist name search query 2020-02-09 19:52:06 -05:00
Deluan
ce11a2f3be feat: fake getArtistInfo/getArtistInfo2, just to enable artist browsing in MusicStash 2020-02-09 19:42:37 -05:00
Deluan
5a95feeedc fix: allow searches with 2 chars. closes #65 2020-02-09 12:20:34 -05:00
Deluan
400fa65326 feat: better scanner logging when level = info 2020-02-08 23:36:09 -05:00
Deluan
ab10719d27 fix: use a regex to match year in ffmpeg date field. close #63 2020-02-08 23:17:12 -05:00
Deluan
029290f304 fix: set default play_count to 0
IncPlayCount was not incrementing when the annotation already existed with play_count = null
2020-02-08 22:55:05 -05:00
Deluan
2c146ea1fe feat: add option to auto-create admin user on first start-up
Useful for development purposes
2020-02-08 14:50:33 -05:00
Deluan
10ead1f5f2 feat: better way to detect initial account creation 2020-02-08 14:32:55 -05:00
Deluan
730722cfe3 feat: better track number formatting 2020-02-08 11:50:11 -05:00
Deluan
dc352834b9 fix: workaround to force check for initial setup 2020-02-08 00:11:15 -05:00
Deluan
313a3342a0 fix: remove unused import 2020-02-07 22:35:04 -05:00
Deluan
0f13bbdbd0 docs: update screenshots 2020-02-07 18:21:51 -05:00
Deluan
4310f2c94f docs: update README 2020-02-07 18:02:44 -05:00
Deluan
6ce4811460 feat: add the remainder of the album to the queue when clicking on an album's track 2020-02-07 17:36:50 -05:00
Deluan
52cd17963f feat: limit size of cover art 2020-02-07 16:51:14 -05:00
Deluan
8f0c07d29f refactor: simplify PlayButton usage 2020-02-07 16:38:01 -05:00
Deluan
a50735a94c feat: custom SimpleList, to allow onClick handle 2020-02-07 16:08:53 -05:00
Deluan
f0e7f3ef25 feat: responsive album view 2020-02-07 16:08:52 -05:00
Deluan
2ca98d8e81 feat: optimized for small screens (only) 2020-02-07 13:50:25 -05:00
Deluan
81e1a7088f feat: new album view (initial implementation) 2020-02-07 11:49:26 -05:00
Deluan
d37351610a feat: initial support for i18n 2020-02-07 10:12:32 -05:00
Deluan
99361c0d9f fix: create a subsonic token on login, to use for subsonic API calls 2020-02-06 20:57:00 -05:00
Deluan
8673533cd4 refactor: move request param extractors to utils 2020-02-06 18:55:38 -05:00
Deluan
d9dd9fe587 refactor: put all subsonic client URLs together 2020-02-06 18:41:34 -05:00
Deluan
abb99a8501 feat: add authentication via JWT token 2020-02-06 18:41:34 -05:00
Deluan
690f92a671 feat: make song list more responsive 2020-02-06 18:41:34 -05:00
Deluan
c57007db52 feat: song list xsmall view 2020-02-06 18:41:34 -05:00
Deluan
cc229dcee6 chore: add direct dependency to react-redux 2020-02-06 18:41:34 -05:00
Deluan
7aab82c246 feat: enable overriding sql sorting 2020-02-06 18:41:34 -05:00
Deluan
989deb1200 feat: change pagination options 2020-02-06 18:41:34 -05:00
Deluan
6aaee4342e feat: smaller play button 2020-02-06 18:41:34 -05:00
Deluan
b5dadf55f4 feat: add an authenticated keepalive, to keep the UI session alive while playing songs 2020-02-06 18:41:34 -05:00
Deluan
18c7397709 feat: scrobbling 2020-02-06 18:41:34 -05:00
Deluan
4a82a6cb02 feat: initial integration of react-jinke-music-player 2020-02-06 18:41:33 -05:00
Deluan
220ffd5324 chore: removed unused code 2020-02-06 18:41:16 -05:00
Deluan
e33d2305a1 feat: support multiple year formats in the date tag (#63) 2020-02-06 14:44:50 -05:00
Deluan
7815b57920 fix: remove docker-compose.override.yml from repo 2020-02-06 12:14:10 -05:00
Deluan
18cbb153f3 chore: add a docker-compose.override.yml file, to support local testing 2020-02-06 12:12:10 -05:00
Deluan
9f086b5f7b docs: fix typo 2020-02-06 09:19:32 -05:00
Deluan
c8d6f2d506 feat: add m4b to mime-type list. fix #62 2020-02-06 08:48:02 -05:00
124 changed files with 3566 additions and 1627 deletions

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

View File

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

Before

Width:  |  Height:  |  Size: 709 KiB

After

Width:  |  Height:  |  Size: 709 KiB

BIN
.github/screenshots/ss-mobile-player.png vendored Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

View File

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

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

@@ -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 }}

1
.gitignore vendored
View File

@@ -19,3 +19,4 @@ navidrome.db
*_gen.go
dist
music
docker-compose.override.yml

2
.nvmrc
View File

@@ -1 +1 @@
v13.7.0
v13.9.0

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.8-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=1.14
NODE_VERSION=v13.9.0
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

@@ -26,14 +26,13 @@ please fill a [GitHub issue](https://github.com/deluan/navidrome/issues) or join
[Airsonic](https://airsonic.github.io/) and [Madsonic](https://www.madsonic.org/).
See the [complete list of available mobile and web apps](https://airsonic.github.io/docs/apps/)
- Transcoding/Downsampling on-the-fly (WIP. Experimental support is available)
- Integrated music player (WIP)
## Road map
This project is being actively worked on. Expect a more polished experience and new features/releases
on a frequent basis. Some upcoming features planned:
- Integrated music player
- Last.FM integration
- Pre-build binaries for Raspberry Pi
- Smart/dynamic playlists (similar to iTunes)
@@ -47,7 +46,7 @@ on a frequent basis. Some upcoming features planned:
Various options are available:
### Pre-build executables
### 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).
@@ -80,14 +79,14 @@ services:
ND_PORT: 4533
volumes:
- "./data:/data"
- "/Users/deluan/Music/iTunes/iTunes Media/Music:/music"
- "/path/to/your/music/folder:/music"
```
To get the cutting-edge, latest version from master, use the image `deluan/navidrome:develop`
### 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.9.0](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)
@@ -119,10 +118,10 @@ For more options, run `navidrome --help`
<p align="center">
<p float="left">
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/screenshot-login-mobile.png">
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/screenshot-mobile.png">
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/screenshot-users-mobile.png">
<img width="900"src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/screenshot-desktop.png">
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-login.png">
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-player.png">
<img width="270" src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-mobile-album-view.png">
<img width="900"src="https://raw.githubusercontent.com/deluan/navidrome/master/.github/screenshots/ss-desktop-player.png">
</p>
</p>

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,24 +13,26 @@ 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"`
DevLogSourceLine bool `default:"false"`
DevDisableBanner bool `default:"false"`
DevLogSourceLine bool `default:"false"`
DevAutoCreateAdminPassword string `default:""`
}
var Server = &nd{}
@@ -81,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"
@@ -13,4 +14,9 @@ const (
JWTTokenExpiration = 30 * time.Minute
UIAssetsLocalPath = "ui/build"
CacheDir = "cache"
DevInitialUserName = "admin"
DevInitialName = "Dev Admin"
)

View File

@@ -8,29 +8,17 @@ func init() {
".ogg": "audio/ogg",
".oga": "audio/ogg",
".opus": "audio/ogg",
".ogx": "application/ogg",
".aac": "audio/mp4",
".m4a": "audio/mp4",
".m4b": "audio/mp4",
".flac": "audio/flac",
".wav": "audio/x-wav",
".wma": "audio/x-ms-wma",
".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

@@ -6,39 +6,43 @@ import (
"sync"
"github.com/deluan/navidrome/conf"
_ "github.com/deluan/navidrome/db/migrations"
_ "github.com/deluan/navidrome/db/migration"
"github.com/deluan/navidrome/log"
_ "github.com/mattn/go-sqlite3"
"github.com/pressly/goose"
)
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

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

View File

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

View File

@@ -0,0 +1,56 @@
package migration
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(Up20200208222418, Down20200208222418)
}
func Up20200208222418(tx *sql.Tx) error {
_, err := tx.Exec(`
update annotation set play_count = 0 where play_count is null;
update annotation set rating = 0 where rating is null;
create table annotation_dg_tmp
(
ann_id varchar(255) not null
primary key,
user_id varchar(255) default '' not null,
item_id varchar(255) default '' not null,
item_type varchar(255) default '' not null,
play_count integer default 0,
play_date datetime,
rating integer default 0,
starred bool default FALSE not null,
starred_at datetime,
unique (user_id, item_id, item_type)
);
insert into annotation_dg_tmp(ann_id, user_id, item_id, item_type, play_count, play_date, rating, starred, starred_at) select ann_id, user_id, item_id, item_type, play_count, play_date, rating, starred, starred_at from annotation;
drop table annotation;
alter table annotation_dg_tmp rename to annotation;
create index annotation_play_count
on annotation (play_count);
create index annotation_play_date
on annotation (play_date);
create index annotation_rating
on annotation (rating);
create index annotation_starred
on annotation (starred);
`)
return err
}
func Down20200208222418(tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

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
}

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

@@ -0,0 +1,46 @@
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)
}
}
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

@@ -3,14 +3,16 @@
version: "3"
services:
navidrome:
build: .
image: deluan/navidrome:latest
ports:
- "4533:4533"
environment:
# See all options and defaults in conf/configuration.go
# All options with their default values:
ND_MUSICFOLDER: /music
ND_DATAFOLDER: /data
ND_SCANINTERVAL: 1m
ND_LOGLEVEL: info
ND_PORT: 4533
ND_SCANINTERVAL: 5s
ND_LOGLEVEL: debug
volumes:
- "./data:/data"
- "/Users/deluan/Music/iTunes/iTunes Media/Music:/music"
- "./music:/music"

64
engine/auth/auth.go Normal file
View File

@@ -0,0 +1,64 @@
package auth
import (
"fmt"
"sync"
"time"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/dgrijalva/jwt-go"
"github.com/go-chi/jwtauth"
)
var (
once sync.Once
JwtSecret []byte
TokenAuth *jwtauth.JWTAuth
)
func InitTokenAuth(ds model.DataStore) {
once.Do(func() {
secret, err := ds.Property(nil).DefaultGet(consts.JWTSecretKey, "not so secret")
if err != nil {
log.Error("No JWT secret found in DB. Setting a temp one, but please report this error", err)
}
JwtSecret = []byte(secret)
TokenAuth = jwtauth.New("HS256", JwtSecret, nil)
})
}
func CreateToken(u *model.User) (string, error) {
token := jwt.New(jwt.SigningMethodHS256)
claims := token.Claims.(jwt.MapClaims)
claims["iss"] = consts.JWTIssuer
claims["sub"] = u.UserName
claims["adm"] = u.IsAdmin
return TouchToken(token)
}
func TouchToken(token *jwt.Token) (string, error) {
expireIn := time.Now().Add(consts.JWTTokenExpiration).Unix()
claims := token.Claims.(jwt.MapClaims)
claims["exp"] = expireIn
return token.SignedString(JwtSecret)
}
func Validate(tokenStr string) (jwt.MapClaims, error) {
token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) {
// Don't forget to validate the alg is what you expect:
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
}
// hmacSampleSecret is a []byte containing your secret, e.g. []byte("my_secret_key")
return JwtSecret, nil
})
if err != nil {
return nil, err
}
return token.Claims.(jwt.MapClaims), err
}

55
engine/auth/auth_test.go Normal file
View File

@@ -0,0 +1,55 @@
package auth_test
import (
"testing"
"time"
"github.com/deluan/navidrome/engine/auth"
"github.com/deluan/navidrome/log"
"github.com/dgrijalva/jwt-go"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
func TestAuth(t *testing.T) {
log.SetLevel(log.LevelCritical)
RegisterFailHandler(Fail)
RunSpecs(t, "Auth Test Suite")
}
const testJWTSecret = "not so secret"
var _ = Describe("Auth", func() {
BeforeEach(func() {
auth.JwtSecret = []byte(testJWTSecret)
})
Context("Validate", func() {
It("returns error with an invalid JWT token", func() {
_, err := auth.Validate("invalid.token")
Expect(err).To(Not(BeNil()))
})
It("returns the claims from a valid JWT token", func() {
token := jwt.New(jwt.SigningMethodHS256)
claims := token.Claims.(jwt.MapClaims)
claims["iss"] = "issuer"
claims["exp"] = time.Now().Add(1 * time.Minute).Unix()
tokenStr, _ := token.SignedString(auth.JwtSecret)
decodedClaims, err := auth.Validate(tokenStr)
Expect(err).To(BeNil())
Expect(decodedClaims["iss"]).To(Equal("issuer"))
})
It("returns ErrExpired if the `exp` field is in the past", func() {
token := jwt.New(jwt.SigningMethodHS256)
claims := token.Claims.(jwt.MapClaims)
claims["iss"] = "issuer"
claims["exp"] = time.Now().Add(-1 * time.Minute).Unix()
tokenStr, _ := token.SignedString(auth.JwtSecret)
_, err := auth.Validate(tokenStr)
Expect(err).To(MatchError("Token is expired"))
})
})
})

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,219 +2,182 @@ 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
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) func() {
return func() {
_, 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) 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 file", "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) 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

@@ -7,11 +7,12 @@ import (
"fmt"
"strings"
"github.com/deluan/navidrome/engine/auth"
"github.com/deluan/navidrome/model"
)
type Users interface {
Authenticate(ctx context.Context, username, password, token, salt string) (*model.User, error)
Authenticate(ctx context.Context, username, password, token, salt, jwt string) (*model.User, error)
}
func NewUsers(ds model.DataStore) Users {
@@ -22,7 +23,7 @@ type users struct {
ds model.DataStore
}
func (u *users) Authenticate(ctx context.Context, username, pass, token, salt string) (*model.User, error) {
func (u *users) Authenticate(ctx context.Context, username, pass, token, salt, jwt string) (*model.User, error) {
user, err := u.ds.User(ctx).FindByUsername(username)
if err == model.ErrNotFound {
return nil, model.ErrInvalidAuth
@@ -33,6 +34,9 @@ func (u *users) Authenticate(ctx context.Context, username, pass, token, salt st
valid := false
switch {
case jwt != "":
claims, err := auth.Validate(jwt)
valid = err == nil && claims["sub"] == username
case pass != "":
if strings.HasPrefix(pass, "enc:") {
if dec, err := hex.DecodeString(pass[4:]); err == nil {

View File

@@ -3,6 +3,7 @@ package engine
import (
"context"
"github.com/deluan/navidrome/engine/auth"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/persistence"
. "github.com/onsi/ginkgo"
@@ -19,20 +20,20 @@ var _ = Describe("Users", func() {
Context("Plaintext password", func() {
It("authenticates with plaintext password ", func() {
usr, err := users.Authenticate(context.TODO(), "admin", "wordpass", "", "")
usr, err := users.Authenticate(context.TODO(), "admin", "wordpass", "", "", "")
Expect(err).NotTo(HaveOccurred())
Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"}))
})
It("fails authentication with wrong password", func() {
_, err := users.Authenticate(context.TODO(), "admin", "INVALID", "", "")
_, err := users.Authenticate(context.TODO(), "admin", "INVALID", "", "", "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
})
Context("Encoded password", func() {
It("authenticates with simple encoded password ", func() {
usr, err := users.Authenticate(context.TODO(), "admin", "enc:776f726470617373", "", "")
usr, err := users.Authenticate(context.TODO(), "admin", "enc:776f726470617373", "", "", "")
Expect(err).NotTo(HaveOccurred())
Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"}))
})
@@ -40,13 +41,41 @@ var _ = Describe("Users", func() {
Context("Token based authentication", func() {
It("authenticates with token based authentication", func() {
usr, err := users.Authenticate(context.TODO(), "admin", "", "23b342970e25c7928831c3317edd0b67", "retnlmjetrymazgkt")
usr, err := users.Authenticate(context.TODO(), "admin", "", "23b342970e25c7928831c3317edd0b67", "retnlmjetrymazgkt", "")
Expect(err).NotTo(HaveOccurred())
Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"}))
})
It("fails if salt is missing", func() {
_, err := users.Authenticate(context.TODO(), "admin", "", "23b342970e25c7928831c3317edd0b67", "")
_, err := users.Authenticate(context.TODO(), "admin", "", "23b342970e25c7928831c3317edd0b67", "", "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
})
Context("JWT based authentication", func() {
var validToken string
BeforeEach(func() {
u := &model.User{UserName: "admin"}
var err error
validToken, err = auth.CreateToken(u)
if err != nil {
panic(err)
}
})
It("authenticates with JWT token based authentication", func() {
usr, err := users.Authenticate(context.TODO(), "admin", "", "", "", validToken)
Expect(err).NotTo(HaveOccurred())
Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"}))
})
It("fails if JWT token is invalid", func() {
_, err := users.Authenticate(context.TODO(), "admin", "", "", "", "invalid.token")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
It("fails if JWT token sub is different than username", func() {
_, err := users.Authenticate(context.TODO(), "not_admin", "", "", "", validToken)
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
})

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,6 +9,7 @@ import (
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/rest"
"github.com/dhowden/tag/mbz"
)
type albumRepository struct {
@@ -36,11 +37,11 @@ func (r *albumRepository) Put(a *model.Album) error {
if err != nil {
return err
}
return r.index(a.ID, a.Name)
return r.index(a.ID, a.Name, a.Artist, mbz.AlbumArtist)
}
func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder {
return r.newSelectWithAnnotation("id", options...).Columns("*")
return r.newSelectWithAnnotation("album.id", options...).Columns("*")
}
func (r *albumRepository) Get(id string) (*model.Album, error) {
@@ -70,12 +71,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

@@ -21,6 +21,10 @@ func NewMediaFileRepository(ctx context.Context, o orm.Ormer) *mediaFileReposito
r.ctx = ctx
r.ormer = o
r.tableName = "media_file"
r.sortMappings = map[string]string{
"artist": "artist asc, album asc, disc_number asc, track_number asc",
"album": "album asc, disc_number asc, track_number asc",
}
return r
}
@@ -37,7 +41,7 @@ func (r mediaFileRepository) Put(m *model.MediaFile) error {
if err != nil {
return err
}
return r.index(m.ID, m.Title)
return r.index(m.ID, m.Title, m.Album, m.Artist, m.AlbumArtist)
}
func (r mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder {
@@ -96,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

@@ -2,6 +2,7 @@ package persistence
import (
"context"
"time"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/log"
@@ -86,4 +87,33 @@ var _ = Describe("MediaRepository", func() {
_, err := mr.Get(id3)
Expect(err).To(MatchError(model.ErrNotFound))
})
Context("Annotations", func() {
It("increments play count when the tracks does not have annotations", func() {
id := "incplay.firsttime"
Expect(mr.Put(&model.MediaFile{ID: id})).To(BeNil())
playDate := time.Now()
Expect(mr.IncPlayCount(id, playDate)).To(BeNil())
mf, err := mr.Get(id)
Expect(err).To(BeNil())
Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix()))
Expect(mf.PlayCount).To(Equal(1))
})
It("increments play count on newly starred items", func() {
id := "star.incplay"
Expect(mr.Put(&model.MediaFile{ID: id})).To(BeNil())
Expect(mr.SetStar(true, id)).To(BeNil())
playDate := time.Now()
Expect(mr.IncPlayCount(id, playDate)).To(BeNil())
mf, err := mr.Get(id)
Expect(err).To(BeNil())
Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix()))
Expect(mf.PlayCount).To(Equal(1))
})
})
})

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

@@ -33,6 +33,14 @@ func (r sqlRepository) newSelectWithAnnotation(idField string, options ...model.
Columns("starred", "starred_at", "play_count", "play_date", "rating")
}
func (r sqlRepository) annId(itemID ...string) And {
return And{
Eq{"user_id": userId(r.ctx)},
Eq{"item_type": r.tableName},
Eq{"item_id": itemID},
}
}
func (r sqlRepository) annUpsert(values map[string]interface{}, itemIDs ...string) error {
upd := Update(annotationTable).Where(r.annId(itemIDs...))
for f, v := range values {
@@ -56,12 +64,13 @@ func (r sqlRepository) annUpsert(values map[string]interface{}, itemIDs ...strin
return err
}
func (r sqlRepository) annId(itemID ...string) And {
return And{
Eq{"user_id": userId(r.ctx)},
Eq{"item_type": r.tableName},
Eq{"item_id": itemID},
}
func (r sqlRepository) SetStar(starred bool, ids ...string) error {
starredAt := time.Now()
return r.annUpsert(map[string]interface{}{"starred": starred, "starred_at": starredAt}, ids...)
}
func (r sqlRepository) SetRating(rating int, itemID string) error {
return r.annUpsert(map[string]interface{}{"rating": rating}, itemID)
}
func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
@@ -88,15 +97,6 @@ func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
return err
}
func (r sqlRepository) SetStar(starred bool, ids ...string) error {
starredAt := time.Now()
return r.annUpsert(map[string]interface{}{"starred": starred, "starred_at": starredAt}, ids...)
}
func (r sqlRepository) SetRating(rating int, itemID string) error {
return r.annUpsert(map[string]interface{}{"rating": rating}, itemID)
}
func (r sqlRepository) cleanAnnotations() error {
del := Delete(annotationTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")")
c, err := r.executeSQL(del)

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strings"
"text/scanner"
"time"
. "github.com/Masterminds/squirrel"
@@ -15,9 +16,10 @@ import (
)
type sqlRepository struct {
ctx context.Context
tableName string
ormer orm.Ormer
ctx context.Context
tableName string
ormer orm.Ormer
sortMappings map[string]string
}
const invalidUserId = "-1"
@@ -55,11 +57,30 @@ func (r sqlRepository) applyOptions(sq SelectBuilder, options ...model.QueryOpti
sq = sq.Offset(uint64(options[0].Offset))
}
if options[0].Sort != "" {
if options[0].Order == "desc" {
sq = sq.OrderBy(toSnakeCase(options[0].Sort + " desc"))
} else {
sq = sq.OrderBy(toSnakeCase(options[0].Sort))
sort := toSnakeCase(options[0].Sort)
if mapping, ok := r.sortMappings[sort]; ok {
sort = mapping
}
if !strings.Contains(sort, "asc") && !strings.Contains(sort, "desc") {
sort = sort + " asc"
}
if options[0].Order == "desc" {
var s scanner.Scanner
s.Init(strings.NewReader(sort))
var newSort string
for tok := s.Scan(); tok != scanner.EOF; tok = s.Scan() {
switch s.TokenText() {
case "asc":
newSort += " " + "desc"
case "desc":
newSort += " " + "asc"
default:
newSort += " " + s.TokenText()
}
}
sort = newSort
}
sq = sq.OrderBy(sort)
}
}
return sq
@@ -78,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" {
@@ -136,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)
@@ -152,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
@@ -167,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
@@ -180,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)
}
}
@@ -190,7 +219,7 @@ func (r sqlRepository) parseRestOptions(options ...rest.QueryOptions) model.Quer
qo := model.QueryOptions{}
if len(options) > 0 {
qo.Sort = options[0].Sort
qo.Order = options[0].Order
qo.Order = strings.ToLower(options[0].Order)
qo.Max = options[0].Max
qo.Offset = options[0].Offset
if len(options[0].Filters) > 0 {

View File

@@ -10,13 +10,16 @@ import (
const searchTable = "search"
func (r sqlRepository) index(id string, text string) error {
sanitizedText := strings.TrimSpace(sanitize.Accents(strings.ToLower(text)))
func (r sqlRepository) index(id string, text ...string) error {
sanitizedText := strings.Builder{}
for _, txt := range text {
sanitizedText.WriteString(strings.TrimSpace(sanitize.Accents(strings.ToLower(txt))) + " ")
}
values := map[string]interface{}{
"id": id,
"item_type": r.tableName,
"full_text": sanitizedText,
"full_text": strings.TrimSpace(sanitizedText.String()),
}
update := Update(searchTable).Where(Eq{"id": id}).SetMap(values)
count, err := r.executeSQL(update)
@@ -33,10 +36,10 @@ func (r sqlRepository) index(id string, text string) error {
func (r sqlRepository) doSearch(q string, offset, size int, results interface{}, orderBys ...string) error {
q = strings.TrimSpace(sanitize.Accents(strings.ToLower(strings.TrimSuffix(q, "*"))))
if len(q) <= 2 {
if len(q) < 2 {
return nil
}
sq := Select("*").From(r.tableName)
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.parseInt("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,17 +206,43 @@ func (m *Metadata) parseInt(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])
var dateRegex = regexp.MustCompile(`^([12]\d\d\d)`)
func (m *Metadata) parseYear(tagName string) int {
if v, ok := m.tags[tagName]; ok {
match := dateRegex.FindStringSubmatch(v)
if len(match) == 0 {
log.Error("Error parsing year from ffmpeg date field. Please report this issue", "file", m.filePath, "date", v)
return 0
}
year, _ := strconv.Atoi(match[1])
return year
}
return 0
}
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
}
@@ -217,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,91 +45,106 @@ 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("parses correctly the compilation tag", func() {
const outputWithOverlappingTitleTag = `
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:
iTunSMPB : 00000000 000002D6 00000216 0000000000CB9F94 02000003 0049D539 00000000 00000000 00000000 00000000 00000000 00000000
iTunNORM : 000002FF 0000027E 00000FEF 00000C17 0002E647 00044605 00007F02 00007A92 0000273E 0000273E
title : Pablo's Blues
artist : Gare Du Nord
album : Putumayo Presents Blues Lounge
TT1 : Putumayo
track : 9/10
compilation : 1
genre : Blues
date : 2004
Duration: 00:05:02.63, start: 0.000000, bitrate: 140 kb/s
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 128 kb/s
Stream #0:1: Video: png, rgb24(pc), 500x478 [SAR 2835:2835 DAR 250:239], 90k tbr, 90k tbn, 90k tbc
Metadata:
comment : Other`
md, _ := extractMetadata("tests/fixtures/test.mp3", outputWithOverlappingTitleTag)
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 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", 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)
artist : Bone 40
track : 1
album : Groovin'
album_artist : Bone 40
comment : Visit http://bone40.bandcamp.com
date : 2016
Duration: 00:03:34.28, start: 0.025056, bitrate: 323 kb/s
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 320 kb/s
Metadata:
encoder : LAME3.99r
Side data:
replaygain: track gain - -6.000000, track peak - unknown, album gain - unknown, album peak - unknown,
Stream #0:1: Video: mjpeg, yuvj444p(pc, bt470bg/unknown/unknown), 700x700 [SAR 72:72 DAR 1:1], 90k tbr, 90k tbn, 90k tbc
Metadata:
title : cover
comment : Cover (front)
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
album_artist : AC/DC
ARTIST : AC/DC
COMPOSER : Angus Young;Malcolm Young;Brian Johnson
DATE : 1980.07.25
disc : 1
GENRE : Hard Rock
LANGUAGE : EN
RATING : 2
TITLE : Back In Black
DISCTOTAL : 1
TRACKTOTAL : 10
track : 6
REPLAYGAIN_TRACK_GAIN: -8.51 dB
REPLAYGAIN_TRACK_PEAK: 0.998322
Duration: 00:04:16.00, start: 0.000000, bitrate: 995 kb/s
Stream #0:0: Audio: flac, 44100 Hz, stereo, s16
Side data:
replaygain: track gain - -8.510000, track peak - 0.000023, album gain - unknown, album peak - unknown,`
md, _ := extractMetadata("tests/fixtures/test.mp3", outputWithOverlappingTitleTag)
Duration: 00:04:16.00, start: 0.000000, bitrate: 995 kb/s`
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"))
@@ -139,7 +154,7 @@ Input #0, flac, from '/Users/deluan/Downloads/06. Back In Black.flac':
n, t = md.DiscNumber()
Expect(n).To(Equal(1))
Expect(t).To(Equal(1))
Expect(md.Year()).To(Equal(1980))
})
// TODO Handle multiline tags
@@ -147,14 +162,6 @@ Input #0, flac, from '/Users/deluan/Downloads/06. Back In Black.flac':
const outputWithMultilineComment = `
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'modulo.m4a':
Metadata:
major_brand : mp42
minor_version : 0
compatible_brands: M4A mp42isom
creation_time : 2014-05-10T21:11:57.000000Z
iTunSMPB : 00000000 00000920 000000E0 00000000021CA200 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
encoder : Nero AAC codec / 1.5.4.0
title : Módulo Especial
artist : Saara Saara
comment : https://www.mixcloud.com/codigorock/30-minutos-com-saara-saara/
:
: Tracklist:
@@ -167,18 +174,7 @@ Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'modulo.m4a':
: 06. Doktor Fritz
: 07. Wunderbar
: 08. Quarta Dimensão
album : Módulo Especial
genre : Electronic
track : 1
Duration: 00:26:46.96, start: 0.052971, bitrate: 69 kb/s
Chapter #0:0: start 0.105941, end 1607.013149
Metadata:
title :
Stream #0:0(und): Audio: aac (HE-AAC) (mp4a / 0x6134706D), 44100 Hz, stereo, fltp, 69 kb/s (default)
Metadata:
creation_time : 2014-05-10T21:11:57.000000Z
handler_name : Sound Media Handler
At least one output file must be specified`
Duration: 00:26:46.96, start: 0.052971, bitrate: 69 kb/s`
const expectedComment = `https://www.mixcloud.com/codigorock/30-minutos-com-saara-saara/
Tracklist:
@@ -196,4 +192,27 @@ Tracklist:
Expect(md.Comment()).To(Equal(expectedComment))
})
})
Context("parseYear", func() {
It("parses the year correctly", func() {
var examples = map[string]int{
"1985": 1985,
"2002-01": 2002,
"1969.06": 1969,
"1980.07.25": 1980,
"2004-00-00": 2004,
"2013-May-12": 2013,
"May 12, 2016": 0,
}
for tag, expected := range examples {
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{"date": "invalid"}}
Expect(md.Year()).To(Equal(0))
})
})
})

View File

@@ -40,7 +40,6 @@ func (s *Scanner) Rescan(mediaFolder string, fullRescan bool) error {
}
s.updateLastModifiedSince(mediaFolder, start)
log.Debug("Finished scanning folder", "folder", mediaFolder, "elapsed", time.Since(start))
return err
}

View File

@@ -39,12 +39,14 @@ func NewTagScanner(rootFolder string, ds model.DataStore) *TagScanner {
// refresh the collected albums and artists with the metadata from the mediafiles
// Delete all empty albums, delete all empty Artists
func (s *TagScanner) Scan(ctx context.Context, lastModifiedSince time.Time) error {
start := time.Now()
changed, deleted, err := s.detector.Scan(lastModifiedSince)
if err != nil {
return err
}
if len(changed)+len(deleted) == 0 {
log.Debug(ctx, "No changes found in Music Folder", "folder", s.rootFolder)
return nil
}
@@ -108,11 +110,10 @@ func (s *TagScanner) Scan(ctx context.Context, lastModifiedSince time.Time) erro
return err
}
if len(changed)+len(deleted) == 0 {
return nil
}
err = s.ds.GC(log.NewContext(nil))
log.Info("Finished Music Folder", "folder", s.rootFolder, "elapsed", time.Since(start))
return s.ds.GC(log.NewContext(nil))
return err
}
func (s *TagScanner) refreshAlbums(ctx context.Context, updatedAlbums map[string]bool) error {
@@ -133,7 +134,6 @@ func (s *TagScanner) refreshArtists(ctx context.Context, updatedArtists map[stri
func (s *TagScanner) processChangedDir(ctx context.Context, dir string, updatedArtists map[string]bool, updatedAlbums map[string]bool) error {
dir = filepath.Join(s.rootFolder, dir)
start := time.Now()
// Load folder's current tracks from DB into a map
@@ -143,54 +143,68 @@ 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
}
}
delete(currentTracks, n.ID)
}
// Remaining tracks from DB that are not in the folder are deleted
for id := range currentTracks {
numPurgedTracks++
if err := s.ds.MediaFile(ctx).Delete(id); err != nil {
err := s.ds.MediaFile(ctx).Put(&n)
updatedArtists[n.ArtistID] = true
updatedAlbums[n.AlbumID] = true
numUpdatedTracks++
if err != nil {
return err
}
}
log.Debug("Finished processing changed folder", "dir", dir, "updated", numUpdatedTracks, "purged", numPurgedTracks, "elapsed", time.Since(start))
// Remaining tracks from DB that are not in the folder are deleted
for _, ct := range currentTracks {
numPurgedTracks++
updatedArtists[ct.ArtistID] = true
updatedAlbums[ct.AlbumID] = true
if err := s.ds.MediaFile(ctx).Delete(ct.ID); err != nil {
return err
}
}
log.Info("Finished processing changed folder", "dir", dir, "updated", numUpdatedTracks, "purged", numPurgedTracks, "elapsed", time.Since(start))
return nil
}
func (s *TagScanner) processDeletedDir(ctx context.Context, dir string, updatedArtists map[string]bool, updatedAlbums map[string]bool) error {
dir = filepath.Join(s.rootFolder, dir)
start := time.Now()
ct, err := s.ds.MediaFile(ctx).FindByPath(dir)
if err != nil {
@@ -201,14 +215,16 @@ func (s *TagScanner) processDeletedDir(ctx context.Context, dir string, updatedA
updatedAlbums[t.AlbumID] = true
}
log.Info("Finished processing deleted folder", "dir", dir, "deleted", len(ct), "elapsed", time.Since(start))
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

@@ -7,18 +7,13 @@ import (
"strings"
"github.com/deluan/navidrome/assets"
"github.com/deluan/navidrome/engine/auth"
"github.com/deluan/navidrome/model"
"github.com/deluan/rest"
"github.com/go-chi/chi"
"github.com/go-chi/jwtauth"
)
var initialUser = model.User{
UserName: "admin",
Name: "Admin",
IsAdmin: true,
}
type Router struct {
ds model.DataStore
mux http.Handler
@@ -38,22 +33,23 @@ func (app *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
func (app *Router) routes() http.Handler {
r := chi.NewRouter()
// Basic unauthenticated ping
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"response":"pong"}`)) })
r.Post("/login", Login(app.ds))
r.Post("/createAdmin", CreateAdmin(app.ds))
r.Route("/api", func(r chi.Router) {
r.Use(jwtauth.Verifier(TokenAuth))
r.Use(jwtauth.Verifier(auth.TokenAuth))
r.Use(Authenticator(app.ds))
app.R(r, "/user", model.User{})
app.R(r, "/song", model.MediaFile{})
app.R(r, "/album", model.Album{})
app.R(r, "/artist", model.Artist{})
// Keepalive endpoint to be used to keep the session valid (ex: while playing songs)
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"response":"ok"}`)) })
})
// Serve UI app assets
r.Handle("/", ServeIndex(app.ds))
r.Handle("/*", http.StripPrefix(app.path, http.FileServer(assets.AssetFile())))
return r

View File

@@ -10,6 +10,7 @@ import (
"time"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/engine/auth"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/rest"
@@ -20,13 +21,11 @@ import (
var (
once sync.Once
jwtSecret []byte
TokenAuth *jwtauth.JWTAuth
ErrFirstTime = errors.New("no users created")
)
func Login(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) {
initTokenAuth(ds)
auth.InitTokenAuth(ds)
return func(w http.ResponseWriter, r *http.Request) {
username, password, err := getCredentialsFromBody(r)
@@ -52,7 +51,7 @@ func handleLogin(ds model.DataStore, username string, password string, w http.Re
return
}
tokenString, err := createToken(user)
tokenString, err := auth.CreateToken(user)
if err != nil {
rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authenticating user. Please try again")
return
@@ -82,7 +81,7 @@ func getCredentialsFromBody(r *http.Request) (username string, password string,
}
func CreateAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) {
initTokenAuth(ds)
auth.InitTokenAuth(ds)
return func(w http.ResponseWriter, r *http.Request) {
username, password, err := getCredentialsFromBody(r)
@@ -129,16 +128,6 @@ func createDefaultUser(ctx context.Context, ds model.DataStore, username, passwo
return nil
}
func initTokenAuth(ds model.DataStore) {
once.Do(func() {
secret, err := ds.Property(nil).DefaultGet(consts.JWTSecretKey, "not so secret")
if err != nil {
log.Error("No JWT secret found in DB. Setting a temp one, but please report this error", err)
}
jwtSecret = []byte(secret)
TokenAuth = jwtauth.New("HS256", jwtSecret, nil)
})
}
func validateLogin(userRepo model.UserRepository, userName, password string) (*model.User, error) {
u, err := userRepo.FindByUsername(userName)
if err == model.ErrNotFound {
@@ -157,24 +146,6 @@ func validateLogin(userRepo model.UserRepository, userName, password string) (*m
return u, nil
}
func createToken(u *model.User) (string, error) {
token := jwt.New(jwt.SigningMethodHS256)
claims := token.Claims.(jwt.MapClaims)
claims["iss"] = consts.JWTIssuer
claims["sub"] = u.UserName
claims["adm"] = u.IsAdmin
return touchToken(token)
}
func touchToken(token *jwt.Token) (string, error) {
expireIn := time.Now().Add(consts.JWTTokenExpiration).Unix()
claims := token.Claims.(jwt.MapClaims)
claims["exp"] = expireIn
return token.SignedString(jwtSecret)
}
func contextWithUser(ctx context.Context, ds model.DataStore, claims jwt.MapClaims) context.Context {
userName := claims["sub"].(string)
user, _ := ds.User(ctx).FindByUsername(userName)
@@ -199,7 +170,7 @@ func getToken(ds model.DataStore, ctx context.Context) (*jwt.Token, error) {
}
func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
initTokenAuth(ds)
auth.InitTokenAuth(ds)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -216,7 +187,7 @@ func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
claims := token.Claims.(jwt.MapClaims)
newCtx := contextWithUser(r.Context(), ds, claims)
newTokenString, err := touchToken(token)
newTokenString, err := auth.TouchToken(token)
if err != nil {
log.Error(r, "signing new token", err)
rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")

43
server/app/serve_index.go Normal file
View File

@@ -0,0 +1,43 @@
package app
import (
"encoding/json"
"html/template"
"io/ioutil"
"net/http"
"github.com/deluan/navidrome/assets"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
)
// Injects the `firstTime` config in the `index.html` template
func ServeIndex(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c, err := ds.User(r.Context()).CountAll()
firstTime := c == 0 && err == nil
t := template.New("initial state")
fs := assets.AssetFile()
indexHtml, err := fs.Open("index.html")
if err != nil {
log.Error(r, "Could not find `index.html` template", err)
}
indexStr, err := ioutil.ReadAll(indexHtml)
if err != nil {
log.Error(r, "Could not read from `index.html`", err)
}
t, _ = t.Parse(string(indexStr))
appConfig := map[string]interface{}{
"firstTime": firstTime,
}
j, _ := json.Marshal(appConfig)
data := map[string]interface{}{
"AppConfig": string(j),
}
err = t.Execute(w, data)
if err != nil {
log.Error(r, "Could not execute `index.html` template", err)
}
}
}

View File

@@ -1,8 +1,11 @@
package server
import (
"context"
"fmt"
"time"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
@@ -20,11 +23,47 @@ func initialSetup(ds model.DataStore) {
return err
}
if conf.Server.DevAutoCreateAdminPassword != "" {
if err = createInitialAdminUser(ds); err != nil {
return err
}
}
err = ds.Property(nil).Put(consts.InitialSetupFlagKey, time.Now().String())
return err
})
}
func createInitialAdminUser(ds model.DataStore) error {
ctx := context.Background()
c, err := ds.User(ctx).CountAll()
if err != nil {
panic(fmt.Sprintf("Could not access User table: %s", err))
}
if c == 0 {
id, _ := uuid.NewRandom()
random, _ := uuid.NewRandom()
initialPassword := random.String()
if conf.Server.DevAutoCreateAdminPassword != "" {
initialPassword = conf.Server.DevAutoCreateAdminPassword
}
log.Warn("Creating initial admin user. This should only be used for development purposes!!", "user", consts.DevInitialUserName, "password", initialPassword)
initialUser := model.User{
ID: id.String(),
UserName: consts.DevInitialUserName,
Name: consts.DevInitialName,
Email: "",
Password: initialPassword,
IsAdmin: true,
}
err := ds.User(ctx).Put(&initialUser)
if err != nil {
log.Error("Could not create initial admin user", "user", initialUser, err)
}
}
return err
}
func createJWTSecret(ds model.DataStore) error {
_, err := ds.Property(nil).Get(consts.JWTSecretKey)
if err == nil {

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

@@ -47,8 +47,8 @@ func (c *AlbumListController) getAlbumList(r *http.Request) (engine.Entries, err
return nil, errors.New("Not implemented!")
}
offset := ParamInt(r, "offset", 0)
size := utils.MinInt(ParamInt(r, "size", 10), 500)
offset := utils.ParamInt(r, "offset", 0)
size := utils.MinInt(utils.ParamInt(r, "size", 10), 500)
albums, err := listFunc(r.Context(), offset, size)
if err != nil {
@@ -132,8 +132,8 @@ func (c *AlbumListController) GetNowPlaying(w http.ResponseWriter, r *http.Reque
}
func (c *AlbumListController) GetRandomSongs(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
size := utils.MinInt(ParamInt(r, "size", 10), 500)
genre := ParamString(r, "genre")
size := utils.MinInt(utils.ParamInt(r, "size", 10), 500)
genre := utils.ParamString(r, "genre")
songs, err := c.listGen.GetRandomSongs(r.Context(), size, genre)
if err != nil {

View File

@@ -9,6 +9,7 @@ import (
"github.com/deluan/navidrome/engine"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/server/subsonic/responses"
"github.com/deluan/navidrome/utils"
"github.com/go-chi/chi"
)
@@ -67,11 +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(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)
@@ -163,7 +165,7 @@ func SendError(w http.ResponseWriter, r *http.Request, err error) {
}
func SendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Subsonic) {
f := ParamString(r, "f")
f := utils.ParamString(r, "f")
var response []byte
switch f {
case "json":
@@ -172,7 +174,7 @@ func SendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub
response, _ = json.Marshal(wrapper)
case "jsonp":
w.Header().Set("Content-Type", "application/javascript")
callback := ParamString(r, "callback")
callback := utils.ParamString(r, "callback")
wrapper := &responses.JsonWrapper{Subsonic: *payload}
data, _ := json.Marshal(wrapper)
response = []byte(fmt.Sprintf("%s(%s)", callback, data))

View File

@@ -59,8 +59,8 @@ func (c *BrowsingController) getArtistIndex(r *http.Request, musicFolderId strin
}
func (c *BrowsingController) GetIndexes(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
musicFolderId := ParamString(r, "musicFolderId")
ifModifiedSince := ParamTime(r, "ifModifiedSince", time.Time{})
musicFolderId := utils.ParamString(r, "musicFolderId")
ifModifiedSince := utils.ParamTime(r, "ifModifiedSince", time.Time{})
res, err := c.getArtistIndex(r, musicFolderId, ifModifiedSince)
if err != nil {
@@ -73,7 +73,7 @@ func (c *BrowsingController) GetIndexes(w http.ResponseWriter, r *http.Request)
}
func (c *BrowsingController) GetArtists(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
musicFolderId := ParamString(r, "musicFolderId")
musicFolderId := utils.ParamString(r, "musicFolderId")
res, err := c.getArtistIndex(r, musicFolderId, time.Time{})
if err != nil {
return nil, err
@@ -85,7 +85,7 @@ func (c *BrowsingController) GetArtists(w http.ResponseWriter, r *http.Request)
}
func (c *BrowsingController) GetMusicDirectory(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
id := ParamString(r, "id")
id := utils.ParamString(r, "id")
dir, err := c.browser.Directory(r.Context(), id)
switch {
case err == model.ErrNotFound:
@@ -102,7 +102,7 @@ func (c *BrowsingController) GetMusicDirectory(w http.ResponseWriter, r *http.Re
}
func (c *BrowsingController) GetArtist(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
id := ParamString(r, "id")
id := utils.ParamString(r, "id")
dir, err := c.browser.Artist(r.Context(), id)
switch {
case err == model.ErrNotFound:
@@ -119,7 +119,7 @@ func (c *BrowsingController) GetArtist(w http.ResponseWriter, r *http.Request) (
}
func (c *BrowsingController) GetAlbum(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
id := ParamString(r, "id")
id := utils.ParamString(r, "id")
dir, err := c.browser.Album(r.Context(), id)
switch {
case err == model.ErrNotFound:
@@ -136,7 +136,7 @@ func (c *BrowsingController) GetAlbum(w http.ResponseWriter, r *http.Request) (*
}
func (c *BrowsingController) GetSong(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
id := ParamString(r, "id")
id := utils.ParamString(r, "id")
song, err := c.browser.GetSong(r.Context(), id)
switch {
case err == model.ErrNotFound:
@@ -165,6 +165,30 @@ func (c *BrowsingController) GetGenres(w http.ResponseWriter, r *http.Request) (
return response, nil
}
const noImageAvailableUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ac/No_image_available.svg/1024px-No_image_available.svg.png"
// TODO Integrate with Last.FM
func (c *BrowsingController) GetArtistInfo(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
response := NewResponse()
response.ArtistInfo = &responses.ArtistInfo{}
response.ArtistInfo.Biography = "Biography not available"
response.ArtistInfo.SmallImageUrl = noImageAvailableUrl
response.ArtistInfo.MediumImageUrl = noImageAvailableUrl
response.ArtistInfo.LargeImageUrl = noImageAvailableUrl
return response, nil
}
// TODO Integrate with Last.FM
func (c *BrowsingController) GetArtistInfo2(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
response := NewResponse()
response.ArtistInfo2 = &responses.ArtistInfo2{}
response.ArtistInfo2.Biography = "Biography not available"
response.ArtistInfo2.SmallImageUrl = noImageAvailableUrl
response.ArtistInfo2.MediumImageUrl = noImageAvailableUrl
response.ArtistInfo2.LargeImageUrl = noImageAvailableUrl
return response, nil
}
func (c *BrowsingController) buildDirectory(d *engine.DirectoryInfo) *responses.Directory {
dir := &responses.Directory{
Id: d.Id,

View File

@@ -5,8 +5,6 @@ import (
"mime"
"net/http"
"strconv"
"strings"
"time"
"github.com/deluan/navidrome/consts"
"github.com/deluan/navidrome/engine"
@@ -20,7 +18,7 @@ func NewResponse() *responses.Subsonic {
}
func RequiredParamString(r *http.Request, param string, msg string) (string, error) {
p := ParamString(r, param)
p := utils.ParamString(r, param)
if p == "" {
return "", NewError(responses.ErrorMissingParameter, msg)
}
@@ -28,83 +26,19 @@ func RequiredParamString(r *http.Request, param string, msg string) (string, err
}
func RequiredParamStrings(r *http.Request, param string, msg string) ([]string, error) {
ps := ParamStrings(r, param)
ps := utils.ParamStrings(r, param)
if len(ps) == 0 {
return nil, NewError(responses.ErrorMissingParameter, msg)
}
return ps, nil
}
func ParamString(r *http.Request, param string) string {
return r.URL.Query().Get(param)
}
func ParamStrings(r *http.Request, param string) []string {
return r.URL.Query()[param]
}
func ParamTimes(r *http.Request, param string) []time.Time {
pStr := ParamStrings(r, param)
times := make([]time.Time, len(pStr))
for i, t := range pStr {
ti, err := strconv.ParseInt(t, 10, 64)
if err == nil {
times[i] = utils.ToTime(ti)
}
}
return times
}
func ParamTime(r *http.Request, param string, def time.Time) time.Time {
v := ParamString(r, param)
if v == "" {
return def
}
value, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return def
}
return utils.ToTime(value)
}
func RequiredParamInt(r *http.Request, param string, msg string) (int, error) {
p := ParamString(r, param)
p := utils.ParamString(r, param)
if p == "" {
return 0, NewError(responses.ErrorMissingParameter, msg)
}
return ParamInt(r, param, 0), nil
}
func ParamInt(r *http.Request, param string, def int) int {
v := ParamString(r, param)
if v == "" {
return def
}
value, err := strconv.ParseInt(v, 10, 32)
if err != nil {
return def
}
return int(value)
}
func ParamInts(r *http.Request, param string) []int {
pStr := ParamStrings(r, param)
ints := make([]int, 0, len(pStr))
for _, s := range pStr {
i, err := strconv.ParseInt(s, 10, 32)
if err == nil {
ints = append(ints, int(i))
}
}
return ints
}
func ParamBool(r *http.Request, param string, def bool) bool {
p := ParamString(r, param)
if p == "" {
return def
}
return strings.Index("/true/on/1/", "/"+p+"/") != -1
return utils.ParamInt(r, param, 0), nil
}
type SubsonicError struct {
@@ -199,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

@@ -9,6 +9,7 @@ import (
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/server/subsonic/responses"
"github.com/deluan/navidrome/utils"
)
type MediaAnnotationController struct {
@@ -49,9 +50,9 @@ func (c *MediaAnnotationController) SetRating(w http.ResponseWriter, r *http.Req
}
func (c *MediaAnnotationController) Star(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
ids := ParamStrings(r, "id")
albumIds := ParamStrings(r, "albumId")
artistIds := ParamStrings(r, "artistId")
ids := utils.ParamStrings(r, "id")
albumIds := utils.ParamStrings(r, "albumId")
artistIds := utils.ParamStrings(r, "artistId")
if len(ids)+len(albumIds)+len(artistIds) == 0 {
return nil, NewError(responses.ErrorMissingParameter, "Required id parameter is missing")
}
@@ -84,9 +85,9 @@ func (c *MediaAnnotationController) star(ctx context.Context, starred bool, ids
}
func (c *MediaAnnotationController) Unstar(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
ids := ParamStrings(r, "id")
albumIds := ParamStrings(r, "albumId")
artistIds := ParamStrings(r, "artistId")
ids := utils.ParamStrings(r, "id")
albumIds := utils.ParamStrings(r, "albumId")
artistIds := utils.ParamStrings(r, "artistId")
if len(ids)+len(albumIds)+len(artistIds) == 0 {
return nil, NewError(responses.ErrorMissingParameter, "Required id parameter is missing")
}
@@ -106,14 +107,14 @@ func (c *MediaAnnotationController) Scrobble(w http.ResponseWriter, r *http.Requ
if err != nil {
return nil, err
}
times := ParamTimes(r, "time")
times := utils.ParamTimes(r, "time")
if len(times) > 0 && len(times) != len(ids) {
return nil, NewError(responses.ErrorGeneric, "Wrong number of timestamps: %d, should be %d", len(times), len(ids))
}
submission := ParamBool(r, "submission", true)
submission := utils.ParamBool(r, "submission", true)
playerId := 1 // TODO Multiple players, based on playerName/username/clientIP(?)
playerName := ParamString(r, "c")
username := ParamString(r, "u")
playerName := utils.ParamString(r, "c")
username := utils.ParamString(r, "u")
log.Debug(r, "Scrobbling tracks", "ids", ids, "times", times, "submission", submission)
for i, id := range ids {

View File

@@ -9,6 +9,7 @@ import (
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/server/subsonic/responses"
"github.com/deluan/navidrome/static"
"github.com/deluan/navidrome/utils"
)
type MediaRetrievalController struct {
@@ -36,8 +37,9 @@ func (c *MediaRetrievalController) GetCoverArt(w http.ResponseWriter, r *http.Re
if err != nil {
return nil, err
}
size := ParamInt(r, "size", 0)
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

@@ -11,6 +11,7 @@ import (
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/server/subsonic/responses"
"github.com/deluan/navidrome/utils"
)
func postFormToQueryParams(next http.Handler) http.Handler {
@@ -36,7 +37,7 @@ func checkRequiredParameters(next http.Handler) http.Handler {
requiredParameters := []string{"u", "v", "c"}
for _, p := range requiredParameters {
if ParamString(r, p) == "" {
if utils.ParamString(r, p) == "" {
msg := fmt.Sprintf(`Missing required parameter "%s"`, p)
log.Warn(r, msg)
SendError(w, r, NewError(responses.ErrorMissingParameter, msg))
@@ -44,13 +45,9 @@ func checkRequiredParameters(next http.Handler) http.Handler {
}
}
if ParamString(r, "p") == "" && (ParamString(r, "s") == "" || ParamString(r, "t") == "") {
log.Warn(r, "Missing authentication information")
}
user := ParamString(r, "u")
client := ParamString(r, "c")
version := ParamString(r, "v")
user := utils.ParamString(r, "u")
client := utils.ParamString(r, "c")
version := utils.ParamString(r, "v")
ctx := r.Context()
ctx = context.WithValue(ctx, "username", user)
ctx = context.WithValue(ctx, "client", client)
@@ -65,12 +62,13 @@ func checkRequiredParameters(next http.Handler) http.Handler {
func authenticate(users engine.Users) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
username := ParamString(r, "u")
pass := ParamString(r, "p")
token := ParamString(r, "t")
salt := ParamString(r, "s")
username := utils.ParamString(r, "u")
pass := utils.ParamString(r, "p")
token := utils.ParamString(r, "t")
salt := utils.ParamString(r, "s")
jwt := utils.ParamString(r, "jwt")
usr, err := users.Authenticate(r.Context(), username, pass, token, salt)
usr, err := users.Authenticate(r.Context(), username, pass, token, salt, jwt)
if err == model.ErrInvalidAuth {
log.Warn(r, "Invalid login", "username", username, err)
} else if err != nil {
@@ -91,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

@@ -113,7 +113,7 @@ var _ = Describe("Middlewares", func() {
})
It("passes all parameters to users.Authenticate ", func() {
r := newGetRequest("u=valid", "p=password", "t=token", "s=salt")
r := newGetRequest("u=valid", "p=password", "t=token", "s=salt", "jwt=jwt")
cp := authenticate(mockedUser)(next)
cp.ServeHTTP(w, r)
@@ -121,6 +121,7 @@ var _ = Describe("Middlewares", func() {
Expect(mockedUser.password).To(Equal("password"))
Expect(mockedUser.token).To(Equal("token"))
Expect(mockedUser.salt).To(Equal("salt"))
Expect(mockedUser.jwt).To(Equal("jwt"))
Expect(next.called).To(BeTrue())
user := next.req.Context().Value("user").(*model.User)
Expect(user.UserName).To(Equal("valid"))
@@ -149,14 +150,15 @@ func (mh *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
type mockUsers struct {
engine.Users
username, password, token, salt string
username, password, token, salt, jwt string
}
func (m *mockUsers) Authenticate(ctx context.Context, username, password, token, salt string) (*model.User, error) {
func (m *mockUsers) Authenticate(ctx context.Context, username, password, token, salt, jwt string) (*model.User, error) {
m.username = username
m.password = password
m.token = token
m.salt = salt
m.jwt = jwt
if username == "valid" {
return &model.User{UserName: username, Password: password}, nil
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/server/subsonic/responses"
"github.com/deluan/navidrome/utils"
)
type PlaylistsController struct {
@@ -31,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
}
@@ -61,9 +62,9 @@ func (c *PlaylistsController) GetPlaylist(w http.ResponseWriter, r *http.Request
}
func (c *PlaylistsController) CreatePlaylist(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
songIds := ParamStrings(r, "songId")
playlistId := ParamString(r, "playlistId")
name := ParamString(r, "name")
songIds := utils.ParamStrings(r, "songId")
playlistId := utils.ParamString(r, "playlistId")
name := utils.ParamString(r, "name")
if playlistId == "" && name == "" {
return nil, errors.New("Required parameter name is missing")
}
@@ -96,8 +97,8 @@ func (c *PlaylistsController) UpdatePlaylist(w http.ResponseWriter, r *http.Requ
if err != nil {
return nil, err
}
songsToAdd := ParamStrings(r, "songIdToAdd")
songIndexesToRemove := ParamInts(r, "songIndexToRemove")
songsToAdd := utils.ParamStrings(r, "songIdToAdd")
songIndexesToRemove := utils.ParamInts(r, "songIndexToRemove")
var pname *string
if len(r.URL.Query()["name"]) > 0 {

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

@@ -0,0 +1 @@
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","artistInfo":{"biography":"Black Sabbath is an English \u003ca target='_blank' href=\"http://www.last.fm/tag/heavy%20metal\" class=\"bbcode_tag\" rel=\"tag\"\u003eheavy metal\u003c/a\u003e band","musicBrainzId":"5182c1d9-c7d2-4dad-afa0-ccfeada921a8","lastFmUrl":"http://www.last.fm/music/Black+Sabbath","smallImageUrl":"http://userserve-ak.last.fm/serve/64/27904353.jpg","mediumImageUrl":"http://userserve-ak.last.fm/serve/126/27904353.jpg","largeImageUrl":"http://userserve-ak.last.fm/serve/_/27904353/Black+Sabbath+sabbath+1970.jpg","similarArtist":[{"id":"22","name":"Accept"},{"id":"101","name":"Bruce Dickinson"},{"id":"26","name":"Aerosmith"}]}}

View File

@@ -0,0 +1 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><artistInfo><biography>Black Sabbath is an English &lt;a target=&#39;_blank&#39; href=&#34;http://www.last.fm/tag/heavy%20metal&#34; class=&#34;bbcode_tag&#34; rel=&#34;tag&#34;&gt;heavy metal&lt;/a&gt; band</biography><musicBrainzId>5182c1d9-c7d2-4dad-afa0-ccfeada921a8</musicBrainzId><lastFmUrl>http://www.last.fm/music/Black+Sabbath</lastFmUrl><smallImageUrl>http://userserve-ak.last.fm/serve/64/27904353.jpg</smallImageUrl><mediumImageUrl>http://userserve-ak.last.fm/serve/126/27904353.jpg</mediumImageUrl><largeImageUrl>http://userserve-ak.last.fm/serve/_/27904353/Black+Sabbath+sabbath+1970.jpg</largeImageUrl><similarArtist id="22" name="Accept"></similarArtist><similarArtist id="101" name="Bruce Dickinson"></similarArtist><similarArtist id="26" name="Aerosmith"></similarArtist></artistInfo></subsonic-response>

View File

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

View File

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

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

@@ -34,6 +34,9 @@ type Subsonic struct {
Artist *Indexes `xml:"artists,omitempty" json:"artists,omitempty"`
ArtistWithAlbumsID3 *ArtistWithAlbumsID3 `xml:"artist,omitempty" json:"artist,omitempty"`
AlbumWithSongsID3 *AlbumWithSongsID3 `xml:"album,omitempty" json:"album,omitempty"`
ArtistInfo *ArtistInfo `xml:"artistInfo,omitempty" json:"artistInfo,omitempty"`
ArtistInfo2 *ArtistInfo2 `xml:"artistInfo2,omitempty" json:"artistInfo2,omitempty"`
}
type JsonWrapper struct {
@@ -109,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 -->
@@ -272,3 +275,22 @@ type Genre struct {
type Genres struct {
Genre []Genre `xml:"genre,omitempty" json:"genre,omitempty"`
}
type ArtistInfoBase struct {
Biography string `xml:"biography,omitempty" json:"biography,omitempty"`
MusicBrainzID string `xml:"musicBrainzId,omitempty" json:"musicBrainzId,omitempty"`
LastFmUrl string `xml:"lastFmUrl,omitempty" json:"lastFmUrl,omitempty"`
SmallImageUrl string `xml:"smallImageUrl,omitempty" json:"smallImageUrl,omitempty"`
MediumImageUrl string `xml:"mediumImageUrl,omitempty" json:"mediumImageUrl,omitempty"`
LargeImageUrl string `xml:"largeImageUrl,omitempty" json:"largeImageUrl,omitempty"`
}
type ArtistInfo struct {
ArtistInfoBase
SimilarArtist []Artist `xml:"similarArtist,omitempty" json:"similarArtist,omitempty"`
}
type ArtistInfo2 struct {
ArtistInfoBase
SimilarArtist []ArtistID3 `xml:"similarArtist,omitempty" json:"similarArtist,omitempty"`
}

View File

@@ -282,4 +282,42 @@ var _ = Describe("Responses", func() {
})
})
})
Describe("ArtistInfo", func() {
BeforeEach(func() {
response.ArtistInfo = &ArtistInfo{}
})
Context("without data", func() {
It("should match .XML", func() {
Expect(xml.Marshal(response)).To(MatchSnapshot())
})
It("should match .JSON", func() {
Expect(json.Marshal(response)).To(MatchSnapshot())
})
})
Context("with data", func() {
BeforeEach(func() {
response.ArtistInfo.Biography = `Black Sabbath is an English <a target='_blank' href="http://www.last.fm/tag/heavy%20metal" class="bbcode_tag" rel="tag">heavy metal</a> band`
response.ArtistInfo.MusicBrainzID = "5182c1d9-c7d2-4dad-afa0-ccfeada921a8"
response.ArtistInfo.LastFmUrl = "http://www.last.fm/music/Black+Sabbath"
response.ArtistInfo.SmallImageUrl = "http://userserve-ak.last.fm/serve/64/27904353.jpg"
response.ArtistInfo.MediumImageUrl = "http://userserve-ak.last.fm/serve/126/27904353.jpg"
response.ArtistInfo.LargeImageUrl = "http://userserve-ak.last.fm/serve/_/27904353/Black+Sabbath+sabbath+1970.jpg"
response.ArtistInfo.SimilarArtist = []Artist{
{Id: "22", Name: "Accept"},
{Id: "101", Name: "Bruce Dickinson"},
{Id: "26", Name: "Aerosmith"},
}
})
It("should match .XML", func() {
Expect(xml.Marshal(response)).To(MatchSnapshot())
})
It("should match .JSON", func() {
Expect(json.Marshal(response)).To(MatchSnapshot())
})
})
})
})

View File

@@ -7,6 +7,7 @@ import (
"github.com/deluan/navidrome/engine"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/server/subsonic/responses"
"github.com/deluan/navidrome/utils"
)
type SearchingController struct {
@@ -34,12 +35,12 @@ func (c *SearchingController) getParams(r *http.Request) (*searchParams, error)
if err != nil {
return nil, err
}
sp.artistCount = ParamInt(r, "artistCount", 20)
sp.artistOffset = ParamInt(r, "artistOffset", 0)
sp.albumCount = ParamInt(r, "albumCount", 20)
sp.albumOffset = ParamInt(r, "albumOffset", 0)
sp.songCount = ParamInt(r, "songCount", 20)
sp.songOffset = ParamInt(r, "songOffset", 0)
sp.artistCount = utils.ParamInt(r, "artistCount", 20)
sp.artistOffset = utils.ParamInt(r, "artistOffset", 0)
sp.albumCount = utils.ParamInt(r, "albumCount", 20)
sp.albumOffset = utils.ParamInt(r, "albumOffset", 0)
sp.songCount = utils.ParamInt(r, "songCount", 20)
sp.songOffset = utils.ParamInt(r, "songOffset", 0)
return sp, nil
}

View File

@@ -1,10 +1,14 @@
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"
)
type StreamController struct {
@@ -20,17 +24,35 @@ func (c *StreamController) Stream(w http.ResponseWriter, r *http.Request) (*resp
if err != nil {
return nil, err
}
maxBitRate := ParamInt(r, "maxBitRate", 0)
format := ParamString(r, "format")
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())
http.ServeContent(w, r, ms.Name(), ms.ModTime(), ms)
return nil, nil
}
@@ -40,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 -"

1655
ui/package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -3,16 +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.4.1",
"@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-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.7.2",
"react-redux": "^7.2.0",
"react-scripts": "^3.4.0"
},
"scripts": {
"start": "react-scripts start",

View File

@@ -25,6 +25,9 @@
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Navidrome</title>
<script>
window.__APP_CONFIG__ = "{{.AppConfig}}"
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

@@ -1,31 +1,54 @@
// in src/App.js
import React from 'react'
import { Admin, Resource } from 'react-admin'
import { Admin, resolveBrowserLocale, Resource } from 'react-admin'
import dataProvider from './dataProvider'
import authProvider from './authProvider'
import { Login, Layout, DarkTheme } from './layout'
import polyglotI18nProvider from 'ra-i18n-polyglot'
import messages from './i18n'
import { DarkTheme, Layout, Login } from './layout'
import user from './user'
import song from './song'
import album from './album'
import artist from './artist'
import { createMuiTheme } from '@material-ui/core/styles'
import { Player, playQueueReducer } from './player'
const theme = createMuiTheme(DarkTheme)
const App = () => (
<Admin
theme={theme}
dataProvider={dataProvider}
authProvider={authProvider}
layout={Layout}
loginPage={Login}
>
{(permissions) => [
<Resource name="artist" {...artist} options={{ subMenu: 'library' }} />,
<Resource name="album" {...album} options={{ subMenu: 'library' }} />,
<Resource name="song" {...song} options={{ subMenu: 'library' }} />,
permissions === 'admin' ? <Resource name="user" {...user} /> : null
]}
</Admin>
const i18nProvider = polyglotI18nProvider(
(locale) => (messages[locale] ? messages[locale] : messages.en),
resolveBrowserLocale()
)
const App = () => {
try {
const appConfig = JSON.parse(window.__APP_CONFIG__)
// This flags to the login process that it should create the first account instead
if (appConfig.firstTime) {
localStorage.setItem('initialAccountCreation', 'true')
}
} catch (e) {}
return (
<Admin
theme={theme}
customReducers={{ queue: playQueueReducer }}
dataProvider={dataProvider}
authProvider={authProvider}
i18nProvider={i18nProvider}
layout={Layout}
loginPage={Login}
>
{(permissions) => [
<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 />
]}
</Admin>
)
}
export default App

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

@@ -0,0 +1,46 @@
import React from 'react'
import { Card, CardContent, CardMedia, Typography } from '@material-ui/core'
import { useTranslate } from 'react-admin'
import { subsonicUrl } from '../subsonic'
import { DurationField } from '../common'
const AlbumDetails = ({ classes, record }) => {
const translate = useTranslate()
const genreYear = (record) => {
let genreDateLine = []
if (record.genre) {
genreDateLine.push(record.genre)
}
if (record.year) {
genreDateLine.push(record.year)
}
return genreDateLine.join(' · ')
}
return (
<Card className={classes.container}>
<CardMedia
image={subsonicUrl('getCoverArt', record.coverArtId || 'not_found', {
size: 500
})}
className={classes.albumCover}
/>
<CardContent className={classes.albumDetails}>
<Typography variant="h5" className={classes.albumTitle}>
{record.name}
</Typography>
<Typography component="h6">
{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>
</CardContent>
</Card>
)
}
export default AlbumDetails

View File

@@ -6,13 +6,15 @@ import {
Filter,
List,
NumberField,
FunctionField,
SearchInput,
TextInput,
Show,
SimpleShowLayout,
TextField
} from 'react-admin'
import { DurationField, Title } from '../common'
import { DurationField, Pagination, Title } from '../common'
import { useMediaQuery } from '@material-ui/core'
const AlbumFilter = (props) => (
<Filter {...props}>
@@ -25,7 +27,7 @@ const AlbumDetails = (props) => {
return (
<Show {...props} title=" ">
<SimpleShowLayout>
<TextField label="Album Artist" source="albumArtist" />
<TextField source="albumArtist" />
<TextField source="genre" />
<BooleanField source="compilation" />
<DateField source="updatedAt" showTime />
@@ -34,32 +36,30 @@ const AlbumDetails = (props) => {
)
}
const albumRowClick = (id, basePath, record) => {
const filter = { album: record.name, album_id: id }
if (!record.compilation) {
filter.artist = record.artist
}
return `/song?filter=${JSON.stringify(filter)}&order=ASC&sort=trackNumber`
const AlbumList = (props) => {
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
return (
<List
{...props}
title={<Title subTitle={'Albums'} />}
sort={{ field: 'name', order: 'ASC' }}
exporter={false}
bulkActionButtons={false}
filters={<AlbumFilter />}
perPage={15}
pagination={<Pagination />}
>
<Datagrid expand={<AlbumDetails />} rowClick={'show'}>
<TextField source="name" />
<FunctionField
source="artist"
render={(r) => (r.albumArtist ? r.albumArtist : r.artist)}
/>
{isDesktop && <NumberField source="songCount" />}
<TextField source="year" />
{isDesktop && <DurationField source="duration" />}
</Datagrid>
</List>
)
}
const AlbumList = (props) => (
<List
{...props}
title={<Title subTitle={'Albums'} />}
sort={{ field: 'name', order: 'ASC' }}
exporter={false}
bulkActionButtons={false}
filters={<AlbumFilter />}
perPage={15}
>
<Datagrid expand={<AlbumDetails />} rowClick={albumRowClick}>
<TextField source="name" />
<TextField source="artist" />
<NumberField source="songCount" />
<TextField source="year" />
<DurationField label="Time" source="duration" />
</Datagrid>
</List>
)
export default AlbumList

76
ui/src/album/AlbumShow.js Normal file
View File

@@ -0,0 +1,76 @@
import React from 'react'
import {
Datagrid,
FunctionField,
List,
Loading,
TextField,
useGetOne
} from 'react-admin'
import AlbumDetails from './AlbumDetails'
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 {...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>
</>
)
}
export default AlbumShow

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>
)
}

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