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 {
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
@ -89,11 +90,17 @@ func (s *scanner) rescan(ctx context.Context, mediaFolder string, fullRescan boo
progress, cancel := s.startProgressTracker(mediaFolder)
defer cancel()
err := folderScanner.Scan(ctx, lastModifiedSince, progress)
changeCount, err := folderScanner.Scan(ctx, lastModifiedSince, progress)
if err != nil {
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)
return err
}

View File

@ -36,15 +36,16 @@ func NewTagScanner(rootFolder string, ds model.DataStore, cacheWarmer core.Cache
}
}
type (
counters struct {
added int64
updated int64
deleted int64
playlists int64
}
dirMap map[string]dirStats
)
type dirMap map[string]dirStats
type counters struct {
added int64
updated int64
deleted int64
playlists int64
}
func (cnt *counters) total() int64 { return cnt.added + cnt.updated + cnt.deleted }
const (
// 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 in the DB and sync == true, import it, or else skip it
// 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)
start := time.Now()
allDBDirs, err := s.getDBDirTree(ctx)
if err != nil {
return err
return 0, err
}
allFSDirs := dirMap{}
@ -101,19 +102,19 @@ func (s *TagScanner) Scan(ctx context.Context, lastModifiedSince time.Time, prog
if err := <-walkerError; err != nil {
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 len(allFSDirs) <= 1 {
log.Error(ctx, "Media Folder is empty. Aborting scan.", "folder", s.rootFolder)
return nil
return 0, nil
}
deletedDirs := s.getDeletedDirs(ctx, allFSDirs, allDBDirs)
if len(deletedDirs)+len(changedDirs) == 0 {
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 {
@ -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),
"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) {

View File

@ -9,16 +9,18 @@ import (
)
type Event interface {
Prepare(Event) string
Name(Event) string
Data(Event) string
}
type baseEvent struct {
Name string `json:"name"`
}
type baseEvent struct{}
func (e *baseEvent) Prepare(evt Event) string {
func (e *baseEvent) Name(evt Event) string {
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)
return string(data)
}
@ -35,6 +37,11 @@ type KeepAlive struct {
TS int64 `json:"ts"`
}
type RefreshResource struct {
baseEvent
Resource string `json:"resource"`
}
type ServerStart struct {
baseEvent
StartTime time.Time `json:"startTime"`

View File

@ -8,8 +8,10 @@ import (
var _ = Describe("Event", func() {
It("marshals Event to JSON", func() {
testEvent := TestEvent{Test: "some data"}
json := testEvent.Prepare(&testEvent)
Expect(json).To(Equal(`{"name":"testEvent","Test":"some data"}`))
data := testEvent.Data(&testEvent)
Expect(data).To(Equal(`{"Test":"some data"}`))
name := testEvent.Name(&testEvent)
Expect(name).To(Equal("testEvent"))
})
})

View File

@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"sync/atomic"
"time"
"code.cloudfoundry.org/go-diodes"
@ -26,12 +27,15 @@ const (
)
var (
eventId uint32
errWriteTimeOut = errors.New("write timeout")
)
type (
message struct {
Data string
ID uint32
Event string
Data string
}
messageChan chan message
clientsChan chan client
@ -81,7 +85,9 @@ func (b *broker) SendMessage(evt Event) {
func (b *broker) prepareMessage(event Event) 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
}
@ -90,7 +96,7 @@ func writeEvent(w io.Writer, event message, timeout time.Duration) (err error) {
flusher, _ := w.(http.Flusher)
complete := make(chan struct{}, 1)
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.
flusher.Flush()
complete <- struct{}{}

View File

@ -1,5 +1,6 @@
export const EVENT_SCAN_STATUS = 'scanStatus'
export const EVENT_SERVER_START = 'serverStart'
export const EVENT_REFRESH_RESOURCE = 'refreshResource'
export const processEvent = (type, data) => {
return {

View File

@ -18,6 +18,7 @@ import {
QuickFilter,
Title,
useAlbumsPerPage,
useResourceRefresh,
useSetToggleableFields,
} from '../common'
import AlbumListActions from './AlbumListActions'
@ -71,6 +72,7 @@ const AlbumList = (props) => {
const albumView = useSelector((state) => state.albumView)
const [perPage, perPageOptions] = useAlbumsPerPage(width)
const location = useLocation()
useResourceRefresh('album')
const albumListType = location.pathname
.replace(/^\/album/, '')

View File

@ -20,6 +20,7 @@ import {
ArtistSimpleList,
RatingField,
useSelectedFields,
useResourceRefresh,
} from '../common'
import config from '../config'
import ArtistListActions from './ArtistListActions'
@ -66,6 +67,7 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => {
const handleArtistLink = useGetHandleArtistClick(width)
const history = useHistory()
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
useResourceRefresh('artist')
const toggleableFields = useMemo(() => {
return {

View File

@ -23,6 +23,7 @@ export * from './Title'
export * from './SongBulkActions'
export * from './useAlbumsPerPage'
export * from './useInterval'
export * from './useResourceRefresh'
export * from './useToggleLove'
export * from './useTraceUpdate'
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
}
const eventHandler = throttle(
(event) => {
const data = JSON.parse(event.data)
if (data.name !== 'keepAlive') {
dispatch(processEvent(data.name, data))
}
setTimeout(defaultIntervalCheck) // Reset timeout on every received message
},
100,
{ trailing: true }
)
const eventHandler = (event) => {
const data = JSON.parse(event.data)
if (event.type !== 'keepAlive') {
dispatch(processEvent(event.type, data))
}
setTimeout(defaultIntervalCheck) // Reset timeout on every received message
}
const throttledEventHandler = throttle(eventHandler, 100, { trailing: true })
const startEventStream = async () => {
setTimeout(currentIntervalCheck)
@ -72,7 +70,10 @@ const startEventStream = async () => {
}
return getEventStream()
.then((newStream) => {
newStream.onmessage = eventHandler
newStream.addEventListener('serverStart', eventHandler)
newStream.addEventListener('scanStatus', throttledEventHandler)
newStream.addEventListener('refreshResource', eventHandler)
newStream.addEventListener('keepAlive', eventHandler)
newStream.onerror = (e) => {
console.log('EventStream error', e)
setTimeout(reconnectIntervalCheck)

View File

@ -18,6 +18,7 @@ import {
Writable,
isWritable,
useSelectedFields,
useResourceRefresh,
} from '../common'
import PlaylistListActions from './PlaylistListActions'
@ -66,6 +67,7 @@ const TogglePublicInput = ({ permissions, resource, record = {}, source }) => {
const PlaylistList = ({ permissions, ...props }) => {
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
useResourceRefresh('playlist')
const toggleableFields = useMemo(() => {
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 = {
scanStatus: { scanning: false, folderCount: 0, count: 0 },
@ -21,6 +25,14 @@ export const activityReducer = (
startTime: data.startTime && Date.parse(data.startTime),
},
}
case EVENT_REFRESH_RESOURCE:
return {
...previousState,
refresh: {
lastTime: Date.now(),
resource: data.resource,
},
}
default:
return previousState
}

View File

@ -18,6 +18,7 @@ import {
SongTitleField,
SongSimpleList,
RatingField,
useResourceRefresh,
} from '../common'
import { useDispatch } from 'react-redux'
import { makeStyles } from '@material-ui/core/styles'
@ -71,6 +72,7 @@ const SongList = (props) => {
const dispatch = useDispatch()
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
useResourceRefresh('song')
const handleRowClick = (id, basePath, record) => {
dispatch(setTrack(record))