diff --git a/approot/admin-database.xml b/approot/admin-database.xml index 435428e8..51b9eeff 100644 --- a/approot/admin-database.xml +++ b/approot/admin-database.xml @@ -76,6 +76,29 @@ + ${status} + + + + ${tr:Lms.Admin.Database.Status.status} +
+
+ +
+ ${last-scan} +
+
+
+ +
+ ${status} +
+
+
diff --git a/approot/messages.xml b/approot/messages.xml index 0c5446d9..a1962507 100644 --- a/approot/messages.xml +++ b/approot/messages.xml @@ -38,6 +38,14 @@ Update start time Weekly +Status +Last scan +Not available +Scanned {1} files in {2} on {3} ({4} errors) +Not scheduled +Scheduled on {1} +Scanning {1} of {2} files ({3} %) + New user Admin diff --git a/approot/messages_fr.xml b/approot/messages_fr.xml index a084ac8b..a3b22cc4 100644 --- a/approot/messages_fr.xml +++ b/approot/messages_fr.xml @@ -38,6 +38,14 @@ Heure de départ de la mise à jour Toutes les semaines +Statut +Dernier scan +Non disponible +{1} fichiers scannés en {2} le {3} ({4} erreurs) +Non planifié +Planifié le {1} +Scan de {1} fichiers sur {2} ({3} %) + Ajouter Admin diff --git a/src/scanner/MediaScanner.cpp b/src/scanner/MediaScanner.cpp index bc1744ed..61323d46 100644 --- a/src/scanner/MediaScanner.cpp +++ b/src/scanner/MediaScanner.cpp @@ -194,9 +194,7 @@ getOrCreateClusters(Wt::Dbo::Session& session, const MetaData::Clusters& cluster namespace Scanner { MediaScanner::MediaScanner(Wt::Dbo::SqlConnectionPool& connectionPool) - : _running(false), -_scheduleTimer(_ioService), -_db(connectionPool) +: _db {connectionPool} { _ioService.setThreadCount(1); @@ -221,8 +219,7 @@ MediaScanner::start(void) { _running = true; - // post some jobs in the io_service - scheduleScan(); + scheduleNextScan(); _ioService.start(); } @@ -241,27 +238,41 @@ MediaScanner::stop(void) } void -MediaScanner::scheduleImmediateScan() +MediaScanner::requestImmediateScan() { _ioService.post([=]() { LMS_LOG(DBUPDATER, INFO) << "Schedule immediate scan"; - scheduleScan(std::chrono::seconds(0)); - }); -} - -void -MediaScanner::reschedule() -{ - _ioService.post([=]() - { - LMS_LOG(DBUPDATER, INFO) << "Rescheduling scan"; scheduleScan(); }); } void -MediaScanner::scheduleScan() +MediaScanner::requestReschedule() +{ + _ioService.post([=]() + { + LMS_LOG(DBUPDATER, INFO) << "Rescheduling scan"; + scheduleNextScan(); + }); +} + +MediaScanner::Status +MediaScanner::getStatus() +{ + Status res; + + std::unique_lock lock {_statusMutex}; + res.currentState = _curState; + res.nextScheduledScan = _nextScheduledScan; + res.lastScanStats = _lastScanStats; + res.inProgressStats = _inProgressStats; + + return res; +} + +void +MediaScanner::scheduleNextScan() { refreshScanSettings(); @@ -296,25 +307,71 @@ MediaScanner::scheduleScan() break; } + Wt::WDateTime nextScanDateTime; + if (nextScanDate.isValid()) - scheduleScan( Wt::WDateTime(nextScanDate, _startTime).toTimePoint() ); + { + nextScanDateTime = Wt::WDateTime {nextScanDate, _startTime}; + scheduleScan(nextScanDateTime); + } + + { + std::unique_lock lock {_statusMutex}; + _curState = nextScanDateTime.isValid() ? State::Scheduled : State::NotScheduled; + _nextScheduledScan = nextScanDateTime; + } + + _sigScheduled.emit(_nextScheduledScan); } void -MediaScanner::scheduleScan(std::chrono::seconds duration) +MediaScanner::countAllFiles(Stats& stats) { - LMS_LOG(DBUPDATER, INFO) << "Scheduling next scan in " << duration.count() << " seconds"; - _scheduleTimer.expires_from_now(duration); - _scheduleTimer.async_wait( std::bind( &MediaScanner::scan, this, std::placeholders::_1) ); + boost::system::error_code ec; + + stats.totalFiles = 0; + + boost::filesystem::recursive_directory_iterator itPath(_mediaDirectory, ec); + if (ec) + { + LMS_LOG(DBUPDATER, ERROR) << "Cannot iterate over '" << _mediaDirectory.string() << "': " << ec.message(); + return; + } + + boost::filesystem::recursive_directory_iterator itEnd; + while (itPath != itEnd && _running) + { + if (stats.totalFiles % 250 == 0) + notifyInProgressIfNeeded(stats); + + const boost::filesystem::path& path {*itPath++}; + + if (!_running) + break; + + if (boost::filesystem::is_regular(path) && isFileSupported(path, _fileExtensions)) + stats.totalFiles++; + } } void -MediaScanner::scheduleScan(std::chrono::system_clock::time_point timePoint) +MediaScanner::scheduleScan(const Wt::WDateTime& dateTime) { - std::time_t t = std::chrono::system_clock::to_time_t(timePoint); - LMS_LOG(DBUPDATER, INFO) << "Scheduling next scan at " << std::string(std::ctime(&t)); - _scheduleTimer.expires_at(timePoint); - _scheduleTimer.async_wait(std::bind(&MediaScanner::scan, this, std::placeholders::_1)); + if (dateTime.isNull()) + { + LMS_LOG(DBUPDATER, INFO) << "Scheduling next scan right now"; + _scheduleTimer.expires_from_now(std::chrono::seconds(0)); + _scheduleTimer.async_wait(std::bind(&MediaScanner::scan, this, std::placeholders::_1)); + } + else + { + std::chrono::system_clock::time_point timePoint {dateTime.toTimePoint()}; + std::time_t t {std::chrono::system_clock::to_time_t(timePoint)}; + + LMS_LOG(DBUPDATER, INFO) << "Scheduling next scan at " << std::string(std::ctime(&t)); + _scheduleTimer.expires_at(timePoint); + _scheduleTimer.async_wait(std::bind(&MediaScanner::scan, this, std::placeholders::_1)); + } } void @@ -323,12 +380,25 @@ MediaScanner::scan(boost::system::error_code err) if (err) return; + { + std::unique_lock lock {_statusMutex}; + _curState = State::InProgress; + _nextScheduledScan = {}; + _inProgressStats = Stats{}; + _inProgressStats->startTime = Wt::WLocalDateTime::currentDateTime().toUTC(); + } + + Stats& stats {*_inProgressStats}; + LMS_LOG(UI, INFO) << "New scan started!"; refreshScanSettings(); - bool forceScan = false; - Stats stats; + bool forceScan {false}; + + LMS_LOG(DBUPDATER, DEBUG) << "Counting files in media directory '" << _mediaDirectory.string() << "'..."; + countAllFiles(stats); + LMS_LOG(DBUPDATER, DEBUG) << "-> Nb files = " << stats.totalFiles; removeMissingTracks(stats); @@ -351,10 +421,23 @@ MediaScanner::scan(boost::system::error_code err) for (auto& addon : _addons) addon->preScanComplete(); - scheduleScan(); + stats.stopTime = Wt::WLocalDateTime::currentDateTime().toUTC(); + { + std::unique_lock lock {_statusMutex}; + _lastScanStats = std::move(_inProgressStats); + _inProgressStats.reset(); + } + + scheduleNextScan(); scanComplete().emit(stats); } + else + { + std::unique_lock lock {_statusMutex}; + _curState = State::NotScheduled; + _inProgressStats.reset(); + } } void @@ -388,9 +471,22 @@ MediaScanner::refreshScanSettings() addon->refreshSettings(); } +void MediaScanner::notifyInProgressIfNeeded(Stats& stats) +{ + std::chrono::system_clock::time_point now {std::chrono::system_clock::now()}; + + if (std::chrono::duration_cast(now - _lastScanInProgressEmit).count() > 2) + { + _sigScanInProgress(stats); + _lastScanInProgressEmit = now; + } +} + void MediaScanner::scanAudioFile(const boost::filesystem::path& file, bool forceScan, Stats& stats) { + notifyInProgressIfNeeded(stats); + auto lastWriteTime = Wt::WDateTime::fromTime_t(boost::filesystem::last_write_time(file)); if (!forceScan) @@ -561,7 +657,7 @@ MediaScanner::scanMediaDirectory(boost::filesystem::path mediaDirectory, bool fo boost::filesystem::recursive_directory_iterator itEnd; while (itPath != itEnd) { - boost::filesystem::path path = *itPath; + const boost::filesystem::path& path {*itPath}; if (!_running) return; diff --git a/src/scanner/MediaScanner.hpp b/src/scanner/MediaScanner.hpp index 5474a6d1..904fca4c 100644 --- a/src/scanner/MediaScanner.hpp +++ b/src/scanner/MediaScanner.hpp @@ -20,7 +20,9 @@ #pragma once #include +#include +#include #include #include @@ -37,7 +39,6 @@ namespace Scanner { class MediaScanner { public: - MediaScanner(Wt::Dbo::SqlConnectionPool& connectionPool); void setAddon(MediaScannerAddon& addon); @@ -46,11 +47,14 @@ class MediaScanner void stop(); void restart(); - void scheduleImmediateScan(); - void reschedule(); + // Async requests + void requestImmediateScan(); + void requestReschedule(); struct Stats { + Wt::WDateTime startTime; + Wt::WDateTime stopTime; std::size_t skips = 0; // no change since last scan std::size_t scans = 0; // actually scanned filed std::size_t scanErrors = 0; // cannot scan file @@ -62,21 +66,46 @@ class MediaScanner std::size_t duplicateHashes = 0; // Same file hashes std::size_t duplicateMBID = 0; // Same MBID + std::size_t totalFiles = 0; // Total number of files to be scanned + std::size_t nbFiles() const { return skips + additions + updates; } std::size_t nbChanges() const { return additions + deletions + updates; } std::size_t nbErrors() const { return scanErrors + incompleteScans; } std::size_t nbDuplicates() const { return duplicateHashes + duplicateMBID; } + float progress() const { return (nbFiles() - totalFiles) / static_cast(totalFiles); } }; + enum class State + { + NotScheduled, + Scheduled, + InProgress, + }; + + struct Status + { + State currentState {State::NotScheduled}; + Wt::WDateTime nextScheduledScan; + boost::optional lastScanStats; + boost::optional inProgressStats; + }; + + Status getStatus(); + // Called just after scan complete Wt::Signal& scanComplete() { return _sigScanComplete; } + // Called during scan in progress + Wt::Signal& scanInProgress() { return _sigScanInProgress; } + + // Called after a schedule + Wt::Signal& scheduled() { return _sigScheduled; } + private: // Job handling - void scheduleScan(); - void scheduleScan(std::chrono::seconds duration); - void scheduleScan(std::chrono::system_clock::time_point time); + void scheduleNextScan(); + void scheduleScan(const Wt::WDateTime& dateTime = {}); // Update database (scheduled callback) void scan(boost::system::error_code ec); @@ -86,30 +115,37 @@ class MediaScanner // Helpers void refreshScanSettings(); + void countAllFiles(Stats& stats); void removeMissingTracks(Stats& stats); void removeOrphanEntries(); void checkDuplicatedAudioFiles(Stats& stats); void scanAudioFile(const boost::filesystem::path& file, bool forceScan, Stats& stats); + void notifyInProgressIfNeeded(Stats& stats); - bool _running; + bool _running {false}; Wt::WIOService _ioService; - + boost::asio::system_timer _scheduleTimer {_ioService}; Wt::Signal _sigScanComplete; - - boost::asio::system_timer _scheduleTimer; - + Wt::Signal _sigScanInProgress; + std::chrono::system_clock::time_point _lastScanInProgressEmit {}; + Wt::Signal _sigScheduled; Database::Handler _db; + MetaData::TagLibParser _metadataParser; + std::vector _addons; + + std::mutex _statusMutex; + State _curState {State::NotScheduled}; + boost::optional _inProgressStats; + boost::optional _lastScanStats; + Wt::WDateTime _nextScheduledScan; // Current scan settings - std::size_t _scanVersion; + std::size_t _scanVersion {}; Wt::WTime _startTime; - Database::ScanSettings::UpdatePeriod _updatePeriod; + Database::ScanSettings::UpdatePeriod _updatePeriod {Database::ScanSettings::UpdatePeriod::Never}; std::set _fileExtensions; boost::filesystem::path _mediaDirectory; - MetaData::TagLibParser _metadataParser; - - std::vector _addons; }; // class MediaScanner diff --git a/src/ui/LmsApplication.cpp b/src/ui/LmsApplication.cpp index 0937abaa..98563177 100644 --- a/src/ui/LmsApplication.cpp +++ b/src/ui/LmsApplication.cpp @@ -458,16 +458,36 @@ LmsApplication::createHome() }); // Events from MediaScanner - std::string sessionId = LmsApp->sessionId(); - getService()->scanComplete().connect([=] (Scanner::MediaScanner::Stats stats) { - // Runs from media scanner context - Wt::WServer::instance()->post(sessionId, [=] + std::string sessionId = LmsApp->sessionId(); + getService()->scanComplete().connect(this, [=] (Scanner::MediaScanner::Stats stats) { - _events.dbScanned.emit(stats); - triggerUpdate(); + Wt::WServer::instance()->post(sessionId, [=] + { + _events.dbScanned.emit(stats); + triggerUpdate(); + }); }); - }); + + getService()->scanInProgress().connect(this, [=] (Scanner::MediaScanner::Stats stats) + { + Wt::WServer::instance()->post(sessionId, [=] + { + _events.dbScanInProgress.emit(stats); + triggerUpdate(); + }); + }); + + getService()->scheduled().connect(this, [=] (Wt::WDateTime dateTime) + { + Wt::WServer::instance()->post(sessionId, [=] + { + _events.dbScanScheduled.emit(dateTime); + triggerUpdate(); + }); + }); + + } _events.dbScanned.connect([=] (Scanner::MediaScanner::Stats stats) { diff --git a/src/ui/LmsApplication.hpp b/src/ui/LmsApplication.hpp index a8d296e1..6d75158e 100644 --- a/src/ui/LmsApplication.hpp +++ b/src/ui/LmsApplication.hpp @@ -55,8 +55,10 @@ struct Events // Unload current track Wt::Signal<> trackUnloaded; - // A database scan is complete + // Database events Wt::Signal dbScanned; + Wt::Signal dbScanInProgress; + Wt::Signal dbScanScheduled; }; // Used to classify the message sent to the user diff --git a/src/ui/admin/DatabaseSettingsView.cpp b/src/ui/admin/DatabaseSettingsView.cpp index 63f704a3..4794c714 100644 --- a/src/ui/admin/DatabaseSettingsView.cpp +++ b/src/ui/admin/DatabaseSettingsView.cpp @@ -19,10 +19,14 @@ #include "DatabaseSettingsView.hpp" +#include +#include + #include #include #include #include +#include #include #include @@ -62,7 +66,7 @@ class DatabaseSettingsModel : public Wt::WFormModel addField(SimilarityEngineTypeField); addField(TagsField); - auto dirValidator = std::make_shared(); + auto dirValidator {std::make_shared()}; dirValidator->setMandatory(true); setValidator(MediaDirectoryField, dirValidator); @@ -174,6 +178,85 @@ const Wt::WFormModel::Field DatabaseSettingsModel::UpdateStartTimeField = "upda const Wt::WFormModel::Field DatabaseSettingsModel::SimilarityEngineTypeField = "similarity-engine-type"; const Wt::WFormModel::Field DatabaseSettingsModel::TagsField = "tags"; +static +std::string durationToString(const Wt::WDateTime& begin, const Wt::WDateTime& end) +{ + auto secs {std::chrono::duration_cast(end.toTimePoint() - begin.toTimePoint()).count()}; + + std::ostringstream oss; + + if (secs >= 3600) + oss << secs/3600 << "h"; + if (secs >= 60) + oss << std::setw(2) << std::setfill('0') << (secs % 3600) / 60 << "m"; + oss << std::setw(2) << std::setfill('0') << (secs % 60) << "s"; + + return oss.str(); +} + +class DatabaseStatus : public Wt::WTemplate +{ + public: + + DatabaseStatus(): WTemplate {Wt::WString::tr("Lms.Admin.Database.Status.template")} + { + addFunction("tr", &Wt::WTemplate::Functions::tr); + + using namespace Scanner; + + auto onDbEvent = [&]() { refreshContents(); }; + + LmsApp->getEvents().dbScanned.connect(this, onDbEvent); + LmsApp->getEvents().dbScanInProgress.connect(this, onDbEvent); + LmsApp->getEvents().dbScanScheduled.connect(this, onDbEvent); + + refreshContents(); + } + + private: + void refreshContents() + { + using namespace Scanner; + + MediaScanner::Status status {getService()->getStatus()}; + + if (status.lastScanStats) + { + bindString("last-scan", Wt::WString::tr("Lms.Admin.Database.Status.last-scan-status") + .arg(status.lastScanStats->totalFiles) + .arg(durationToString(status.lastScanStats->startTime, status.lastScanStats->stopTime)) + .arg(status.lastScanStats->stopTime.toString()) + .arg(status.lastScanStats->nbErrors()) + ); + } + else + { + bindString("last-scan", Wt::WString::tr("Lms.Admin.Database.Status.last-scan-not-available")); + } + + switch (status.currentState) + { + case MediaScanner::State::NotScheduled: + bindString("status", Wt::WString::tr("Lms.Admin.Database.Status.status-not-scheduled")); + break; + case MediaScanner::State::Scheduled: + bindString("status", Wt::WString::tr("Lms.Admin.Database.Status.status-scheduled") + .arg(status.nextScheduledScan.toString())); + break; + case MediaScanner::State::InProgress: + { + std::ostringstream oss; + bindString("status", Wt::WString::tr("Lms.Admin.Database.Status.status-in-progress") + .arg(status.inProgressStats->nbFiles()) + .arg(status.inProgressStats->totalFiles) + .arg(static_cast(status.inProgressStats->nbFiles() / static_cast(status.inProgressStats->totalFiles ? status.inProgressStats->totalFiles : 1) * 100))); + } + break; + } + } + private: +}; + DatabaseSettingsView::DatabaseSettingsView() { wApp->internalPathChanged().connect(std::bind([=] @@ -221,6 +304,8 @@ DatabaseSettingsView::refreshView() Wt::WPushButton *discardBtn = t->bindWidget("discard-btn", std::make_unique(Wt::WString::tr("Lms.discard"))); Wt::WPushButton *immScanBtn = t->bindWidget("immediate-scan-btn", std::make_unique(Wt::WString::tr("Lms.Admin.Database.immediate-scan"))); + t->bindNew("status"); + saveBtn->clicked().connect([=] () { t->updateModel(model.get()); @@ -229,7 +314,7 @@ DatabaseSettingsView::refreshView() { model->saveData(); - getService()->reschedule(); + getService()->requestReschedule(); LmsApp->notifyMsg(MsgType::Success, Wt::WString::tr("Lms.Admin.Database.settings-saved")); } @@ -246,7 +331,7 @@ DatabaseSettingsView::refreshView() immScanBtn->clicked().connect([=] () { - getService()->scheduleImmediateScan(); + getService()->requestImmediateScan(); LmsApp->notifyMsg(MsgType::Info, Wt::WString::tr("Lms.Admin.Database.scan-launched")); });