mirror of
https://github.com/navidrome/navidrome.git
synced 2025-04-04 21:17:37 +03:00
parent
cb6aa49439
commit
7f85ecd515
14 changed files with 110 additions and 41 deletions
|
@ -35,7 +35,8 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
type FolderScanner interface {
|
type FolderScanner interface {
|
||||||
Scan(ctx context.Context, lastModifiedSince time.Time, progress chan uint32) error
|
// Scan process finds any changes after `lastModifiedSince` and returns the number of changes found
|
||||||
|
Scan(ctx context.Context, lastModifiedSince time.Time, progress chan uint32) (int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
var isScanning utils.AtomicBool
|
var isScanning utils.AtomicBool
|
||||||
|
@ -89,11 +90,17 @@ func (s *scanner) rescan(ctx context.Context, mediaFolder string, fullRescan boo
|
||||||
progress, cancel := s.startProgressTracker(mediaFolder)
|
progress, cancel := s.startProgressTracker(mediaFolder)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
err := folderScanner.Scan(ctx, lastModifiedSince, progress)
|
changeCount, err := folderScanner.Scan(ctx, lastModifiedSince, progress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Error importing MediaFolder", "folder", mediaFolder, err)
|
log.Error("Error importing MediaFolder", "folder", mediaFolder, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if changeCount > 0 {
|
||||||
|
log.Debug(ctx, "Detected changes in the music folder. Sending refresh event",
|
||||||
|
"folder", mediaFolder, "changeCount", changeCount)
|
||||||
|
s.broker.SendMessage(&events.RefreshResource{})
|
||||||
|
}
|
||||||
|
|
||||||
s.updateLastModifiedSince(mediaFolder, start)
|
s.updateLastModifiedSince(mediaFolder, start)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,15 +36,16 @@ func NewTagScanner(rootFolder string, ds model.DataStore, cacheWarmer core.Cache
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type (
|
type dirMap map[string]dirStats
|
||||||
counters struct {
|
|
||||||
added int64
|
type counters struct {
|
||||||
updated int64
|
added int64
|
||||||
deleted int64
|
updated int64
|
||||||
playlists int64
|
deleted int64
|
||||||
}
|
playlists int64
|
||||||
dirMap map[string]dirStats
|
}
|
||||||
)
|
|
||||||
|
func (cnt *counters) total() int64 { return cnt.added + cnt.updated + cnt.deleted }
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// filesBatchSize used for batching file metadata extraction
|
// filesBatchSize used for batching file metadata extraction
|
||||||
|
@ -67,13 +68,13 @@ const (
|
||||||
// If the playlist is not in the DB, import it, setting sync = true
|
// If the playlist is not in the DB, import it, setting sync = true
|
||||||
// If the playlist is in the DB and sync == true, import it, or else skip it
|
// If the playlist is in the DB and sync == true, import it, or else skip it
|
||||||
// Delete all empty albums, delete all empty artists, clean-up playlists
|
// Delete all empty albums, delete all empty artists, clean-up playlists
|
||||||
func (s *TagScanner) Scan(ctx context.Context, lastModifiedSince time.Time, progress chan uint32) error {
|
func (s *TagScanner) Scan(ctx context.Context, lastModifiedSince time.Time, progress chan uint32) (int64, error) {
|
||||||
ctx = s.withAdminUser(ctx)
|
ctx = s.withAdminUser(ctx)
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
allDBDirs, err := s.getDBDirTree(ctx)
|
allDBDirs, err := s.getDBDirTree(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
allFSDirs := dirMap{}
|
allFSDirs := dirMap{}
|
||||||
|
@ -101,19 +102,19 @@ func (s *TagScanner) Scan(ctx context.Context, lastModifiedSince time.Time, prog
|
||||||
|
|
||||||
if err := <-walkerError; err != nil {
|
if err := <-walkerError; err != nil {
|
||||||
log.Error("Scan was interrupted by error. See errors above", err)
|
log.Error("Scan was interrupted by error. See errors above", err)
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the media folder is empty, abort to avoid deleting all data
|
// If the media folder is empty, abort to avoid deleting all data
|
||||||
if len(allFSDirs) <= 1 {
|
if len(allFSDirs) <= 1 {
|
||||||
log.Error(ctx, "Media Folder is empty. Aborting scan.", "folder", s.rootFolder)
|
log.Error(ctx, "Media Folder is empty. Aborting scan.", "folder", s.rootFolder)
|
||||||
return nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
deletedDirs := s.getDeletedDirs(ctx, allFSDirs, allDBDirs)
|
deletedDirs := s.getDeletedDirs(ctx, allFSDirs, allDBDirs)
|
||||||
if len(deletedDirs)+len(changedDirs) == 0 {
|
if len(deletedDirs)+len(changedDirs) == 0 {
|
||||||
log.Debug(ctx, "No changes found in Music Folder", "folder", s.rootFolder, "elapsed", time.Since(start))
|
log.Debug(ctx, "No changes found in Music Folder", "folder", s.rootFolder, "elapsed", time.Since(start))
|
||||||
return nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, dir := range deletedDirs {
|
for _, dir := range deletedDirs {
|
||||||
|
@ -146,7 +147,7 @@ func (s *TagScanner) Scan(ctx context.Context, lastModifiedSince time.Time, prog
|
||||||
log.Info("Finished processing Music Folder", "folder", s.rootFolder, "elapsed", time.Since(start),
|
log.Info("Finished processing Music Folder", "folder", s.rootFolder, "elapsed", time.Since(start),
|
||||||
"added", s.cnt.added, "updated", s.cnt.updated, "deleted", s.cnt.deleted, "playlistsImported", s.cnt.playlists)
|
"added", s.cnt.added, "updated", s.cnt.updated, "deleted", s.cnt.deleted, "playlistsImported", s.cnt.playlists)
|
||||||
|
|
||||||
return err
|
return s.cnt.total(), err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TagScanner) getRootFolderWalker(ctx context.Context) (walkResults, chan error) {
|
func (s *TagScanner) getRootFolderWalker(ctx context.Context) (walkResults, chan error) {
|
||||||
|
|
|
@ -9,16 +9,18 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Event interface {
|
type Event interface {
|
||||||
Prepare(Event) string
|
Name(Event) string
|
||||||
|
Data(Event) string
|
||||||
}
|
}
|
||||||
|
|
||||||
type baseEvent struct {
|
type baseEvent struct{}
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *baseEvent) Prepare(evt Event) string {
|
func (e *baseEvent) Name(evt Event) string {
|
||||||
str := strings.TrimPrefix(reflect.TypeOf(evt).String(), "*events.")
|
str := strings.TrimPrefix(reflect.TypeOf(evt).String(), "*events.")
|
||||||
e.Name = str[:0] + string(unicode.ToLower(rune(str[0]))) + str[1:]
|
return str[:0] + string(unicode.ToLower(rune(str[0]))) + str[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *baseEvent) Data(evt Event) string {
|
||||||
data, _ := json.Marshal(evt)
|
data, _ := json.Marshal(evt)
|
||||||
return string(data)
|
return string(data)
|
||||||
}
|
}
|
||||||
|
@ -35,6 +37,11 @@ type KeepAlive struct {
|
||||||
TS int64 `json:"ts"`
|
TS int64 `json:"ts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RefreshResource struct {
|
||||||
|
baseEvent
|
||||||
|
Resource string `json:"resource"`
|
||||||
|
}
|
||||||
|
|
||||||
type ServerStart struct {
|
type ServerStart struct {
|
||||||
baseEvent
|
baseEvent
|
||||||
StartTime time.Time `json:"startTime"`
|
StartTime time.Time `json:"startTime"`
|
||||||
|
|
|
@ -8,8 +8,10 @@ import (
|
||||||
var _ = Describe("Event", func() {
|
var _ = Describe("Event", func() {
|
||||||
It("marshals Event to JSON", func() {
|
It("marshals Event to JSON", func() {
|
||||||
testEvent := TestEvent{Test: "some data"}
|
testEvent := TestEvent{Test: "some data"}
|
||||||
json := testEvent.Prepare(&testEvent)
|
data := testEvent.Data(&testEvent)
|
||||||
Expect(json).To(Equal(`{"name":"testEvent","Test":"some data"}`))
|
Expect(data).To(Equal(`{"Test":"some data"}`))
|
||||||
|
name := testEvent.Name(&testEvent)
|
||||||
|
Expect(name).To(Equal("testEvent"))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.cloudfoundry.org/go-diodes"
|
"code.cloudfoundry.org/go-diodes"
|
||||||
|
@ -26,12 +27,15 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
eventId uint32
|
||||||
errWriteTimeOut = errors.New("write timeout")
|
errWriteTimeOut = errors.New("write timeout")
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
message struct {
|
message struct {
|
||||||
Data string
|
ID uint32
|
||||||
|
Event string
|
||||||
|
Data string
|
||||||
}
|
}
|
||||||
messageChan chan message
|
messageChan chan message
|
||||||
clientsChan chan client
|
clientsChan chan client
|
||||||
|
@ -81,7 +85,9 @@ func (b *broker) SendMessage(evt Event) {
|
||||||
|
|
||||||
func (b *broker) prepareMessage(event Event) message {
|
func (b *broker) prepareMessage(event Event) message {
|
||||||
msg := message{}
|
msg := message{}
|
||||||
msg.Data = event.Prepare(event)
|
msg.ID = atomic.AddUint32(&eventId, 1)
|
||||||
|
msg.Data = event.Data(event)
|
||||||
|
msg.Event = event.Name(event)
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -90,7 +96,7 @@ func writeEvent(w io.Writer, event message, timeout time.Duration) (err error) {
|
||||||
flusher, _ := w.(http.Flusher)
|
flusher, _ := w.(http.Flusher)
|
||||||
complete := make(chan struct{}, 1)
|
complete := make(chan struct{}, 1)
|
||||||
go func() {
|
go func() {
|
||||||
_, err = fmt.Fprintf(w, "data: %s\n\n", event.Data)
|
_, err = fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", event.ID, event.Event, event.Data)
|
||||||
// Flush the data immediately instead of buffering it for later.
|
// Flush the data immediately instead of buffering it for later.
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
complete <- struct{}{}
|
complete <- struct{}{}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
export const EVENT_SCAN_STATUS = 'scanStatus'
|
export const EVENT_SCAN_STATUS = 'scanStatus'
|
||||||
export const EVENT_SERVER_START = 'serverStart'
|
export const EVENT_SERVER_START = 'serverStart'
|
||||||
|
export const EVENT_REFRESH_RESOURCE = 'refreshResource'
|
||||||
|
|
||||||
export const processEvent = (type, data) => {
|
export const processEvent = (type, data) => {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -18,6 +18,7 @@ import {
|
||||||
QuickFilter,
|
QuickFilter,
|
||||||
Title,
|
Title,
|
||||||
useAlbumsPerPage,
|
useAlbumsPerPage,
|
||||||
|
useResourceRefresh,
|
||||||
useSetToggleableFields,
|
useSetToggleableFields,
|
||||||
} from '../common'
|
} from '../common'
|
||||||
import AlbumListActions from './AlbumListActions'
|
import AlbumListActions from './AlbumListActions'
|
||||||
|
@ -71,6 +72,7 @@ const AlbumList = (props) => {
|
||||||
const albumView = useSelector((state) => state.albumView)
|
const albumView = useSelector((state) => state.albumView)
|
||||||
const [perPage, perPageOptions] = useAlbumsPerPage(width)
|
const [perPage, perPageOptions] = useAlbumsPerPage(width)
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
useResourceRefresh('album')
|
||||||
|
|
||||||
const albumListType = location.pathname
|
const albumListType = location.pathname
|
||||||
.replace(/^\/album/, '')
|
.replace(/^\/album/, '')
|
||||||
|
|
|
@ -20,6 +20,7 @@ import {
|
||||||
ArtistSimpleList,
|
ArtistSimpleList,
|
||||||
RatingField,
|
RatingField,
|
||||||
useSelectedFields,
|
useSelectedFields,
|
||||||
|
useResourceRefresh,
|
||||||
} from '../common'
|
} from '../common'
|
||||||
import config from '../config'
|
import config from '../config'
|
||||||
import ArtistListActions from './ArtistListActions'
|
import ArtistListActions from './ArtistListActions'
|
||||||
|
@ -66,6 +67,7 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => {
|
||||||
const handleArtistLink = useGetHandleArtistClick(width)
|
const handleArtistLink = useGetHandleArtistClick(width)
|
||||||
const history = useHistory()
|
const history = useHistory()
|
||||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||||
|
useResourceRefresh('artist')
|
||||||
|
|
||||||
const toggleableFields = useMemo(() => {
|
const toggleableFields = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -23,6 +23,7 @@ export * from './Title'
|
||||||
export * from './SongBulkActions'
|
export * from './SongBulkActions'
|
||||||
export * from './useAlbumsPerPage'
|
export * from './useAlbumsPerPage'
|
||||||
export * from './useInterval'
|
export * from './useInterval'
|
||||||
|
export * from './useResourceRefresh'
|
||||||
export * from './useToggleLove'
|
export * from './useToggleLove'
|
||||||
export * from './useTraceUpdate'
|
export * from './useTraceUpdate'
|
||||||
export * from './Writable'
|
export * from './Writable'
|
||||||
|
|
23
ui/src/common/useResourceRefresh.js
Normal file
23
ui/src/common/useResourceRefresh.js
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { useSelector } from 'react-redux'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRefresh } from 'react-admin'
|
||||||
|
|
||||||
|
export const useResourceRefresh = (...resources) => {
|
||||||
|
const [lastTime, setLastTime] = useState(Date.now())
|
||||||
|
const refreshData = useSelector(
|
||||||
|
(state) => state.activity?.refresh || { lastTime }
|
||||||
|
)
|
||||||
|
const refresh = useRefresh()
|
||||||
|
|
||||||
|
const resource = refreshData.resource
|
||||||
|
if (refreshData.lastTime > lastTime) {
|
||||||
|
if (
|
||||||
|
resource === '' ||
|
||||||
|
resources.length === 0 ||
|
||||||
|
resources.includes(resource)
|
||||||
|
) {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
setLastTime(refreshData.lastTime)
|
||||||
|
}
|
||||||
|
}
|
|
@ -52,17 +52,15 @@ const setDispatch = (dispatchFunc) => {
|
||||||
dispatch = dispatchFunc
|
dispatch = dispatchFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventHandler = throttle(
|
const eventHandler = (event) => {
|
||||||
(event) => {
|
const data = JSON.parse(event.data)
|
||||||
const data = JSON.parse(event.data)
|
if (event.type !== 'keepAlive') {
|
||||||
if (data.name !== 'keepAlive') {
|
dispatch(processEvent(event.type, data))
|
||||||
dispatch(processEvent(data.name, data))
|
}
|
||||||
}
|
setTimeout(defaultIntervalCheck) // Reset timeout on every received message
|
||||||
setTimeout(defaultIntervalCheck) // Reset timeout on every received message
|
}
|
||||||
},
|
|
||||||
100,
|
const throttledEventHandler = throttle(eventHandler, 100, { trailing: true })
|
||||||
{ trailing: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
const startEventStream = async () => {
|
const startEventStream = async () => {
|
||||||
setTimeout(currentIntervalCheck)
|
setTimeout(currentIntervalCheck)
|
||||||
|
@ -72,7 +70,10 @@ const startEventStream = async () => {
|
||||||
}
|
}
|
||||||
return getEventStream()
|
return getEventStream()
|
||||||
.then((newStream) => {
|
.then((newStream) => {
|
||||||
newStream.onmessage = eventHandler
|
newStream.addEventListener('serverStart', eventHandler)
|
||||||
|
newStream.addEventListener('scanStatus', throttledEventHandler)
|
||||||
|
newStream.addEventListener('refreshResource', eventHandler)
|
||||||
|
newStream.addEventListener('keepAlive', eventHandler)
|
||||||
newStream.onerror = (e) => {
|
newStream.onerror = (e) => {
|
||||||
console.log('EventStream error', e)
|
console.log('EventStream error', e)
|
||||||
setTimeout(reconnectIntervalCheck)
|
setTimeout(reconnectIntervalCheck)
|
||||||
|
|
|
@ -18,6 +18,7 @@ import {
|
||||||
Writable,
|
Writable,
|
||||||
isWritable,
|
isWritable,
|
||||||
useSelectedFields,
|
useSelectedFields,
|
||||||
|
useResourceRefresh,
|
||||||
} from '../common'
|
} from '../common'
|
||||||
import PlaylistListActions from './PlaylistListActions'
|
import PlaylistListActions from './PlaylistListActions'
|
||||||
|
|
||||||
|
@ -66,6 +67,7 @@ const TogglePublicInput = ({ permissions, resource, record = {}, source }) => {
|
||||||
const PlaylistList = ({ permissions, ...props }) => {
|
const PlaylistList = ({ permissions, ...props }) => {
|
||||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
|
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
|
||||||
|
useResourceRefresh('playlist')
|
||||||
|
|
||||||
const toggleableFields = useMemo(() => {
|
const toggleableFields = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
import { EVENT_SCAN_STATUS, EVENT_SERVER_START } from '../actions'
|
import {
|
||||||
|
EVENT_REFRESH_RESOURCE,
|
||||||
|
EVENT_SCAN_STATUS,
|
||||||
|
EVENT_SERVER_START,
|
||||||
|
} from '../actions'
|
||||||
|
|
||||||
const defaultState = {
|
const defaultState = {
|
||||||
scanStatus: { scanning: false, folderCount: 0, count: 0 },
|
scanStatus: { scanning: false, folderCount: 0, count: 0 },
|
||||||
|
@ -21,6 +25,14 @@ export const activityReducer = (
|
||||||
startTime: data.startTime && Date.parse(data.startTime),
|
startTime: data.startTime && Date.parse(data.startTime),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
case EVENT_REFRESH_RESOURCE:
|
||||||
|
return {
|
||||||
|
...previousState,
|
||||||
|
refresh: {
|
||||||
|
lastTime: Date.now(),
|
||||||
|
resource: data.resource,
|
||||||
|
},
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return previousState
|
return previousState
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@ import {
|
||||||
SongTitleField,
|
SongTitleField,
|
||||||
SongSimpleList,
|
SongSimpleList,
|
||||||
RatingField,
|
RatingField,
|
||||||
|
useResourceRefresh,
|
||||||
} from '../common'
|
} from '../common'
|
||||||
import { useDispatch } from 'react-redux'
|
import { useDispatch } from 'react-redux'
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
@ -71,6 +72,7 @@ const SongList = (props) => {
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
|
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
|
||||||
|
useResourceRefresh('song')
|
||||||
|
|
||||||
const handleRowClick = (id, basePath, record) => {
|
const handleRowClick = (id, basePath, record) => {
|
||||||
dispatch(setTrack(record))
|
dispatch(setTrack(record))
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue