Trigger a UI refresh when the scanner finds changes.

Closes #1025
This commit is contained in:
Deluan 2021-06-09 21:02:20 -04:00
parent cb6aa49439
commit 7f85ecd515
14 changed files with 110 additions and 41 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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/, '')

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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