Compare commits

..

8 Commits

Author SHA1 Message Date
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
19 changed files with 252 additions and 75 deletions

View File

@@ -29,8 +29,9 @@ type nd struct {
ScanInterval string `default:"1m"`
// 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{}

View File

@@ -13,4 +13,7 @@ const (
JWTTokenExpiration = 30 * time.Minute
UIAssetsLocalPath = "ui/build"
DevInitialUserName = "admin"
DevInitialName = "Dev Admin"
)

View File

@@ -6,7 +6,7 @@ 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"

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

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

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

@@ -201,20 +201,17 @@ var tagYearFormats = []string{
time.RFC3339,
}
var dateRegex = regexp.MustCompile(`^([12]\d\d\d)`)
func (m *Metadata) parseYear(tagName string) int {
if v, ok := m.tags[tagName]; ok {
var y time.Time
var err error
for _, fmt := range tagYearFormats {
if y, err = time.Parse(fmt, v); err == nil {
break
}
}
if err != nil {
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
}
return y.Year()
year, _ := strconv.Atoi(match[1])
return year
}
return 0
}

View File

@@ -145,10 +145,13 @@ Tracklist:
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,
"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{"year": tag}}

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
@@ -185,12 +185,13 @@ func (s *TagScanner) processChangedDir(ctx context.Context, dir string, updatedA
}
}
log.Debug("Finished processing changed folder", "dir", dir, "updated", numUpdatedTracks, "purged", numPurgedTracks, "elapsed", time.Since(start))
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,6 +202,7 @@ 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)
}

View File

@@ -49,6 +49,7 @@ func (app *Router) routes() http.Handler {
})
// Serve UI app assets
r.Handle("/", ServeIndex(app.ds))
r.Handle("/*", http.StripPrefix(app.path, http.FileServer(assets.AssetFile())))
return r

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

@@ -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,5 +1,5 @@
import React from 'react'
import { Admin, Resource, resolveBrowserLocale } from 'react-admin'
import { Admin, resolveBrowserLocale, Resource } from 'react-admin'
import dataProvider from './dataProvider'
import authProvider from './authProvider'
import polyglotI18nProvider from 'ra-i18n-polyglot'
@@ -19,31 +19,35 @@ const i18nProvider = polyglotI18nProvider(
resolveBrowserLocale()
)
const App = () => (
<>
<div>
<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' }} />,
permissions === 'admin' ? <Resource name="user" {...user} /> : null,
<Player />
]}
</Admin>
</div>
</>
)
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' }} />,
permissions === 'admin' ? <Resource name="user" {...user} /> : null,
<Player />
]}
</Admin>
)
}
export default App

View File

@@ -1,7 +1,7 @@
import React from 'react'
import { useGetList } from 'react-admin'
import { DurationField, PlayButton, SimpleList } from '../common'
import { addTrack, setTrack } from '../player'
import { addTrack } from '../player'
import AddIcon from '@material-ui/icons/Add'
import { useDispatch } from 'react-redux'
import { playAlbum } from '../player/queue'
@@ -24,7 +24,7 @@ const AlbumSongList = (props) => {
const trackName = (r) => {
const name = r.title
if (r.trackNumber) {
return r.trackNumber + '. ' + name
return r.trackNumber.toString().padStart(2, '0') + ' ' + name
}
return name
}

View File

@@ -56,11 +56,7 @@ const authProvider = {
checkAuth: () =>
localStorage.getItem('token') ? Promise.resolve() : Promise.reject(),
checkError: (error) => {
const { status, message } = error
if (message === 'no users created') {
localStorage.setItem('initialAccountCreation', 'true')
}
checkError: ({ status }) => {
if (status === 401 || status === 403) {
removeItems()
return Promise.reject()