diff --git a/scanner/scanner.go b/scanner/scanner.go index 593de3dc..b963e12d 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -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 } diff --git a/scanner/tag_scanner.go b/scanner/tag_scanner.go index 3f2b191c..64fc9df7 100644 --- a/scanner/tag_scanner.go +++ b/scanner/tag_scanner.go @@ -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) { diff --git a/server/events/events.go b/server/events/events.go index 52c1cc64..938ca37f 100644 --- a/server/events/events.go +++ b/server/events/events.go @@ -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"` diff --git a/server/events/events_test.go b/server/events/events_test.go index c52e4074..0194e4c7 100644 --- a/server/events/events_test.go +++ b/server/events/events_test.go @@ -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")) }) }) diff --git a/server/events/sse.go b/server/events/sse.go index e9d04eb8..d707c3f3 100644 --- a/server/events/sse.go +++ b/server/events/sse.go @@ -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{}{} diff --git a/ui/src/actions/serverEvents.js b/ui/src/actions/serverEvents.js index a94c2478..3e4e77b2 100644 --- a/ui/src/actions/serverEvents.js +++ b/ui/src/actions/serverEvents.js @@ -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 { diff --git a/ui/src/album/AlbumList.js b/ui/src/album/AlbumList.js index b8fbabd6..5d2e464c 100644 --- a/ui/src/album/AlbumList.js +++ b/ui/src/album/AlbumList.js @@ -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/, '') diff --git a/ui/src/artist/ArtistList.js b/ui/src/artist/ArtistList.js index 51d08de1..6e0c3d34 100644 --- a/ui/src/artist/ArtistList.js +++ b/ui/src/artist/ArtistList.js @@ -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 { diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 2aba393e..a722a749 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -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' diff --git a/ui/src/common/useResourceRefresh.js b/ui/src/common/useResourceRefresh.js new file mode 100644 index 00000000..aa9309b7 --- /dev/null +++ b/ui/src/common/useResourceRefresh.js @@ -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) + } +} diff --git a/ui/src/eventStream.js b/ui/src/eventStream.js index 32471c8d..61ab0630 100644 --- a/ui/src/eventStream.js +++ b/ui/src/eventStream.js @@ -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) diff --git a/ui/src/playlist/PlaylistList.js b/ui/src/playlist/PlaylistList.js index 8a1c6821..10de7116 100644 --- a/ui/src/playlist/PlaylistList.js +++ b/ui/src/playlist/PlaylistList.js @@ -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 { diff --git a/ui/src/reducers/activityReducer.js b/ui/src/reducers/activityReducer.js index aa57f888..042607be 100644 --- a/ui/src/reducers/activityReducer.js +++ b/ui/src/reducers/activityReducer.js @@ -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 } diff --git a/ui/src/song/SongList.js b/ui/src/song/SongList.js index 61dce6c4..0a6e179a 100644 --- a/ui/src/song/SongList.js +++ b/ui/src/song/SongList.js @@ -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))