mirror of
https://github.com/navidrome/navidrome.git
synced 2025-04-03 20:47:35 +03:00
parent
cb6aa49439
commit
7f85ecd515
14 changed files with 110 additions and 41 deletions
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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"`
|
||||
|
|
|
@ -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"))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -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{}{}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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/, '')
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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'
|
||||
|
|
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
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue