mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-06 05:48:09 -05:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
400fa65326 | ||
|
|
ab10719d27 | ||
|
|
029290f304 | ||
|
|
2c146ea1fe | ||
|
|
10ead1f5f2 | ||
|
|
730722cfe3 | ||
|
|
dc352834b9 | ||
|
|
313a3342a0 |
@@ -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{}
|
||||
|
||||
@@ -13,4 +13,7 @@ const (
|
||||
JWTTokenExpiration = 30 * time.Minute
|
||||
|
||||
UIAssetsLocalPath = "ui/build"
|
||||
|
||||
DevInitialUserName = "admin"
|
||||
DevInitialName = "Dev Admin"
|
||||
)
|
||||
|
||||
2
db/db.go
2
db/db.go
@@ -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"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package migrations
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -1,4 +1,4 @@
|
||||
package migrations
|
||||
package migration
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
56
db/migration/20200208222418_add_defaults_to_annotations.go
Normal file
56
db/migration/20200208222418_add_defaults_to_annotations.go
Normal 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
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
43
server/app/serve_index.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user